论文推荐与 Zotero 归档
这个技能解决什么
推送一篇论文不难,难的是「连续跑三个月不出事」。历史故障几乎全部来自同一个原因:把 API 操作写成散文,每次由模型现场拼命令。所以本技能把动作收敛成一个可执行内核 scripts/zotero_reco.py(仅标准库),SKILL.md 只负责决策与话术。
三条硬原则:
- Zotero 库是唯一事实源,
push_log.jsonl是事件流水,interest_profile.md只是派生视图——永远由脚本重算,不手工追加。这样不会再出现「库里 7 条、档案 4 条」的漂移。 - DOI 是主键,入库一律 upsert:重复运行、重推、补挂附件都不会产生重复条目。
- 每个写操作都要能回读:
add之后必跑verify,字节级确认中文没坏、collection 归属正确、附件链接能打开。
依赖与环境
- Python 3.9+,无第三方依赖(urllib / json / configparser 全标准库)。
- 需要能直连
api.zotero.org;uploads.zotero.org在部分网络(含中国大陆部分地区)不可达,届时自动走链接式附件。
状态目录(首次务必先 init)
状态与技能本体分离,路径按以下顺序解析:
--state <path>参数- 环境变量
ZOTERO_STATE_ROOT - 默认
~/.zotero-paper-reco
> 不要把状态放进会话/聊天目录:换会话、换设备、云端跑定时任务时就找不到了,这是历史故障的根源之一。
<state_root>/
├── config.ini # API Key、user_id、collection 名、兴趣种子、降级链接
├── push_log.jsonl # 事件流水:push / selected / ignored / ignored_auto
└── interest_profile.md # 派生视图,由 profile / sync 生成,不手改
config.ini 模板见 templates/config.ini.example,字段含义见 references/api-notes.md
四个段:[zotero] 凭证与 collection;[interests] 方向与种子权重;[keywords] 方向的关键词别名(中英混排文献靠它推断方向);[fulltext] 链接不可达时的降级候选。
决策树(每次触发先判断)
读 config.ini →
├─ 不存在 / 仍是占位符 → 走「阶段一:首次配置」
└─ 存在 → 跑 preflight
├─ ok=false(write=false / collection 不存在 / Key 失效)→ 先修配置,明确告知用户卡在哪,不要硬跑
└─ ok=true → 按触发来源分两条线
├─ 定时推送 → 工作流 A
└─ 用户说「加入 Zotero / 感兴趣」→ 工作流 B
阶段一:首次配置
- 运行
python scripts/zotero_reco.py init,得到config.ini。 - 引导用户:
https://www.zotero.org/settings/keys建 private key,必须勾 Allow write access;https://www.zotero.org/settings抄数字 User ID。 - 填好后运行
preflight --ensure-collection,把write、collection、upload_endpoint三项结果如实回报给用户。 - 绝不把 Key 写进对话、query、日志或产物;只在
config.ini里。
工作流 A:每日推送
- 读画像:
interest_profile.md的权重 + 最近推送表(去重依据)。 - 检索:优先近 1–2 年、与研究兴趣直接相关;候选池至少 5 篇,避免「只搜到一篇就推」。
- 去重:每篇候选都先跑
lookup --doi <doi>;in_library=true或already_pushed=true→ 直接丢弃。 - 打分:按
references/scoring-rubric.md的五维打分,取最高分且 ≥3.5 的推;不足则如实说「今天没有值得推的」,不要凑数。 - 元数据:作者、期刊、卷期页、年份、DOI 一律用 Crossref/OpenAlex 按 DOI 取,不要从检索摘要里抄(历史上有把作者身份写错的记录)。
- 推送话术(口语化,5 行内):标题 / 作者 / 期刊·年份 / 一句话讲清它解决什么问题 / 「为什么推给你」/ DOI 或链接 / 一句「感兴趣就回复"加入 Zotero"」。
- 记流水:
log --event push --doi ... --title ... --direction ... --score ...(这一步不能省,否则下次去重失效、画像失忆)。
工作流 B:用户点头 → 入库(事务化)
一条链路,五步,任一步失败都要停下并说明,不许「报成功但其实没进库」:
# 1) 组装条目 JSON(UTF-8),字段示例见 templates/item.example.json
# 2) 幂等入库:建条目时就把 collection 写进 payload,不事后补
python scripts/zotero_reco.py add --payload item.json --direction 交通能源 --score 4.2
# 3) 挂全文:先探活,不可达自动降级,别把 403 的链接挂上去
python scripts/zotero_reco.py attach --item-key <key> --link <URL> \
--fallback "https://doi.org/<doi>" "https://search.napstic.cn/search?q=<doi>"
# 4) 回读校验:中文、collection、附件状态一把梭
python scripts/zotero_reco.py verify --item-key <key> --expect-title "<标题>" \
--collection-key <collectionKey>
# 5) 回报用户:条目直达链接 + 附件是全文还是题录页
要点:
- collection 归属写在创建 payload 里(
add已内置),不要先建条目再POST /collections/{key}/items——那个端点对新建条目经常返回 400「Item not found in library」,必须先 PUT 改data.collections兜底。脚本已把这条弯路封掉。 - 附件分层策略:OA PDF 且上传端点可达 →
imported_file传本体;否则优先linked_url指向https://doi.org/<doi>(比出版社页面稳定);中文期刊官网常见校外 403 → 挂聚合库题录页并在附件标题写「题录/摘要页(全文需校园网)」;本机已有 PDF →linked_file(仅同一台电脑的客户端能打开)。 - 中文编码:脚本内部
json.dumps(ensure_ascii=False).encode("utf-8"),响应decode("utf-8", strict)。不要再走「Write 写 JSON 文件 + curl --data-binary」那套,也别用 PowerShell 的ConvertTo-Json拼中文请求体。
工作流 C:反馈与画像
| 用户反应 | 命令 | 效果 |
|---|---|---|
| 感兴趣/已入库 | log --event selected --doi ... --direction ... |
该方向 +0.2(30 天半衰期) |
| 明确不感兴趣 | log --event ignored --doi ... --direction ... |
该方向 −0.15;同一方向累计 ≥2 次进入负面清单 |
| 推送后 3 天无回应 | log --event ignored_auto --doi ... |
弱负反馈 −0.05,防止权重只涨不跌 |
| 想重算画像 | profile |
从流水重生成 interest_profile.md |
| 换了库/补历史 | sync --collection <名> |
从 collection 回填 selected,修正漂移 |
推荐配比:80% 按权重主攻 + 20% 探索位(相邻方向或新方法),否则权重高的方向会自我强化成一个越来越窄的专题;探索位命中也记 selected,画像自然扩边。
命令速查
| 命令 | 用途 | 关键参数 |
|---|---|---|
init |
生成状态目录与配置模板 | --force |
preflight |
开跑前体检 | --ensure-collection |
lookup |
DOI 查重(库内/已推送) | --doi |
add |
幂等入库 + 归集 + 附件 + 记流水 | --payload --link --pdf --direction --score --dry-run |
attach |
挂全文,链接探活自动降级 | --item-key --link --fallback --pdf --linked-file |
verify |
回读校验中文/归属/附件 | --item-key --expect-title --collection-key |
log |
记事件并重算画像 | --event --doi --direction |
sync |
从库回填流水 | --collection |
profile |
重算兴趣画像 | — |
migrate |
旧状态迁移到新目录 | --src --dst |
所有命令输出 UTF-8 JSON,ok 字段是唯一判定依据;退出码:0 成功、1 一般失败、2 配置、3 鉴权、4 网络、5 版本冲突。
故障处理表
| 现象 | 根因 | 处置 |
|---|---|---|
403 Write access denied |
Key 创建时没勾 Allow write access | 去 settings/keys 编辑该 Key 勾选保存;preflight 会先拦住 |
归入 collection 返回 400 Item not found in library |
先建条目再关联,顺序错了 | 用 add(collection 随创建写入);历史条目用 PATCH data.collections |
标题变 æºæ §å ¬è·¯ |
请求体被非 UTF-8 通道二次编码 | 只走脚本;旧条目用 verify 定位后按当前 version 整体 PUT 重写 |
附件点开 403 Forbidden |
挂了出版社/学报官网,对校外 IP 拦截 | attach 先探活再挂;已挂的用 attach --link <可达页> 重建附件 |
uploads.zotero.org 超时 |
上传端点被网络屏蔽,或返回二次签名地址 | 降级 linked_url;要本体就先连校园网/VPN 再 attach --pdf |
preflight 说 collection 不存在 |
首次使用 / 名字改了 | preflight --ensure-collection |
| 库里条数 > 档案条数 | 入库成功但没记流水 | sync 回填,之后每次 add 自动记 |
更细的接口行为、错误码与降级顺序见 references/api-notes.md;打分与权重规则见 references/scoring-rubric.md。
隐私与发布
- 技能包内不含任何账号、User ID、API Key、个人路径、真实 DOI 偏好;
templates/全是占位符。 - 用户的
config.ini与push_log.jsonl属本地私有数据,只存在状态目录,不进技能包、不进对话、不进定时任务 query。 - 打包上架用
scripts/pack_skillhub.py:校验 frontmatter 必填字段(name/description/version/display_name)、强制<skill-name>/根目录与两级目录限制、回读 zip 自查。
平台约束(SkillHub / WorkBuddy)
- 包内目录最多两级:
<skill-name>/<子目录>/<文件>;references/、scripts/、templates/只放平铺文件。 - ≤200 个文件、≤10 MB;排除
__pycache__、.pyc。