zotero-arxiv-daily 项目架构解析:基于 Zotero 文献库的每日论文推荐流水线

原创2026-10-02 01:23:0144 阅读
文章标签:人工智能AI 应用RAG科研

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):

  1. Fetch Zotero corpus(拉取 Zotero 语料):通过 pyzotero API 拉取用户文献库,只保留 conferencePaper || journalArticle || preprint 三种条目类型且含摘要(abstractNote != '')的论文,同时递归解析每个条目的 Collection 路径(get_collection_path 沿 parentCollection 逐级向上拼接出如 2026/survey 的层级路径,见 executor.py#L49-L56)。
  2. Filter corpus(过滤语料):按 include_path / ignore_path 两组 glob 模式筛选相关 Collection,这决定了"你的兴趣画像"具体由库中哪些论文构成(详见下文"Collection 路径过滤"小节)。
  3. Retrieve new papers(抓取新论文):从配置指定的来源抓取新论文。arXiv 走 RSS feed,bioRxiv/medRxiv 走 REST API,chemRxiv 经 Crossref REST API。
  4. Rerank(重排打分):用嵌入模型计算候选论文与语料的相似度,并按时间衰减权重加权——越晚加入 Zotero 的论文权重越高,代表"最近的研究兴趣"。
  5. Generate TLDRs + affiliations(生成摘要与机构):通过 OpenAI 兼容的 LLM API,为每篇候选论文生成一句话 TLDR,并尽量解析出作者机构列表。
  6. 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 增强"工程范式的开发者来说,这份仓库是一份低门槛、可逐行验证的完整参考实现。

登录后查看全文
zotero-arxiv-daily