feat: 重构 PDF/文献检索同步机制、升级引力图交互与控制台 UI 样式

- [后端/PDF解析] 重构 MinerU PDF 解析流程:引入预签名两阶段直传机制,解决大文件 API 传输限制问题;支持轮询机制与本地 images 备用目录存储。
- [后端/同步与下载] 新增经典 ADS SCAN 扫描件 PDF 和 ADS_PDF 直接通道的下载逻辑;新增常用同步检索配置的持久化存储与去重管理 API。
- [后端/日志] 重构日志系统,支持控制台 pretty 输出与每日滚动文件日志(使用上海 +08:00 时区),引入 HTTP 路由请求链路追踪。
- [前端/引力图] 升级引用星系图 canvas 交互:支持平移拖拽与滚轮缩放,添加引力圈轨道装饰及未导入文献的半透明视觉区分。
- [前端/控制台] 统一重构为扁平高对比度浅色纯中文控制台样式;重新设计文献详情弹窗与状态进度条。
- [数据库] 新增 papers 表的 doctype 字段及 sync_queries 检索配置表。
This commit is contained in:
fmq
2026-06-10 17:29:07 +08:00
parent e13fa2ad40
commit cd6af4f995
43 changed files with 4029 additions and 958 deletions
+1
View File
@@ -20,6 +20,7 @@
* **[parser.rs](services/parser.rs)**:文献排版转换与清洗器,支持 MathJax LaTeX 占位符防护及 MinerU 图文 PDF 降级解析。
* **[translation.rs](services/translation.rs)**:大模型对比翻译流水线。支持基于天文学对照词表的分词过滤,通过 Trie 树最长匹配机制生成 Glossary 专有名词注入 Prompt。
* **[query_parser.rs](services/query_parser.rs)**:解析并标准化学术检索式,为 ADS 和 arXiv 分别生成合规的专有检索语法。
* **[logging.rs](services/logging.rs)**:系统日志服务,支持控制台彩色日志输出、每日滚动写入磁盘日志文件,采用自定义上海时区 (+08:00) 格式化时间。
---
+152 -14
View File
@@ -57,6 +57,7 @@ pub struct StandardPaper {
pub is_downloaded: bool,
pub has_markdown: bool,
pub has_translation: bool,
pub doctype: String,
}
// ── GET /api/search ──
@@ -197,6 +198,7 @@ pub async fn download_paper(
};
if pdf_path.is_none() && html_path.is_none() {
error!("文献 {} PDF 和 HTML 均下载失败,无可用物理文件格式", req.bibcode);
return Err((StatusCode::INTERNAL_SERVER_ERROR, "PDF 和 HTML 均下载失败,请检查网络".to_string()));
}
@@ -324,9 +326,11 @@ pub async fn parse_paper(
}
}
} else {
error!("文献 {} 解析失败:本地 PDF 文件 {:?} 丢失", req.bibcode, pdf_abs);
return Err((StatusCode::NOT_FOUND, "本地 PDF 文件未找到".to_string()));
}
} else {
error!("文献 {} 解析失败:请先下载该文献的 HTML 或 PDF 文件", req.bibcode);
return Err((StatusCode::BAD_REQUEST, "请先下载该文献的 HTML 或 PDF 文件".to_string()));
}
}
@@ -381,19 +385,32 @@ pub async fn translate_paper(
}
// 检查英文解析文件是否存在
let md_rel = md_opt.ok_or((StatusCode::BAD_REQUEST, "文献必须先完成解析方可翻译".to_string()))?;
let md_rel = match md_opt {
Some(rel) => rel,
None => {
error!("文献 {} 翻译失败:文献未完成解析,缺少英文 Markdown 路径", req.bibcode);
return Err((StatusCode::BAD_REQUEST, "文献必须先完成解析方可翻译".to_string()));
}
};
let md_abs = state.config.library_dir.join(&md_rel);
if !md_abs.exists() {
error!("文献 {} 翻译失败:解析的英文 Markdown 文件 {:?} 不存在", req.bibcode, md_abs);
return Err((StatusCode::BAD_REQUEST, "解析 Markdown 文件丢失".to_string()));
}
let english_markdown = fs::read_to_string(&md_abs)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("读取解析内容失败: {}", e)))?;
.map_err(|e| {
error!("文献 {} 翻译失败:读取解析内容失败: {}", req.bibcode, e);
(StatusCode::INTERNAL_SERVER_ERROR, format!("读取解析内容失败: {}", e))
})?;
// 调用 LLM 翻译服务并注入对照词表
let translated_markdown = crate::services::translation::translate_markdown(&english_markdown, &state.dict, &state.config)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("调用 LLM 翻译失败: {}", e)))?;
.map_err(|e| {
error!("文献 {} 翻译失败:调用 LLM 翻译发生错误: {}", req.bibcode, e);
(StatusCode::INTERNAL_SERVER_ERROR, format!("调用 LLM 翻译失败: {}", e))
})?;
// 翻译结果物理写入本地
let tr_filename = format!("{}_zh.md", req.bibcode);
@@ -425,6 +442,7 @@ pub struct CitationsResponse {
pub reference_count: i32,
pub references: Vec<String>, // 该文献参考文献 bibcode 数组
pub citations: Vec<String>, // 引用该文献的 bibcode 数组
pub citation_counts: std::collections::HashMap<String, i32>, // 相关文献与被引数映射
}
// 从 SQLite 查询引用关联,生成引用星系关系树
@@ -432,9 +450,49 @@ pub async fn get_citation_network(
State(state): State<Arc<AppState>>,
Query(params): Query<DownloadRequest>,
) -> Result<Json<CitationsResponse>, (StatusCode, String)> {
let paper = get_paper_from_db(&state.db, &state.config.library_dir, &params.bibcode)
.await
.map_err(|e| (StatusCode::NOT_FOUND, format!("未找到文献数据: {}", e)))?;
let paper = match get_paper_from_db(&state.db, &state.config.library_dir, &params.bibcode).await {
Ok(p) => p,
Err(_) => {
// 如果本地数据库查不到,尝试从 ADS 在线 API 动态获取
if !state.config.ads_api_key.is_empty() {
match state.ads.search(&format!("bibcode:{}", params.bibcode), 0, 1, "relevance").await {
Ok(docs) => {
if let Some(doc) = docs.first() {
let standard_paper = convert_ads_doc_to_standard(doc);
// 保存至数据库缓存,并保存引用关联
let _ = save_paper_to_db(&state.db, &standard_paper).await;
if let Some(refs) = &doc.reference {
for ref_bib in refs {
let _ = sqlx::query("INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)")
.bind(&standard_paper.bibcode)
.bind(ref_bib)
.execute(&state.db)
.await;
}
}
if let Some(cits) = &doc.citation {
for cit_bib in cits {
let _ = sqlx::query("INSERT OR IGNORE INTO citations_references (source_bibcode, target_bibcode) VALUES (?, ?)")
.bind(cit_bib)
.bind(&standard_paper.bibcode)
.execute(&state.db)
.await;
}
}
standard_paper
} else {
return Err((StatusCode::NOT_FOUND, format!("在本地库及 ADS 中均未找到该文献: {}", params.bibcode)));
}
}
Err(e) => {
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("在线检索文献元数据失败: {}", e)));
}
}
} else {
return Err((StatusCode::NOT_FOUND, format!("本地数据库未收录该文献,且未配置 ADS_API_KEY,无法在线加载: {}", params.bibcode)));
}
}
};
// 加载引用的文献
let refs_rows = sqlx::query("SELECT target_bibcode FROM citations_references WHERE source_bibcode = ?")
@@ -452,6 +510,21 @@ pub async fn get_citation_network(
.unwrap_or_default();
let citations: Vec<String> = cits_rows.iter().map(|row| row.get(0)).collect();
// 加载关联文献的被引数量 (从 SQLite papers 表获取)
let mut citation_counts = std::collections::HashMap::new();
let mut all_related = references.clone();
all_related.extend(citations.clone());
for bib in all_related {
let count_opt: Option<i32> = sqlx::query_scalar("SELECT citation_count FROM papers WHERE bibcode = ?")
.bind(&bib)
.fetch_optional(&state.db)
.await
.unwrap_or_default();
if let Some(c) = count_opt {
citation_counts.insert(bib, c);
}
}
Ok(Json(CitationsResponse {
bibcode: paper.bibcode,
title: paper.title,
@@ -459,6 +532,7 @@ pub async fn get_citation_network(
reference_count: paper.reference_count,
references,
citations,
citation_counts,
}))
}
@@ -499,7 +573,7 @@ pub async fn get_paper_detail(
pub async fn get_library(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<StandardPaper>>, (StatusCode, String)> {
let rows = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path FROM papers ORDER BY created_at DESC")
let rows = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype FROM papers ORDER BY created_at DESC")
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("访问本地数据库失败: {}", e)))?;
@@ -510,6 +584,7 @@ pub async fn get_library(
let html_path: Option<String> = r.get(12);
let markdown_path: Option<String> = r.get(13);
let translation_path: Option<String> = r.get(14);
let doctype_val: Option<String> = r.get(15);
let authors_str: Option<String> = r.get(2);
let authors: Vec<String> = authors_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
@@ -532,6 +607,7 @@ pub async fn get_library(
|| html_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false),
has_markdown: markdown_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false),
has_translation: translation_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false),
doctype: doctype_val.unwrap_or_else(|| "article".to_string()),
});
}
@@ -714,6 +790,7 @@ pub(crate) fn convert_ads_doc_to_standard(doc: &AdsPaperDoc) -> StandardPaper {
is_downloaded: false,
has_markdown: false,
has_translation: false,
doctype: doc.doctype.clone().unwrap_or_else(|| "article".to_string()),
}
}
@@ -733,6 +810,7 @@ pub(crate) fn convert_arxiv_to_standard(doc: &ArxivPaper) -> StandardPaper {
is_downloaded: false,
has_markdown: false,
has_translation: false,
doctype: "eprint".to_string(),
}
}
@@ -759,7 +837,7 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
if is_existing_temp && is_new_formal {
info!("发现相同 arXiv ID 的文献,将临时主键 {} 升级为正式 ADS Bibcode: {}", existing_bibcode, p.bibcode);
sqlx::query(
"UPDATE papers SET bibcode = ?, title = ?, authors = ?, year = ?, pub = ?, keywords = ?, abstract = ?, doi = ?, citation_count = ?, reference_count = ? WHERE bibcode = ?"
"UPDATE papers SET bibcode = ?, title = ?, authors = ?, year = ?, pub = ?, keywords = ?, abstract = ?, doi = ?, citation_count = ?, reference_count = ?, doctype = ? WHERE bibcode = ?"
)
.bind(&p.bibcode)
.bind(&p.title)
@@ -771,6 +849,7 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
.bind(&p.doi)
.bind(p.citation_count)
.bind(p.reference_count)
.bind(&p.doctype)
.bind(&existing_bibcode)
.execute(db)
.await?;
@@ -787,8 +866,8 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
// 2. 正常插入/冲突更新
sqlx::query(
"INSERT INTO papers (bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
"INSERT INTO papers (bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, doctype) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
ON CONFLICT(bibcode) DO UPDATE SET \
title=excluded.title, \
authors=excluded.authors, \
@@ -798,7 +877,8 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
doi=excluded.doi, \
arxiv_id=excluded.arxiv_id, \
citation_count=excluded.citation_count, \
reference_count=excluded.reference_count"
reference_count=excluded.reference_count, \
doctype=excluded.doctype"
)
.bind(&p.bibcode)
.bind(&p.title)
@@ -811,6 +891,7 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
.bind(&p.arxiv_id)
.bind(p.citation_count)
.bind(p.reference_count)
.bind(&p.doctype)
.execute(db)
.await?;
@@ -818,7 +899,7 @@ pub(crate) async fn save_paper_to_db(db: &SqlitePool, p: &StandardPaper) -> anyh
}
async fn get_paper_from_db(db: &SqlitePool, library_dir: &std::path::Path, bibcode: &str) -> anyhow::Result<StandardPaper> {
let r = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path FROM papers WHERE bibcode = ?")
let r = sqlx::query("SELECT bibcode, title, authors, year, pub, keywords, abstract, doi, arxiv_id, citation_count, reference_count, pdf_path, html_path, markdown_path, translation_path, doctype FROM papers WHERE bibcode = ?")
.bind(bibcode)
.fetch_one(db)
.await?;
@@ -827,6 +908,7 @@ async fn get_paper_from_db(db: &SqlitePool, library_dir: &std::path::Path, bibco
let html_path: Option<String> = r.get(12);
let markdown_path: Option<String> = r.get(13);
let translation_path: Option<String> = r.get(14);
let doctype_val: Option<String> = r.get(15);
let authors_str: Option<String> = r.get(2);
let authors: Vec<String> = authors_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
@@ -853,6 +935,7 @@ async fn get_paper_from_db(db: &SqlitePool, library_dir: &std::path::Path, bibco
is_downloaded: is_pdf_exist || is_html_exist,
has_markdown: is_md_exist,
has_translation: is_tr_exist,
doctype: doctype_val.unwrap_or_else(|| "article".to_string()),
})
}
@@ -1011,8 +1094,8 @@ pub async fn run_asset_sync(
}
}
"unparsed" | "all_unparsed" => {
// 查询所有本地无 Markdown 文件的文献
let rows = sqlx::query("SELECT bibcode FROM papers WHERE markdown_path IS NULL")
// 查询所有本地无 Markdown 文件的文献 (或者处于 mineru_batch: 状态的任务)
let rows = sqlx::query("SELECT bibcode FROM papers WHERE markdown_path IS NULL OR markdown_path LIKE 'mineru_batch:%'")
.fetch_all(&state.db)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("读取数据库失败: {}", e)))?;
@@ -1055,6 +1138,58 @@ pub async fn stop_asset_sync(
StatusCode::OK
}
// ── GET /api/sync/queries ──
#[derive(Debug, Serialize, Deserialize)]
pub struct SavedSyncQuery {
pub id: i64,
pub query: String,
pub source: String,
pub limit_count: i32,
pub last_run: String,
}
pub async fn get_sync_queries(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<SavedSyncQuery>>, (StatusCode, String)> {
let rows = sqlx::query("SELECT id, query, source, limit_count, datetime(last_run, 'localtime') FROM sync_queries ORDER BY last_run DESC")
.fetch_all(&state.db)
.await
.map_err(|e| {
error!("获取已存同步检索配置失败: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, format!("获取已存同步检索配置失败: {}", e))
})?;
let mut list = Vec::new();
for r in rows {
list.push(SavedSyncQuery {
id: r.get(0),
query: r.get(1),
source: r.get(2),
limit_count: r.get(3),
last_run: r.get(4),
});
}
Ok(Json(list))
}
// ── DELETE /api/sync/queries/:id ──
pub async fn delete_sync_query(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
sqlx::query("DELETE FROM sync_queries WHERE id = ?")
.bind(id)
.execute(&state.db)
.await
.map_err(|e| {
error!("删除同步检索配置失败: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, format!("删除同步检索配置失败: {}", e))
})?;
Ok(StatusCode::OK)
}
// ── GET /api/sync/asset/status ──
pub async fn get_asset_sync_status(
State(state): State<Arc<AppState>>,
@@ -1086,6 +1221,7 @@ mod tests {
reference: None,
citation: None,
identifier: None,
doctype: Some("article".to_string()),
};
let paper = convert_ads_doc_to_standard(&doc);
@@ -1118,6 +1254,7 @@ mod tests {
reference: None,
citation: None,
identifier: Some(vec!["2026MNRAS.530.1234A".to_string(), "arXiv:2606.12345".to_string()]),
doctype: Some("article".to_string()),
};
let paper = convert_ads_doc_to_standard(&doc);
@@ -1174,6 +1311,7 @@ mod tests {
is_downloaded: false,
has_markdown: false,
has_translation: false,
doctype: "article".to_string(),
};
// 保存
+5 -2
View File
@@ -20,6 +20,7 @@ pub struct AdsPaperDoc {
pub reference: Option<Vec<String>>,
pub citation: Option<Vec<String>>,
pub identifier: Option<Vec<String>>,
pub doctype: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -69,8 +70,8 @@ impl AdsClient {
let translated = crate::services::query_parser::to_ads_query(query);
// fl 声明返回字段,包括 reference 和 citation 引用关系数组及 identifier
let fl = "bibcode,title,author,year,pub,keyword,abstract,doi,citation_count,reference_count,reference,citation,identifier";
// fl 声明返回字段,包括 reference 和 citation 引用关系数组及 identifier 和 doctype
let fl = "bibcode,title,author,year,pub,keyword,abstract,doi,citation_count,reference_count,reference,citation,identifier,doctype";
let ads_sort = match sort {
"date_desc" => "date desc",
@@ -120,6 +121,7 @@ impl AdsClient {
reference: d.reference,
citation: d.citation,
identifier: d.identifier,
doctype: d.doctype,
}
}).collect();
@@ -204,6 +206,7 @@ struct RawDoc {
reference: Option<Vec<String>>,
citation: Option<Vec<String>>,
identifier: Option<Vec<String>>,
doctype: Option<String>,
}
#[derive(Debug, Deserialize)]
+6 -6
View File
@@ -1,6 +1,6 @@
use sha1::Sha1;
use hmac::{Hmac, Mac};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use base64::{Engine as _, engine::general_purpose::URL_SAFE};
use reqwest::multipart;
use tracing::{info, error};
@@ -43,7 +43,7 @@ impl QiniuClient {
});
let policy_str = policy.to_string();
let encoded_policy = URL_SAFE_NO_PAD.encode(policy_str.as_bytes());
let encoded_policy = URL_SAFE.encode(policy_str.as_bytes());
let mut mac = HmacSha1::new_from_slice(self.secret_key.as_bytes())
.expect("HMAC 密钥可接收任意大小");
@@ -51,7 +51,7 @@ impl QiniuClient {
let result = mac.finalize();
let signature = result.into_bytes();
let encoded_signature = URL_SAFE_NO_PAD.encode(&signature);
let encoded_signature = URL_SAFE.encode(&signature);
format!("{}:{}:{}", self.access_key, encoded_signature, encoded_policy)
}
@@ -62,9 +62,9 @@ impl QiniuClient {
return Err(anyhow::anyhow!("本地 .env 文件中未正确配置七牛云参数"));
}
// 使用毫秒级时间戳防重名覆盖
// 使用毫秒级时间戳防重名覆盖,并放置在 astroresearch 虚拟文件夹下
let timestamp = chrono::Utc::now().timestamp_millis();
let key = format!("astroresearch_{}_{}", timestamp, filename);
let key = format!("astroresearch/{}_{}", timestamp, filename);
let token = self.generate_upload_token(&key);
info!("正在上传文献提取图片到七牛云: key='{}'", key);
@@ -74,7 +74,7 @@ impl QiniuClient {
.text("key", key.clone())
.part("file", multipart::Part::bytes(buffer).file_name(filename.to_string()));
let upload_url = "https://up.qiniu.com";
let upload_url = "https://up-z1.qiniup.com";
let response = self.client.post(upload_url)
.multipart(form)
+6 -8
View File
@@ -21,13 +21,8 @@ use astroresearch::api::handlers::{AppState, self};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. 初始化日志记录器
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,astroresearch=debug")),
)
.init();
// 1. 初始化日志记录器并保留异步写保护 Guard
let _logging_guards = astroresearch::services::logging::init_logging()?;
info!("正在启动 AstroResearch 天文学文献辅助系统后端服务...");
@@ -124,7 +119,9 @@ async fn main() -> anyhow::Result<()> {
.route("/sync/meta/status", get(handlers::get_meta_sync_status))
.route("/sync/asset/run", post(handlers::run_asset_sync))
.route("/sync/asset/stop", post(handlers::stop_asset_sync))
.route("/sync/asset/status", get(handlers::get_asset_sync_status));
.route("/sync/asset/status", get(handlers::get_asset_sync_status))
.route("/sync/queries", get(handlers::get_sync_queries))
.route("/sync/queries/:id", axum::routing::delete(handlers::delete_sync_query));
// 静态文件资源代理托管(当前端打包至 dashboard/dist 后,直接挂载到主域名根路由)
let serve_dir = ServeDir::new("dashboard/dist")
@@ -134,6 +131,7 @@ async fn main() -> anyhow::Result<()> {
.nest("/api", api_routes)
.fallback_service(serve_dir)
.layer(cors)
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(app_state);
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
+205 -65
View File
@@ -87,6 +87,18 @@ impl MetaSync {
tokio::spawn(async move {
info!("启动后台批量收割任务: 查询词='{}', 源='{}', 上限={}", query_clone, source_clone, limit);
// 自动将检索配置存入/更新至 sync_queries 数据库表中进行去重和时间更新
let _ = sqlx::query(
"INSERT INTO sync_queries (query, source, limit_count, last_run) \
VALUES (?, ?, ?, CURRENT_TIMESTAMP) \
ON CONFLICT(query, source, limit_count) DO UPDATE SET last_run=excluded.last_run"
)
.bind(&query_clone)
.bind(&source_clone)
.bind(limit)
.execute(&db)
.await;
// 1. 并行获取两端预估总量
let ads_count_fut = {
let ads = ads.clone();
@@ -282,6 +294,8 @@ pub struct AssetSyncStatus {
pub total: i32,
pub downloaded: i32,
pub parsed: i32,
pub download_failed: i32,
pub parse_failed: i32,
pub current_bibcode: String,
pub logs: Vec<String>,
pub action: Option<SyncAction>,
@@ -294,6 +308,8 @@ impl AssetSyncStatus {
total: 0,
downloaded: 0,
parsed: 0,
download_failed: 0,
parse_failed: 0,
current_bibcode: String::new(),
logs: Vec::new(),
action: None,
@@ -331,6 +347,8 @@ impl AssetSync {
s.total = total;
s.downloaded = 0;
s.parsed = 0;
s.download_failed = 0;
s.parse_failed = 0;
s.current_bibcode = String::new();
s.logs.clear();
s.action = Some(action);
@@ -344,7 +362,8 @@ impl AssetSync {
}
let mut dl_count = 0;
let mut parse_count = 0;
let mut dl_failed_count = 0;
let mut join_handles = Vec::new();
for bibcode in bibcodes {
// 每次循环前,检查是否被外部停止了(active 设为 false
@@ -364,20 +383,21 @@ impl AssetSync {
// 1. 获取文献元数据与当前路径状态
let paper_res = sqlx::query(
"SELECT arxiv_id, doi, pdf_path, html_path, markdown_path FROM papers WHERE bibcode = ?"
"SELECT arxiv_id, doi, pdf_path, html_path, markdown_path, doctype FROM papers WHERE bibcode = ?"
)
.bind(&bibcode)
.fetch_optional(&db)
.await;
let (arxiv_id, doi, mut pdf_path, mut html_path, markdown_path) = match paper_res {
let (arxiv_id, doi, mut pdf_path, mut html_path, markdown_path, doctype) = match paper_res {
Ok(Some(row)) => {
let arxiv_id: String = row.get(0);
let doi: String = row.get(1);
let pdf_path: Option<String> = row.get(2);
let html_path: Option<String> = row.get(3);
let markdown_path: Option<String> = row.get(4);
(arxiv_id, doi, pdf_path, html_path, markdown_path)
let doctype: Option<String> = row.get(5);
(arxiv_id, doi, pdf_path, html_path, markdown_path, doctype)
}
_ => {
let mut s = status.lock().await;
@@ -386,6 +406,22 @@ impl AssetSync {
}
};
// 1b. 检查 doctype,如果是 proposal, abstract, catalog, software 等无数字全文的文件,直接跳过处理
let doctype_str = doctype.unwrap_or_else(|| "article".to_string()).to_lowercase();
if doctype_str == "proposal" || doctype_str == "abstract" || doctype_str == "catalog" || doctype_str == "software" {
let mut s = status.lock().await;
s.add_log(format!("文献 {} 的类型为 {} (无数字版全文),跳过下载与解析。", bibcode, doctype_str));
// 同样更新处理进度,防止任务进度条卡住
if action == SyncAction::Download || action == SyncAction::All {
dl_count += 1;
s.downloaded = dl_count;
}
if action == SyncAction::Parse || action == SyncAction::All {
s.parsed += 1;
}
continue;
}
// 2. 检查并执行下载
if action == SyncAction::Download || action == SyncAction::All {
let is_pdf_exist = pdf_path.as_ref().map(|p| config.library_dir.join(p).exists()).unwrap_or(false);
@@ -427,7 +463,9 @@ impl AssetSync {
s.add_log(format!("文献 {} 下载成功!", bibcode));
}
} else {
dl_failed_count += 1;
let mut s = status.lock().await;
s.download_failed = dl_failed_count;
s.add_log(format!("文献 {} 下载失败(PDF 和 HTML 均下载失败)", bibcode));
}
@@ -457,7 +495,6 @@ impl AssetSync {
s.add_log(format!("文献 {} 开始进行排版提取与 Markdown 转换...", bibcode));
}
let mut parsed_markdown = String::new();
let mut relative_md_path = String::new();
// 确定源链接
@@ -499,7 +536,7 @@ impl AssetSync {
year,
keywords.join(",")
);
parsed_markdown = format!("{}{}", front_matter, md);
let parsed_markdown = format!("{}{}", front_matter, md);
let md_filename = format!("{}.md", bibcode);
let md_dest = config.library_dir.join("Markdown").join(&md_filename);
let _ = fs::create_dir_all(md_dest.parent().unwrap());
@@ -511,74 +548,170 @@ impl AssetSync {
}
}
// 策略 2PDF 回退(远程 MinerU
if parsed_markdown.is_empty() {
if let Some(pdf_rel) = &pdf_path {
let pdf_abs = config.library_dir.join(pdf_rel);
if pdf_abs.exists() {
match crate::services::parser::parse_pdf_via_mineru(&pdf_abs, &qiniu, &config).await {
Ok(md) => {
let paper_meta_res = sqlx::query("SELECT title, authors, pub, year, keywords FROM papers WHERE bibcode = ?")
.bind(&bibcode)
.fetch_optional(&db)
.await;
if let Ok(Some(meta_row)) = paper_meta_res {
let title: String = meta_row.get(0);
let authors_json: String = meta_row.get(1);
let pub_journal: String = meta_row.get(2);
let year: String = meta_row.get(3);
let keywords_json: String = meta_row.get(4);
let authors: Vec<String> = serde_json::from_str(&authors_json).unwrap_or_default();
let keywords: Vec<String> = serde_json::from_str(&keywords_json).unwrap_or_default();
let front_matter = format!(
"---\ntitle: {}\nauthor: [{}]\npublisher: {}\nsource: \"{}\"\ndate: \"{}\"\ntags: \"{}\"\n---\n\n",
serde_json::to_string(&title).unwrap_or_else(|_| format!("\"{}\"", title)),
authors.iter().map(|a| format!("\"{}\"", a)).collect::<Vec<_>>().join(", "),
serde_json::to_string(&pub_journal).unwrap_or_else(|_| format!("\"{}\"", pub_journal)),
source_url,
year,
keywords.join(",")
);
parsed_markdown = format!("{}{}", front_matter, md);
let md_filename = format!("{}.md", bibcode);
let md_dest = config.library_dir.join("Markdown").join(&md_filename);
let _ = fs::create_dir_all(md_dest.parent().unwrap());
if fs::write(&md_dest, &parsed_markdown).is_ok() {
relative_md_path = format!("Markdown/{}", md_filename);
}
}
}
Err(e) => {
let mut s = status.lock().await;
s.add_log(format!("PDF 结构解析失败 (MinerU): {}", e));
}
}
}
}
}
if !relative_md_path.is_empty() {
// HTML 解析成功,直接写入数据库并记录成功
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
.bind(&relative_md_path)
.bind(&bibcode)
.execute(&db)
.await;
parse_count += 1;
{
let mut s = status.lock().await;
s.parsed = parse_count;
s.add_log(format!("文献 {} Markdown 解析成功!", bibcode));
s.parsed += 1;
s.add_log(format!("文献 {} HTML 本地解析成功!", bibcode));
}
} else {
let mut s = status.lock().await;
s.add_log(format!("文献 {} 转换为 Markdown 失败。", bibcode));
// HTML 解析失败或无 HTML,执行 PDF 回退(异步非阻塞提交 MinerU)
if let Some(pdf_rel) = &pdf_path {
let pdf_abs = config.library_dir.join(pdf_rel);
if pdf_abs.exists() {
// 检查是否已经是 mineru_batch: 状态
let existing_batch_id = markdown_path.as_ref()
.and_then(|p| p.strip_prefix("mineru_batch:"))
.map(|s| s.trim().to_string());
let db_clone = db.clone();
let config_clone = config.clone();
let qiniu_clone = qiniu.clone();
let status_clone = status.clone();
let bibcode_clone = bibcode.clone();
let source_url_clone = source_url.clone();
let mut submitted_ok = true;
let batch_id = if let Some(id) = existing_batch_id {
{
let mut s = status.lock().await;
s.add_log(format!("文献 {} 检测到未完成的 MinerU 任务,正在恢复轮询 (Batch ID: {})...", bibcode, id));
}
id
} else {
{
let mut s = status.lock().await;
s.add_log(format!("文献 {} PDF 提交后台解析 (MinerU)...", bibcode));
}
match crate::services::parser::submit_pdf_to_mineru(&pdf_abs, &config).await {
Ok(id) => {
// 提交成功,立刻把 batch_id 存入数据库以备断点续跑
let marker = format!("mineru_batch:{}", id);
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
.bind(&marker)
.bind(&bibcode)
.execute(&db)
.await;
id
}
Err(e) => {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} PDF 提交 MinerU 失败: {}", bibcode, e));
let err_reason = format!("error: {}", e);
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
.bind(&err_reason)
.bind(&bibcode)
.execute(&db)
.await;
submitted_ok = false;
String::new()
}
}
};
if submitted_ok {
let handle = tokio::spawn(async move {
match crate::services::parser::poll_and_extract_mineru(&batch_id, &bibcode_clone, &qiniu_clone, &config_clone).await {
Ok(md) => {
let paper_meta_res = sqlx::query("SELECT title, authors, pub, year, keywords FROM papers WHERE bibcode = ?")
.bind(&bibcode_clone)
.fetch_optional(&db_clone)
.await;
let mut rel_md = String::new();
if let Ok(Some(meta_row)) = paper_meta_res {
let title: String = meta_row.get(0);
let authors_json: String = meta_row.get(1);
let pub_journal: String = meta_row.get(2);
let year: String = meta_row.get(3);
let keywords_json: String = meta_row.get(4);
let authors: Vec<String> = serde_json::from_str(&authors_json).unwrap_or_default();
let keywords: Vec<String> = serde_json::from_str(&keywords_json).unwrap_or_default();
let front_matter = format!(
"---\ntitle: {}\nauthor: [{}]\npublisher: {}\nsource: \"{}\"\ndate: \"{}\"\ntags: \"{}\"\n---\n\n",
serde_json::to_string(&title).unwrap_or_else(|_| format!("\"{}\"", title)),
authors.iter().map(|a| format!("\"{}\"", a)).collect::<Vec<_>>().join(", "),
serde_json::to_string(&pub_journal).unwrap_or_else(|_| format!("\"{}\"", pub_journal)),
source_url_clone,
year,
keywords.join(",")
);
let parsed_markdown = format!("{}{}", front_matter, md);
let md_filename = format!("{}.md", bibcode_clone);
let md_dest = config_clone.library_dir.join("Markdown").join(&md_filename);
let _ = fs::create_dir_all(md_dest.parent().unwrap());
if fs::write(&md_dest, &parsed_markdown).is_ok() {
rel_md = format!("Markdown/{}", md_filename);
}
}
if !rel_md.is_empty() {
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
.bind(&rel_md)
.bind(&bibcode_clone)
.execute(&db_clone)
.await;
let mut s = status_clone.lock().await;
s.parsed += 1;
s.add_log(format!("文献 {} PDF (MinerU) 解析成功!", bibcode_clone));
} else {
let mut s = status_clone.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} PDF 写入 Markdown 失败。", bibcode_clone));
let _ = sqlx::query("UPDATE papers SET markdown_path = 'error: PDF 写入 Markdown 失败' WHERE bibcode = ?")
.bind(&bibcode_clone)
.execute(&db_clone)
.await;
}
}
Err(e) => {
let mut s = status_clone.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} PDF 结构解析失败 (MinerU): {}", bibcode_clone, e));
let err_reason = format!("error: {}", e);
let _ = sqlx::query("UPDATE papers SET markdown_path = ? WHERE bibcode = ?")
.bind(&err_reason)
.bind(&bibcode_clone)
.execute(&db_clone)
.await;
}
}
});
join_handles.push(handle);
}
} else {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} 本地 PDF 文件不存在,无法解析。", bibcode));
let _ = sqlx::query("UPDATE papers SET markdown_path = 'error: 本地 PDF 文件不存在' WHERE bibcode = ?")
.bind(&bibcode)
.execute(&db)
.await;
}
} else {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} HTML 转换失败,且无本地 PDF,无法解析。", bibcode));
let _ = sqlx::query("UPDATE papers SET markdown_path = 'error: HTML 转换失败且无本地 PDF' WHERE bibcode = ?")
.bind(&bibcode)
.execute(&db)
.await;
}
}
} else {
let mut s = status.lock().await;
s.parse_failed += 1;
s.add_log(format!("文献 {} 无本地 PDF/HTML,无法解析,跳过。", bibcode));
}
} else {
@@ -586,15 +719,22 @@ impl AssetSync {
let mut s = status.lock().await;
s.add_log(format!("文献 {} 已存在解析后的 Markdown,跳过。", bibcode));
}
parse_count += 1;
{
let mut s = status.lock().await;
s.parsed = parse_count;
}
let mut s = status.lock().await;
s.parsed += 1;
}
}
}
if !join_handles.is_empty() {
{
let mut s = status.lock().await;
s.add_log(format!("本地下载与快速解析已完成,正在等待后台共 {} 个 MinerU 异步解析任务结束...", join_handles.len()));
}
for handle in join_handles {
let _ = handle.await;
}
}
{
let mut s = status.lock().await;
s.active = false;
+48 -11
View File
@@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
use reqwest::header::{HeaderMap, HeaderValue};
use tokio::io::AsyncWriteExt;
use url::Url;
use tracing::{info, warn};
use tracing::{info, warn, debug};
use anyhow::{Context, Result};
// ─── 浏览器伪装辅助 ────────────────────────────────────────────
@@ -43,7 +43,6 @@ fn build_browser_headers() -> HeaderMap {
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
));
h.insert("Accept-Language", HeaderValue::from_static("en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7"));
h.insert("Accept-Encoding", HeaderValue::from_static("gzip, deflate, br"));
h.insert("DNT", HeaderValue::from_static("1"));
h.insert("Connection", HeaderValue::from_static("keep-alive"));
h.insert("Upgrade-Insecure-Requests", HeaderValue::from_static("1"));
@@ -64,7 +63,6 @@ fn build_chrome_headers(referer: Option<&str>) -> HeaderMap {
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
));
h.insert("Accept-Language", HeaderValue::from_static("en-US,en;q=0.9"));
h.insert("Accept-Encoding", HeaderValue::from_static("gzip, deflate, br, zstd"));
h.insert("Sec-Ch-Ua", HeaderValue::from_static(
"\"Google Chrome\";v=\"143\", \"Chromium\";v=\"143\", \"Not A(Brand\";v=\"24\"",
));
@@ -224,7 +222,7 @@ impl Downloader {
/// 解析 ADS Link Gateway 路由,若遇 perfdrive 防护则提取 ssc 参数绕过
async fn resolve_ads_gateway(&self, gateway_url: &str) -> Result<String> {
info!("解析 ADS 网关: {}", gateway_url);
debug!("解析 ADS 网关: {}", gateway_url);
// HEAD 请求跟踪重定向(部分出版商阻断 HEAD,自动降级 GET)
let response = match self.client.head(gateway_url).send().await {
@@ -234,7 +232,7 @@ impl Downloader {
};
let final_url = response.url().as_str().to_string();
info!("网关解析结果: {}", final_url);
debug!("网关解析结果: {}", final_url);
// 如重定向至 validate.perfdrive.com,提取 ssc 参数中的真实 URL
if final_url.contains("validate.perfdrive.com") {
@@ -242,7 +240,7 @@ impl Downloader {
if let Some(ssc) = parsed.query_pairs().find(|(k, _)| k == "ssc").map(|(_, v)| v.into_owned()) {
if let Ok(decoded) = urlencoding::decode(&ssc) {
let real_url = decoded.into_owned();
info!("检测到 perfdrive 拦截,解码真实地址: {}", real_url);
debug!("检测到 perfdrive 拦截,解码真实地址: {}", real_url);
return Ok(real_url);
}
}
@@ -276,18 +274,18 @@ impl Downloader {
let pdf_url = format!("https://iopscience.iop.org/article/{}/pdf", doi);
// 步骤 1:访问文章主页,建立 Cookie 会话
info!("[IOP] 预热主页: {}", main_url);
debug!("[IOP] 预热主页: {}", main_url);
Self::maybe_delay().await;
match self.client.get(&main_url)
.headers(build_chrome_headers(None))
.send().await
{
Ok(r) => info!("[IOP] 主页响应: {}", r.status()),
Ok(r) => debug!("[IOP] 主页响应: {}", r.status()),
Err(e) => warn!("[IOP] 主页访问失败(继续尝试): {:?}", e),
}
// 步骤 2:携带 Referer 下载 PDF
info!("[IOP] 下载 PDF: {}", pdf_url);
debug!("[IOP] 下载 PDF: {}", pdf_url);
Self::maybe_delay().await;
let response = self.client.get(&pdf_url)
.headers(build_chrome_headers(Some(&main_url)))
@@ -516,7 +514,19 @@ impl Downloader {
Err(e) => warn!("[PUB_PDF] 网关解析失败: {:?}", e),
}
// 1b. ADS EPRINT_PDF 网关
// 1b. ADS_PDF 网关 (经典 ADS 整合 PDF 直接通道)
let gw = format!("{}/{}/ADS_PDF", base, bibcode);
match self.resolve_ads_gateway(&gw).await {
Ok(resolved) => {
match self.download_pdf_direct(&resolved, &pdf_dest, "ADS_PDF").await {
Ok(_) => { pdf_ok = Some(pdf_dest.clone()); break 'pdf; }
Err(e) => warn!("[ADS_PDF] 下载失败: {:?}", e),
}
}
Err(e) => warn!("[ADS_PDF] 网关解析失败: {:?}", e),
}
// 1c. ADS EPRINT_PDF 网关
let gw = format!("{}/{}/EPRINT_PDF", base, bibcode);
match self.resolve_ads_gateway(&gw).await {
Ok(resolved) => {
@@ -531,10 +541,17 @@ impl Downloader {
// 1c. CrossRef API 回退(需要 DOI
if let Some(doi_str) = doi {
match self.download_crossref_pdf(doi_str, &pdf_dest).await {
Ok(_) => { pdf_ok = Some(pdf_dest.clone()); }
Ok(_) => { pdf_ok = Some(pdf_dest.clone()); break 'pdf; }
Err(e) => warn!("[CrossRef] PDF 下载失败: {:?}", e),
}
}
// 1d. ADS SCAN 扫描版文献直接合并下载 PDF(主要针对早期/不可下载直接 PDF 的文献)
let scan_url = format!("https://articles.adsabs.harvard.edu/cgi-bin/nph-iarticle_query?bibcode={}&db_key=AST&data_type=PDF_HIGH", bibcode);
match self.download_pdf_direct(&scan_url, &pdf_dest, "ADS_SCAN").await {
Ok(_) => { pdf_ok = Some(pdf_dest.clone()); }
Err(e) => warn!("[ADS_SCAN] 下载失败: {:?}", e),
}
}
// ── HTML 下载 ──────────────────────────────────────────
@@ -710,5 +727,25 @@ mod tests {
Some("2101.00001".to_string())
);
}
#[tokio::test]
#[ignore]
async fn test_download_scan_pdf() -> anyhow::Result<()> {
let downloader = Downloader::new();
let bibcode = "2005MNRAS.359..315E";
let temp_dir = std::env::temp_dir();
let (pdf_path, _html_path) = downloader.download_paper(bibcode, None, &temp_dir).await;
assert!(pdf_path.is_some());
let path = pdf_path.unwrap();
assert!(path.exists());
let bytes = std::fs::read(&path)?;
assert!(bytes.starts_with(b"%PDF"));
let _ = std::fs::remove_file(&path);
Ok(())
}
}
+84
View File
@@ -0,0 +1,84 @@
// src/services/logging.rs
use chrono::{DateTime, FixedOffset, Utc};
use std::env;
use std::fs;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling;
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::time::FormatTime;
use tracing_subscriber::{
fmt, layer::SubscriberExt, util::SubscriberInitExt,
EnvFilter, Layer,
};
pub struct ShanghaiTime;
impl FormatTime for ShanghaiTime {
fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result {
let now: DateTime<Utc> = Utc::now();
let offset = FixedOffset::east_opt(8 * 3600).unwrap();
let shanghai_time = now.with_timezone(&offset);
write!(w, "{}", shanghai_time.format("%Y-%m-%dT%H:%M:%S%.3f%:z"))
}
}
/// 初始化系统全局日志模块,支持控制台输出与每天自动滚动的日志文件
pub fn init_logging() -> anyhow::Result<Vec<WorkerGuard>> {
let mut guards = Vec::new();
// 从环境变量中读取配置
let log_level = env::var("LOG_LEVEL").unwrap_or_else(|_| "info,astroresearch=debug".to_string());
let log_format = env::var("LOG_FORMAT").unwrap_or_else(|_| "pretty".to_string());
let log_outputs = env::var("LOG_OUTPUTS").unwrap_or_else(|_| "stdout,file".to_string());
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(&log_level));
let is_json = log_format.to_lowercase() == "json";
let mut layers: Vec<Box<dyn Layer<tracing_subscriber::Registry> + Send + Sync>> = Vec::new();
// 1. 控制台输出层 (stdout)
if log_outputs.contains("stdout") {
let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stdout());
guards.push(guard);
let fmt_layer = fmt::layer()
.with_timer(ShanghaiTime)
.with_writer(non_blocking);
if is_json {
layers.push(fmt_layer.json().with_ansi(false).boxed());
} else {
layers.push(fmt_layer.pretty().with_ansi(true).boxed());
}
}
// 2. 每日滚动文件日志层 (file)
if log_outputs.contains("file") {
let log_dir = env::var("LOG_DIR").unwrap_or_else(|_| "logs".to_string());
fs::create_dir_all(&log_dir).unwrap_or(());
let file_appender = rolling::daily(log_dir, "astro_research.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
guards.push(guard);
let fmt_layer = fmt::layer()
.with_timer(ShanghaiTime)
.with_writer(non_blocking)
.with_ansi(false);
if is_json {
layers.push(fmt_layer.json().boxed());
} else {
layers.push(fmt_layer.boxed());
}
}
// 3. 注册全部日志层
tracing_subscriber::registry()
.with(layers)
.with(env_filter)
.init();
Ok(guards)
}
+1
View File
@@ -3,3 +3,4 @@ pub mod parser;
pub mod translation;
pub mod query_parser;
pub mod batch_sync;
pub mod logging;
+261 -38
View File
@@ -1,11 +1,9 @@
// src/parser.rs
use std::fs;
use std::path::Path;
use serde::Deserialize;
use reqwest::multipart;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use regex::Regex;
use base64::Engine;
use crate::Config;
use crate::clients::qiniu::QiniuClient;
@@ -13,7 +11,20 @@ use crate::clients::qiniu::QiniuClient;
// 清理 HTML 结构,仅提取正文部分并转换为标准 Markdown
pub fn html_to_markdown(html_path: &Path) -> anyhow::Result<String> {
info!("正在解析本地 HTML 并提取 Markdown: {:?}", html_path);
let html_content = fs::read_to_string(html_path)?;
let html_bytes = fs::read(html_path)?;
// 检查是否为 Gzip 压缩文件 (Gzip 幻数: 0x1f 0x8b)
let decompressed_bytes = if html_bytes.starts_with(&[0x1f, 0x8b]) {
use std::io::Read;
let mut decoder = flate2::read::GzDecoder::new(&html_bytes[..]);
let mut buf = Vec::new();
decoder.read_to_end(&mut buf)?;
buf
} else {
html_bytes
};
let html_content = String::from_utf8_lossy(&decompressed_bytes).into_owned();
// 截断页脚及之后的不相关内容以防干扰解析
let mut truncated_html = html_content.as_str();
@@ -287,10 +298,61 @@ fn strip_html_tags(html: &str) -> String {
.replace("&#39;", "'")
}
#[derive(Serialize)]
struct BatchUploadRequest {
files: Vec<PendingFile>,
language: String,
is_ocr: bool,
model_version: String,
}
#[derive(Serialize)]
struct PendingFile {
name: String,
data_id: String,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct BatchUploadResponse {
code: i32,
msg: String,
data: BatchUploadData,
}
#[derive(Deserialize)]
struct BatchUploadData {
batch_id: String,
file_urls: Vec<String>,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct BatchResultResponse {
code: i32,
msg: String,
data: Option<BatchResultData>,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct BatchResultData {
batch_id: String,
extract_result: Vec<ExtractResult>,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct ExtractResult {
file_name: String,
state: String,
full_zip_url: Option<String>,
err_msg: Option<String>,
}
// 调用 MinerU 远程接口解析 PDF,并在提取出图片后自动上传至七牛云进行外链替换
pub async fn parse_pdf_via_mineru(
pub async fn submit_pdf_to_mineru(
pdf_path: &Path,
qiniu_client: &QiniuClient,
config: &Config
) -> anyhow::Result<String> {
info!("正在请求 MinerU 解析本地 PDF 文献: {:?}", pdf_path);
@@ -305,60 +367,221 @@ pub async fn parse_pdf_via_mineru(
.unwrap_or("paper.pdf")
.to_string();
let file_part = multipart::Part::bytes(pdf_bytes).file_name(filename);
let form = multipart::Form::new()
.part("file", file_part);
let bibcode = pdf_path.file_stem()
.and_then(|f| f.to_str())
.unwrap_or("paper")
.to_string();
// 提取 base_url
let base_url = config.mineru_api_url
.replace("/extract/task", "")
.replace("/extract", "")
.trim_end_matches('/')
.to_string();
info!("正在发送 PDF 字节流至 MinerU 接口地址: {}", config.mineru_api_url);
let client = reqwest::Client::new();
let mut request = client.post(&config.mineru_api_url).multipart(form);
let data_id = uuid::Uuid::new_v4().to_string();
// 1. 获取预签名上传 URL
info!("MinerU: 正在请求批量直传 URL (Bibcode: {})", bibcode);
let upload_req = BatchUploadRequest {
files: vec![PendingFile {
name: filename.clone(),
data_id: data_id.clone(),
}],
language: "en".to_string(),
is_ocr: true,
model_version: "vlm".to_string(),
};
let mut request = client.post(format!("{}/file-urls/batch/", base_url))
.json(&upload_req);
if !config.mineru_api_key.is_empty() {
request = request.header("Authorization", format!("Bearer {}", config.mineru_api_key));
}
let response = request.send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("MinerU 解析接口返回失败码: {}", response.status()));
let status = response.status();
let res_text = response.text().await?;
if !status.is_success() {
return Err(anyhow::anyhow!("请求 MinerU 批量上传 URL 失败 (状态码: {}): {}", status, res_text));
}
// MinerU 远程服务响应 JSON,包含转换出的 markdown 正文和图片映射
#[derive(Deserialize)]
struct MinerUResponse {
markdown: String,
images: Option<std::collections::HashMap<String, String>>, // 图片文件名 -> Base64 字符串
let upload_res: BatchUploadResponse = serde_json::from_str(&res_text)?;
if upload_res.code != 0 {
return Err(anyhow::anyhow!("MinerU API 错误: {}", upload_res.msg));
}
let result: MinerUResponse = response.json().await?;
let mut markdown = result.markdown;
let upload_url = upload_res.data.file_urls.first()
.ok_or_else(|| anyhow::anyhow!("MinerU 未返回上传 URL"))?;
// 上传图片并重写 Markdown 连接地址
if let Some(images) = result.images {
if qiniu_client.is_configured() {
info!("MinerU 成功解析出 {} 张本地插图。正在准备同步至七牛云...", images.len());
for (img_name, base64_data) in images {
if let Ok(img_bytes) = base64::engine::general_purpose::STANDARD.decode(base64_data) {
match qiniu_client.upload_buffer(img_bytes, &img_name).await {
Ok(qiniu_url) => {
// 使用正则将 Markdown 中的本地临时图地址替换为七牛云 CDN 地址
let escaped_img_name = regex::escape(&img_name);
let link_re = Regex::new(&format!(r"\(([^)]*?){}\)", escaped_img_name)).unwrap();
markdown = link_re.replace_all(&markdown, |_: &regex::Captures| {
format!("({})", qiniu_url)
}).to_string();
},
Err(e) => warn!("上传图片至七牛云失败 {}: {}", img_name, e),
// 2. 上传文件 (PUT)
info!("MinerU: 正在直接上传 PDF 字节流至对象存储...");
let put_res = client.put(upload_url)
.body(pdf_bytes)
.send()
.await?;
if !put_res.status().is_success() {
return Err(anyhow::anyhow!("上传 PDF 至 MinerU 对象存储直传 URL 失败: {}", put_res.status()));
}
let batch_id = upload_res.data.batch_id;
Ok(batch_id)
}
pub async fn poll_and_extract_mineru(
batch_id: &str,
bibcode: &str,
qiniu_client: &QiniuClient,
config: &Config
) -> anyhow::Result<String> {
let client = reqwest::Client::new();
let base_url = config.mineru_api_url
.replace("/extract/task", "")
.replace("/extract", "")
.trim_end_matches('/')
.to_string();
let mut poll_count = 0;
let max_polls = 45; // 45 * 10s = 7.5 min
info!("MinerU: 开始轮询任务结果 (Batch ID: {})...", batch_id);
let mut full_zip_url = String::new();
loop {
poll_count += 1;
if poll_count > max_polls {
return Err(anyhow::anyhow!("MinerU 结构化解析超时 (Bibcode: {})", bibcode));
}
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
let mut status_req = client.get(format!("{}/extract-results/batch/{}", base_url, batch_id));
if !config.mineru_api_key.is_empty() {
status_req = status_req.header("Authorization", format!("Bearer {}", config.mineru_api_key));
}
let status_res = status_req.send().await?;
let status_text = status_res.text().await?;
let result_data: BatchResultResponse = serde_json::from_str(&status_text)?;
if let Some(data) = result_data.data {
if let Some(file_result) = data.extract_result.first() {
match file_result.state.as_str() {
"done" => {
info!("MinerU: 解析成功!");
full_zip_url = file_result.full_zip_url.clone().unwrap_or_default();
break;
}
"error" | "failed" => {
let err_msg = file_result.err_msg.clone().unwrap_or_default();
return Err(anyhow::anyhow!("MinerU 批量解析任务失败: {}", err_msg));
}
other => {
info!("MinerU 任务处理中... 当前状态: {}", other);
}
}
} else {
return Err(anyhow::anyhow!("MinerU 轮询响应中未发现文件解析任务结果"));
}
} else {
return Err(anyhow::anyhow!("MinerU 轮询响应数据为空"));
}
}
if full_zip_url.is_empty() {
return Err(anyhow::anyhow!("MinerU 转换成功但未返回结果 ZIP 下载 URL"));
}
// 4. 下载并解压 ZIP
info!("MinerU: 正在下载最终提取压缩包: {}", full_zip_url);
let zip_bytes = client.get(&full_zip_url).send().await?.bytes().await?;
let reader = std::io::Cursor::new(zip_bytes);
let mut archive = zip::ZipArchive::new(reader)?;
let mut markdown = String::new();
let mut image_buffers = std::collections::HashMap::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let name = file.name().to_string();
if name.ends_with(".md") {
let mut md_content = String::new();
std::io::Read::read_to_string(&mut file, &mut md_content)?;
markdown = md_content;
} else if file.is_file() {
let lower = name.to_lowercase();
if lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") || lower.ends_with(".gif") || lower.ends_with(".svg") {
let mut buf = Vec::new();
std::io::copy(&mut file, &mut buf)?;
let file_basename = Path::new(&name)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(&name)
.to_string();
image_buffers.insert(file_basename, buf);
}
}
}
if markdown.is_empty() {
return Err(anyhow::anyhow!("解析后的压缩包中未发现核心 Markdown 文档"));
}
// 5. 上传图片并重写链接
if !image_buffers.is_empty() {
let local_img_dir = config.library_dir.join("images").join(bibcode);
let _ = fs::create_dir_all(&local_img_dir);
if qiniu_client.is_configured() {
info!("MinerU 批量模式解析出 {} 张本地插图。准备上传至七牛云...", image_buffers.len());
for (img_name, img_bytes) in image_buffers {
let local_path = local_img_dir.join(&img_name);
let _ = fs::write(&local_path, &img_bytes);
match qiniu_client.upload_buffer(img_bytes, &img_name).await {
Ok(qiniu_url) => {
let escaped_img_name = regex::escape(&img_name);
let link_re = Regex::new(&format!(r"\(([^)]*?){}\)", escaped_img_name)).unwrap();
markdown = link_re.replace_all(&markdown, |_: &regex::Captures| {
format!("({})", qiniu_url)
}).to_string();
}
Err(e) => warn!("上传图片至七牛云失败 {}: {}", img_name, e),
}
}
} else {
warn!("未检测到七牛云配置,解析出的图片将保留临时地址,无法在外网或 Obsidian 中直观预览");
warn!("未检测到七牛云配置,解析出的图片将保存在本地 images 目录下");
for (img_name, img_bytes) in image_buffers {
let local_path = local_img_dir.join(&img_name);
let _ = fs::write(&local_path, &img_bytes);
let escaped_img_name = regex::escape(&img_name);
let link_re = Regex::new(&format!(r"\(([^)]*?){}\)", escaped_img_name)).unwrap();
let replacement_link = format!("(images/{}/{})", bibcode, img_name);
markdown = link_re.replace_all(&markdown, replacement_link.as_str()).to_string();
}
}
}
Ok(markdown)
}
pub async fn parse_pdf_via_mineru(
pdf_path: &Path,
qiniu_client: &QiniuClient,
config: &Config
) -> anyhow::Result<String> {
let bibcode = pdf_path.file_stem()
.and_then(|f| f.to_str())
.unwrap_or("paper")
.to_string();
let batch_id = submit_pdf_to_mineru(pdf_path, config).await?;
poll_and_extract_mineru(&batch_id, &bibcode, qiniu_client, config).await
}
// 采用栈式解析模型,将 LaTeXML 用 span/div 模拟出的表格容器(ltx_tabular/tbody/thead/tfoot/tr/td/th)还原为真正的 HTML <table> 结构
fn replace_latexml_tables(html: &str) -> String {
use regex::Regex;
+3
View File
@@ -129,6 +129,7 @@ pub async fn translate_markdown(
);
info!("正在请求大模型开展中英翻译。所选大模型: {}", config.llm_model);
let start_time = std::time::Instant::now();
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", config.llm_api_base);
@@ -179,6 +180,8 @@ pub async fn translate_markdown(
let res_data: LLMResponse = response.json().await?;
if let Some(choice) = res_data.choices.first() {
let duration = start_time.elapsed();
info!("LLM 翻译成功。所选大模型: {}, 耗时: {:?}, 译文字符数: {}", config.llm_model, duration, choice.message.content.len());
Ok(choice.message.content.clone())
} else {
Err(anyhow::anyhow!("大模型返回空翻译选项集"))