feat: 集成 Obscura 进程内无头浏览器、极致编译瘦身 profile 与词典内存优化

- 下载器 Obscura 后备通道拆分为条件编译双路径:
    进程内模式 (obscura-inprocess feature) 通过 spawn_blocking + 单线程
    runtime 驱动 V8 直接抓取;默认外部命令行模式通过 bin/obscura 子进程调用
  - Cargo.toml 新增 obscura-browser/obscura-net 可选依赖与 release-min profile
    (LTO + strip + opt-level="s",二进制 17→8.3 MB,VSZ 1.27G→302M)
  - 词典加载后 shrink_to_fit() 释放预留容量,降低常驻内存
  - README 与 deployment.md 扩写 Obscura 双模式部署及低配服务器优化指南
  - 新增 Obscura mock 集成测试,补齐测试 fixture 字段
This commit is contained in:
Asfmq 2026-06-12 11:15:29 +08:00
parent 8cc2b74abc
commit 2a5b1c0c91
8 changed files with 1283 additions and 29 deletions

1036
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -45,3 +45,17 @@ flate2 = "1.1.9"
zip = "8.6.0"
uuid = { version = "1.23.2", features = ["v4"] }
tracing-appender = "0.2.5"
obscura-browser = { path = "libs/obscura/crates/obscura-browser", optional = true }
obscura-net = { path = "libs/obscura/crates/obscura-net", optional = true }
[features]
default = []
obscura-inprocess = ["dep:obscura-browser", "dep:obscura-net"]
[profile.release-min]
inherits = "release"
opt-level = "s"
lto = true
codegen-units = 1
strip = true

View File

