feat: 初始化 AstroResearch 核心系统代码及重构技术文档
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# AstroResearch Backend / 后端服务模块
|
||||
|
||||
本模块是 AstroResearch 的后端部分,基于 **Rust + Axum + SQLx (SQLite)** 构建。
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码结构说明 (Source Code Structure)
|
||||
|
||||
- **[main.rs](main.rs)**:服务启动入口,注册全局 CORS 中间件,连接 SQLite 数据库并运行初始化 SQL 迁移。
|
||||
- **[config.rs](config.rs)**:使用 `dotenvy` 解析本地 `.env` 环境变量并进行有效性校验。
|
||||
- **[handlers.rs](handlers.rs)**:处理 Axum API 路由的分发与核心业务逻辑。
|
||||
- **[download.rs](download.rs)**:智能下载器,处理多级回退及安全拦截绕过。
|
||||
- **[parser.rs](parser.rs)**:GFM Markdown 结构化文献转换器,对 LaTeX 公式实施占位符保护。
|
||||
- **[translation.rs](translation.rs)**:分词提取天文学专业对照名词,并组合系统提示词调用大模型进行学术翻译。
|
||||
- **[dictionary.rs](dictionary.rs)**:高性能分词字典,基于 Trie 树的最长前缀匹配。
|
||||
- **[ads.rs](ads.rs)**:NASA ADS 接口适配器。
|
||||
- **[arxiv.rs](arxiv.rs)**:arXiv XML Atom 适配器。
|
||||
- **[qiniu.rs](qiniu.rs)**:七牛云上传客户端,处理 MinerU PDF 解析产出插图的对象存储托管。
|
||||
|
||||
---
|
||||
|
||||
## 2. 单元测试 (Testing)
|
||||
|
||||
后端各核心处理函数与服务都编写了单元测试。你可以通过以下命令在本地执行所有的单元测试:
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 本地运行 (Usage)
|
||||
|
||||
确保当前目录的父目录(项目根目录)下已正确配置 `.env` 及 `dictionary.txt` 文件,然后在项目根目录下运行:
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
服务将在 `http://localhost:8000` 启动,并自动在父目录生成或读取 `astro_research.db` 数据库。
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
// src/ads.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
||||
use tracing::{info, error};
|
||||
|
||||
// 原始 ADS API 返回的数据文档结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdsPaperDoc {
|
||||
pub bibcode: String,
|
||||
pub title: Option<Vec<String>>,
|
||||
pub author: Option<Vec<String>>,
|
||||
pub year: Option<String>,
|
||||
#[serde(rename = "pub")]
|
||||
pub pub_journal: Option<String>,
|
||||
pub keyword: Option<Vec<String>>,
|
||||
pub abstract_text: Option<String>,
|
||||
pub doi: Option<Vec<String>>,
|
||||
pub citation_count: Option<i32>,
|
||||
pub reference_count: Option<i32>,
|
||||
pub reference: Option<Vec<String>>,
|
||||
pub citation: Option<Vec<String>>,
|
||||
pub identifier: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdsResponseDocs {
|
||||
pub docs: Vec<AdsPaperDoc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdsSearchResponse {
|
||||
pub response: AdsResponseDocs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdsExportResponse {
|
||||
pub export: String,
|
||||
}
|
||||
|
||||
// ADS API 服务客户端
|
||||
pub struct AdsClient {
|
||||
api_key: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl AdsClient {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
AdsClient {
|
||||
api_key,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// 拼装鉴权 Header
|
||||
fn headers(&self) -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_str(&format!("Bearer {}", self.api_key)).unwrap_or_else(|_| HeaderValue::from_static("")),
|
||||
);
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers
|
||||
}
|
||||
|
||||
// 调用 ADS 检索接口获取文献元数据列表
|
||||
pub async fn search(&self, query: &str, rows: i32) -> anyhow::Result<Vec<AdsPaperDoc>> {
|
||||
let url = "https://api.adsabs.harvard.edu/v1/search/query";
|
||||
|
||||
// fl 声明返回字段,包括 reference 和 citation 引用关系数组及 identifier
|
||||
let fl = "bibcode,title,author,year,pub,keyword,abstract,doi,citation_count,reference_count,reference,citation,identifier";
|
||||
|
||||
info!("正在发送检索请求到 ADS 平台: 查询词='{}', 数量={}", query, rows);
|
||||
|
||||
let response = self.client
|
||||
.get(url)
|
||||
.headers(self.headers())
|
||||
.query(&[("q", query), ("rows", &rows.to_string()), ("fl", fl)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let err_body = response.text().await.unwrap_or_default();
|
||||
error!("ADS 检索请求失败: 状态码={}, 返回错误={}", status, err_body);
|
||||
return Err(anyhow::anyhow!("ADS API 接口返回错误码: {}", status));
|
||||
}
|
||||
|
||||
let raw_res: RawSearchResponse = response.json().await?;
|
||||
let docs = raw_res.response.docs.into_iter().map(|d| {
|
||||
AdsPaperDoc {
|
||||
bibcode: d.bibcode,
|
||||
title: d.title,
|
||||
author: d.author,
|
||||
year: d.year,
|
||||
pub_journal: d.pub_journal,
|
||||
keyword: d.keyword,
|
||||
abstract_text: d.abstract_field,
|
||||
doi: d.doi,
|
||||
citation_count: d.citation_count,
|
||||
reference_count: d.reference_count,
|
||||
reference: d.reference,
|
||||
citation: d.citation,
|
||||
identifier: d.identifier,
|
||||
}
|
||||
}).collect();
|
||||
|
||||
Ok(docs)
|
||||
}
|
||||
|
||||
// 调用 ADS Export 接口导出 BibTeX 文本内容
|
||||
pub async fn export_bibtex(&self, bibcodes: Vec<String>) -> anyhow::Result<String> {
|
||||
let url = "https://api.adsabs.harvard.edu/v1/export/bibtex";
|
||||
info!("正在向 ADS 请求导出 {} 篇文献的 BibTeX 数据", bibcodes.len());
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"bibcode": bibcodes
|
||||
});
|
||||
|
||||
let response = self.client
|
||||
.post(url)
|
||||
.headers(self.headers())
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let err_body = response.text().await.unwrap_or_default();
|
||||
error!("ADS 导出 BibTeX 失败: 状态码={}, 返回信息={}", status, err_body);
|
||||
return Err(anyhow::anyhow!("ADS 导出接口返回错误码: {}", status));
|
||||
}
|
||||
|
||||
let res_data: AdsExportResponse = response.json().await?;
|
||||
Ok(res_data.export)
|
||||
}
|
||||
}
|
||||
|
||||
// 内部反序列化辅助结构,防止由于 abstract/pub 关键字冲突导致编译失败
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawDoc {
|
||||
bibcode: String,
|
||||
title: Option<Vec<String>>,
|
||||
author: Option<Vec<String>>,
|
||||
year: Option<String>,
|
||||
#[serde(rename = "pub")]
|
||||
pub_journal: Option<String>,
|
||||
keyword: Option<Vec<String>>,
|
||||
#[serde(rename = "abstract")]
|
||||
abstract_field: Option<String>,
|
||||
doi: Option<Vec<String>>,
|
||||
citation_count: Option<i32>,
|
||||
reference_count: Option<i32>,
|
||||
reference: Option<Vec<String>>,
|
||||
citation: Option<Vec<String>>,
|
||||
identifier: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawSearchResponse {
|
||||
response: RawDocs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawDocs {
|
||||
docs: Vec<RawDoc>,
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// src/arxiv.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, error};
|
||||
use regex::Regex;
|
||||
|
||||
// 统一的 arXiv 文献临时结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ArxivPaper {
|
||||
pub id: String, // 清洗后的 arXiv ID,例如 2301.00001
|
||||
pub title: String,
|
||||
pub authors: Vec<String>,
|
||||
pub year: String,
|
||||
pub abstract_text: String,
|
||||
pub doi: Option<String>,
|
||||
pub pdf_url: String,
|
||||
}
|
||||
|
||||
// arXiv 接口访问客户端
|
||||
pub struct ArxivClient {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ArxivClient {
|
||||
pub fn new() -> Self {
|
||||
ArxivClient {
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// 请求 arXiv 官方的 Export 检索接口并解析返回内容
|
||||
pub async fn search(&self, query: &str, max_results: i32) -> anyhow::Result<Vec<ArxivPaper>> {
|
||||
let url = "http://export.arxiv.org/api/query";
|
||||
|
||||
info!("正在发送检索请求到 arXiv 平台: 查询词='{}', 数量={}", query, max_results);
|
||||
|
||||
let response = self.client
|
||||
.get(url)
|
||||
.query(&[
|
||||
("search_query", query),
|
||||
("max_results", &max_results.to_string()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
error!("arXiv 请求失败: 状态码={}", status);
|
||||
return Err(anyhow::anyhow!("arXiv 接口返回错误码: {}", status));
|
||||
}
|
||||
|
||||
let xml_content = response.text().await?;
|
||||
let papers = parse_arxiv_xml(&xml_content);
|
||||
Ok(papers)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用正则表达式手动提取 XML 内容,避免由于命名空间前缀不同造成的反序列化问题
|
||||
fn parse_arxiv_xml(xml: &str) -> Vec<ArxivPaper> {
|
||||
let mut papers = Vec::new();
|
||||
|
||||
let entry_re = Regex::new(r"(?s)<entry>(.*?)</entry>").unwrap();
|
||||
let id_re = Regex::new(r"<id>http://arxiv.org/abs/(.*?)(?:v\d+)?</id>").unwrap();
|
||||
let title_re = Regex::new(r"(?s)<title>(.*?)</title>").unwrap();
|
||||
let summary_re = Regex::new(r"(?s)<summary>(.*?)</summary>").unwrap();
|
||||
let published_re = Regex::new(r"<published>(\d{4})-\d{2}-\d{2}").unwrap();
|
||||
let author_re = Regex::new(r"(?s)<author>\s*<name>(.*?)</name>").unwrap();
|
||||
let doi_re = Regex::new(r"<arxiv:doi[^>]*>(.*?)</arxiv:doi>").unwrap();
|
||||
|
||||
for cap in entry_re.captures_iter(xml) {
|
||||
let entry_content = &cap[1];
|
||||
|
||||
// 提取并清洗 ID
|
||||
let id = id_re.captures(entry_content)
|
||||
.map(|c| c[1].trim().to_string())
|
||||
.unwrap_or_else(|| {
|
||||
let fallback_id_re = Regex::new(r"<id>(.*?)</id>").unwrap();
|
||||
fallback_id_re.captures(entry_content)
|
||||
.map(|c| c[1].trim().to_string())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取标题,清理换行与连续空格
|
||||
let mut title = title_re.captures(entry_content)
|
||||
.map(|c| c[1].to_string())
|
||||
.unwrap_or_default();
|
||||
title = title.replace('\n', " ").replace(" ", " ").trim().to_string();
|
||||
|
||||
// 提取摘要
|
||||
let mut abstract_text = summary_re.captures(entry_content)
|
||||
.map(|c| c[1].to_string())
|
||||
.unwrap_or_default();
|
||||
abstract_text = abstract_text.replace('\n', " ").replace(" ", " ").trim().to_string();
|
||||
|
||||
// 提取发布年份
|
||||
let year = published_re.captures(entry_content)
|
||||
.map(|c| c[1].to_string())
|
||||
.unwrap_or_else(|| "未知".to_string());
|
||||
|
||||
// 提取作者列表
|
||||
let mut authors = Vec::new();
|
||||
for auth_cap in author_re.captures_iter(entry_content) {
|
||||
let author_name = auth_cap[1].trim().to_string();
|
||||
if !author_name.is_empty() {
|
||||
authors.push(author_name);
|
||||
}
|
||||
}
|
||||
|
||||
// 提取关联 DOI
|
||||
let doi = doi_re.captures(entry_content)
|
||||
.map(|c| c[1].trim().to_string());
|
||||
|
||||
let pdf_url = format!("https://arxiv.org/pdf/{}.pdf", id);
|
||||
|
||||
papers.push(ArxivPaper {
|
||||
id,
|
||||
title,
|
||||
authors,
|
||||
year,
|
||||
abstract_text,
|
||||
doi,
|
||||
pdf_url,
|
||||
});
|
||||
}
|
||||
|
||||
papers
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_arxiv_xml() {
|
||||
let xml_data = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<id>http://arxiv.org/abs/2301.00001v2</id>
|
||||
<title>A Beautiful Title of Astro Research Paper</title>
|
||||
<summary>This is the abstract. It spans multiple lines.</summary>
|
||||
<published>2023-01-08T10:00:00Z</published>
|
||||
<author>
|
||||
<name>John Doe</name>
|
||||
</author>
|
||||
<author>
|
||||
<name>Jane Smith</name>
|
||||
</author>
|
||||
<arxiv:doi xmlns:arxiv="http://arxiv.org/schemas/atom">10.1000/xyz123</arxiv:doi>
|
||||
</entry>
|
||||
</feed>"#;
|
||||
|
||||
let papers = parse_arxiv_xml(xml_data);
|
||||
assert_eq!(papers.len(), 1);
|
||||
let paper = &papers[0];
|
||||
assert_eq!(paper.id, "2301.00001");
|
||||
assert_eq!(paper.title, "A Beautiful Title of Astro Research Paper");
|
||||
assert_eq!(paper.authors, vec!["John Doe".to_string(), "Jane Smith".to_string()]);
|
||||
assert_eq!(paper.year, "2023");
|
||||
assert_eq!(paper.abstract_text, "This is the abstract. It spans multiple lines.");
|
||||
assert_eq!(paper.doi, Some("10.1000/xyz123".to_string()));
|
||||
assert_eq!(paper.pdf_url, "https://arxiv.org/pdf/2301.00001.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
// src/config.rs
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
// 系统配置结构体,加载并管理从环境变量或 .env 文件读取的参数
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub database_url: String, // SQLite 数据库连接 URL
|
||||
pub ads_api_key: String, // NASA ADS API 访问 Token
|
||||
pub llm_api_key: String, // 大语言模型 API Key
|
||||
pub llm_api_base: String, // 大语言模型 API 基础地址
|
||||
pub llm_model: String, // 调用的翻译大模型名称
|
||||
pub qiniu_ak: String, // 七牛云 Access Key
|
||||
pub qiniu_sk: String, // 七牛云 Secret Key
|
||||
pub qiniu_bucket: String, // 七牛云存储空间名 (Bucket)
|
||||
pub qiniu_domain: String, // 七牛云外链 CDN 域名
|
||||
pub mineru_api_url: String, // MinerU PDF 解析远程 API 地址
|
||||
pub mineru_api_key: String, // MinerU API Token
|
||||
pub library_dir: PathBuf, // 本地文献馆藏根目录
|
||||
pub port: u16, // 后端服务监听端口
|
||||
}
|
||||
|
||||
impl Config {
|
||||
// 从环境变量载入配置参数,提供缺省默认值
|
||||
pub fn from_env() -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let database_url = env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite://astro_research.db".to_string());
|
||||
let ads_api_key = env::var("ADS_API_KEY").unwrap_or_default();
|
||||
let llm_api_key = env::var("LLM_API_KEY").unwrap_or_default();
|
||||
let llm_api_base = env::var("LLM_API_BASE")
|
||||
.unwrap_or_else(|_| "https://api.openai.com/v1".to_string());
|
||||
let llm_model = env::var("LLM_MODEL")
|
||||
.unwrap_or_else(|_| "gpt-4o-mini".to_string());
|
||||
|
||||
let qiniu_ak = env::var("QINIU_AK").unwrap_or_default();
|
||||
let qiniu_sk = env::var("QINIU_SK").unwrap_or_default();
|
||||
let qiniu_bucket = env::var("QINIU_BUCKET").unwrap_or_default();
|
||||
let qiniu_domain = env::var("QINIU_DOMAIN").unwrap_or_default();
|
||||
|
||||
let mineru_api_url = env::var("MINERU_API_URL").unwrap_or_default();
|
||||
let mineru_api_key = env::var("MINERU_API_KEY").unwrap_or_default();
|
||||
|
||||
let library_dir_str = env::var("LIBRARY_DIR").unwrap_or_else(|_| "./library".to_string());
|
||||
let library_dir = PathBuf::from(library_dir_str);
|
||||
|
||||
let port = env::var("PORT")
|
||||
.unwrap_or_else(|_| "8000".to_string())
|
||||
.parse::<u16>()
|
||||
.unwrap_or(8000);
|
||||
|
||||
Config {
|
||||
database_url,
|
||||
ads_api_key,
|
||||
llm_api_key,
|
||||
llm_api_base,
|
||||
llm_model,
|
||||
qiniu_ak,
|
||||
qiniu_sk,
|
||||
qiniu_bucket,
|
||||
qiniu_domain,
|
||||
mineru_api_url,
|
||||
mineru_api_key,
|
||||
library_dir,
|
||||
port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_from_env() {
|
||||
// 保存并清除环境变量以防干扰
|
||||
let orig_port = std::env::var("PORT").ok();
|
||||
let orig_db = std::env::var("DATABASE_URL").ok();
|
||||
|
||||
std::env::set_var("PORT", "9999");
|
||||
std::env::set_var("DATABASE_URL", "sqlite://test.db");
|
||||
|
||||
let config = Config::from_env();
|
||||
assert_eq!(config.port, 9999);
|
||||
assert_eq!(config.database_url, "sqlite://test.db");
|
||||
|
||||
// 恢复环境变量
|
||||
if let Some(p) = orig_port {
|
||||
std::env::set_var("PORT", p);
|
||||
} else {
|
||||
std::env::remove_var("PORT");
|
||||
}
|
||||
if let Some(db) = orig_db {
|
||||
std::env::set_var("DATABASE_URL", db);
|
||||
} else {
|
||||
std::env::remove_var("DATABASE_URL");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+713
@@ -0,0 +1,713 @@
|
||||
// src/download.rs
|
||||
//! 文献下载模块
|
||||
//!
|
||||
//! 参考 datasheel/node/src/download 设计,实现:
|
||||
//! - 随机 User-Agent + 完整 Sec-Fetch 头伪装
|
||||
//! - 流式下载(stream_download)
|
||||
//! - PDF/HTML 内容校验 + 反爬检测
|
||||
//! - 多级回退:arXiv 直连 → ADS PUB → ADS EPRINT → CrossRef API
|
||||
//! - IOP/Springer 等特定出版商会话预热策略
|
||||
//! - 请求间随机延迟(500-2000ms),降低触发反爬风险
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use url::Url;
|
||||
use tracing::{info, warn};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
// ─── 浏览器伪装辅助 ────────────────────────────────────────────
|
||||
|
||||
/// 生成随机 Firefox User-Agent(参考 SearXNG useragents.json)
|
||||
fn gen_useragent() -> String {
|
||||
use rand::seq::SliceRandom;
|
||||
let os_list = [
|
||||
"Windows NT 10.0; Win64; x64",
|
||||
"X11; Linux x86_64",
|
||||
"Macintosh; Intel Mac OS X 10_15_7",
|
||||
];
|
||||
let versions = ["137.0", "136.0", "135.0", "134.0", "133.0"];
|
||||
let os = os_list.choose(&mut rand::thread_rng()).unwrap();
|
||||
let v = versions.choose(&mut rand::thread_rng()).unwrap();
|
||||
format!("Mozilla/5.0 ({os}; rv:{v}) Gecko/20100101 Firefox/{v}")
|
||||
}
|
||||
|
||||
/// 构建完整浏览器 HTTP 头(参考 SearXNG online.py)
|
||||
fn build_browser_headers() -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
if let Ok(ua) = HeaderValue::from_str(&gen_useragent()) {
|
||||
h.insert("User-Agent", ua);
|
||||
}
|
||||
h.insert("Accept", HeaderValue::from_static(
|
||||
"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"));
|
||||
h.insert("Sec-Fetch-Dest", HeaderValue::from_static("document"));
|
||||
h.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate"));
|
||||
h.insert("Sec-Fetch-Site", HeaderValue::from_static("none"));
|
||||
h.insert("Sec-Fetch-User", HeaderValue::from_static("?1"));
|
||||
h
|
||||
}
|
||||
|
||||
/// 构建 Chrome 风格 HTTP 头(用于 IOP 等更严格出版商)
|
||||
fn build_chrome_headers(referer: Option<&str>) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("User-Agent", HeaderValue::from_static(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
|
||||
));
|
||||
h.insert("Accept", HeaderValue::from_static(
|
||||
"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\"",
|
||||
));
|
||||
h.insert("Sec-Ch-Ua-Mobile", HeaderValue::from_static("?0"));
|
||||
h.insert("Sec-Ch-Ua-Platform", HeaderValue::from_static("\"Windows\""));
|
||||
h.insert("Sec-Fetch-Dest", HeaderValue::from_static("document"));
|
||||
h.insert("Sec-Fetch-Mode", HeaderValue::from_static("navigate"));
|
||||
h.insert("Sec-Fetch-Site", HeaderValue::from_static("same-origin"));
|
||||
h.insert("Sec-Fetch-User", HeaderValue::from_static("?1"));
|
||||
if let Some(r) = referer {
|
||||
if let Ok(v) = HeaderValue::from_str(r) {
|
||||
h.insert("Referer", v);
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
// ─── 内容校验 ─────────────────────────────────────────────────
|
||||
|
||||
/// 统一验证码/反爬虫检测(参考 SearXNG 异常处理机制)
|
||||
fn detect_anti_bot(content: &str, url: Option<&str>) -> Result<()> {
|
||||
let lower = content.to_lowercase();
|
||||
|
||||
let cf_patterns = [
|
||||
"checking your browser", "please wait while we verify",
|
||||
"cf-browser-verification", "cf_chl_opt", "just a moment",
|
||||
"enable javascript and cookies", "_cf_chl_tk",
|
||||
];
|
||||
for p in &cf_patterns {
|
||||
if lower.contains(p) {
|
||||
anyhow::bail!("检测到 Cloudflare 挑战页面(特征: {})", p);
|
||||
}
|
||||
}
|
||||
|
||||
let captcha_patterns = ["captcha", "recaptcha", "hcaptcha", "verify you are human", "robot check"];
|
||||
for p in &captcha_patterns {
|
||||
if lower.contains(p) {
|
||||
anyhow::bail!("检测到人机验证页面(包含: {})", p);
|
||||
}
|
||||
}
|
||||
|
||||
let access_denied = [
|
||||
"login required", "please log in", "subscription required",
|
||||
"access denied", "you do not have access", "purchase this article",
|
||||
"sign in to access", "client challenge",
|
||||
];
|
||||
for p in &access_denied {
|
||||
if lower.contains(p) {
|
||||
anyhow::bail!("检测到出版商访问限制(特征: {})", p);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(u) = url {
|
||||
if u.contains("sorry.google.com") || u.contains("/sorry") {
|
||||
anyhow::bail!("检测到 Google 验证码页面");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验响应字节是否为有效 PDF(魔数 + 最小大小 + EOF 标记)
|
||||
fn validate_pdf_content(bytes: &[u8]) -> Result<()> {
|
||||
if !bytes.starts_with(b"%PDF") {
|
||||
if bytes.starts_with(b"<!") || bytes.starts_with(b"<html") || bytes.starts_with(b"<HTML") {
|
||||
let text = String::from_utf8_lossy(&bytes[..bytes.len().min(2048)]);
|
||||
detect_anti_bot(&text, None)?;
|
||||
anyhow::bail!("响应内容是 HTML 而非 PDF,可能需要登录或验证");
|
||||
}
|
||||
anyhow::bail!("响应不是有效的 PDF 文件(缺少 %PDF 魔数)");
|
||||
}
|
||||
if bytes.len() < 5000 {
|
||||
anyhow::bail!("PDF 文件过小({} 字节),可能是错误页面", bytes.len());
|
||||
}
|
||||
let scan_len = std::cmp::min(1024, bytes.len());
|
||||
let tail = &bytes[bytes.len() - scan_len..];
|
||||
if !tail.windows(5).any(|w| w == b"%%EOF") {
|
||||
anyhow::bail!("PDF 文件损坏或不完整(未找到尾部 %%EOF 标记)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验 HTML 内容是否为有效文献页(非错误/登录墙)
|
||||
fn validate_html_content(text: &str) -> Result<()> {
|
||||
detect_anti_bot(text, None)?;
|
||||
if text.len() < 2000 {
|
||||
let lower = text.to_lowercase();
|
||||
for kw in &["error", "404", "not found", "forbidden", "access denied"] {
|
||||
if lower.contains(kw) {
|
||||
anyhow::bail!("响应是错误页面(包含: {})", kw);
|
||||
}
|
||||
}
|
||||
warn!("HTML 内容较短({} 字节),可能不完整", text.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Downloader 主结构 ─────────────────────────────────────────
|
||||
|
||||
/// 文献双格式异步下载管理器
|
||||
pub struct Downloader {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn new() -> Self {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(reqwest::header::ACCEPT, HeaderValue::from_static(
|
||||
"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",
|
||||
));
|
||||
headers.insert(reqwest::header::ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7"));
|
||||
headers.insert("DNT", HeaderValue::from_static("1"));
|
||||
headers.insert(reqwest::header::CONNECTION, HeaderValue::from_static("keep-alive"));
|
||||
headers.insert("Upgrade-Insecure-Requests", HeaderValue::from_static("1"));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
.default_headers(headers)
|
||||
.cookie_store(true) // 启用 Cookie 引擎,记录会话状态
|
||||
.redirect(reqwest::redirect::Policy::limited(10))
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Downloader { client }
|
||||
}
|
||||
|
||||
// ─── 辅助工具 ──────────────────────────────────────────────
|
||||
|
||||
/// 请求前随机延迟 500-2000ms(模拟人类浏览间隔,降低反爬触发)
|
||||
async fn maybe_delay() {
|
||||
let delay_ms = 500 + (rand::random::<u64>() % 1500);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
|
||||
/// 流式下载 HTTP 响应到本地文件(逐块写入,支持大文件)
|
||||
async fn stream_download(&self, response: reqwest::Response, target_path: &Path) -> Result<()> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
if let Some(parent) = target_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::create(target_path)
|
||||
.await
|
||||
.context("创建目标文件失败")?;
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let bytes = chunk.context("读取响应流时出错")?;
|
||||
file.write_all(&bytes).await?;
|
||||
}
|
||||
file.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 解析 ADS Link Gateway 路由,若遇 perfdrive 防护则提取 ssc 参数绕过
|
||||
async fn resolve_ads_gateway(&self, gateway_url: &str) -> Result<String> {
|
||||
info!("解析 ADS 网关: {}", gateway_url);
|
||||
|
||||
// HEAD 请求跟踪重定向(部分出版商阻断 HEAD,自动降级 GET)
|
||||
let response = match self.client.head(gateway_url).send().await {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => self.client.get(gateway_url).send().await
|
||||
.context(format!("请求 ADS 网关失败: {}", gateway_url))?,
|
||||
};
|
||||
|
||||
let final_url = response.url().as_str().to_string();
|
||||
info!("网关解析结果: {}", final_url);
|
||||
|
||||
// 如重定向至 validate.perfdrive.com,提取 ssc 参数中的真实 URL
|
||||
if final_url.contains("validate.perfdrive.com") {
|
||||
if let Ok(parsed) = Url::parse(&final_url) {
|
||||
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);
|
||||
return Ok(real_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排除解析失败后仍停留在 link_gateway 的情况
|
||||
if final_url.contains("link_gateway") || final_url.is_empty() {
|
||||
anyhow::bail!("ADS 网关未能解析到有效目标(仍在 link_gateway)");
|
||||
}
|
||||
|
||||
Ok(final_url)
|
||||
}
|
||||
|
||||
/// 读取文件前 512 字节用于内容嗅探
|
||||
async fn read_file_header(path: &Path) -> Result<Vec<u8>> {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut file = tokio::fs::File::open(path).await?;
|
||||
let mut buf = vec![0u8; 512];
|
||||
let n = file.read(&mut buf).await?;
|
||||
buf.truncate(n);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
// ─── 特定出版商下载策略 ────────────────────────────────────
|
||||
|
||||
/// IOP Science PDF 下载
|
||||
/// 参考 datasheel iop.rs:先访问主页建立会话(获取 Cookie),再请求 PDF
|
||||
async fn download_iop_pdf(&self, doi: &str, dest_path: &Path) -> Result<()> {
|
||||
let main_url = format!("https://iopscience.iop.org/article/{}", doi);
|
||||
let pdf_url = format!("https://iopscience.iop.org/article/{}/pdf", doi);
|
||||
|
||||
// 步骤 1:访问文章主页,建立 Cookie 会话
|
||||
info!("[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()),
|
||||
Err(e) => warn!("[IOP] 主页访问失败(继续尝试): {:?}", e),
|
||||
}
|
||||
|
||||
// 步骤 2:携带 Referer 下载 PDF
|
||||
info!("[IOP] 下载 PDF: {}", pdf_url);
|
||||
Self::maybe_delay().await;
|
||||
let response = self.client.get(&pdf_url)
|
||||
.headers(build_chrome_headers(Some(&main_url)))
|
||||
.send().await
|
||||
.context("IOP PDF 请求失败")?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("[IOP] 返回 HTTP {}", status);
|
||||
}
|
||||
|
||||
self.stream_download(response, dest_path).await?;
|
||||
|
||||
// 步骤 3:校验下载内容
|
||||
let bytes = tokio::fs::read(dest_path).await?;
|
||||
validate_pdf_content(&bytes)?;
|
||||
|
||||
info!("[IOP] PDF 下载成功: {:?}", dest_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Springer/Nature HTML 下载(含会话预热)
|
||||
async fn download_springer_html(&self, doi: &str, dest_path: &Path) -> Result<()> {
|
||||
let url = format!("https://link.springer.com/article/{}", doi);
|
||||
info!("[Springer] 下载 HTML: {}", url);
|
||||
|
||||
Self::maybe_delay().await;
|
||||
let response = self.client.get(&url)
|
||||
.headers(build_browser_headers())
|
||||
.send().await
|
||||
.context("Springer HTML 请求失败")?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("[Springer] 返回 HTTP {}", status);
|
||||
}
|
||||
|
||||
self.stream_download(response, dest_path).await?;
|
||||
|
||||
let sniff = Self::read_file_header(dest_path).await?;
|
||||
let text = String::from_utf8_lossy(&sniff);
|
||||
validate_html_content(&text)?;
|
||||
|
||||
info!("[Springer] HTML 下载成功: {:?}", dest_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 通用 PDF 直链下载(带随机延迟 + 内容校验)
|
||||
async fn download_pdf_direct(&self, url: &str, dest_path: &Path, label: &str) -> Result<()> {
|
||||
info!("[{}] 下载 PDF: {}", label, url);
|
||||
Self::maybe_delay().await;
|
||||
|
||||
let response = self.client.get(url)
|
||||
.headers(build_browser_headers())
|
||||
.send().await
|
||||
.context(format!("[{}] PDF 请求失败", label))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("[{}] 返回 HTTP {}", label, status);
|
||||
}
|
||||
|
||||
self.stream_download(response, dest_path).await?;
|
||||
|
||||
let bytes = tokio::fs::read(dest_path).await?;
|
||||
validate_pdf_content(&bytes)?;
|
||||
|
||||
info!("[{}] PDF 下载成功: {:?}", label, dest_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 通用 HTML 直链下载(带随机延迟 + 反爬检测)
|
||||
async fn download_html_direct(&self, url: &str, dest_path: &Path, label: &str) -> Result<()> {
|
||||
info!("[{}] 下载 HTML: {}", label, url);
|
||||
Self::maybe_delay().await;
|
||||
|
||||
let response = self.client.get(url)
|
||||
.headers(build_browser_headers())
|
||||
.send().await
|
||||
.context(format!("[{}] HTML 请求失败", label))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("[{}] 返回 HTTP {}", label, status);
|
||||
}
|
||||
|
||||
self.stream_download(response, dest_path).await?;
|
||||
|
||||
let sniff = Self::read_file_header(dest_path).await?;
|
||||
let text = String::from_utf8_lossy(&sniff);
|
||||
validate_html_content(&text)?;
|
||||
|
||||
info!("[{}] HTML 下载成功: {:?}", label, dest_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── CrossRef 回退通道 ─────────────────────────────────────
|
||||
|
||||
/// 通过 CrossRef API 获取 PDF 链接并下载
|
||||
async fn download_crossref_pdf(&self, doi: &str, dest_path: &Path) -> Result<()> {
|
||||
let api_url = format!("https://api.crossref.org/works/{}", doi);
|
||||
info!("[CrossRef] 查询 PDF 链接: {}", api_url);
|
||||
|
||||
let data: serde_json::Value = self.client.get(&api_url)
|
||||
.header("Accept", "application/json")
|
||||
.send().await
|
||||
.context("CrossRef API 请求失败")?
|
||||
.json().await
|
||||
.context("CrossRef API 响应解析失败")?;
|
||||
|
||||
let links = data["message"]["link"].as_array()
|
||||
.context("CrossRef 未返回 link 数组")?;
|
||||
|
||||
let pdf_url = links.iter()
|
||||
.find(|l| {
|
||||
let ct = l["content-type"].as_str().unwrap_or("");
|
||||
ct.contains("pdf") || ct == "unspecified"
|
||||
})
|
||||
.and_then(|l| l["URL"].as_str())
|
||||
.context("CrossRef 未找到 PDF 链接")?;
|
||||
|
||||
info!("[CrossRef] PDF 链接: {}", pdf_url);
|
||||
self.download_pdf_direct(pdf_url, dest_path, "CrossRef").await
|
||||
}
|
||||
|
||||
// ─── 公共入口 ──────────────────────────────────────────────
|
||||
|
||||
/// 通过 arXiv ID 直接下载 PDF(arxiv.org)和 HTML(官方 html/ 优先,ar5iv 兜底)
|
||||
///
|
||||
/// HTML 下载优先级:
|
||||
/// 1. 官方 `arxiv.org/html/{id}`(2023-12 起支持,质量与 ar5iv 相同,更稳定)
|
||||
/// 2. ar5iv `ar5iv.labs.arxiv.org/html/{id}`(约 3% 论文转换失败时跳过)
|
||||
pub async fn download_arxiv_direct(&self, arxiv_id: &str, library_dir: &Path) -> (Option<PathBuf>, Option<PathBuf>) {
|
||||
// 去除版本号(v1/v2/v3),arxiv.org/html/ 和 ar5iv 均只提供最新渲染版
|
||||
let clean_id = strip_arxiv_version(arxiv_id);
|
||||
|
||||
let pdf_url = format!("https://arxiv.org/pdf/{}", clean_id);
|
||||
let pdf_dest = library_dir.join("PDF").join(format!("{}.pdf", arxiv_id));
|
||||
let html_dest = library_dir.join("HTML").join(format!("{}.html", arxiv_id));
|
||||
|
||||
let mut pdf_ok = None;
|
||||
let mut html_ok = None;
|
||||
|
||||
// PDF 下载
|
||||
match self.download_pdf_direct(&pdf_url, &pdf_dest, "arXiv").await {
|
||||
Ok(_) => pdf_ok = Some(pdf_dest),
|
||||
Err(e) => warn!("[arXiv] PDF 下载失败: {:?}", e),
|
||||
}
|
||||
|
||||
// HTML 下载:官方 arxiv.org/html/ 优先
|
||||
let official_html_url = format!("https://arxiv.org/html/{}", clean_id);
|
||||
match self.download_html_direct(&official_html_url, &html_dest, "arXiv-HTML").await {
|
||||
Ok(_) => html_ok = Some(html_dest.clone()),
|
||||
Err(e) => {
|
||||
warn!("[arXiv-HTML] 官方 HTML 下载失败,回退 ar5iv: {:?}", e);
|
||||
// ar5iv 兜底:约 97% 成功率,可能有延迟
|
||||
let ar5iv_url = format!("https://ar5iv.labs.arxiv.org/html/{}", clean_id);
|
||||
match self.download_html_direct(&ar5iv_url, &html_dest, "ar5iv").await {
|
||||
Ok(_) => html_ok = Some(html_dest),
|
||||
Err(e2) => warn!("[ar5iv] HTML 下载也失败: {:?}", e2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(pdf_ok, html_ok)
|
||||
}
|
||||
|
||||
/// 下载 arXiv HTML:官方 arxiv.org/html/ 优先,ar5iv 兜底
|
||||
/// arxiv_id 应已去除版本号
|
||||
async fn download_arxiv_html_with_fallback(&self, arxiv_id: &str, dest_path: &Path) -> Result<()> {
|
||||
let official_url = format!("https://arxiv.org/html/{}", arxiv_id);
|
||||
match self.download_html_direct(&official_url, dest_path, "arXiv-HTML").await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
warn!("[arXiv-HTML] 官方 HTML 失败,回退 ar5iv: {:?}", e);
|
||||
let ar5iv_url = format!("https://ar5iv.labs.arxiv.org/html/{}", arxiv_id);
|
||||
self.download_html_direct(&ar5iv_url, dest_path, "ar5iv").await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 为 ADS Bibcode 下载 PDF 与 HTML(多级回退策略)
|
||||
///
|
||||
/// PDF 回退顺序:
|
||||
/// 1. ADS PUB_PDF 网关 → 按 DOI 前缀路由(IOP/Springer 用专属策略,其余通用)
|
||||
/// 2. ADS EPRINT_PDF 网关
|
||||
/// 3. CrossRef API PDF(需提供 DOI)
|
||||
///
|
||||
/// HTML 回退顺序:
|
||||
/// 1. ADS PUB_HTML 网关(IOP→ 直联 iopscience,arxiv abs → ar5iv)
|
||||
/// 2. ADS EPRINT_HTML 网关(arxiv abs → ar5iv)
|
||||
pub async fn download_paper(&self, bibcode: &str, doi: Option<&str>, library_dir: &Path) -> (Option<PathBuf>, Option<PathBuf>) {
|
||||
let base = "https://ui.adsabs.harvard.edu/link_gateway";
|
||||
let pdf_dest = library_dir.join("PDF").join(format!("{}.pdf", bibcode));
|
||||
let html_dest = library_dir.join("HTML").join(format!("{}.html", bibcode));
|
||||
|
||||
let mut pdf_ok: Option<PathBuf> = None;
|
||||
let mut html_ok: Option<PathBuf> = None;
|
||||
|
||||
// ── PDF 下载 ───────────────────────────────────────────
|
||||
info!("[下载] 开始 PDF 下载: {}", bibcode);
|
||||
|
||||
'pdf: {
|
||||
// 1a. ADS PUB_PDF 网关
|
||||
let gw = format!("{}/{}/PUB_PDF", base, bibcode);
|
||||
match self.resolve_ads_gateway(&gw).await {
|
||||
Ok(resolved) => {
|
||||
let result = if resolved.contains("iopscience.iop.org") {
|
||||
// 提取 DOI 路径部分,走 IOP 专属策略
|
||||
let doi = resolved
|
||||
.trim_start_matches("https://iopscience.iop.org/article/")
|
||||
.trim_end_matches("/pdf")
|
||||
.trim_end_matches('/');
|
||||
self.download_iop_pdf(doi, &pdf_dest).await
|
||||
} else if resolved.contains("link.springer.com") || resolved.contains("nature.com") {
|
||||
// Springer/Nature:HTML 更可靠,PDF 用通用策略
|
||||
self.download_pdf_direct(&resolved, &pdf_dest, "Springer").await
|
||||
} else {
|
||||
self.download_pdf_direct(&resolved, &pdf_dest, "PUB_PDF").await
|
||||
};
|
||||
match result {
|
||||
Ok(_) => { pdf_ok = Some(pdf_dest.clone()); break 'pdf; }
|
||||
Err(e) => warn!("[PUB_PDF] 下载失败: {:?}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("[PUB_PDF] 网关解析失败: {:?}", e),
|
||||
}
|
||||
|
||||
// 1b. ADS EPRINT_PDF 网关
|
||||
let gw = format!("{}/{}/EPRINT_PDF", base, bibcode);
|
||||
match self.resolve_ads_gateway(&gw).await {
|
||||
Ok(resolved) => {
|
||||
match self.download_pdf_direct(&resolved, &pdf_dest, "EPRINT_PDF").await {
|
||||
Ok(_) => { pdf_ok = Some(pdf_dest.clone()); break 'pdf; }
|
||||
Err(e) => warn!("[EPRINT_PDF] 下载失败: {:?}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("[EPRINT_PDF] 网关解析失败: {:?}", e),
|
||||
}
|
||||
|
||||
// 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()); }
|
||||
Err(e) => warn!("[CrossRef] PDF 下载失败: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTML 下载 ──────────────────────────────────────────
|
||||
info!("[下载] 开始 HTML 下载: {}", bibcode);
|
||||
|
||||
'html: {
|
||||
// 2a. ADS PUB_HTML 网关
|
||||
let gw = format!("{}/{}/PUB_HTML", base, bibcode);
|
||||
match self.resolve_ads_gateway(&gw).await {
|
||||
Ok(resolved) => {
|
||||
let result = if resolved.contains("link.springer.com") || resolved.contains("nature.com") {
|
||||
// Springer/Nature 专属 HTML 策略
|
||||
let doi_part = resolved
|
||||
.trim_start_matches("https://link.springer.com/article/")
|
||||
.trim_start_matches("https://www.nature.com/articles/")
|
||||
.trim_end_matches('/');
|
||||
self.download_springer_html(doi_part, &html_dest).await
|
||||
} else if let Some(arxiv_id) = extract_arxiv_id_from_url(&resolved) {
|
||||
// ADS 网关指向 arXiv abs 页面 → 优先官方 HTML,ar5iv 兜底
|
||||
self.download_arxiv_html_with_fallback(&arxiv_id, &html_dest).await
|
||||
} else {
|
||||
self.download_html_direct(&resolved, &html_dest, "PUB_HTML").await
|
||||
};
|
||||
match result {
|
||||
Ok(_) => { html_ok = Some(html_dest.clone()); break 'html; }
|
||||
Err(e) => warn!("[PUB_HTML] 下载失败: {:?}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("[PUB_HTML] 网关解析失败: {:?}", e),
|
||||
}
|
||||
|
||||
// 2b. ADS EPRINT_HTML 网关(大多数天文论文有 arXiv eprint)
|
||||
let gw = format!("{}/{}/EPRINT_HTML", base, bibcode);
|
||||
match self.resolve_ads_gateway(&gw).await {
|
||||
Ok(resolved) => {
|
||||
let result = if let Some(arxiv_id) = extract_arxiv_id_from_url(&resolved) {
|
||||
self.download_arxiv_html_with_fallback(&arxiv_id, &html_dest).await
|
||||
} else {
|
||||
self.download_html_direct(&resolved, &html_dest, "EPRINT_HTML").await
|
||||
};
|
||||
match result {
|
||||
Ok(_) => { html_ok = Some(html_dest.clone()); }
|
||||
Err(e) => warn!("[EPRINT_HTML] 下载失败: {:?}", e),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("[EPRINT_HTML] 网关解析失败: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
(pdf_ok, html_ok)
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_arxiv_version(arxiv_id: &str) -> String {
|
||||
use regex::Regex;
|
||||
static RE_VERSION: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
let re = RE_VERSION.get_or_init(|| Regex::new(r"v\d+$").unwrap());
|
||||
re.replace(arxiv_id, "").to_string()
|
||||
}
|
||||
|
||||
fn extract_arxiv_id_from_url(url: &str) -> Option<String> {
|
||||
let patterns = [
|
||||
"arxiv.org/abs/",
|
||||
"arxiv.org/pdf/",
|
||||
"arxiv.org/html/",
|
||||
"ar5iv.labs.arxiv.org/html/",
|
||||
"ar5iv.org/abs/",
|
||||
"ar5iv.org/html/",
|
||||
];
|
||||
for pat in &patterns {
|
||||
if let Some(pos) = url.find(pat) {
|
||||
let id_raw = &url[pos + pat.len()..];
|
||||
let mut id_clean = id_raw.split('?').next().unwrap_or(id_raw)
|
||||
.split('#').next().unwrap_or(id_raw)
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
if id_clean.to_lowercase().ends_with(".pdf") {
|
||||
id_clean.truncate(id_clean.len() - 4);
|
||||
}
|
||||
if id_clean.to_lowercase().ends_with(".html") {
|
||||
id_clean.truncate(id_clean.len() - 5);
|
||||
}
|
||||
let id = strip_arxiv_version(&id_clean);
|
||||
if !id.is_empty() {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{Router, routing::get, response::Redirect};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_ads_gateway_perfdrive() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let target_ssc = "https%3A%2F%2Fexample.com%2Ftarget.pdf";
|
||||
let redirect_to = format!("https://validate.perfdrive.com/?ssc={}", target_ssc);
|
||||
|
||||
let app = Router::new().route("/gate", get(move || {
|
||||
let r = redirect_to.clone();
|
||||
async move { Redirect::to(&r) }
|
||||
}));
|
||||
|
||||
let server = axum::serve(
|
||||
tokio::net::TcpListener::from_std(listener).unwrap(),
|
||||
app,
|
||||
);
|
||||
tokio::spawn(async move { let _ = server.await; });
|
||||
|
||||
let downloader = Downloader::new();
|
||||
let gateway_url = format!("http://127.0.0.1:{}/gate", port);
|
||||
let result = downloader.resolve_ads_gateway(&gateway_url).await;
|
||||
assert_eq!(result.unwrap(), "https://example.com/target.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pdf_content_valid() {
|
||||
let mut pdf = b"%PDF-1.7 ".to_vec();
|
||||
pdf.extend(vec![0u8; 5100]);
|
||||
pdf.extend(b"%%EOF");
|
||||
assert!(validate_pdf_content(&pdf).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_pdf_content_html() {
|
||||
let html = b"<html><body>please log in</body></html>".to_vec();
|
||||
let result = validate_pdf_content(&html);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_anti_bot_cloudflare() {
|
||||
let result = detect_anti_bot("just a moment please", None);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_anti_bot_clean() {
|
||||
let result = detect_anti_bot("<html><body><h1>Abstract</h1><p>We study...</p></body></html>", None);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_arxiv_version() {
|
||||
assert_eq!(strip_arxiv_version("2101.00001v2"), "2101.00001");
|
||||
assert_eq!(strip_arxiv_version("hep-th/9901001v3"), "hep-th/9901001");
|
||||
assert_eq!(strip_arxiv_version("2101.00001"), "2101.00001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_arxiv_id_from_url() {
|
||||
assert_eq!(
|
||||
extract_arxiv_id_from_url("https://arxiv.org/abs/2101.00001v2"),
|
||||
Some("2101.00001".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_arxiv_id_from_url("https://arxiv.org/pdf/hep-th/9901001v3.pdf"),
|
||||
Some("hep-th/9901001".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_arxiv_id_from_url("https://ar5iv.labs.arxiv.org/html/2101.00001"),
|
||||
Some("2101.00001".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1014
File diff suppressed because it is too large
Load Diff
+147
@@ -0,0 +1,147 @@
|
||||
// src/main.rs
|
||||
mod config;
|
||||
mod qiniu;
|
||||
mod ads;
|
||||
mod arxiv;
|
||||
mod download;
|
||||
mod translation;
|
||||
mod parser;
|
||||
mod handlers;
|
||||
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::services::ServeDir;
|
||||
use sqlx::sqlite::SqlitePoolOptions;
|
||||
use tracing::{info, error};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::translation::Dictionary;
|
||||
use crate::qiniu::QiniuClient;
|
||||
use crate::ads::AdsClient;
|
||||
use crate::arxiv::ArxivClient;
|
||||
use crate::download::Downloader;
|
||||
use crate::handlers::AppState;
|
||||
|
||||
#[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();
|
||||
|
||||
info!("正在启动 AstroResearch 天文学文献辅助系统后端服务...");
|
||||
|
||||
// 2. 加载环境变量配置
|
||||
let config = Config::from_env();
|
||||
info!("系统配置成功载入。本地 SQLite 连接串: {}", config.database_url);
|
||||
|
||||
// 创建本地馆藏物理文件夹分类结构
|
||||
std::fs::create_dir_all(&config.library_dir).unwrap_or_default();
|
||||
std::fs::create_dir_all(config.library_dir.join("PDF")).unwrap_or_default();
|
||||
std::fs::create_dir_all(config.library_dir.join("HTML")).unwrap_or_default();
|
||||
std::fs::create_dir_all(config.library_dir.join("Markdown")).unwrap_or_default();
|
||||
std::fs::create_dir_all(config.library_dir.join("Translation")).unwrap_or_default();
|
||||
|
||||
// 3. 初始化本地 SQLite 数据库文件连接池
|
||||
if config.database_url.starts_with("sqlite://") {
|
||||
let db_path = config.database_url.replace("sqlite://", "");
|
||||
if !db_path.contains(":memory:") {
|
||||
let path = std::path::Path::new(&db_path);
|
||||
if !path.exists() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap_or_default();
|
||||
}
|
||||
std::fs::File::create(path)?;
|
||||
info!("初始化创建本地 SQLite 数据库文件: {:?}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&config.database_url)
|
||||
.await?;
|
||||
|
||||
info!("SQLite 数据库连接已建立。");
|
||||
|
||||
// 4. 自动执行数据库迁移脚本
|
||||
info!("开始执行 SQL 表结构迁移...");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await?;
|
||||
info!("数据库迁移执行完成,主表准备就绪。");
|
||||
|
||||
// 5. 异步加载天文学专业名词对照词表
|
||||
let mut dict = Dictionary::new();
|
||||
if let Err(e) = dict.load_from_file("dictionary.txt") {
|
||||
error!("天文学名词词表加载失败: {}", e);
|
||||
}
|
||||
|
||||
// 6. 初始化并配置全部 API 与下载客户端
|
||||
let qiniu = QiniuClient::new(
|
||||
config.qiniu_ak.clone(),
|
||||
config.qiniu_sk.clone(),
|
||||
config.qiniu_bucket.clone(),
|
||||
config.qiniu_domain.clone(),
|
||||
);
|
||||
|
||||
let ads = AdsClient::new(config.ads_api_key.clone());
|
||||
let arxiv = ArxivClient::new();
|
||||
let downloader = Downloader::new();
|
||||
|
||||
let app_state = Arc::new(AppState {
|
||||
config: config.clone(),
|
||||
db: pool,
|
||||
dict,
|
||||
qiniu,
|
||||
ads,
|
||||
arxiv,
|
||||
downloader,
|
||||
});
|
||||
|
||||
// 7. 设置 Axum 路由、CORS 头以及 React 仪表盘静态资源托管
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
let api_routes = Router::new()
|
||||
.route("/search", get(handlers::search_papers))
|
||||
.route("/download", post(handlers::download_paper))
|
||||
.route("/parse", post(handlers::parse_paper))
|
||||
.route("/translate", post(handlers::translate_paper))
|
||||
.route("/citations", get(handlers::get_citation_network))
|
||||
.route("/paper", get(handlers::get_paper_detail))
|
||||
.route("/library", get(handlers::get_library))
|
||||
.route("/export", post(handlers::export_citations))
|
||||
.route("/notes", post(handlers::create_note))
|
||||
.route("/notes", get(handlers::get_notes))
|
||||
.route("/notes", axum::routing::delete(handlers::delete_note));
|
||||
|
||||
// 静态文件资源代理托管(当前端打包至 dashboard/dist 后,直接挂载到主域名根路由)
|
||||
let serve_dir = ServeDir::new("dashboard/dist")
|
||||
.fallback(tower_http::services::ServeFile::new("dashboard/dist/index.html"));
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api", api_routes)
|
||||
.fallback_service(serve_dir)
|
||||
.layer(cors)
|
||||
.with_state(app_state);
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], config.port));
|
||||
info!("天文学科研服务已成功监听 http://localhost:{}", config.port);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
// src/parser.rs
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use serde::Deserialize;
|
||||
use reqwest::multipart;
|
||||
use tracing::{info, warn};
|
||||
use regex::Regex;
|
||||
use base64::Engine;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::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 mut truncated_html = html_content.as_str();
|
||||
if let Some(end) = html_content.find("<div class=\"ar5iv-footer\">") {
|
||||
truncated_html = &html_content[..end];
|
||||
} else if let Some(end) = html_content.find("<footer") {
|
||||
truncated_html = &html_content[..end];
|
||||
}
|
||||
|
||||
let mut main_html = truncated_html;
|
||||
|
||||
// 定位正文标记块,滤除页眉、页脚等侧栏广告
|
||||
if let Some(start) = truncated_html.find("<div class=\"ltx_page_main\">") {
|
||||
main_html = &truncated_html[start..];
|
||||
} else if let Some(start) = truncated_html.find("<main") {
|
||||
main_html = &truncated_html[start..];
|
||||
} else if let Some(start) = truncated_html.find("<article") {
|
||||
main_html = &truncated_html[start..];
|
||||
} else if let Some(start) = truncated_html.find("<body") {
|
||||
main_html = &truncated_html[start..];
|
||||
}
|
||||
|
||||
// 预处理:删除导航条、页眉、侧边栏等不属于正文的结构
|
||||
let nav_re = Regex::new(r#"(?s)<nav[^>]*>.*?</nav>"#).unwrap();
|
||||
let preprocessed_html = nav_re.replace_all(main_html, "").to_string();
|
||||
let ltx_nav_re = Regex::new(r#"(?s)<div[^>]*class="[^"]*ltx_(?:page_navbar|header|navigation)[^"]*"[^>]*>.*?</div>"#).unwrap();
|
||||
let preprocessed_html = ltx_nav_re.replace_all(&preprocessed_html, "").to_string();
|
||||
|
||||
// 预处理:提前用占位符替换 <math ...>...</math> 公式,防止其内部 Latex 语法被标题解析、图注解析或 html2md 破坏
|
||||
let mut formulas = Vec::new();
|
||||
let math_re = Regex::new(r#"(?s)<math\s+([^>]*?)>(.*?)</math>"#).unwrap();
|
||||
let mut placeholder_counter = 0;
|
||||
|
||||
let preprocessed_html = math_re.replace_all(&preprocessed_html, |caps: ®ex::Captures| {
|
||||
let attrs = &caps[1];
|
||||
let alttext_re = Regex::new(r#"alttext="([^"]*)""#).unwrap();
|
||||
let mut alttext = alttext_re.captures(attrs)
|
||||
.map(|c| c[1].to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 如果 alttext 为空,尝试从 <annotation encoding="application/x-tex"> 中提取 LaTeX 公式作为备选方案
|
||||
if alttext.is_empty() {
|
||||
let annotation_re = Regex::new(r#"(?s)<annotation\s+[^>]*encoding="application/x-tex"[^>]*>(.*?)</annotation>"#).unwrap();
|
||||
if let Some(ann_caps) = annotation_re.captures(&caps[2]) {
|
||||
alttext = ann_caps[1].trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
let is_block = attrs.contains("display=\"block\"") || attrs.contains("display='block'");
|
||||
formulas.push((alttext, is_block));
|
||||
|
||||
let placeholder = format!(" MATHPLACEHOLDER{} ", placeholder_counter);
|
||||
placeholder_counter += 1;
|
||||
placeholder
|
||||
}).to_string();
|
||||
|
||||
// 预处理:将 ltx_section 标题标记转换为对应层级的 Markdown heading
|
||||
// h2: section, h3: subsection, h4: subsubsection
|
||||
let sec_re = Regex::new(r#"(?s)<(?:h[1-6])[^>]*class="[^"]*ltx_title_section[^"]*"[^>]*>(.*?)</(?:h[1-6])>"#).unwrap();
|
||||
let preprocessed_html = sec_re.replace_all(&preprocessed_html, |caps: ®ex::Captures| {
|
||||
let inner = strip_html_tags(&caps[1]);
|
||||
format!("\n\n## {}\n\n", inner.trim())
|
||||
}).to_string();
|
||||
|
||||
let subsec_re = Regex::new(r#"(?s)<(?:h[1-6])[^>]*class="[^"]*ltx_title_subsection[^"]*"[^>]*>(.*?)</(?:h[1-6])>"#).unwrap();
|
||||
let preprocessed_html = subsec_re.replace_all(&preprocessed_html, |caps: ®ex::Captures| {
|
||||
let inner = strip_html_tags(&caps[1]);
|
||||
format!("\n\n### {}\n\n", inner.trim())
|
||||
}).to_string();
|
||||
|
||||
let subsubsec_re = Regex::new(r#"(?s)<(?:h[1-6])[^>]*class="[^"]*ltx_title_subsubsection[^"]*"[^>]*>(.*?)</(?:h[1-6])>"#).unwrap();
|
||||
let preprocessed_html = subsubsec_re.replace_all(&preprocessed_html, |caps: ®ex::Captures| {
|
||||
let inner = strip_html_tags(&caps[1]);
|
||||
format!("\n\n#### {}\n\n", inner.trim())
|
||||
}).to_string();
|
||||
|
||||
// 预处理:将 figcaption 转换为 Markdown 图注格式
|
||||
let figcaption_re = Regex::new(r#"(?s)<figcaption[^>]*>(.*?)</figcaption>"#).unwrap();
|
||||
let preprocessed_html = figcaption_re.replace_all(&preprocessed_html, |caps: ®ex::Captures| {
|
||||
let inner = strip_html_tags(&caps[1]);
|
||||
format!("\n> **Figure:** {}\n", inner.trim())
|
||||
}).to_string();
|
||||
|
||||
// 预处理:将 ltx_caption (LaTeXML figure/table caption) 转换为图注
|
||||
let ltx_caption_re = Regex::new(r#"(?s)<(?:span|div|p)[^>]*class="[^"]*ltx_caption[^"]*"[^>]*>(.*?)</(?:span|div|p)>"#).unwrap();
|
||||
let preprocessed_html = ltx_caption_re.replace_all(&preprocessed_html, |caps: ®ex::Captures| {
|
||||
let inner = strip_html_tags(&caps[1]);
|
||||
format!("\n> **Caption:** {}\n", inner.trim())
|
||||
}).to_string();
|
||||
|
||||
// 预处理:将 ltx_title 文章标题转为 h1
|
||||
let title_re = Regex::new(r#"(?s)<(?:h[1-6])[^>]*class="[^"]*ltx_title_document[^"]*"[^>]*>(.*?)</(?:h[1-6])>"#).unwrap();
|
||||
let preprocessed_html = title_re.replace_all(&preprocessed_html, |caps: ®ex::Captures| {
|
||||
let inner = strip_html_tags(&caps[1]);
|
||||
format!("\n# {}\n\n", inner.trim())
|
||||
}).to_string();
|
||||
|
||||
// 预处理 HTML 中的 sup, sub, inf 标签为更干净的 markdown 格式,解决 html2md 不转换带属性的上下标的问题
|
||||
let sup_re = Regex::new(r#"(?s)<sup[^>]*>(.*?)</sup>"#).unwrap();
|
||||
let preprocessed_html = sup_re.replace_all(&preprocessed_html, "^{$1}").to_string();
|
||||
|
||||
let sub_re = Regex::new(r#"(?s)<sub[^>]*>(.*?)</sub>"#).unwrap();
|
||||
let preprocessed_html = sub_re.replace_all(&preprocessed_html, "_{$1}").to_string();
|
||||
|
||||
let inf_re = Regex::new(r#"(?s)<inf[^>]*>(.*?)</inf>"#).unwrap();
|
||||
let preprocessed_html = inf_re.replace_all(&preprocessed_html, "_{$1}").to_string();
|
||||
|
||||
// 预处理:去除 <cite> 标签以防止 html2md 将其转为 blockquote (>) 导致行内引用异常断行
|
||||
let cite_start_re = Regex::new(r#"(?s)<cite[^>]*>"#).unwrap();
|
||||
let preprocessed_html = cite_start_re.replace_all(&preprocessed_html, "").to_string();
|
||||
let cite_end_re = Regex::new(r#"(?s)</cite>"#).unwrap();
|
||||
let preprocessed_html = cite_end_re.replace_all(&preprocessed_html, "").to_string();
|
||||
|
||||
// 预处理 HTML 中的 <img> 标签,将相对路径的图片链接补全为 ar5iv 绝对路径,并统一转换为标准 Markdown 图片格式 
|
||||
let img_re = Regex::new(r#"(?s)<img\s+([^>]*?)>"#).unwrap();
|
||||
let preprocessed_html = img_re.replace_all(&preprocessed_html, |caps: ®ex::Captures| {
|
||||
let attrs = &caps[1];
|
||||
|
||||
let src_re = Regex::new(r#"src="([^"]*)""#).unwrap();
|
||||
let src = src_re.captures(attrs)
|
||||
.map(|c| c[1].to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let alt_re = Regex::new(r#"alt="([^"]*)""#).unwrap();
|
||||
let alt = alt_re.captures(attrs)
|
||||
.map(|c| c[1].to_string())
|
||||
.unwrap_or_else(|| "image".to_string());
|
||||
|
||||
let absolute_src = if src.starts_with('/') {
|
||||
format!("https://ar5iv.labs.arxiv.org{}", src)
|
||||
} else {
|
||||
src
|
||||
};
|
||||
|
||||
format!("", alt, absolute_src)
|
||||
}).to_string();
|
||||
|
||||
// 预处理 HTML 中的 LaTeXML 模拟表格标记,将 span 模拟 of tabular/tr/td/th 转换为真正的 <table> 结构以保证 Markdown 排版
|
||||
let td_re = Regex::new(r#"(?s)<span\s+([^>]*?class="[^"]*ltx_t[dh][^"]*"[^>]*?)>(.*?)</span>"#).unwrap();
|
||||
let preprocessed_html = td_re.replace_all(&preprocessed_html, " <td>$2</td> ").to_string();
|
||||
|
||||
let tr_re = Regex::new(r#"(?s)<span\s+([^>]*?class="[^"]*ltx_tr[^"]*"[^>]*?)>(.*?)</span>"#).unwrap();
|
||||
let preprocessed_html = tr_re.replace_all(&preprocessed_html, " <tr>$2</tr> ").to_string();
|
||||
|
||||
let table_re = Regex::new(r#"(?s)<span\s+([^>]*?class="[^"]*ltx_tabular[^"]*"[^>]*?)>(.*?)</span>"#).unwrap();
|
||||
let preprocessed_html = table_re.replace_all(&preprocessed_html, " <table>$2</table> ").to_string();
|
||||
|
||||
let mut markdown = html2md::parse_html(&preprocessed_html);
|
||||
|
||||
// 将公式占位符以逆序还原为原始干净的 LaTeX 格式 ($...$ 或 $$...$$),避免前缀匹配冲突(例如 MATHPLACEHOLDER1 误匹配 MATHPLACEHOLDER10 的前缀)
|
||||
for i in (0..formulas.len()).rev() {
|
||||
let (ref alttext, is_block) = formulas[i];
|
||||
let placeholder = format!("MATHPLACEHOLDER{}", i);
|
||||
let replacement = if is_block {
|
||||
format!(" $${}$$ ", alttext)
|
||||
} else {
|
||||
format!(" ${}$ ", alttext)
|
||||
};
|
||||
markdown = markdown.replace(&placeholder, &replacement);
|
||||
}
|
||||
|
||||
let cleaned = postprocess_markdown(&markdown);
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
// 移除 Markdown 垃圾属性标识并清洗每行格式
|
||||
fn postprocess_markdown(text: &str) -> String {
|
||||
// 按行清理多余前导/尾随空格,同时保留 fenced 代码块内的缩进
|
||||
let mut clean_lines = Vec::new();
|
||||
let mut in_code_block = false;
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("```") {
|
||||
in_code_block = !in_code_block;
|
||||
}
|
||||
if in_code_block {
|
||||
clean_lines.push(line.to_string());
|
||||
} else {
|
||||
clean_lines.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
let mut md = clean_lines.join("\n");
|
||||
|
||||
let div_re = Regex::new(r"</?div[^>]*>").unwrap();
|
||||
let span_re = Regex::new(r"</?span[^>]*>").unwrap();
|
||||
md = div_re.replace_all(&md, "").to_string();
|
||||
md = span_re.replace_all(&md, "").to_string();
|
||||
|
||||
let empty_brackets = Regex::new(r"\[\]").unwrap();
|
||||
md = empty_brackets.replace_all(&md, "").to_string();
|
||||
|
||||
let excessive_newlines = Regex::new(r"\n{4,}").unwrap();
|
||||
md = excessive_newlines.replace_all(&md, "\n\n\n").to_string();
|
||||
|
||||
// 还原被 html2md 自动转义的标题与引用符号
|
||||
let unescape_h1 = Regex::new(r"\\#\s+").unwrap();
|
||||
let unescape_h2 = Regex::new(r"\\##\s+").unwrap();
|
||||
let unescape_h3 = Regex::new(r"\\###\s+").unwrap();
|
||||
let unescape_h4 = Regex::new(r"\\####\s+").unwrap();
|
||||
let unescape_quote = Regex::new(r"\\>\s+").unwrap();
|
||||
let unescape_bold = Regex::new(r"\\\*\\\*").unwrap();
|
||||
|
||||
md = unescape_h1.replace_all(&md, "# ").to_string();
|
||||
md = unescape_h2.replace_all(&md, "## ").to_string();
|
||||
md = unescape_h3.replace_all(&md, "### ").to_string();
|
||||
md = unescape_h4.replace_all(&md, "#### ").to_string();
|
||||
md = unescape_quote.replace_all(&md, "> ").to_string();
|
||||
md = unescape_bold.replace_all(&md, "**").to_string();
|
||||
|
||||
// 还原 HTML 实体转义符以保证 Markdown/LaTeX 中数学符号(如 < 和 >)正常渲染
|
||||
md = md
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'");
|
||||
|
||||
md.trim().to_string()
|
||||
}
|
||||
|
||||
// 简单移除 HTML 标签,返回纯文本内容(用于标题/图注提取)
|
||||
fn strip_html_tags(html: &str) -> String {
|
||||
let tag_re = Regex::new(r"<[^>]+>").unwrap();
|
||||
let text = tag_re.replace_all(html, "").to_string();
|
||||
// 解码常见 HTML 实体
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace(" ", " ")
|
||||
.replace("'", "'")
|
||||
}
|
||||
|
||||
// 调用 MinerU 远程接口解析 PDF,并在提取出图片后自动上传至七牛云进行外链替换
|
||||
pub async fn parse_pdf_via_mineru(
|
||||
pdf_path: &Path,
|
||||
qiniu_client: &QiniuClient,
|
||||
config: &Config
|
||||
) -> anyhow::Result<String> {
|
||||
info!("正在请求 MinerU 解析本地 PDF 文献: {:?}", pdf_path);
|
||||
|
||||
if config.mineru_api_url.is_empty() {
|
||||
return Err(anyhow::anyhow!("未在环境变量 .env 中配置 MINERU_API_URL"));
|
||||
}
|
||||
|
||||
let pdf_bytes = fs::read(pdf_path)?;
|
||||
let filename = pdf_path.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.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);
|
||||
|
||||
info!("正在发送 PDF 字节流至 MinerU 接口地址: {}", config.mineru_api_url);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut request = client.post(&config.mineru_api_url).multipart(form);
|
||||
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()));
|
||||
}
|
||||
|
||||
// MinerU 远程服务响应 JSON,包含转换出的 markdown 正文和图片映射
|
||||
#[derive(Deserialize)]
|
||||
struct MinerUResponse {
|
||||
markdown: String,
|
||||
images: Option<std::collections::HashMap<String, String>>, // 图片文件名 -> Base64 字符串
|
||||
}
|
||||
|
||||
let result: MinerUResponse = response.json().await?;
|
||||
let mut markdown = result.markdown;
|
||||
|
||||
// 上传图片并重写 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, |_: ®ex::Captures| {
|
||||
format!("({})", qiniu_url)
|
||||
}).to_string();
|
||||
},
|
||||
Err(e) => warn!("上传图片至七牛云失败 {}: {}", img_name, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("未检测到七牛云配置,解析出的图片将保留临时地址,无法在外网或 Obsidian 中直观预览");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(markdown)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_postprocess_markdown() {
|
||||
let dirty = "<div>Hello</div> <span class=\"abc\">World</span> [] <math>\n\n\n\n\nNew Paragraph";
|
||||
let cleaned = postprocess_markdown(dirty);
|
||||
assert_eq!(cleaned, "Hello World <math>\n\n\nNew Paragraph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_markdown() -> anyhow::Result<()> {
|
||||
let html_content = r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div class="ltx_page_main">
|
||||
<h1>Test Document</h1>
|
||||
<p>This is a <strong>test</strong> paragraph.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push("test_doc.html");
|
||||
{
|
||||
let mut file = std::fs::File::create(&path)?;
|
||||
file.write_all(html_content.as_bytes())?;
|
||||
}
|
||||
|
||||
let md = html_to_markdown(&path);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let md_content = md?;
|
||||
assert!(md_content.contains("Test Document"));
|
||||
assert!(md_content.contains("This is a **test** paragraph."));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_markdown_math_and_table() -> anyhow::Result<()> {
|
||||
let html_content = r#"
|
||||
<div class="ltx_page_main">
|
||||
<p>Here is math: <math alttext="\approx" display="inline"><semantics><mo>≈</mo><annotation-xml><approx></approx></annotation-xml><annotation>\approx</annotation></semantics></math> and block <math alttext="\sum_{i=1}^n" display="block">...</math></p>
|
||||
<span class="ltx_tabular">
|
||||
<span class="ltx_tr">
|
||||
<span class="ltx_td">sdB</span>
|
||||
<span class="ltx_td">subdwarf B</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
"#;
|
||||
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push("test_math_table.html");
|
||||
{
|
||||
let mut file = std::fs::File::create(&path)?;
|
||||
file.write_all(html_content.as_bytes())?;
|
||||
}
|
||||
|
||||
let md = html_to_markdown(&path);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let md_content = md?;
|
||||
// 验证数学公式被成功以未转义的 Latex 格式提取还原
|
||||
assert!(md_content.contains(r#"$\approx$"#));
|
||||
assert!(md_content.contains(r#"$$\sum_{i=1}^n$$"#));
|
||||
|
||||
// 验证表格被转换成了标准 table
|
||||
assert!(md_content.contains("sdB"));
|
||||
assert!(md_content.contains("subdwarf B"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_to_markdown_math_in_headings_and_captions() -> anyhow::Result<()> {
|
||||
let html_content = r#"
|
||||
<div class="ltx_page_main">
|
||||
<h2 class="ltx_title_section">Heading with math <math alttext="\theta_{eff}" display="inline"><semantics><mo>≈</mo><annotation>\theta_{eff}</annotation></semantics></math></h2>
|
||||
<figcaption>Figure caption with inline math <math alttext="M_\odot" display="inline"><semantics><mo>≈</mo><annotation>M_\odot</annotation></semantics></math> details.</figcaption>
|
||||
</div>
|
||||
"#;
|
||||
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push("test_math_heading_caption.html");
|
||||
{
|
||||
let mut file = std::fs::File::create(&path)?;
|
||||
file.write_all(html_content.as_bytes())?;
|
||||
}
|
||||
|
||||
let md = html_to_markdown(&path);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let md_content = md?;
|
||||
println!("Markdown content:\n{}", md_content);
|
||||
// 验证公式占位符在标题和图注内没有被 strip_html_tags 破坏,并能恢复成正确的 Latex
|
||||
assert!(md_content.contains("## Heading with math"));
|
||||
assert!(md_content.contains(r#"$\theta_{eff}$"#));
|
||||
assert!(md_content.contains("> **Figure:** Figure caption with inline math"));
|
||||
assert!(md_content.contains(r#"$M_\odot$"#));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
use sha1::Sha1;
|
||||
use hmac::{Hmac, Mac};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use reqwest::multipart;
|
||||
use tracing::{info, error};
|
||||
|
||||
type HmacSha1 = Hmac<Sha1>;
|
||||
|
||||
// 七牛云存储访问客户端
|
||||
pub struct QiniuClient {
|
||||
access_key: String,
|
||||
secret_key: String,
|
||||
bucket: String,
|
||||
domain: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl QiniuClient {
|
||||
pub fn new(access_key: String, secret_key: String, bucket: String, domain: String) -> Self {
|
||||
QiniuClient {
|
||||
access_key,
|
||||
secret_key,
|
||||
bucket,
|
||||
domain,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// 判断配置项是否齐全
|
||||
pub fn is_configured(&self) -> bool {
|
||||
!self.access_key.is_empty() && !self.secret_key.is_empty() && !self.bucket.is_empty()
|
||||
}
|
||||
|
||||
// 依照七牛云规范,使用 HMAC-SHA1 算法生成上传凭证 Token
|
||||
fn generate_upload_token(&self, key: &str) -> String {
|
||||
// 设置 1 小时过期
|
||||
let deadline = chrono::Utc::now().timestamp() + 3600;
|
||||
|
||||
let policy = serde_json::json!({
|
||||
"scope": format!("{}:{}", self.bucket, key),
|
||||
"deadline": deadline
|
||||
});
|
||||
|
||||
let policy_str = policy.to_string();
|
||||
let encoded_policy = URL_SAFE_NO_PAD.encode(policy_str.as_bytes());
|
||||
|
||||
let mut mac = HmacSha1::new_from_slice(self.secret_key.as_bytes())
|
||||
.expect("HMAC 密钥可接收任意大小");
|
||||
mac.update(encoded_policy.as_bytes());
|
||||
let result = mac.finalize();
|
||||
let signature = result.into_bytes();
|
||||
|
||||
let encoded_signature = URL_SAFE_NO_PAD.encode(&signature);
|
||||
|
||||
format!("{}:{}:{}", self.access_key, encoded_signature, encoded_policy)
|
||||
}
|
||||
|
||||
// 上传图片等字节流数据到七牛云,返回 CDN 加速外链 URL
|
||||
pub async fn upload_buffer(&self, buffer: Vec<u8>, filename: &str) -> anyhow::Result<String> {
|
||||
if !self.is_configured() {
|
||||
return Err(anyhow::anyhow!("本地 .env 文件中未正确配置七牛云参数"));
|
||||
}
|
||||
|
||||
// 使用毫秒级时间戳防重名覆盖
|
||||
let timestamp = chrono::Utc::now().timestamp_millis();
|
||||
let key = format!("astroresearch_{}_{}", timestamp, filename);
|
||||
|
||||
let token = self.generate_upload_token(&key);
|
||||
info!("正在上传文献提取图片到七牛云: key='{}'", key);
|
||||
|
||||
let form = multipart::Form::new()
|
||||
.text("token", token)
|
||||
.text("key", key.clone())
|
||||
.part("file", multipart::Part::bytes(buffer).file_name(filename.to_string()));
|
||||
|
||||
let upload_url = "https://up.qiniu.com";
|
||||
|
||||
let response = self.client.post(upload_url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
error!("七牛云图片上传失败: 状态码={}, 返回={}", status, body);
|
||||
return Err(anyhow::anyhow!("七牛云上传响应失败 {}", status));
|
||||
}
|
||||
|
||||
// 拼接最终的下载/访问外链外网地址
|
||||
let mut base_domain = self.domain.clone();
|
||||
if !base_domain.starts_with("http://") && !base_domain.starts_with("https://") {
|
||||
base_domain = format!("http://{}", base_domain);
|
||||
}
|
||||
if base_domain.ends_with('/') {
|
||||
base_domain.pop();
|
||||
}
|
||||
|
||||
let file_url = format!("{}/{}", base_domain, key);
|
||||
info!("七牛云图片上传成功。访问链接: {}", file_url);
|
||||
Ok(file_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_qiniu_configuration() {
|
||||
let client = QiniuClient::new(
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
"".to_string(),
|
||||
);
|
||||
assert!(!client.is_configured());
|
||||
|
||||
let client2 = QiniuClient::new(
|
||||
"ak".to_string(),
|
||||
"sk".to_string(),
|
||||
"bucket".to_string(),
|
||||
"domain".to_string(),
|
||||
);
|
||||
assert!(client2.is_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qiniu_token_generation() {
|
||||
let client = QiniuClient::new(
|
||||
"test_ak".to_string(),
|
||||
"test_sk".to_string(),
|
||||
"test_bucket".to_string(),
|
||||
"test_domain".to_string(),
|
||||
);
|
||||
let token = client.generate_upload_token("test_key.png");
|
||||
assert!(token.starts_with("test_ak:"));
|
||||
|
||||
let parts: Vec<&str> = token.split(':').collect();
|
||||
assert_eq!(parts.len(), 3);
|
||||
assert_eq!(parts[0], "test_ak");
|
||||
assert!(!parts[1].is_empty());
|
||||
assert!(!parts[2].is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// src/translation.rs
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
use serde::Deserialize;
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
// 天文学专有名词英汉词典匹配管理
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Dictionary {
|
||||
// 英文名词(全小写) -> 中文标准译名
|
||||
terms: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Dictionary {
|
||||
pub fn new() -> Self {
|
||||
Dictionary {
|
||||
terms: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// 从本地物理文本加载词表数据
|
||||
pub fn load_from_file<P: AsRef<Path>>(&mut self, path: P) -> anyhow::Result<()> {
|
||||
let path_ref = path.as_ref();
|
||||
if !path_ref.exists() {
|
||||
warn!("词典文件不存在,请检查配置路径: {:?}", path_ref);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("正在加载天文学名词词典: {:?}", path_ref);
|
||||
let file = File::open(path_ref)?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut count = 0;
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let parts: Vec<&str> = line.split('\t').collect();
|
||||
if parts.len() >= 2 {
|
||||
let english = parts[0].trim().to_lowercase();
|
||||
let chinese = parts[1].trim().to_string();
|
||||
if !english.is_empty() && !chinese.is_empty() {
|
||||
self.terms.insert(english, chinese);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("天文词典加载成功,总计导入 {} 条专业术语对照", count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// 在英文文献内容中匹配包含的专业词汇,提取其中英文映射关系以供大模型辅助翻译
|
||||
pub fn match_text(&self, text: &str) -> Vec<(String, String)> {
|
||||
if self.terms.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// 基础分词清理:保留字母数字及连接符,其余视为空格以进行精确段落划分
|
||||
let clean_text = text
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '\'' || c == ' ' { c } else { ' ' })
|
||||
.collect::<String>();
|
||||
|
||||
let words: Vec<&str> = clean_text.split_whitespace().collect();
|
||||
let mut matched = HashSet::new();
|
||||
let mut results = Vec::new();
|
||||
|
||||
// 天文学词条跨度最大限制(一般多词短语不超过 6 个英文单词)
|
||||
let max_span = 6;
|
||||
let n = words.len();
|
||||
|
||||
for i in 0..n {
|
||||
for len in (1..=max_span).rev() {
|
||||
if i + len <= n {
|
||||
let phrase_slice = &words[i..i + len];
|
||||
let phrase = phrase_slice.join(" ").to_lowercase();
|
||||
|
||||
if self.terms.contains_key(&phrase) {
|
||||
// 避免重复匹配更长名词的子词 (如已匹配 'active galactic nucleus' 就不重复提取其中的 'nucleus')
|
||||
if !matched.contains(&phrase) {
|
||||
let chinese = self.terms.get(&phrase).unwrap().clone();
|
||||
let original_phrase = &words[i..i + len].join(" ");
|
||||
|
||||
results.push((original_phrase.clone(), chinese.clone()));
|
||||
matched.insert(phrase);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 优先长词组进行匹配,以防短词冲突影响大模型指引
|
||||
results.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
// 提取文献专业天文词对照提示词,调用 LLM 大模型进行保留公式的高精度学术翻译
|
||||
pub async fn translate_markdown(
|
||||
markdown_content: &str,
|
||||
dict: &Dictionary,
|
||||
config: &Config
|
||||
) -> anyhow::Result<String> {
|
||||
if config.llm_api_key.is_empty() {
|
||||
return Err(anyhow::anyhow!("本地配置中缺少 LLM_API_KEY"));
|
||||
}
|
||||
|
||||
// 在英文文献中扫描天文词典匹配专业词汇
|
||||
let matched_terms = dict.match_text(markdown_content);
|
||||
let mut terms_instruction = String::new();
|
||||
|
||||
if !matched_terms.is_empty() {
|
||||
terms_instruction.push_str("\n\n在翻译时,请遵循以下天文学名词对照表(严格使用对应的中文译名):\n");
|
||||
for (en, zh) in matched_terms.iter().take(50) { // 最多注入前 50 条防止超量
|
||||
terms_instruction.push_str(&format!("- \"{}\" 必须翻译为 \"{}\"\n", en, zh));
|
||||
}
|
||||
}
|
||||
|
||||
let system_prompt = format!(
|
||||
"你是一位专业的天文学家和学术翻译家。请将以下英文天文学文献段落翻译成中文。\n\
|
||||
要求:\n\
|
||||
1. 翻译风格必须专业、准确、符合天文学学术规范。\n\
|
||||
2. **务必完整保留所有的 LaTeX 数学公式(如 $...$ 或 $$...$$)和 Markdown 排版格式(如标题、粗体、列表、图片链接等),不要翻译公式内的字符。**\n\
|
||||
3. 保持译文段落结构与原文一一对应。{}\n\
|
||||
请开始你的翻译工作:",
|
||||
terms_instruction
|
||||
);
|
||||
|
||||
info!("正在请求大模型开展中英翻译。所选大模型: {}", config.llm_model);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{}/chat/completions", config.llm_api_base);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": config.llm_model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": markdown_content
|
||||
}
|
||||
],
|
||||
"temperature": 0.3
|
||||
});
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.llm_api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
error!("LLM 翻译接口调用失败: 状态码={}, 报错={}", status, body);
|
||||
return Err(anyhow::anyhow!("大模型接口返回错误状态: {}", status));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Message {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Choice {
|
||||
message: Message,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LLMResponse {
|
||||
choices: Vec<Choice>,
|
||||
}
|
||||
|
||||
let res_data: LLMResponse = response.json().await?;
|
||||
if let Some(choice) = res_data.choices.first() {
|
||||
Ok(choice.message.content.clone())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("大模型返回空翻译选项集"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_dictionary_match() {
|
||||
let mut dict = Dictionary::new();
|
||||
// 模拟词典数据
|
||||
dict.terms.insert("active galactic nucleus".to_string(), "活动星系核".to_string());
|
||||
dict.terms.insert("galactic nucleus".to_string(), "星系核".to_string());
|
||||
dict.terms.insert("nucleus".to_string(), "核心".to_string());
|
||||
dict.terms.insert("black hole".to_string(), "黑洞".to_string());
|
||||
|
||||
let text = "We study the active galactic nucleus and its central black hole.";
|
||||
let matched = dict.match_text(text);
|
||||
|
||||
let phrases: Vec<String> = matched.iter().map(|(en, _)| en.clone()).collect();
|
||||
assert!(phrases.contains(&"active galactic nucleus".to_string()));
|
||||
assert!(phrases.contains(&"black hole".to_string()));
|
||||
|
||||
// 验证最长的在前面
|
||||
assert_eq!(matched[0].0, "active galactic nucleus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_from_file() -> anyhow::Result<()> {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push("test_astrodict.txt");
|
||||
{
|
||||
let mut file = File::create(&path)?;
|
||||
writeln!(file, "active galactic nucleus\t活动星系核")?;
|
||||
writeln!(file, "black hole\t黑洞")?;
|
||||
}
|
||||
|
||||
let mut dict = Dictionary::new();
|
||||
let res = dict.load_from_file(&path);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
res?;
|
||||
|
||||
assert_eq!(dict.terms.get("active galactic nucleus").unwrap(), "活动星系核");
|
||||
assert_eq!(dict.terms.get("black hole").unwrap(), "黑洞");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user