feat: LLM/Embedding 客户端模块化、侧边栏折叠交互、arXiv→ADS 下载回退与前端体验重构
**后端架构**
- 抽取翻译服务中内嵌的 LLM HTTP 调用为独立的 LlmClient /
EmbeddingClient(src/clients/llm.rs),翻译模块改为委托调用,消除
对 reqwest/serde 的直接耦合
- Config 新增 EMBEDDING_API_KEY/EMBEDDING_API_BASE/EMBEDDING_MODEL
三项配置,默认 fallback 至 LLM 对应值,补齐向量嵌入基础设施
**下载策略优化**
- arXiv 直连下载失败后自动回退至 ADS 网关 PUB_PDF→EPRINT_PDF→CrossRef
多级通道,替换此前单路径策略;批量同步同步应用此逻辑
- PDF/HTML 任一方成功时,失败方的 path 字段不再存储 "error:" 报错字符串,
改为置 NULL,防止日志污染数据
**前端交互增强**
- 侧边栏支持折叠/展开:收起为仅图标模式(w-16),展开恢复完整模式(w-64);
收起后点击 Logo 展开,含流畅 cubic-bezier 过渡动画
- 阅读面板新增 PDF 内嵌预览:已下载 PDF 时可通过 iframe 切换查看
/api/files 下的本地文献
- reader/citation 面板未选文献时展示带图标的空状态引导页,替代空白页
- 文献详情面板改为固定高度弹性布局(h-[460px]),各区块按比例分配避免
内容挤压;期刊名过长截断+悬停tooltip;关键词无数据显式占位
- 全局移除 emoji Unicode,统一替换为 lucide-react 图标组件,
消除跨平台字体渲染差异
**反爬检测精细化**
- 按响应长度分层:>150KB 跳过检测(完整文献),<5KB 才扫描通用 HTTP
错误关键字,杜绝长文献误触 Cloudflare/503 模式匹配
- 新增 Radware Bot Manager、ShieldSquare WAF 特征识别
**健壮性**
- Obscura 下载校验失败后自动清理硬盘残留坏文件
- 健康检查工具:文献已有有效 HTML 但 PDF 字段为旧报错时自动判定可修复
- 上传接口 body limit 提升至 100MB,新增 /api/files 静态文件服务路由
- StandardPaper 新增 has_pdf/has_html 字段区分格式级下载状态
This commit is contained in:
@@ -45,6 +45,8 @@ pub fn convert_ads_doc_to_standard(doc: &AdsPaperDoc) -> StandardPaper {
|
||||
citation_count: doc.citation_count.unwrap_or(0),
|
||||
reference_count: doc.reference_count.unwrap_or(0),
|
||||
is_downloaded: false,
|
||||
has_pdf: false,
|
||||
has_html: false,
|
||||
has_markdown: false,
|
||||
has_translation: false,
|
||||
doctype: doc.doctype.clone().unwrap_or_else(|| "article".to_string()),
|
||||
@@ -67,6 +69,8 @@ pub fn convert_arxiv_to_standard(doc: &ArxivPaper) -> StandardPaper {
|
||||
citation_count: 0,
|
||||
reference_count: 0,
|
||||
is_downloaded: false,
|
||||
has_pdf: false,
|
||||
has_html: false,
|
||||
has_markdown: false,
|
||||
has_translation: false,
|
||||
doctype: "eprint".to_string(),
|
||||
@@ -201,6 +205,8 @@ pub async fn get_paper_from_db(db: &SqlitePool, library_dir: &std::path::Path, b
|
||||
citation_count: r.get(9),
|
||||
reference_count: r.get(10),
|
||||
is_downloaded: is_pdf_exist || is_html_exist,
|
||||
has_pdf: is_pdf_exist,
|
||||
has_html: is_html_exist,
|
||||
has_markdown: is_md_exist,
|
||||
has_translation: is_tr_exist,
|
||||
doctype: doctype_val.unwrap_or_else(|| "article".to_string()),
|
||||
@@ -345,6 +351,8 @@ mod tests {
|
||||
citation_count: 5,
|
||||
reference_count: 10,
|
||||
is_downloaded: false,
|
||||
has_pdf: false,
|
||||
has_html: false,
|
||||
has_markdown: false,
|
||||
has_translation: false,
|
||||
doctype: "article".to_string(),
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::services::translation::Dictionary;
|
||||
use crate::clients::qiniu::QiniuClient;
|
||||
use crate::clients::ads::AdsClient;
|
||||
use crate::clients::arxiv::ArxivClient;
|
||||
use crate::clients::llm::{LlmClient, EmbeddingClient};
|
||||
use crate::services::download::Downloader;
|
||||
|
||||
// 全局共享的 Axum 应用上下文状态
|
||||
@@ -17,6 +18,8 @@ pub struct AppState {
|
||||
pub qiniu: QiniuClient,
|
||||
pub ads: AdsClient,
|
||||
pub arxiv: ArxivClient,
|
||||
pub llm: LlmClient,
|
||||
pub embedding: EmbeddingClient,
|
||||
pub downloader: Downloader,
|
||||
pub harvest_status: Arc<tokio::sync::Mutex<crate::services::batch_sync::MetaSyncStatus>>,
|
||||
pub process_status: Arc<tokio::sync::Mutex<crate::services::batch_sync::AssetSyncStatus>>,
|
||||
@@ -38,6 +41,8 @@ pub struct StandardPaper {
|
||||
pub citation_count: i32,
|
||||
pub reference_count: i32,
|
||||
pub is_downloaded: bool,
|
||||
pub has_pdf: bool,
|
||||
pub has_html: bool,
|
||||
pub has_markdown: bool,
|
||||
pub has_translation: bool,
|
||||
pub doctype: String,
|
||||
|
||||
+18
-8
@@ -150,11 +150,17 @@ pub async fn download_paper(
|
||||
|
||||
// 下载策略:
|
||||
// 1. 如有 arXiv ID,优先走 arXiv 直连(绕过出版商防护墙,成功率高)
|
||||
// 2. 否则走 ADS 网关多级回退(PUB_PDF → EPRINT_PDF → CrossRef)
|
||||
// 3. 若 ADS 路径 PDF/HTML 均失败但有 arXiv ID,再尝试 arXiv 作为兜底
|
||||
// 2. 若 arXiv 直连失败,回退走 ADS 网关多级回退(PUB_PDF → EPRINT_PDF → CrossRef)
|
||||
let (pdf_res, html_res) = if !paper.arxiv_id.is_empty() {
|
||||
info!("[下载] 优先使用 arXiv 通道: {}", paper.arxiv_id);
|
||||
state.downloader.download_arxiv_direct(&paper.arxiv_id, &state.config.library_dir).await
|
||||
let res = state.downloader.download_arxiv_direct(&paper.arxiv_id, &state.config.library_dir).await;
|
||||
if res.0.is_ok() || res.1.is_ok() {
|
||||
res
|
||||
} else {
|
||||
warn!("[下载] arXiv 通道下载失败,开始回退至 ADS/出版商通道: {}", req.bibcode);
|
||||
let doi_opt = if !paper.doi.is_empty() { Some(paper.doi.as_str()) } else { None };
|
||||
state.downloader.download_paper(&req.bibcode, doi_opt, &state.config.library_dir).await
|
||||
}
|
||||
} else {
|
||||
let doi_opt = if !paper.doi.is_empty() { Some(paper.doi.as_str()) } else { None };
|
||||
state.downloader.download_paper(&req.bibcode, doi_opt, &state.config.library_dir).await
|
||||
@@ -179,11 +185,11 @@ pub async fn download_paper(
|
||||
|
||||
let pdf_rel = match pdf_res {
|
||||
Ok(p) => Some(p.strip_prefix(&state.config.library_dir).unwrap_or(&p).to_string_lossy().to_string()),
|
||||
Err(e) => Some(format!("error: {}", e)),
|
||||
Err(_) => None, // 只要有一方下载成功,失败的一方字段置空(NULL),避免在 path 字段中留存报错日志
|
||||
};
|
||||
let html_rel = match html_res {
|
||||
Ok(p) => Some(p.strip_prefix(&state.config.library_dir).unwrap_or(&p).to_string_lossy().to_string()),
|
||||
Err(e) => Some(format!("error: {}", e)),
|
||||
Err(_) => None, // 只要有一方下载成功,失败的一方字段置空(NULL),避免在 path 字段中留存报错日志
|
||||
};
|
||||
|
||||
// 回写存储路径至数据库
|
||||
@@ -386,7 +392,7 @@ pub async fn translate_paper(
|
||||
})?;
|
||||
|
||||
// 调用 LLM 翻译服务并注入对照词表
|
||||
let translated_markdown = crate::services::translation::translate_markdown(&english_markdown, &state.dict, &state.config)
|
||||
let translated_markdown = crate::services::translation::translate_markdown(&english_markdown, &state.dict, &state.llm)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("文献 {} 翻译失败:调用 LLM 翻译发生错误: {}", req.bibcode, e);
|
||||
@@ -571,6 +577,9 @@ pub async fn get_library(
|
||||
let keywords_str: Option<String> = r.get(5);
|
||||
let keywords: Vec<String> = keywords_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
|
||||
|
||||
let is_pdf_exist = pdf_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false);
|
||||
let is_html_exist = html_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false);
|
||||
|
||||
let pdf_error = pdf_path.as_ref()
|
||||
.filter(|p| p.starts_with("error:"))
|
||||
.map(|p| p["error:".len()..].trim().to_string());
|
||||
@@ -590,8 +599,9 @@ pub async fn get_library(
|
||||
arxiv_id: r.get(8),
|
||||
citation_count: r.get(9),
|
||||
reference_count: r.get(10),
|
||||
is_downloaded: pdf_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false)
|
||||
|| html_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false),
|
||||
is_downloaded: is_pdf_exist || is_html_exist,
|
||||
has_pdf: is_pdf_exist,
|
||||
has_html: is_html_exist,
|
||||
has_markdown: markdown_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false),
|
||||
has_translation: translation_path.as_ref().map(|p| state.config.library_dir.join(p).exists()).unwrap_or(false),
|
||||
doctype: doctype_val.unwrap_or_else(|| "article".to_string()),
|
||||
|
||||
+53
-13
@@ -8,8 +8,13 @@ use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
// 检测防爬、验证码、登录墙特征
|
||||
fn detect_anti_bot(content: &str) -> Option<&'static str> {
|
||||
if content.len() > 150_000 {
|
||||
return None;
|
||||
}
|
||||
let lower = content.to_lowercase();
|
||||
let cf_patterns = [
|
||||
|
||||
// 1. 强特征防爬与 WAF 挑战(任何小于 150KB 的内容都做检测)
|
||||
let waf_patterns = [
|
||||
("checking your browser", "Cloudflare WAF 浏览器检查"),
|
||||
("please wait while we verify", "Cloudflare WAF 验证"),
|
||||
("cf-browser-verification", "Cloudflare WAF 验证特征"),
|
||||
@@ -35,11 +40,32 @@ fn detect_anti_bot(content: &str) -> Option<&'static str> {
|
||||
("shieldsquare_styles", "ShieldSquare WAF 拦截"),
|
||||
];
|
||||
|
||||
for &(p, desc) in &cf_patterns {
|
||||
for &(p, desc) in &waf_patterns {
|
||||
if lower.contains(p) {
|
||||
return Some(desc);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 通用 HTTP 错误与 CDN 关键字检测(仅当内容长度小于 5000 字节时检测,避免在正常文献中误判 CDN 脚本等)
|
||||
if content.len() < 5000 {
|
||||
let err_patterns = [
|
||||
("cloudflare", "Cloudflare 错误/防护页面"),
|
||||
("service temporarily unavailable", "503 服务暂时不可用"),
|
||||
("503 service", "503 服务异常"),
|
||||
("502 bad gateway", "502 网关错误"),
|
||||
("504 gateway timeout", "504 网关超时"),
|
||||
("403 forbidden", "403 访问被拒绝"),
|
||||
("404 not found", "404 资源未找到"),
|
||||
("500 internal server error", "500 服务器错误"),
|
||||
("site error", "网站错误"),
|
||||
];
|
||||
for &(p, desc) in &err_patterns {
|
||||
if lower.contains(p) {
|
||||
return Some(desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -342,10 +368,24 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
db_skip_type_cleaned += 1;
|
||||
}
|
||||
} else {
|
||||
let has_valid_pdf = pdf_path_opt.as_ref()
|
||||
.map(|p| !p.starts_with("error:") && library_dir.join(p).exists())
|
||||
.unwrap_or(false);
|
||||
let has_valid_html = html_path_opt.as_ref()
|
||||
.map(|p| !p.starts_with("error:") && library_dir.join(p).exists())
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(ref pdf_p) = pdf_path_opt {
|
||||
if pdf_p.starts_with("error:") {
|
||||
db_pdf_err_text += 1;
|
||||
pdf_db_msg = format!("数据库存储了报错字符串: {}", pdf_p);
|
||||
if has_valid_html {
|
||||
db_pdf_err_text += 1;
|
||||
pdf_db_msg = format!("文献已成功下载 HTML 格式,但 PDF 仍留有报错日志(将清理为 NULL): {}", pdf_p);
|
||||
need_db_fix = true;
|
||||
pdf_needs_fix = true;
|
||||
} else {
|
||||
db_pdf_err_text += 1;
|
||||
pdf_db_msg = format!("数据库存储了报错字符串: {}", pdf_p);
|
||||
}
|
||||
} else if !library_dir.join(pdf_p).exists() {
|
||||
db_pdf_missing += 1;
|
||||
pdf_db_msg = format!("物理 PDF 文件丢失 (路径: {})", pdf_p);
|
||||
@@ -356,8 +396,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
if let Some(ref html_p) = html_path_opt {
|
||||
if html_p.starts_with("error:") {
|
||||
db_html_err_text += 1;
|
||||
html_db_msg = format!("数据库存储了报错字符串: {}", html_p);
|
||||
if has_valid_pdf {
|
||||
db_html_err_text += 1;
|
||||
html_db_msg = format!("文献已成功下载 PDF 格式,但 HTML 仍留有报错日志(将清理为 NULL): {}", html_p);
|
||||
need_db_fix = true;
|
||||
html_needs_fix = true;
|
||||
} else {
|
||||
db_html_err_text += 1;
|
||||
html_db_msg = format!("数据库存储了报错字符串: {}", html_p);
|
||||
}
|
||||
} else if !library_dir.join(html_p).exists() {
|
||||
db_html_missing += 1;
|
||||
html_db_msg = format!("物理 HTML 文件丢失 (路径: {})", html_p);
|
||||
@@ -374,13 +421,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
markdown_needs_fix = true;
|
||||
} else {
|
||||
// 如果 Markdown 物理文件存在,但它既没有有效 PDF 也没有有效 HTML
|
||||
let has_valid_pdf = pdf_path_opt.as_ref()
|
||||
.map(|p| !p.starts_with("error:") && library_dir.join(p).exists())
|
||||
.unwrap_or(false);
|
||||
let has_valid_html = html_path_opt.as_ref()
|
||||
.map(|p| !p.starts_with("error:") && library_dir.join(p).exists())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_valid_pdf && !has_valid_html {
|
||||
db_markdown_orphaned += 1;
|
||||
markdown_db_msg = format!("Markdown 存在且完好,但失去有效 PDF/HTML 数据源,判定为孤立的 Markdown (路径: {})", md_p);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// src/clients/llm.rs
|
||||
use serde::Deserialize;
|
||||
use reqwest::Client;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LlmClient {
|
||||
api_key: String,
|
||||
api_base: String,
|
||||
model: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl LlmClient {
|
||||
pub fn new(api_key: String, api_base: String, model: String) -> Self {
|
||||
LlmClient {
|
||||
api_key,
|
||||
api_base,
|
||||
model,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
pub fn api_base(&self) -> &str {
|
||||
&self.api_base
|
||||
}
|
||||
|
||||
pub fn api_key(&self) -> &str {
|
||||
&self.api_key
|
||||
}
|
||||
|
||||
pub async fn chat_completion(&self, system_prompt: &str, user_content: &str) -> anyhow::Result<String> {
|
||||
let url = format!("{}/chat/completions", self.api_base);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": self.model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": user_content
|
||||
}
|
||||
],
|
||||
"temperature": 0.3
|
||||
});
|
||||
|
||||
let response = self.client.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.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!("大模型返回空翻译选项集"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EmbeddingClient {
|
||||
api_key: String,
|
||||
api_base: String,
|
||||
model: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl EmbeddingClient {
|
||||
pub fn new(api_key: String, api_base: String, model: String) -> Self {
|
||||
EmbeddingClient {
|
||||
api_key,
|
||||
api_base,
|
||||
model,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
pub fn api_base(&self) -> &str {
|
||||
&self.api_base
|
||||
}
|
||||
|
||||
pub fn api_key(&self) -> &str {
|
||||
&self.api_key
|
||||
}
|
||||
|
||||
pub async fn create_embedding(&self, text: &str) -> anyhow::Result<Vec<f32>> {
|
||||
let url = format!("{}/embeddings", self.api_base);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"model": self.model,
|
||||
"input": text,
|
||||
});
|
||||
|
||||
let response = self.client.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.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!("Embedding 接口调用失败: 状态码={}, 报错={}", status, body);
|
||||
return Err(anyhow::anyhow!("向量接口返回错误状态: {}", status));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbeddingData {
|
||||
embedding: Vec<f32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EmbeddingResponse {
|
||||
data: Vec<EmbeddingData>,
|
||||
}
|
||||
|
||||
let res_data: EmbeddingResponse = response.json().await?;
|
||||
if let Some(data) = res_data.data.first() {
|
||||
Ok(data.embedding.clone())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("向量接口返回空向量数据"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_llm_client_initialization() {
|
||||
let client = LlmClient::new(
|
||||
"key".to_string(),
|
||||
"base".to_string(),
|
||||
"model".to_string(),
|
||||
);
|
||||
assert_eq!(client.api_key(), "key");
|
||||
assert_eq!(client.api_base(), "base");
|
||||
assert_eq!(client.model(), "model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_embedding_client_initialization() {
|
||||
let client = EmbeddingClient::new(
|
||||
"key".to_string(),
|
||||
"base".to_string(),
|
||||
"model".to_string(),
|
||||
);
|
||||
assert_eq!(client.api_key(), "key");
|
||||
assert_eq!(client.api_base(), "base");
|
||||
assert_eq!(client.model(), "model");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod ads;
|
||||
pub mod arxiv;
|
||||
pub mod qiniu;
|
||||
pub mod llm;
|
||||
|
||||
+13
@@ -10,6 +10,9 @@ pub struct Config {
|
||||
pub llm_api_key: String, // 大语言模型 API Key
|
||||
pub llm_api_base: String, // 大语言模型 API 基础地址
|
||||
pub llm_model: String, // 调用的翻译大模型名称
|
||||
pub embedding_api_key: String, // 向量模型 API Key
|
||||
pub embedding_api_base: String,// 向量模型 API 基础地址
|
||||
pub embedding_model: String, // 向量模型名称
|
||||
pub qiniu_ak: String, // 七牛云 Access Key
|
||||
pub qiniu_sk: String, // 七牛云 Secret Key
|
||||
pub qiniu_bucket: String, // 七牛云存储空间名 (Bucket)
|
||||
@@ -34,6 +37,13 @@ impl Config {
|
||||
let llm_model = env::var("LLM_MODEL")
|
||||
.unwrap_or_else(|_| "gpt-4o-mini".to_string());
|
||||
|
||||
let embedding_api_key = env::var("EMBEDDING_API_KEY")
|
||||
.unwrap_or_else(|_| llm_api_key.clone());
|
||||
let embedding_api_base = env::var("EMBEDDING_API_BASE")
|
||||
.unwrap_or_else(|_| llm_api_base.clone());
|
||||
let embedding_model = env::var("EMBEDDING_MODEL")
|
||||
.unwrap_or_else(|_| "text-embedding-3-small".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();
|
||||
@@ -56,6 +66,9 @@ impl Config {
|
||||
llm_api_key,
|
||||
llm_api_base,
|
||||
llm_model,
|
||||
embedding_api_key,
|
||||
embedding_api_base,
|
||||
embedding_model,
|
||||
qiniu_ak,
|
||||
qiniu_sk,
|
||||
qiniu_bucket,
|
||||
|
||||
+15
-1
@@ -16,6 +16,7 @@ use astroresearch::services::translation::Dictionary;
|
||||
use astroresearch::clients::qiniu::QiniuClient;
|
||||
use astroresearch::clients::ads::AdsClient;
|
||||
use astroresearch::clients::arxiv::ArxivClient;
|
||||
use astroresearch::clients::llm::{LlmClient, EmbeddingClient};
|
||||
use astroresearch::services::download::Downloader;
|
||||
use astroresearch::api::handlers::{AppState, self};
|
||||
|
||||
@@ -83,6 +84,16 @@ async fn main() -> anyhow::Result<()> {
|
||||
let ads = AdsClient::new(config.ads_api_key.clone());
|
||||
let arxiv = ArxivClient::new();
|
||||
let downloader = Downloader::new();
|
||||
let llm = LlmClient::new(
|
||||
config.llm_api_key.clone(),
|
||||
config.llm_api_base.clone(),
|
||||
config.llm_model.clone(),
|
||||
);
|
||||
let embedding = EmbeddingClient::new(
|
||||
config.embedding_api_key.clone(),
|
||||
config.embedding_api_base.clone(),
|
||||
config.embedding_model.clone(),
|
||||
);
|
||||
|
||||
let app_state = Arc::new(AppState {
|
||||
config: config.clone(),
|
||||
@@ -91,6 +102,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
qiniu,
|
||||
ads,
|
||||
arxiv,
|
||||
llm,
|
||||
embedding,
|
||||
downloader,
|
||||
harvest_status: Arc::new(tokio::sync::Mutex::new(astroresearch::services::batch_sync::MetaSyncStatus::new())),
|
||||
process_status: Arc::new(tokio::sync::Mutex::new(astroresearch::services::batch_sync::AssetSyncStatus::new())),
|
||||
@@ -106,7 +119,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let api_routes = Router::new()
|
||||
.route("/search", get(handlers::search_papers))
|
||||
.route("/download", post(handlers::download_paper))
|
||||
.route("/upload", post(handlers::upload_paper_file))
|
||||
.route("/upload", post(handlers::upload_paper_file).layer(axum::extract::DefaultBodyLimit::max(100 * 1024 * 1024)))
|
||||
.route("/no_resource", post(handlers::mark_no_resource))
|
||||
.route("/parse", post(handlers::parse_paper))
|
||||
.route("/translate", post(handlers::translate_paper))
|
||||
@@ -133,6 +146,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api", api_routes)
|
||||
.nest_service("/api/files", ServeDir::new(&config.library_dir))
|
||||
.fallback_service(serve_dir)
|
||||
.layer(cors)
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
|
||||
@@ -72,6 +72,11 @@ impl AssetSync {
|
||||
status: Arc<Mutex<AssetSyncStatus>>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let llm_client = crate::clients::llm::LlmClient::new(
|
||||
config.llm_api_key.clone(),
|
||||
config.llm_api_base.clone(),
|
||||
config.llm_model.clone(),
|
||||
);
|
||||
let total = bibcodes.len() as i32;
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
@@ -177,7 +182,17 @@ impl AssetSync {
|
||||
}
|
||||
|
||||
let (pdf_res, html_res) = if !arxiv_id.is_empty() {
|
||||
downloader.download_arxiv_direct(&arxiv_id, &config.library_dir).await
|
||||
let res = downloader.download_arxiv_direct(&arxiv_id, &config.library_dir).await;
|
||||
if res.0.is_ok() || res.1.is_ok() {
|
||||
res
|
||||
} else {
|
||||
{
|
||||
let mut s = status.lock().await;
|
||||
s.add_log(format!("文献 {} arXiv 通道下载失败,回退尝试 ADS/出版商下载...", bibcode));
|
||||
}
|
||||
let doi_opt = if !doi.is_empty() { Some(doi.as_str()) } else { None };
|
||||
downloader.download_paper(&bibcode, doi_opt, &config.library_dir).await
|
||||
}
|
||||
} else {
|
||||
let doi_opt = if !doi.is_empty() { Some(doi.as_str()) } else { None };
|
||||
downloader.download_paper(&bibcode, doi_opt, &config.library_dir).await
|
||||
@@ -186,11 +201,11 @@ impl AssetSync {
|
||||
if pdf_res.is_ok() || html_res.is_ok() {
|
||||
let pdf_rel = match pdf_res {
|
||||
Ok(p) => Some(p.strip_prefix(&config.library_dir).unwrap_or(&p).to_string_lossy().to_string()),
|
||||
Err(e) => Some(format!("error: {}", e)),
|
||||
Err(_) => None, // 只要有一方下载成功,失败的一方字段置空(NULL),避免在 path 字段中留存报错日志
|
||||
};
|
||||
let html_rel = match html_res {
|
||||
Ok(p) => Some(p.strip_prefix(&config.library_dir).unwrap_or(&p).to_string_lossy().to_string()),
|
||||
Err(e) => Some(format!("error: {}", e)),
|
||||
Err(_) => None, // 只要有一方下载成功,失败的一方字段置空(NULL),避免在 path 字段中留存报错日志
|
||||
};
|
||||
|
||||
// 更新路径变量与数据库
|
||||
@@ -504,7 +519,7 @@ impl AssetSync {
|
||||
|
||||
match fs::read_to_string(&md_abs) {
|
||||
Ok(english_markdown) => {
|
||||
match crate::services::translation::translate_markdown(&english_markdown, &dict, &config).await {
|
||||
match crate::services::translation::translate_markdown(&english_markdown, &dict, &llm_client).await {
|
||||
Ok(translated_markdown) => {
|
||||
let tr_filename = format!("{}_zh.md", bibcode);
|
||||
let tr_dest = config.library_dir.join("Translation").join(&tr_filename);
|
||||
|
||||
@@ -84,21 +84,29 @@ fn build_chrome_headers(referer: Option<&str>) -> HeaderMap {
|
||||
|
||||
/// 统一验证码/反爬虫检测(参考 SearXNG 异常处理机制)
|
||||
fn detect_anti_bot(content: &str, url: Option<&str>) -> Result<()> {
|
||||
// 如果页面长度大于 150KB,通常是完整渲染的文献正文,忽略反爬/人机验证特征检测以避免误伤(例如正常页面中嵌有 recaptcha 的 sitekey 配置)
|
||||
if content.len() > 150_000 {
|
||||
return Ok(());
|
||||
}
|
||||
let lower = content.to_lowercase();
|
||||
|
||||
let cf_patterns = [
|
||||
// 1. 强特征防爬与 WAF 挑战(任何小于 150KB 的内容都做检测)
|
||||
let waf_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",
|
||||
"awswafintegration", "aws waf",
|
||||
];
|
||||
for p in &cf_patterns {
|
||||
for p in &waf_patterns {
|
||||
if lower.contains(p) {
|
||||
anyhow::bail!("检测到 Cloudflare 或 AWS WAF 挑战页面(特征: {})", p);
|
||||
anyhow::bail!("检测到 Cloudflare 或 AWS WAF 挑战/错误页面(特征: {})", p);
|
||||
}
|
||||
}
|
||||
|
||||
let captcha_patterns = ["captcha", "recaptcha", "hcaptcha", "verify you are human", "robot check"];
|
||||
let captcha_patterns = [
|
||||
"captcha", "recaptcha", "hcaptcha", "verify you are human", "robot check",
|
||||
"radware bot manager", "shieldsquare",
|
||||
];
|
||||
for p in &captcha_patterns {
|
||||
if lower.contains(p) {
|
||||
anyhow::bail!("检测到人机验证页面(包含: {})", p);
|
||||
@@ -122,6 +130,20 @@ fn detect_anti_bot(content: &str, url: Option<&str>) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 通用 HTTP 错误与 CDN 关键字检测(仅当内容长度小于 5000 字节时检测,避免在正常文献中误判 CDN 脚本等)
|
||||
if content.len() < 5000 {
|
||||
let err_patterns = [
|
||||
"cloudflare", "service temporarily unavailable", "503 service",
|
||||
"502 bad gateway", "504 gateway timeout", "403 forbidden",
|
||||
"404 not found", "500 internal server error", "site error",
|
||||
];
|
||||
for p in &err_patterns {
|
||||
if lower.contains(p) {
|
||||
anyhow::bail!("检测到服务错误或防护页面(特征: {})", p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -348,11 +370,19 @@ impl Downloader {
|
||||
});
|
||||
|
||||
match handle.await {
|
||||
Ok(res) => {
|
||||
Ok(Ok(())) => {
|
||||
info!("[Obscura 进程内后备通道] 下载并校验成功: {:?}", dest_path);
|
||||
res
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!("[Obscura 进程内后备通道] 下载或校验失败: {:?}", e);
|
||||
let _ = std::fs::remove_file(dest_path); // 清理校验失败的残留文件
|
||||
Err(e)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(dest_path); // 清理校验失败的残留文件
|
||||
anyhow::bail!("进程内 Obscura 执行线程异常退出: {:?}", e)
|
||||
}
|
||||
Err(e) => anyhow::bail!("进程内 Obscura 执行线程异常退出: {:?}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,16 +403,23 @@ impl Downloader {
|
||||
.context("启动 Obscura 进程失败,请检查 bin/obscura 是否存在且有执行权限")?;
|
||||
|
||||
if !status.success() {
|
||||
let _ = tokio::fs::remove_file(dest_path).await; // 清理可能的残留坏文件
|
||||
anyhow::bail!("Obscura 进程退出状态非成功: {:?}", status);
|
||||
}
|
||||
|
||||
// 校验下载得到的文件
|
||||
if is_pdf {
|
||||
let bytes = tokio::fs::read(dest_path).await?;
|
||||
validate_pdf_content(&bytes)?;
|
||||
if let Err(e) = validate_pdf_content(&bytes) {
|
||||
let _ = tokio::fs::remove_file(dest_path).await; // 清理校验失败的残留文件
|
||||
return Err(e);
|
||||
}
|
||||
} else {
|
||||
let text = tokio::fs::read_to_string(dest_path).await?;
|
||||
validate_html_content(&text)?;
|
||||
if let Err(e) = validate_html_content(&text) {
|
||||
let _ = tokio::fs::remove_file(dest_path).await; // 清理校验失败的残留文件
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
info!("[Obscura 命令行后备通道] 下载并校验成功: {:?}", dest_path);
|
||||
@@ -604,6 +641,7 @@ impl Downloader {
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(dest_path).await; // 清理直连失败的残留物理文件
|
||||
let err_msg = e.to_string();
|
||||
if err_msg.contains("人机验证")
|
||||
|| err_msg.contains("挑战页面")
|
||||
|
||||
+11
-61
@@ -3,10 +3,7 @@ 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;
|
||||
use tracing::{info, warn};
|
||||
|
||||
// 天文学专有名词英汉词典匹配管理
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -102,9 +99,9 @@ impl Dictionary {
|
||||
pub async fn translate_markdown(
|
||||
markdown_content: &str,
|
||||
dict: &Dictionary,
|
||||
config: &Config
|
||||
llm_client: &crate::clients::llm::LlmClient,
|
||||
) -> anyhow::Result<String> {
|
||||
if config.llm_api_key.is_empty() {
|
||||
if llm_client.api_key().is_empty() {
|
||||
return Err(anyhow::anyhow!("本地配置中缺少 LLM_API_KEY"));
|
||||
}
|
||||
|
||||
@@ -129,63 +126,16 @@ pub async fn translate_markdown(
|
||||
terms_instruction
|
||||
);
|
||||
|
||||
info!("正在请求大模型开展中英翻译。所选大模型: {}", config.llm_model);
|
||||
info!("正在请求大模型开展中英翻译。所选大模型: {}", llm_client.model());
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
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() {
|
||||
let duration = start_time.elapsed();
|
||||
info!("LLM 翻译成功。所选大模型: {}, 耗时: {:?}, 译文字符数: {}", config.llm_model, duration, choice.message.content.len());
|
||||
Ok(choice.message.content.clone())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("大模型返回空翻译选项集"))
|
||||
match llm_client.chat_completion(&system_prompt, markdown_content).await {
|
||||
Ok(translated) => {
|
||||
let duration = start_time.elapsed();
|
||||
info!("LLM 翻译成功。所选大模型: {}, 耗时: {:?}, 译文字符数: {}", llm_client.model(), duration, translated.len());
|
||||
Ok(translated)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user