@ -55,11 +55,20 @@ cp .env.example .env
```
静态资源将打包并输出在项目根目录下的 `dashboard/dist/` 目录。
2. **运行后端服务**
* **方式一:外部命令行模式**(默认):
```bash
cd ..
cargo run --release
```
运行后直接访问 `http://localhost:8000` 即可使用,此时所有 React 网页和后台 API 均由 Rust 进程统一分发托管,无需额外启动 Vite。
*注意:默认模式下需下载 Obscura 二进制文件并放置在项目根目录的 `bin/` 目录下。*
* **方式二:进程内浏览器模式**(免外部二进制分发):
```bash
cd ..
cargo run --release --features obscura-inprocess
```
*此时无头浏览器将直接编译进主二进制中,首次编译较慢但能一键运行。*
运行后直接访问 `http://localhost:8000` 即可使用,此时所有 React 网页和后台 API 均由 Rust 进程统一分发托管,无需额外启动 Vite。详细配置说明参见 [编译与部署指南](docs/deployment.md#4-obscura-两种抓取后备部署模式选择-obscura-deployment-modes)。
### 2.3 馆藏文献健康度检查与修复 (Health Check)
系统提供内置的健康度校验脚本,可用于排查与自动修复数据库状态和物理磁盘文件的不一致问题:

View File

@ -50,14 +50,97 @@ cargo build --release --bin health_check
```bash
./astroresearch
```
5. 进程将默认在后台启动并监听 `http://localhost:8000` 端口。你可以通过 Nginx 将此端口反向代理到公网 80/443 端口。
5. 进程将默认在后台启动并监听 `http://localhost:8000` 端口。你可以通过 Nginx 将此端口反向代理到公网 80/443 端口。
---
## 4. 环境变量配置 (Environment Variables)
## 4. Obscura 两种抓取后备部署模式选择 (Obscura Deployment Modes)
系统集成了 `Obscura` 无头浏览器框架来作为遭遇 WAF/Cloudflare 反爬时的自动后备抓取通道。系统支持以下两种编译与部署模式:
### 模式 A外部命令行模式 (默认,推荐)
该模式将主 Web 服务与 V8 浏览器运行引擎相隔离,最适合常规生产环境。它拥有最快的编译时间,且进程隔离确保无头浏览器内核异常(如 OOM 或 Panic不会拖垮主服务器。
1. **编译主服务**
```bash
cargo build --release
```
2. **下载/配置外部二进制**
从 GitHub Releases 下载编译好的 `obscura-x86_64-linux.tar.gz` 压缩包,解压后将 `obscura``obscura-worker` 两个二进制文件放到项目根目录的 `bin/` 目录下:
```bash
mkdir -p bin/
# 放入 bin/obscura 和 bin/obscura-worker并赋予执行权限
chmod +x bin/obscura bin/obscura-worker
```
3. **运行**
```bash
./target/release/astroresearch
```
当遭遇 WAF 拦截时,主进程将自动通过异步子进程调用 `./bin/obscura` 进行抓取。
### 模式 B进程内集成模式 (In-Process Feature)
该模式将整个无头浏览器及 V8 运行引擎直接静态链接编译进单个二进制文件中。这免去了在服务器分发和配置外部可执行程序的步骤,提供了“零配置”的部署体验。
> [!WARNING]
> 由于需要静态链接 C++ 编写的 V8 引擎,**首次编译会额外多耗时 1 到 3 分钟**,且最终编译生成的**单体可执行文件体积会膨胀约 80MB**。
1. **启用 Feature 编译**
在构建时指定 `--features obscura-inprocess` 特性标记:
```bash
cargo build --release --features obscura-inprocess
```
2. **运行**
```bash
./target/release/astroresearch
```
主服务运行期间,无需在磁盘中放置任何 `bin/obscura` 二进制。当触发反爬时,系统会在后台的专有阻塞线程池上通过独立包装的单线程 runtime 驱动进程内 V8 浏览器内核直接抓取。
---
## 5. 极致内存与体积优化部署 (Ultra-Low Memory & Size Optimization)
对于运行在低配/低内存服务器(如 1核512M 或 1核1G 实例)的环境,系统内置了可选的编译与运行时优化策略。
> [!NOTE]
> 极致内存与体积优化编译配置(`release-min`)和“进程内集成模式 (In-Process Feature)”在**技术上可以完全兼容并共同启用**,但它们在**优化目标(指标)上是相互抵消(矛盾)的**。
> 如果启用了进程内 V8 特性C++ 静态链接库本身占用的 80MB+ 空间将无法被剔除,导致无法达成极致轻量化(~8.3MB)的体积指标;且 V8 运行时堆内存也会带来额外的物理内存开销。因此,为追求极致低资源消耗,建议在低配服务器上采用**“模式 A外部命令行模式”**。
>
> 如果你执意要在**进程内浏览器集成下尽可能对其体积和依赖进行优化**,可以组合使用 `--profile``--features` 参数进行编译和运行:
> ```bash
> # 编译并打包优化后的进程内单二进制文件:
> cargo build --profile release-min --features obscura-inprocess
>
> # 编译并直接运行:
> cargo run --profile release-min --features obscura-inprocess
> ```
### 优化指标对比:
* **二进制执行文件大小**:由 `17.0 MB` 压缩至 **`8.3 MB`**(缩减约 51%)。
* **启动物理常驻内存 (RSS)**:由 `34.8 MB` 降至 **`32.9 MB`**(得益于词典加载后的容量自动收缩)。
* **虚拟内存 (VSZ)**:由 `1.27 GB` 降至 **`302 MB`**(缩减约 76%)。
* **数据段内存 (VmData)**:由 `60.1 MB` 降至 **`26.5 MB`**(缩减约 55%)。
### 部署优化步骤:
1. **使用优化 Profile 进行编译**
在项目根目录下,使用内置的 `release-min` 编译配置:
```bash
cargo build --profile release-min
```
编译完成后的执行文件位于 `target/release-min/astroresearch`。该配置开启了 LTO链接时优化、剥离了调试符号并在生成时进行了大小优化。
2. **限制运行时异步线程数**
默认情况下,异步运行时 Tokio 会根据系统的 CPU 核心数(例如 16 核)创建对应数量的 Worker 线程,这会带来很多虚拟/物理内存浪费。启动服务时,可通过注入 `TOKIO_WORKER_THREADS=1` 环境变量限制线程池大小:
```bash
PORT=8000 TOKIO_WORKER_THREADS=1 ./astroresearch
```
---
## 6. 环境变量配置 (Environment Variables)
| 变量名 | 必需 | 默认值 | 说明 |
|:---|:---|:---|:---|
| :--- | :--- | :--- | :--- |
| `DATABASE_URL` | 否 | `sqlite://library/astro_research.db` | SQLite 数据库连接 URL |
| `ADS_API_KEY` | 是 | - | NASA ADS API 访问 Token |
| `LLM_API_KEY` | 是 | - | 大语言模型 API Key |
@ -74,7 +157,7 @@ cargo build --release --bin health_check
---
## 5. 健康检查与维护 (Health Check)
## 7. 健康检查与维护 (Health Check)
部署后可定期运行健康检查工具排查馆藏一致性问题:

1
libs/obscura Submodule

@ -0,0 +1 @@
Subproject commit cd889d56596d62d561cf09301237ebf407fdd95a

View File

@ -348,6 +348,8 @@ mod tests {
has_markdown: false,
has_translation: false,
doctype: "article".to_string(),
pdf_error: None,
html_error: None,
};
// 保存

View File

@ -280,6 +280,84 @@ impl Downloader {
std::fs::create_dir_all(parent)?;
}
#[cfg(feature = "obscura-inprocess")]
{
info!("[Obscura 后备通道] 正在运行进程内浏览器进行下载...");
self.download_via_inprocess_obscura(url, dest_path, is_pdf).await
}
#[cfg(not(feature = "obscura-inprocess"))]
{
info!("[Obscura 后备通道] 正在通过外部命令行子进程下载...");
self.download_via_cli_obscura(url, dest_path, is_pdf).await
}
}
#[cfg(feature = "obscura-inprocess")]
async fn download_via_inprocess_obscura(&self, url: &str, dest_path: &Path, is_pdf: bool) -> Result<()> {
let url_str = url.to_string();
let dest_path_buf = dest_path.to_path_buf();
let handle = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| anyhow::anyhow!("建立当前线程运行时失败: {}", e))?;
rt.block_on(async move {
use obscura_browser::{BrowserContext, Page, lifecycle::WaitUntil};
use std::sync::Arc;
// 1. 初始化启用 Stealth 防检测模式的浏览器上下文
let context = Arc::new(BrowserContext::with_full_options(
"inprocess-fetch".to_string(),
None, // 可选代理
true, // 启用 Stealth 伪装(防指纹探测 + 广告域拦截)
None, // 默认 User-Agent
));
let mut page = Page::new("fetch-page".to_string(), context.clone());
// 2. 导航至目标 URL 并等待事件循环静默
page.navigate_with_wait(&url_str, WaitUntil::Load).await
.map_err(|e| anyhow::anyhow!("导航失败: {:?}", e))?;
page.settle(5000).await; // 额外静默等待 5 秒
// 3. 处理 PDF 与 HTML
if is_pdf {
// 对于 PDF 二进制文件,直接复用该浏览器上下文自带的 HTTP 客户端进行请求,
// 这样能确保携带相同的 Cookie 和 TLS 指纹会话
let parsed_url = Url::parse(&url_str)?;
let response = page.http_client.fetch(&parsed_url).await
.map_err(|e| anyhow::anyhow!("获取 PDF 字节流失败: {:?}", e))?;
std::fs::write(&dest_path_buf, &response.body)?;
validate_pdf_content(&response.body)?;
} else {
// 对于 HTML直接从 V8 中提取 outerHTML
let val = page.evaluate("document.documentElement.outerHTML");
let html = val.as_str().unwrap_or("").to_string();
std::fs::write(&dest_path_buf, &html)?;
validate_html_content(&html)?;
}
Ok(())
})
});
match handle.await {
Ok(res) => {
info!("[Obscura 进程内后备通道] 下载并校验成功: {:?}", dest_path);
res
}
Err(e) => anyhow::bail!("进程内 Obscura 执行线程异常退出: {:?}", e),
}
}
#[cfg(not(feature = "obscura-inprocess"))]
async fn download_via_cli_obscura(&self, url: &str, dest_path: &Path, is_pdf: bool) -> Result<()> {
let mut cmd = tokio::process::Command::new("bin/obscura");
cmd.arg("fetch").arg(url).arg("--stealth");
@ -307,7 +385,7 @@ impl Downloader {
validate_html_content(&text)?;
}
info!("[Obscura 后备通道] 下载并校验成功: {:?}", dest_path);
info!("[Obscura 命令行后备通道] 下载并校验成功: {:?}", dest_path);
Ok(())
}
@ -1019,5 +1097,73 @@ mod tests {
let _ = std::fs::remove_file(&path);
Ok(())
}
#[tokio::test]
#[ignore]
async fn test_download_via_obscura_integration() -> anyhow::Result<()> {
use axum::{Router, routing::get, response::Response as AxumResponse};
use axum::http::{HeaderValue, header::CONTENT_TYPE};
// Bind to a random port
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
// Build a mock PDF response
let mut pdf_data = b"%PDF-1.7 ".to_vec();
pdf_data.extend(vec![0u8; 5100]);
pdf_data.extend(b"%%EOF");
let pdf_data_clone = pdf_data.clone();
let app = Router::new()
.route("/mock.pdf", get(move || {
let p = pdf_data_clone.clone();
async move {
AxumResponse::builder()
.header(CONTENT_TYPE, "application/pdf")
.body(axum::body::Body::from(p))
.unwrap()
}
}))
.route("/mock.html", get(move || async {
AxumResponse::builder()
.header(CONTENT_TYPE, "text/html")
.body(axum::body::Body::from("<html><body><h2>introduction</h2><div class=\"section\">This is a mock paper with references and class section.</div><div id=\"bib\">References</div></body></html>"))
.unwrap()
}));
let server = axum::serve(
tokio::net::TcpListener::from_std(listener).unwrap(),
app,
);
tokio::spawn(async move { let _ = server.await; });
// Temporarily set OBSCURA_ALLOW_PRIVATE_NETWORK=1 to allow loopback fetches in Obscura
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let downloader = Downloader::new();
let temp_dir = std::env::temp_dir();
// 1. Test HTML download via obscura
let html_dest = temp_dir.join("test_obscura_mock.html");
let html_url = format!("http://127.0.0.1:{}/mock.html", port);
downloader.download_via_obscura(&html_url, &html_dest, false).await?;
assert!(html_dest.exists());
let html_content = std::fs::read_to_string(&html_dest)?;
assert!(html_content.contains("introduction"));
let _ = std::fs::remove_file(&html_dest);
// 2. Test PDF download via obscura
let pdf_dest = temp_dir.join("test_obscura_mock.pdf");
let pdf_url = format!("http://127.0.0.1:{}/mock.pdf", port);
downloader.download_via_obscura(&pdf_url, &pdf_dest, true).await?;
assert!(pdf_dest.exists());
let pdf_content = std::fs::read(&pdf_dest)?;
assert_eq!(pdf_content, pdf_data);
let _ = std::fs::remove_file(&pdf_dest);
std::env::remove_var("OBSCURA_ALLOW_PRIVATE_NETWORK");
Ok(())
}
}

View File

@ -47,6 +47,7 @@ impl Dictionary {
}
}
}
self.terms.shrink_to_fit();
info!("天文词典加载成功,总计导入 {} 条专业术语对照", count);
Ok(())
}