后端: - runtime: 拆分 AgentConfig/events/duplicate_detector 为独立模块 - error_recovery: 1499行单体拆为 classification(21种FailoverReason)/overflow/mod - executor: 提取 helpers.rs (PreparedCall/ToolExecutionResult/execute_single_tool) - skills: 新增 SkillCreator + SelfImprovePipeline(模式检测→自动生成SKILL.md→质量审查) - clients/llm: 拆分为 chat/embedding/types 三个子模块 - services/download: 1548行拆为 mod/headers(反爬+SSRF)/strategies(多级回退) - services/batch/asset: 1264行拆为 mod/helpers/process 前端: - 设计系统统一: sky/indigo → blueprint 色系, rounded-xl→lg, shadow-lg→sm - 删除 Vite 模板残留 App.css - GlobalDialog/PaperDetailModal/UncachedPaperModal 提取公共 BaseModal 组件
239 lines
7.3 KiB
Rust
239 lines
7.3 KiB
Rust
// src/services/batch/asset/helpers.rs
|
||
//
|
||
// 批量处理各步骤的独立函数,从 process_single_bibcode 提取,
|
||
// 降低单文件行数并提高可测试性。
|
||
|
||
use std::sync::atomic::{AtomicI32, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
use sqlx::SqlitePool;
|
||
use tokio::sync::Mutex;
|
||
|
||
use crate::services::download::Downloader;
|
||
use crate::Config;
|
||
|
||
use super::{AssetBatchStatus, BatchAction};
|
||
|
||
/// 执行单篇文献的下载步骤。
|
||
///
|
||
/// 若成功下载 PDF 或 HTML 任一格式,更新 `pdf_path` / `html_path` 并写回数据库。
|
||
/// 返回 true 表示下载成功(至少一个文件),false 表示全部失败。
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub(super) async fn run_download_step(
|
||
bibcode: &str,
|
||
action: BatchAction,
|
||
db: &SqlitePool,
|
||
config: &Config,
|
||
downloader: &Arc<Downloader>,
|
||
status: &Arc<Mutex<AssetBatchStatus>>,
|
||
dl_count: &AtomicI32,
|
||
dl_failed_count: &AtomicI32,
|
||
arxiv_id: &str,
|
||
doi: &str,
|
||
pdf_path: &mut Option<String>,
|
||
html_path: &mut Option<String>,
|
||
) -> bool {
|
||
// 仅当 action 包含下载时执行
|
||
if action != BatchAction::Download && action != BatchAction::All {
|
||
return true; // 无需下载,视为"成功"
|
||
}
|
||
|
||
let is_pdf_exist = pdf_path
|
||
.as_ref()
|
||
.map(|p| config.library_dir.join(p).exists())
|
||
.unwrap_or(false);
|
||
let is_html_exist = html_path
|
||
.as_ref()
|
||
.map(|p| config.library_dir.join(p).exists())
|
||
.unwrap_or(false);
|
||
|
||
if is_pdf_exist || is_html_exist {
|
||
{
|
||
let mut s = status.lock().await;
|
||
s.add_log(format!(
|
||
"文献 {} 本地已存在 PDF 或 HTML,跳过下载。",
|
||
bibcode
|
||
));
|
||
}
|
||
let _c = dl_count.fetch_add(1, Ordering::SeqCst) + 1;
|
||
{
|
||
let mut s = status.lock().await;
|
||
s.downloaded = _c;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// 需要执行下载
|
||
{
|
||
let mut s = status.lock().await;
|
||
s.add_log(format!("文献 {} 本地无 PDF/HTML,开始下载...", bibcode));
|
||
}
|
||
|
||
let (pdf_res, html_res) = if !arxiv_id.is_empty() {
|
||
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) } else { None };
|
||
downloader
|
||
.download_paper(bibcode, doi_opt, &config.library_dir)
|
||
.await
|
||
}
|
||
} else {
|
||
let doi_opt = if !doi.is_empty() { Some(doi) } else { None };
|
||
downloader
|
||
.download_paper(bibcode, doi_opt, &config.library_dir)
|
||
.await
|
||
};
|
||
|
||
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(_) => None,
|
||
};
|
||
let html_rel = match html_res {
|
||
Ok(p) => Some(
|
||
p.strip_prefix(&config.library_dir)
|
||
.unwrap_or(&p)
|
||
.to_string_lossy()
|
||
.to_string(),
|
||
),
|
||
Err(_) => None,
|
||
};
|
||
|
||
*pdf_path = pdf_rel.clone();
|
||
*html_path = html_rel.clone();
|
||
|
||
let _ = sqlx::query("UPDATE papers SET pdf_path = ?, html_path = ? WHERE bibcode = ?")
|
||
.bind(pdf_rel)
|
||
.bind(html_rel)
|
||
.bind(bibcode)
|
||
.execute(db)
|
||
.await;
|
||
|
||
let _c = dl_count.fetch_add(1, Ordering::SeqCst) + 1;
|
||
{
|
||
let mut s = status.lock().await;
|
||
s.downloaded = _c;
|
||
s.add_log(format!("文献 {} 下载成功!", bibcode));
|
||
}
|
||
true
|
||
} else {
|
||
let _f = dl_failed_count.fetch_add(1, Ordering::SeqCst) + 1;
|
||
let mut s = status.lock().await;
|
||
s.download_failed = _f;
|
||
|
||
let pdf_err = match pdf_res {
|
||
Err(e) => format!("error: {}", e),
|
||
_ => "error: 未知错误".to_string(),
|
||
};
|
||
let html_err = match html_res {
|
||
Err(e) => format!("error: {}", e),
|
||
_ => "error: 未知错误".to_string(),
|
||
};
|
||
|
||
s.add_log(format!(
|
||
"文献 {} 下载失败。PDF: {}, HTML: {}",
|
||
bibcode, pdf_err, html_err
|
||
));
|
||
|
||
let _ = sqlx::query("UPDATE papers SET pdf_path = ?, html_path = ? WHERE bibcode = ?")
|
||
.bind(&pdf_err)
|
||
.bind(&html_err)
|
||
.bind(bibcode)
|
||
.execute(db)
|
||
.await;
|
||
|
||
false
|
||
}
|
||
}
|
||
|
||
/// 生成文献的源链接 URL(优先 ADS 标准 bibcode,其次 arXiv)。
|
||
pub(super) fn build_source_url(bibcode: &str, arxiv_id: &str) -> String {
|
||
if bibcode.len() == 19 {
|
||
format!("https://ui.adsabs.harvard.edu/abs/{}/abstract", bibcode)
|
||
} else if !arxiv_id.is_empty() {
|
||
format!(
|
||
"https://ui.adsabs.harvard.edu/abs/arXiv:{}/abstract",
|
||
arxiv_id
|
||
)
|
||
} else {
|
||
format!("https://ui.adsabs.harvard.edu/abs/{}/abstract", bibcode)
|
||
}
|
||
}
|
||
|
||
/// 为已解析的 Markdown 内容构建 YAML frontmatter 头部。
|
||
pub(super) async fn build_markdown_with_frontmatter(
|
||
db: &SqlitePool,
|
||
bibcode: &str,
|
||
md_body: &str,
|
||
source_url: &str,
|
||
) -> Option<String> {
|
||
use sqlx::Row;
|
||
|
||
let row =
|
||
sqlx::query("SELECT title, authors, pub, year, keywords FROM papers WHERE bibcode = ?")
|
||
.bind(bibcode)
|
||
.fetch_optional(db)
|
||
.await
|
||
.ok()??;
|
||
|
||
let title: String = row.get(0);
|
||
let authors_json: String = row.get(1);
|
||
let pub_journal: String = row.get(2);
|
||
let year: String = row.get(3);
|
||
let keywords_json: String = row.get(4);
|
||
|
||
let authors: Vec<String> = serde_json::from_str(&authors_json).unwrap_or_default();
|
||
let keywords: Vec<String> = serde_json::from_str(&keywords_json).unwrap_or_default();
|
||
|
||
let front_matter = format!(
|
||
"---\ntitle: {}\nauthor: [{}]\npublisher: {}\nsource: \"{}\"\ndate: \"{}\"\ntags: \"{}\"\n---\n\n",
|
||
serde_json::to_string(&title).unwrap_or_else(|_| format!("\"{}\"", title)),
|
||
authors
|
||
.iter()
|
||
.map(|a| format!("\"{}\"", a))
|
||
.collect::<Vec<_>>()
|
||
.join(", "),
|
||
serde_json::to_string(&pub_journal)
|
||
.unwrap_or_else(|_| format!("\"{}\"", pub_journal)),
|
||
source_url,
|
||
year,
|
||
keywords.join(",")
|
||
);
|
||
|
||
Some(format!("{}{}", front_matter, md_body))
|
||
}
|
||
|
||
/// 将 Markdown 内容写入 library_dir 并更新数据库 markdown_path。
|
||
pub(super) fn write_markdown_and_update_db(
|
||
config: &Config,
|
||
bibcode: &str,
|
||
content: &str,
|
||
) -> Option<String> {
|
||
let md_filename = format!("{}.md", bibcode);
|
||
let md_dest = config.library_dir.join("Markdown").join(&md_filename);
|
||
if let Some(parent) = md_dest.parent() {
|
||
let _ = std::fs::create_dir_all(parent);
|
||
}
|
||
if std::fs::write(&md_dest, content).is_ok() {
|
||
Some(format!("Markdown/{}", md_filename))
|
||
} else {
|
||
None
|
||
}
|
||
}
|