zotero-arxiv-daily 项目架构解析:基于 Zotero 文献库的每日论文推荐流水线
zotero-arxiv-daily 项目架构解析:基于 Zotero 文献库的每日论文推荐流水线
本文以仓库 CLAUDE.md 为骨架,深度解析 zotero-arxiv-daily 的整体架构:它如何以你的 Zotero 文献库为"兴趣画像",通过嵌入相似度对 arXiv/bioRxiv/medRxiv/chemRxiv 每日新论文做相关性重排,再用 LLM 生成 TLDR 与机构信息并通过邮件送达。读完本文,你将掌握这条流水线每一阶段的实现原理、Hydra + OmegaConf 的组合配置体系、Retriever/Reranker 插件扩展机制、测试策略与本地运行方式,可直接在仓库源码中逐行验证。
项目定位与核心思想
Zotero-arXiv-Daily 的核心目标非常朴素:"你 Zotero 里存了什么,就代表你对什么感兴趣,把每天新发布的论文里和你库中论文最相关的挑出来,发到你的邮箱。" 它不是为了泛泛抓取 arXiv 每日更新,而是基于用户自身的文献积累做个性化推荐。
从 CLAUDE.md 的项目概述看,它覆盖四个论文来源(arXiv / bioRxiv / medRxiv / chemRxiv),计算新论文与用户现有库的嵌入相似度,通过 LLM 生成 TLDR(太长不看版摘要),最终以 HTML 邮件形式投递,且被设计为零成本运行在 GitHub Actions 工作流上。整个应用由 Executor 编排成一条线性流水线,没有任何复杂的调度框架,这让它极易被理解、扩展和二次开发。
上图为项目 README 中展示的最终邮件呈现效果:论文按相关性排序,附带 AI 生成的 TLDR、作者机构与 PDF/代码链接。
六阶段线性流水线:从 Zotero 到邮箱
应用的全部业务逻辑集中在 executor.py,由 Executor 类驱动。流水线共六个阶段,代码路径 Executor.run()(见 executor.py#L93-L124):
- Fetch Zotero corpus(拉取 Zotero 语料):通过
pyzoteroAPI 拉取用户文献库,只保留conferencePaper || journalArticle || preprint三种条目类型且含摘要(abstractNote != '')的论文,同时递归解析每个条目的 Collection 路径(get_collection_path沿parentCollection逐级向上拼接出如2026/survey的层级路径,见 executor.py#L49-L56)。 - Filter corpus(过滤语料):按
include_path/ignore_path两组 glob 模式筛选相关 Collection,这决定了"你的兴趣画像"具体由库中哪些论文构成(详见下文"Collection 路径过滤"小节)。 - Retrieve new papers(抓取新论文):从配置指定的来源抓取新论文。arXiv 走 RSS feed,bioRxiv/medRxiv 走 REST API,chemRxiv 经 Crossref REST API。
- Rerank(重排打分):用嵌入模型计算候选论文与语料的相似度,并按时间衰减权重加权——越晚加入 Zotero 的论文权重越高,代表"最近的研究兴趣"。
- Generate TLDRs + affiliations(生成摘要与机构):通过 OpenAI 兼容的 LLM API,为每篇候选论文生成一句话 TLDR,并尽量解析出作者机构列表。
- Render + send email(渲染并发送邮件):将结果渲染为 HTML 邮件,经 SMTP 发送到收件箱。
流水线中的关键防御逻辑值得一提:若过滤后语料为空(len(corpus) == 0),run() 会记录错误日志并直接返回(executor.py#L96-L98);若当天无任何新论文,除非配置了 send_empty: true,否则不发送空邮件(executor.py#L118-L120)。
Collection 路径过滤:精准圈定"兴趣画像"范围
默认情况下你的全部 Zotero 文献都会参与相似度计算,但通过 config/base.yaml 中的两个配置项可以精准圈定范围:
zotero:
include_path: null # 只保留匹配的 Collection,例:["2026/survey/**", "2026/reading-group/**"]
ignore_path: null # 排除匹配的 Collection,例:["2026/ignore/**", "archive/**"]
其实现逻辑在 Executor.filter_corpus()(executor.py#L65-L90):
include_path存在时,仅保留 任一 Collection 路径匹配任一模式 的论文;ignore_path存在时,剔除所有匹配的论文;- 两者可同时配置(先 include 后 ignore);
- 过滤后若两者任一启用,会从结果中随机采样至多 5 篇论文打印标题与路径,便于在日志中确认"兴趣画像"构成。
参数校验由 normalize_path_patterns()(executor.py#L16-L29)完成:配置值必须是字符串列表或 null,不支持单个字符串(例如 "2026/survey/**" 会被拒绝并抛出 TypeError,提示应写成 <a href="https://link.gitcode.com/i/721133cd692f18372007d859c169a02d" target="_blank">"2026/survey/**"])。glob 匹配的具体实现位于 [utils.py 的 glob_match,并有对应的单元测试可参考。
插件系统:Retriever 与 Reranker 的注册-发现机制
CLAUDE.md 明确指出本项目的两套插件体系,其设计高度对称:
Retriever(论文源插件)
- 注册:在类上使用
@register_retriever("arxiv")装饰器(如 arxiv_retriever.py#L210),装饰器把类挂到registered_retrievers字典并写入cls.name(retriever/base.py#L39-L46); - 发现:
get_retriever_cls(name)按名称查表,未注册的名称抛出ValueError(retriever/base.py#L48-L51); - 契约:每个 Retriever 继承
BaseRetriever,实现两个抽象方法——_retrieve_raw_papers()抓取原始数据、convert_to_paper()将原始数据转换为统一的Paper对象(retriever/base.py#L16-L22); - 批量处理:
retrieve_papers()模板方法逐条转换,失败的单条论文被跳过并告警,每条之间sleep(1)限速(retriever/base.py#L24-L37)。
以 ArxivRetriever 为例:它通过 feedparser 解析 https://rss.arxiv.org/atom/{category} 组合出的 RSS 地址(category 用 + 连接),带 5 次重试与 HTTP 状态/解析器状态校验,失败 5 次才抛出 RuntimeError。include_cross_list: false 时只保留 arxiv_announce_type == "new" 的条目;debug 模式下只取前 10 条(arxiv_retriever.py#L259-L265)。转换阶段还会按 tar 源码包 → HTML → PDF 的优先级提取全文,PDF/TeX 提取均在子进程中执行并设有硬超时(PDF 180 秒、TAR 180 秒),超时或失败自动降级(见 _run_with_hard_timeout,arxiv_retriever.py#L57-L90)。
Reranker(重排插件)
- 注册/发现机制与 Retriever 完全同构:
@register_reranker+get_reranker_cls()(reranker/base.py#L26-L36); - 两个内置实现:
local(local.py,sentence-transformers 本地嵌入模型)与api(api.py,OpenAI 兼容嵌入接口); - 打分核心在
BaseReranker.rerank()(reranker/base.py#L10-L20):语料先按加入日期降序排列,时间衰减权重为1 / (1 + log10(序号 + 1))并归一化,最终得分为(相似度矩阵 × 时间权重).sum(axis=1) × 10,再按得分降序输出。
这个时间衰减公式是整个推荐算法的灵魂:Zotero 中第 1 篇论文权重最高,第 100 篇的权重约为第 1 篇的 1 / (1 + log10(101)) ≈ 1/3,即近期加入的文献对"兴趣画像"的贡献显著大于早期文献——因为研究者当前关注的方向往往与最近阅读的文献一致。
配置体系:Hydra + OmegaConf 的组合式配置
项目采用 Hydra + OmegaConf 管理配置,这是 CLAUDE.md 明确点出的技术选型。
配置组合方式
入口 main.py 通过 @hydra.main(version_base=None, config_path="../../config", config_name="default")(main.py#L12)启动。default.yaml 仅做两件事——组合两个配置组(default.yaml):
defaults:
- base # 全量配置模板,`???` 为必填占位
- custom # 用户覆盖层,用环境变量插值填充
base.yaml 定义了全部配置项的默认值与注释说明(??? 表示必须填写),custom.yaml 则以 ${oc.env:VAR_NAME,default} 语法从环境变量读取实际值,例如:
zotero:
user_id: ${oc.env:ZOTERO_ID}
api_key: ${oc.env:ZOTERO_KEY}
include_path: null
email:
sender: ${oc.env:SENDER}
receiver: ${oc.env:RECEIVER}
smtp_server: smtp.qq.com
smtp_port: 465
sender_password: ${oc.env:SENDER_PASSWORD}
llm:
api:
key: ${oc.env:OPENAI_API_KEY}
base_url: ${oc.env:OPENAI_API_BASE}
api_mode: chat_completion
generation_kwargs:
model: gpt-4o-mini
source:
arxiv:
category: ["cs.AI","cs.CV","cs.LG","cs.CL"]
executor:
debug: ${oc.env:DEBUG,null}
source: ['arxiv']
在 GitHub Actions 部署场景中,这份 custom.yaml 的内容会被完整粘贴到名为 CUSTOM_CONFIG 的仓库变量里,作为运行时覆盖层写入,从而实现"仓库代码 + 用户秘密/变量"的完全解耦。${oc.env:XXX,yyy} 语义为:取环境变量 XXX 的值,未设置则回退到默认值 yyy。
配置项全景
下表汇总 config/base.yaml 中全部配置参数(??? 为必填):
| 配置路径 | 默认值 | 说明 |
|---|---|---|
zotero.user_id |
??? |
Zotero 账户的 User ID(数字串,非用户名) |
zotero.api_key |
??? |
具有读权限的 Zotero API Key |
zotero.include_path |
null |
参与推荐的 Collection glob 列表,如 ["2026/survey/**"] |
zotero.ignore_path |
null |
排除的 Collection glob 列表 |
source.arxiv.category |
null |
arXiv 订阅分类缩写,如 ["cs.AI","cs.CV","cs.LG","cs.CL"] |
source.arxiv.include_cross_list |
false |
是否纳入 cross-list 条目 |
source.biorxiv.category |
null |
bioRxiv 分类(按站点分类名填写) |
source.medrxiv.category |
null |
medRxiv 分类 |
source.chemrxiv.include_new_versions |
false |
是否纳入已发布预印本的修订版;chemRxiv 无分类过滤,每天新预印本(约几十篇)全量抓取交由重排器筛选 |
email.sender / receiver |
??? |
发件邮箱 / 收件邮箱 |
email.smtp_server / smtp_port |
??? / ??? |
SMTP 服务器与端口(如 smtp.qq.com:465) |
email.sender_password |
??? |
SMTP 授权码(不一定是邮箱登录密码) |
llm.api.key / base_url |
??? |
LLM API Key 与 Base URL |
llm.api_mode |
chat_completion |
chat_completion 或 response(Responses API) |
llm.generation_kwargs.max_tokens |
16384 |
生成最大 token 数 |
llm.generation_kwargs.model |
??? |
使用的模型名,如 gpt-4o-mini |
llm.language |
English |
TLDR 输出语言 |
reranker.local.model |
jinaai/jina-embeddings-v5-text-nano-retrieval |
本地嵌入模型名 |
reranker.local.encode_kwargs |
{task: retrieval, prompt_name: document} |
传给 SentenceTransformer.encode 的参数 |
reranker.api.key/base_url/model/batch_size |
null |
API 型嵌入模型的配置 |
executor.debug |
false |
调试模式(启用更详细日志、缩小数据量) |
executor.send_empty |
false |
无新论文时是否仍发送(空)邮件 |
executor.max_paper_num |
100 |
邮件中最多呈现的论文数 |
executor.source |
??? |
论文来源列表,如 ['arxiv','biorxiv','medrxiv','chemrxiv'] |
executor.reranker |
local |
使用的重排器,local 或 api |
api_mode 与 generation_kwargs 的语义可在 protocol.py#L12-L36 的 _request_llm() 中验证:chat_completion 模式调用 openai_client.chat.completions.create(messages=...),response 模式则调用 openai_client.responses.create(input=...) 并把 max_tokens 自动映射为 max_output_tokens,两者都透传剩余的 generation 参数。
核心数据结构:Paper 与 CorpusPaper
两类数据类定义在 protocol.py#L39-L139,是贯穿全流水线的类型契约:
Paper(候选新论文):携带 source、title、authors、abstract、url、pdf_url、可选的 full_text、tldr、affiliations 与 score。它的两个 LLM 增强方法直接在数据类上实现:
generate_tldr():构造系统提示("你是一位完美总结科学论文的助手")与用户提示(含标题、摘要、全文预览),先用 gpt-4o 分词器把 prompt 截断到 4000 token,再调用 LLM,语言由llm.language控制(protocol.py#L52-L96)。异常时降级为返回原始摘要;generate_affiliations():仅在有full_text时执行,要求 LLM 输出按作者顺序排列的 Python 列表、只保留顶层机构、去重;随后用正则\<a href="https://link.gitcode.com/i/4fece84c3c156997844d27595c51b38c" target="_blank">.*?\]提取列表并json.loads解析,异常时置为None([protocol.py#L98-L133)。
CorpusPaper(Zotero 语料论文):只包含 title、abstract、added_date(加入日期,用于时间衰减加权)和 paths(所属 Collection 路径列表,用于 glob 过滤)。
在流水线中,Executor 构造时即创建各来源 Retriever、Reranker 和 OpenAI 客户端(OpenAI(api_key=..., base_url=...),executor.py#L37-L41);重排后按 max_paper_num 截断,逐个 generate_tldr + generate_affiliations,最后由 construct_email.py 的 render_email() 渲染、utils.py 的 send_email() 发送(executor.py#L109-L124)。
测试策略:默认跳过慢测试,纯 Python stub 即可运行
CLAUDE.md 用专节说明了测试策略,这是项目工程质量的关键设计:
- 慢测试标记:标注
@pytest.mark.slow的测试依赖重型依赖(典型如 sentence-transformers 模型下载),默认被跳过; - 默认排除机制:
pyproject.toml中配置addopts = "-m 'not slow'",因此本地uv run pytest默认只跑非慢测试; - 零 Docker 依赖:除慢测试外,其余测试使用纯 Python stub(如 mock Zotero / mock OpenAI 服务器)即可运行,无需任何容器。测试基建见 tests/utils/mock_openai/(含 Dockerfile 与
openai_server.py)与 tests/utils/mock_zotero/。
仓库内已具备覆盖各模块的测试:test_executor.py、test_protocol.py、test_utils.py(含 TestGlobMatch)、test_construct_email.py、test_main.py,以及 retriever 与 reranker 各自的测试目录(tests/retriever/、tests/reranker/),其中 tests/retriever/arxiv_rss_example.xml 提供了可离线复用的 RSS 样例数据。
常用命令:运行、测试与依赖管理
CLAUDE.md 给出的命令体系如下(项目由 uv,依赖声明见 pyproject.toml):
# 运行应用(默认从 config/ 组合 default.yaml 配置)
uv run src/zotero_arxiv_daily/main.py
# 运行测试(默认排除慢测试)
uv run pytest
# 运行全部测试(包括慢测试)
uv run pytest -m ""
# 运行单个测试
uv run pytest tests/test_utils.py::TestGlobMatch -v
# 安装/同步依赖
uv sync
# 带覆盖率运行
uv run pytest --cov=src/zotero_arxiv_daily --cov-report=term-missing
值得注意的实现细节:main.py 启动时设置 TOKENIZERS_PARALLELISM=false 并用 dotenv.load_dotenv() 加载本地 .env 文件(main.py#L9-L10),本地运行时可直接在项目根目录准备 .env 注入秘密;日志级别由 config.executor.debug 决定(DEBUG/INFO),并借助 loguru 输出带颜色与调用位置信息的结构化日志,同时把第三方库日志静默到 WARNING(main.py#L15-L26)。项目未配置 linter 或 formatter,属于刻意精简的工具链。
本地完整运行示例(需先按上文表格导出环境变量):
export ZOTERO_ID=xxxx
export ZOTERO_KEY=xxxx
export SENDER=abc@qq.com
export SENDER_PASSWORD=xxxx
export RECEIVER=abc@outlook.com
export OPENAI_API_KEY=sk-xxx
export OPENAI_API_BASE=https://api.openai.com/v1
uv run src/zotero_arxiv_daily/main.py
调试模式与运行边界
executor.debug: true 是一把双刃剑:它一方面把日志级别降到 DEBUG 便于排查,另一方面会显著缩小数据规模——arXiv Retriever 只取前 10 个 RSS 条目(arxiv_retriever.py#L264-L265),本地嵌入模型会保留进度条输出。README 中对应的 GitHub Actions "Test-Workflow" 工作流正是 debug 版:无论日期如何始终抓取少量论文用于验证链路,而主工作流每天自动运行、只抓取昨天发布的新论文(周末与节假日无新论文时,主工作流日志会出现 "No new papers found")。
关于 max_paper_num,README 明确提示其上限受限于 GitHub Actions 运行器配额(公共仓库单次 6 小时、私有仓库每月 2000 分钟):数值过高会导致执行超时。需要更大吞吐时,可考虑自有服务器部署(参考 assets/use_docker.md 的 Docker/Compose 方案,支持定时执行、日志持久化与模型缓存)。
扩展指南:如何接入新论文源或新嵌入模型
结合插件机制,扩展点非常清晰:
- 新增论文源:在
retriever/下新建类,继承BaseRetriever,用@register_retriever("你的名字")注册,实现_retrieve_raw_papers()(返回原始条目列表)与convert_to_paper()(转为Paper),然后在config.executor.source中追加该名称即可; - 新增嵌入方式:在
reranker/下继承BaseReranker,用@register_reranker("你的名字")注册,只需实现get_similarity_score(s1, s2) -> np.ndarray(返回候选×语料的相似度矩阵),时间衰减加权与排序逻辑由基类免费提供; - 换 LLM:无需改代码,
llm.api.base_url+generation_kwargs.model支持任意 OpenAI 兼容端点。
可以推断,这种"注册-发现-模板方法"的组合,使项目在保持流水线单线推进的前提下,将"抓什么""怎么排序""谁生成摘要"三个易变点全部开放为配置与插件,这是它作为 GitHub Actions 零成本服务仍能保持良好可维护性的根本原因。
总结
zotero-arxiv-daily 用一个克制而完整的架构回答了"如何每天自动推荐感兴趣的论文":Executor 单线程编排六阶段流水线,CorpusPaper/Paper 数据类承载全链路类型契约,Hydra + OmegaConf 把"仓库默认配置、用户覆盖配置、环境变量秘密"三者干净分离,Retriever/Reranker 双插件系统让数据源与排序算法可独立替换,时间衰减加权让推荐始终偏向最近的研究兴趣,而"慢测试默认跳过 + 纯 stub 可测"的策略保障了零成本 CI 的可行性。对希望借鉴"订阅制个性化推荐 + 定时任务 + LLM 增强"工程范式的开发者来说,这份仓库是一份低门槛、可逐行验证的完整参考实现。
