feat: 初始化 AstroResearch 核心系统代码及重构技术文档

This commit is contained in:
fmq
2026-06-08 17:23:27 +08:00
commit 307a1c0cee
53 changed files with 45076 additions and 0 deletions
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 直接下载 PDFarxiv.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→ 直联 iopsciencearxiv 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/NatureHTML 更可靠,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 页面 → 优先官方 HTMLar5iv 兜底
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
View File
File diff suppressed because it is too large Load Diff
+147
View File
@@ -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
View File
@@ -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: &regex::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: &regex::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: &regex::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: &regex::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: &regex::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: &regex::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: &regex::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 图片格式 ![alt](url)
let img_re = Regex::new(r#"(?s)<img\s+([^>]*?)>"#).unwrap();
let preprocessed_html = img_re.replace_all(&preprocessed_html, |caps: &regex::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("&lt;", "<")
.replace("&gt;", ">")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'");
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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&nbsp;", " ")
.replace("&#39;", "'")
}
// 调用 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, |_: &regex::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> [] &lt;math&gt;\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
View File
@@ -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());
}
}
+232
View File
@@ -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(())
}
}