dsh-zotero-skill 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,281 +1,281 @@
1
- # Zotero Skill for DeepSeek Harness
2
-
3
- [![npm](https://img.shields.io/npm/v/dsh-zotero-skill.svg)](https://www.npmjs.com/package/dsh-zotero-skill)
4
- ![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)
5
- ![License](https://img.shields.io/badge/License-MIT-green)
6
- ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
7
-
8
- 让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 把文献工作串成一条流水线:**找文献 → 追前沿 → 筛核心 → 管理归集 → 批量阅读 → 分类整理 → 提炼脉络 → 发现空白**,再加上导出 BibTeX 与给 LaTeX / Markdown 草稿插入引用。
9
-
10
- 纯 Python 标准库实现,**不需要 pip、MCP 服务或 Zotero 云端 API Key**;默认只与本机 `127.0.0.1:23119` 通信。
11
-
12
- ## 特性
13
-
14
- - **找文献**:从 OpenAlex 按主题检索,按期刊影响力、引用速度、相关度、新鲜度排序,并与本地库去重。
15
- - **追前沿**:把常读方向存成 watch,每次只拉「上次运行以来」的新论文,不重复推送。
16
- - **筛核心论文**:把候选或库内条目分成 核心 / 重要 / 参考 三档,输出可复制的短名单。
17
- - **管理文献**:批量打标签、归集到分类、按 DOI / 标题查重并清理副本。
18
- - **批量阅读**:一次为几十篇生成阅读包(元数据 + 摘要 + 章节大纲 + 笔记骨架),不必把原文全塞进上下文。
19
- - **分类整理**:从题名和摘要识别方法与数据集,生成 `auto:method:*` / `auto:dataset:*` 标签与集合计划,预览后写入。
20
- - **提炼脉络**:统计年份、期刊、方法、数据集及共现,直接产出综述可用的表格。
21
- - **发现空白**:按项目分组找「方法 × 数据集」从未交叉的组合、停更标签与标注缺失。
22
- - **检索与引用**:按关键词搜索条目,返回题名、作者、年份、item key 与 BibTeX key;在草稿唯一标记处插入 `\cite{key}` 或 `[@key]` 并同步 `.bib`。
23
- - **每日收录**:按主题每天从 OpenAlex 抓新论文,去重排序后自动导入指定分类,数量 / 开关 / 时间都由你决定。
24
- - **不碰数据库**:只走 Zotero 官方本地 API,不直接读写 `zotero.sqlite`;改库前先 `--dry-run`,写入需要你在 Zotero 弹窗里点一次「Always Allow」。
25
-
26
- ## 安装
27
-
28
- ### 1. 准备
29
-
30
- - Python 3.10+(Windows 上用 `py -3` 或 `python`,macOS / Linux 用 `python3`)
31
- - 桌面版 Zotero(开发时验证于 Zotero 10.0.6)
32
- - DeepSeek Harness
33
-
34
- ### 2. 装进 Harness
35
-
36
- 两种方式,任选其一。
37
-
38
- **方式 A:作为 DSH 插件安装(推荐)**
39
-
40
- 包已发布到 npm,自带 `dsh.bundle` 清单,装成插件后 skill 自动出现在技能目录,不需要复制文件:
41
-
42
- ```bash
43
- dsh plugin --profile <你的 profile> add dsh-zotero-skill
44
- ```
45
-
46
- 想跟最新代码,或 npm 不可达时,可直接从 GitHub 安装:
47
-
48
- ```bash
49
- dsh plugin --profile <你的 profile> add github:Eason-0712/dsh-zotero-skill
50
- ```
51
-
52
- 也可以从本地检出安装(开发调试用):
53
-
54
- ```bash
55
- dsh plugin --profile <你的 profile> add "link:/path/to/dsh-zotero-skill"
56
- ```
57
-
58
- 卸载:`dsh plugin --profile <你的 profile> remove dsh-zotero-skill`。
59
-
60
- **方式 B:作为独立 skill 目录安装**
61
-
62
- ```text
63
- # 仓库根目录下执行
64
- python install.py
65
- ```
66
-
67
- 安装器默认复制到 `$DSH_HOME/skills/zotero`(未设置时 `~/.dsh/skills/zotero`),目标已存在时不会覆盖。也可以手动把整个 `zotero/` 目录复制过去,最终结构必须是 `skills/zotero/SKILL.md`。
68
-
69
- 项目级(只在某个仓库生效):
70
-
71
- ```bash
72
- python install.py --skills-dir .dsh/skills
73
- ```
74
-
75
- 想装到别的盘或自定义目录,用 `--skills-dir` 指定,再让 Harness 扫描该目录即可。
76
-
77
- ### 3. 打开 Zotero 本地 API
78
-
79
- Zotero → 设置 → 高级 → 其他 → 勾选「允许此计算机上的其他应用程序与 Zotero 通信」。
80
-
81
- 也可以让脚本改配置(**先完全退出 Zotero**):
82
-
83
- ```bash
84
- python zotero/scripts/zotero.py enable --restart
85
- ```
86
-
87
- 它会备份 `prefs.js` 后写入 `extensions.zotero.httpServer.localAPI.enabled`,再启动 Zotero;不会强杀进程。
88
-
89
- ### 4. 验证
90
-
91
- ```bash
92
- python zotero/scripts/zotero.py status --json
93
- ```
94
-
95
- `api_running: true`、`api_status: 200` 即连通。
96
-
97
- ## 使用示例
98
-
99
- 在 Harness 里直接说:
100
-
101
- > 帮我找一下 knowledge distillation 用在运动想象 EEG 上的新论文,按重要性排个序。
102
-
103
- > 我常看的方向有这几个,做成 watch,以后只给我新出现的。
104
-
105
- > 从库里 project:MI-EEG 这 18 篇里挑出最核心的几篇,列个短名单。
106
-
107
- > 把这批论文生成阅读包,我看完再写综述。
108
-
109
- > 按方法和数据集给整个库打上标签,先给我看计划。
110
-
111
- > 这个方向现在有哪些明显的空白?
112
-
113
- > 读一下 `/path/to/draft.md`,把相关论断后面补上引用,并更新同目录的 references.bib。
114
-
115
- Skill 会自己决定调用哪些子命令,并在写库或写文件前先给预览。
116
-
117
- ## 每日自动收录
118
-
119
- 每天早上自动把新论文收进 Zotero,数量、主题、开关都由你决定。
120
-
121
- ### 开启
122
-
123
- ```bash
124
- # 设定主题与数量(最多 30 篇),并在 Zotero 里先选中一个目标分类
125
- python zotero/scripts/zotero.py daily-config --enable \
126
- --topics "transformer;large language model" --max 10 --mailto "you@example.com"
127
-
128
- # 先看候选,不写库
129
- python zotero/scripts/zotero.py daily --dry-run
130
-
131
- # 真正导入
132
- python zotero/scripts/zotero.py daily
133
- ```
134
-
135
- 关闭:`daily-config --disable`。关闭状态下 `daily` 只返回提示,加 `--force` 可临时跑一次。
136
-
137
- ### 定时
138
-
139
- ```bash
140
- # 每天 08:00 自动运行(Windows 任务计划程序)
141
- python zotero/scripts/zotero.py daily-schedule --time 08:00
142
-
143
- # 删除定时任务
144
- python zotero/scripts/zotero.py daily-schedule --remove
145
- ```
146
-
147
- 任务只在该 Windows 用户登录时触发;Zotero 没开着时脚本会尝试启动它并等待接口就绪。
148
-
149
- ### 排序依据
150
-
151
- 候选论文先按主题从 [OpenAlex](https://openalex.org)(免费、无需 API Key)检索最近 N 天,再按加权分数排序:
152
-
153
- | 维度 | 默认权重 | 含义 |
154
- | --- | --- | --- |
155
- | 期刊影响力 | 0.45 | 期刊近两年平均被引(OpenAlex `2yr_mean_citedness`) |
156
- | 引用速度 | 0.30 | 年化被引次数,避免老论文靠累计引用刷分 |
157
- | 主题相关度 | 0.15 | OpenAlex 搜索相关度 |
158
- | 新鲜度 | 0.10 | 越新越高,7 天窗口内自然衰减 |
159
-
160
- 同一期刊默认最多 3 篇(`--max-per-venue`),避免一天全是同一个刊物。已导入过的 DOI 会记录在 `~/.zotero-skill/state.json`,不会重复推送。
161
-
162
- ### 配置项
163
-
164
- | 键 | 默认 | 说明 |
165
- | --- | --- | --- |
166
- | `enabled` | `false` | 总开关 |
167
- | `max_items` | `10` | 每次导入上限,最大 30 |
168
- | `days_back` | `7` | 回溯天数,1–30 |
169
- | `topics` | `[]` | 研究主题,分号分隔 |
170
- | `max_per_venue` | `3` | 同一期刊上限 |
171
- | `include_preprints` | `false` | 是否纳入预印本 |
172
- | `min_venue_score` | `0` | 期刊 2 年被引下限,过滤水刊 |
173
- | `weights` | 见上表 | 各维度权重 |
174
- | `mailto` | `""` | OpenAlex 礼貌池联系邮箱 |
175
- | `schedule_time` | `08:00` | 定时任务时间 |
176
-
177
- 配置和状态都在 `~/.zotero-skill/`,可用 `ZOTERO_SKILL_CONFIG_DIR` 改位置。**注意**:这里用的是 OpenAlex 的期刊被引指标,不是 JCR 影响因子;两者不等价,别把分数当 IF 用。
178
-
179
- ## 命令
180
-
181
- | 命令 | 作用 |
182
- | --- | --- |
183
- | `status` | 检查本地 API、Connector、版本 |
184
- | `authorize` | 取得本地 API 写权限(Zotero 弹窗点「Always Allow」) |
185
- | `find <query>` | OpenAlex 检索并按分数排序,`--oa-only` / `--json` / `--out` |
186
- | `frontier add\|run\|list\|remove` | 保存研究方向,只拉上次之后的新论文 |
187
- | `screen` | 把候选或库内条目分成 核心 / 重要 / 参考 |
188
- | `tag` | 批量加 / 删 / 替换标签(`--dry-run`) |
189
- | `collect <name>` | 归集到分类,不存在则新建 |
190
- | `classify` | 识别方法与数据集,生成标签/集合计划,`--apply` 写入 |
191
- | `dupes` | 按 DOI 或标题查重,`--trash` 清理副本 |
192
- | `digest` | 批量生成阅读包(每篇一个 Markdown + index.md) |
193
- | `landscape` | 年份 / 期刊 / 方法 / 数据集统计与共现 |
194
- | `gaps` | 方法 × 数据集空组合、停更标签、标注缺失 |
195
- | `inventory` | 列出条目 |
196
- | `collections` / `tags` / `groups` | 列出集合、标签、分组 |
197
- | `search <query>` | 关键词检索,`--with-bibtex-keys` 附带引用键 |
198
- | `export-bibtex` | 导出 BibTeX(`--item-key` 单篇,`--out` 写文件) |
199
- | `sync-bib` | 导出整库到 `references.bib` |
200
- | `citations` | 按样式渲染引用文本(如 `--style apa`) |
201
- | `cite` | 在草稿标记处插入引用并同步 `.bib` |
202
- | `children` / `fulltext` / `file-url` | 附件、全文、文件路径 |
203
- | `selected-target` | 查看 Connector 当前选中的库/集合 |
204
- | `import-bibtex` / `import-ris` | 经 Connector 导入(需 `--yes`) |
205
- | `daily` | 按主题抓取并按分数导入新论文(`--dry-run` 预览) |
206
- | `daily-config` | 查看/修改每日收录配置 |
207
- | `daily-schedule` | 创建/删除 Windows 每日定时任务 |
208
- | `probe` | 探测各 API 路由可用性 |
209
- | `enable` / `disable` / `restart` | 管理本地 API 开关与启动 |
210
-
211
- 所有命令加 `--json` 输出机器可读结果。批量选择统一用 `--keys`、`--query`、`--tag`、`--collection`、`--all`,`--limit` 限制条数。
212
-
213
- ## 目录结构
214
-
215
- ```text
216
- .
217
- ├── package.json # dsh.bundle 清单,dsh plugin add 靠它识别
218
- ├── cordis.patch.yml # 插件挂载行
219
- ├── lib/index.js # 向 ctx.skills 注册内置 skill 的 provider
220
- ├── zotero/
221
- │ ├── SKILL.md # skill 入口,Harness 读取它来发现技能
222
- │ ├── references/
223
- │ │ ├── deepseek-harness.md # 安装、连接排查、环境变量
224
- │ │ └── local-api-routes.md # 本地 API 与 Connector 路由
225
- │ └── scripts/zotero.py # 全部功能,仅标准库
226
- ├── test/provider.test.mjs # provider 的 Node 测试
227
- ├── tests/test_zotero.py # 模拟 API 的 Python 单元测试
228
- ├── tests/test_literature.py # 术语抽取、分类计划、脉络、查重、空组合
229
- ├── install.py # 复制式安装器(方式 B)
230
- ├── LICENSE
231
- └── NOTICE.md
232
- ```
233
-
234
- 装成插件后,`lib/index.js` 在 `apply()` 里注册一个 rank 600 的 provider,直接读包内的 `zotero/SKILL.md`。相比复制到 `~/.dsh/skills`,升级只需 `dsh plugin` 更新,也不会有两份副本不同步的问题。
235
-
236
- ## 设计取舍
237
-
238
- - **读写都走官方 API**:检索、导出、全文走 `/api/` 的 GET(免认证);打标签、归集、查重清理走同一套本地 API 的 PATCH/POST,需要本地 API key 与 `Zotero-Server-ID`,脚本用 `authorize` 一次性取得并缓存,绝不直接改 `zotero.sqlite`。
239
- - **先预览再写入**:所有写命令都支持 `--dry-run`,Skill 要求先把计划给用户看,确认后才加 `--yes`。
240
- - **规则可解释**:方法 / 数据集识别是正则词表,分歧能被逐条检查;空组合与停更标签都是可复算的统计,而不是模型编的结论。
241
- - **写文件前备份**:`cite` 会先校验草稿和标记,再备份草稿与 `.bib`;`sync-bib` / `export-bibtex --out` 会覆盖目标文件,所以 Skill 要求先确认路径。
242
- - **本机请求绕过代理**:`localhost`、`127.0.0.1`、`::1` 直连,避免系统代理吞掉本机请求;非回环地址才走代理配置。
243
- - **UTF-8**:Windows 控制台下的中文 JSON 与路径不会乱码;`--input` 也能读 PowerShell 写出的 UTF-16 文件。
244
- - **不强制结束 Zotero**:需要改配置时要求先正常退出,避免 `prefs.js` 被覆盖。
245
-
246
- ## 测试
247
-
248
- ```bash
249
- python -m unittest discover -s tests -v # Python 侧
250
- node --test test/*.test.mjs # 插件 provider
251
- ```
252
-
253
- 测试用临时文件和模拟 API,不读写真实文献库。GitHub Actions 会在 Ubuntu / Windows 与 Python 3.10 / 3.13 上跑 Python 测试,并在 Node 20 / 22 上跑 provider 测试。
254
-
255
- ## 已知限制
256
-
257
- - Word 里的 Zotero 动态引用域不在能力范围内;脚本不会用替换文本的方式伪装成已插入引用。
258
- - 远端 / WSL / 容器里 `127.0.0.1` 指向的是运行环境本身,不在同一台机器上时无法直接连到桌面 Zotero。
259
- - `enable` / `disable` 只改本机 `prefs.js`,对远端 Zotero 无效。
260
- - 本地 API 的能力随 Zotero 版本变化,遇到 403 / 404 先检查开关和版本。
261
- - 写入需要你在 Zotero 弹窗里授权一次(选「Always Allow」才是持久的);选「Allow」的 key 只能写一次。
262
- - 方法 / 数据集识别基于固定正则词表,冷门术语可能漏标;`gaps` 会把这些条目列成「标注缺失」供人工补。
263
- - `landscape` / `gaps` 的结论来自题录与摘要的统计,不是全文精读;写进论文前需要回头看原文。
264
- - 每日收录依赖 OpenAlex 的网络访问;离线或被限流时会跳过本次抓取,不会写坏文献库。
265
- - 收录只导入题录与摘要,不下载 PDF;需要全文时再按需获取。
266
- - 期刊指标来自 OpenAlex,与 JCR 影响因子不是同一套数据,仅用于相对排序。
267
- - 定时任务只在该 Windows 用户登录时运行;机器关机或睡眠时当次会跳过。
268
-
269
- ## 来源与许可
270
-
271
- 本项目改编自 OpenAI 的 Zotero 插件 0.1.2(MIT):保留了其 `zotero.py` 与本地 API 路由说明,并针对独立运行、DeepSeek Harness、Windows 和本机代理环境做了修改。
272
-
273
- - 原始版权与许可全文见 [LICENSE](LICENSE)
274
- - 来源与改动范围见 [NOTICE.md](NOTICE.md)
275
- - 本项目不是 OpenAI、DeepSeek 或 Zotero 官方发布的适配版本
276
-
277
- ## English
278
-
279
- A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) skill that runs the whole literature loop against your local Zotero library: find papers on OpenAlex, track research fronts with saved watches, screen candidates into core / important / reference tiers, manage items (batch tags, collections, DOI and title de-duplication), generate batch reading packs, classify items by method and dataset, chart the landscape of years / venues / methods / datasets, and surface candidate research gaps. It also exports BibTeX, inserts citations into LaTeX/Markdown drafts, and can run a ranked daily digest of new papers.
280
-
281
- Install with `dsh plugin --profile <name> add dsh-zotero-skill` (or copy `zotero/` to `~/.dsh/skills/zotero`, or run `python install.py`), enable Zotero's local API, and run `zotero.py authorize` once if you want write operations. Adapted from OpenAI's Zotero plugin 0.1.2 under the MIT license.
1
+ # Zotero Skill for DeepSeek Harness
2
+
3
+ [![npm](https://img.shields.io/npm/v/dsh-zotero-skill.svg)](https://www.npmjs.com/package/dsh-zotero-skill)
4
+ ![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)
5
+ ![License](https://img.shields.io/badge/License-MIT-green)
6
+ ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)
7
+
8
+ 让 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 把文献工作串成一条流水线:**找文献 → 追前沿 → 筛核心 → 管理归集 → 批量阅读 → 分类整理 → 提炼脉络 → 发现空白**,再加上导出 BibTeX 与给 LaTeX / Markdown 草稿插入引用。
9
+
10
+ 纯 Python 标准库实现,**不需要 pip、MCP 服务或 Zotero 云端 API Key**;默认只与本机 `127.0.0.1:23119` 通信。
11
+
12
+ ## 特性
13
+
14
+ - **找文献**:从 OpenAlex 按主题检索,按期刊影响力、引用速度、相关度、新鲜度排序,并与本地库去重。
15
+ - **追前沿**:把常读方向存成 watch,每次只拉「上次运行以来」的新论文,不重复推送。
16
+ - **筛核心论文**:把候选或库内条目分成 核心 / 重要 / 参考 三档,输出可复制的短名单。
17
+ - **管理文献**:批量打标签、归集到分类、按 DOI / 标题查重并清理副本。
18
+ - **批量阅读**:一次为几十篇生成阅读包(元数据 + 摘要 + 章节大纲 + 笔记骨架),不必把原文全塞进上下文。
19
+ - **分类整理**:按你想要的维度(论文类型 / 方法 / 数据集 / 年份 / 期刊 / 主题 / 项目)在 Zotero 里直接建文件夹并归入条目,也可以只生成 `auto:method:*` / `auto:dataset:*` 标签计划,预览后写入。
20
+ - **提炼脉络**:统计年份、期刊、方法、数据集及共现,直接产出综述可用的表格。
21
+ - **发现空白**:按项目分组找「方法 × 数据集」从未交叉的组合、停更标签与标注缺失。
22
+ - **检索与引用**:按关键词搜索条目,返回题名、作者、年份、item key 与 BibTeX key;在草稿唯一标记处插入 `\cite{key}` 或 `[@key]` 并同步 `.bib`。
23
+ - **每日收录**:按主题每天从 OpenAlex 抓新论文,去重排序后自动导入指定分类,数量 / 开关 / 时间都由你决定。
24
+ - **不碰数据库**:只走 Zotero 官方本地 API,不直接读写 `zotero.sqlite`;改库前先 `--dry-run`,写入需要你在 Zotero 弹窗里点一次「Always Allow」。
25
+
26
+ ## 安装
27
+
28
+ ### 1. 准备
29
+
30
+ - Python 3.10+(Windows 上用 `py -3` 或 `python`,macOS / Linux 用 `python3`)
31
+ - 桌面版 Zotero(开发时验证于 Zotero 10.0.6)
32
+ - DeepSeek Harness
33
+
34
+ ### 2. 装进 Harness
35
+
36
+ 两种方式,任选其一。
37
+
38
+ **方式 A:作为 DSH 插件安装(推荐)**
39
+
40
+ 包已发布到 npm,自带 `dsh.bundle` 清单,装成插件后 skill 自动出现在技能目录,不需要复制文件:
41
+
42
+ ```bash
43
+ dsh plugin --profile <你的 profile> add dsh-zotero-skill
44
+ ```
45
+
46
+ 想跟最新代码,或 npm 不可达时,可直接从 GitHub 安装:
47
+
48
+ ```bash
49
+ dsh plugin --profile <你的 profile> add github:Eason-0712/dsh-zotero-skill
50
+ ```
51
+
52
+ 也可以从本地检出安装(开发调试用):
53
+
54
+ ```bash
55
+ dsh plugin --profile <你的 profile> add "link:/path/to/dsh-zotero-skill"
56
+ ```
57
+
58
+ 卸载:`dsh plugin --profile <你的 profile> remove dsh-zotero-skill`。
59
+
60
+ **方式 B:作为独立 skill 目录安装**
61
+
62
+ ```text
63
+ # 仓库根目录下执行
64
+ python install.py
65
+ ```
66
+
67
+ 安装器默认复制到 `$DSH_HOME/skills/zotero`(未设置时 `~/.dsh/skills/zotero`),目标已存在时不会覆盖。也可以手动把整个 `zotero/` 目录复制过去,最终结构必须是 `skills/zotero/SKILL.md`。
68
+
69
+ 项目级(只在某个仓库生效):
70
+
71
+ ```bash
72
+ python install.py --skills-dir .dsh/skills
73
+ ```
74
+
75
+ 想装到别的盘或自定义目录,用 `--skills-dir` 指定,再让 Harness 扫描该目录即可。
76
+
77
+ ### 3. 打开 Zotero 本地 API
78
+
79
+ Zotero → 设置 → 高级 → 其他 → 勾选「允许此计算机上的其他应用程序与 Zotero 通信」。
80
+
81
+ 也可以让脚本改配置(**先完全退出 Zotero**):
82
+
83
+ ```bash
84
+ python zotero/scripts/zotero.py enable --restart
85
+ ```
86
+
87
+ 它会备份 `prefs.js` 后写入 `extensions.zotero.httpServer.localAPI.enabled`,再启动 Zotero;不会强杀进程。
88
+
89
+ ### 4. 验证
90
+
91
+ ```bash
92
+ python zotero/scripts/zotero.py status --json
93
+ ```
94
+
95
+ `api_running: true`、`api_status: 200` 即连通。
96
+
97
+ ## 使用示例
98
+
99
+ 在 Harness 里直接说:
100
+
101
+ > 帮我找一下 knowledge distillation 用在运动想象 EEG 上的新论文,按重要性排个序。
102
+
103
+ > 我常看的方向有这几个,做成 watch,以后只给我新出现的。
104
+
105
+ > 从库里 project:MI-EEG 这 18 篇里挑出最核心的几篇,列个短名单。
106
+
107
+ > 把这批论文生成阅读包,我看完再写综述。
108
+
109
+ > 按方法和数据集给整个库打上标签,先给我看计划。
110
+
111
+ > 这个方向现在有哪些明显的空白?
112
+
113
+ > 读一下 `/path/to/draft.md`,把相关论断后面补上引用,并更新同目录的 references.bib。
114
+
115
+ Skill 会自己决定调用哪些子命令,并在写库或写文件前先给预览。
116
+
117
+ ## 每日自动收录
118
+
119
+ 每天早上自动把新论文收进 Zotero,数量、主题、开关都由你决定。
120
+
121
+ ### 开启
122
+
123
+ ```bash
124
+ # 设定主题与数量(最多 30 篇),并在 Zotero 里先选中一个目标分类
125
+ python zotero/scripts/zotero.py daily-config --enable \
126
+ --topics "transformer;large language model" --max 10 --mailto "you@example.com"
127
+
128
+ # 先看候选,不写库
129
+ python zotero/scripts/zotero.py daily --dry-run
130
+
131
+ # 真正导入
132
+ python zotero/scripts/zotero.py daily
133
+ ```
134
+
135
+ 关闭:`daily-config --disable`。关闭状态下 `daily` 只返回提示,加 `--force` 可临时跑一次。
136
+
137
+ ### 定时
138
+
139
+ ```bash
140
+ # 每天 08:00 自动运行(Windows 任务计划程序)
141
+ python zotero/scripts/zotero.py daily-schedule --time 08:00
142
+
143
+ # 删除定时任务
144
+ python zotero/scripts/zotero.py daily-schedule --remove
145
+ ```
146
+
147
+ 任务只在该 Windows 用户登录时触发;Zotero 没开着时脚本会尝试启动它并等待接口就绪。
148
+
149
+ ### 排序依据
150
+
151
+ 候选论文先按主题从 [OpenAlex](https://openalex.org)(免费、无需 API Key)检索最近 N 天,再按加权分数排序:
152
+
153
+ | 维度 | 默认权重 | 含义 |
154
+ | --- | --- | --- |
155
+ | 期刊影响力 | 0.45 | 期刊近两年平均被引(OpenAlex `2yr_mean_citedness`) |
156
+ | 引用速度 | 0.30 | 年化被引次数,避免老论文靠累计引用刷分 |
157
+ | 主题相关度 | 0.15 | OpenAlex 搜索相关度 |
158
+ | 新鲜度 | 0.10 | 越新越高,7 天窗口内自然衰减 |
159
+
160
+ 同一期刊默认最多 3 篇(`--max-per-venue`),避免一天全是同一个刊物。已导入过的 DOI 会记录在 `~/.zotero-skill/state.json`,不会重复推送。
161
+
162
+ ### 配置项
163
+
164
+ | 键 | 默认 | 说明 |
165
+ | --- | --- | --- |
166
+ | `enabled` | `false` | 总开关 |
167
+ | `max_items` | `10` | 每次导入上限,最大 30 |
168
+ | `days_back` | `7` | 回溯天数,1–30 |
169
+ | `topics` | `[]` | 研究主题,分号分隔 |
170
+ | `max_per_venue` | `3` | 同一期刊上限 |
171
+ | `include_preprints` | `false` | 是否纳入预印本 |
172
+ | `min_venue_score` | `0` | 期刊 2 年被引下限,过滤水刊 |
173
+ | `weights` | 见上表 | 各维度权重 |
174
+ | `mailto` | `""` | OpenAlex 礼貌池联系邮箱 |
175
+ | `schedule_time` | `08:00` | 定时任务时间 |
176
+
177
+ 配置和状态都在 `~/.zotero-skill/`,可用 `ZOTERO_SKILL_CONFIG_DIR` 改位置。**注意**:这里用的是 OpenAlex 的期刊被引指标,不是 JCR 影响因子;两者不等价,别把分数当 IF 用。
178
+
179
+ ## 命令
180
+
181
+ | 命令 | 作用 |
182
+ | --- | --- |
183
+ | `status` | 检查本地 API、Connector、版本 |
184
+ | `authorize` | 取得本地 API 写权限(Zotero 弹窗点「Always Allow」) |
185
+ | `find <query>` | OpenAlex 检索并按分数排序,`--oa-only` / `--json` / `--out` |
186
+ | `frontier add\|run\|list\|remove` | 保存研究方向,只拉上次之后的新论文 |
187
+ | `screen` | 把候选或库内条目分成 核心 / 重要 / 参考 |
188
+ | `tag` | 批量加 / 删 / 替换标签(`--dry-run`) |
189
+ | `collect <name>` | 归集到分类,不存在则新建 |
190
+ | `classify` | 建文件夹:`方向 / 维度 / 取值`,方向取 `project:` 标签并可 `--rename` 改名;也可生成标签计划;`--apply` 写入 |
191
+ | `dupes` | 按 DOI 或标题查重,`--trash` 清理副本 |
192
+ | `digest` | 批量生成阅读包(每篇一个 Markdown + index.md) |
193
+ | `landscape` | 年份 / 期刊 / 方法 / 数据集统计与共现 |
194
+ | `gaps` | 方法 × 数据集空组合、停更标签、标注缺失 |
195
+ | `inventory` | 列出条目 |
196
+ | `collections` / `tags` / `groups` | 列出集合、标签、分组 |
197
+ | `search <query>` | 关键词检索,`--with-bibtex-keys` 附带引用键 |
198
+ | `export-bibtex` | 导出 BibTeX(`--item-key` 单篇,`--out` 写文件) |
199
+ | `sync-bib` | 导出整库到 `references.bib` |
200
+ | `citations` | 按样式渲染引用文本(如 `--style apa`) |
201
+ | `cite` | 在草稿标记处插入引用并同步 `.bib` |
202
+ | `children` / `fulltext` / `file-url` | 附件、全文、文件路径 |
203
+ | `selected-target` | 查看 Connector 当前选中的库/集合 |
204
+ | `import-bibtex` / `import-ris` | 经 Connector 导入(需 `--yes`) |
205
+ | `daily` | 按主题抓取并按分数导入新论文(`--dry-run` 预览) |
206
+ | `daily-config` | 查看/修改每日收录配置 |
207
+ | `daily-schedule` | 创建/删除 Windows 每日定时任务 |
208
+ | `probe` | 探测各 API 路由可用性 |
209
+ | `enable` / `disable` / `restart` | 管理本地 API 开关与启动 |
210
+
211
+ 所有命令加 `--json` 输出机器可读结果。批量选择统一用 `--keys`、`--query`、`--tag`、`--collection`、`--all`,`--limit` 限制条数。
212
+
213
+ ## 目录结构
214
+
215
+ ```text
216
+ .
217
+ ├── package.json # dsh.bundle 清单,dsh plugin add 靠它识别
218
+ ├── cordis.patch.yml # 插件挂载行
219
+ ├── lib/index.js # 向 ctx.skills 注册内置 skill 的 provider
220
+ ├── zotero/
221
+ │ ├── SKILL.md # skill 入口,Harness 读取它来发现技能
222
+ │ ├── references/
223
+ │ │ ├── deepseek-harness.md # 安装、连接排查、环境变量
224
+ │ │ └── local-api-routes.md # 本地 API 与 Connector 路由
225
+ │ └── scripts/zotero.py # 全部功能,仅标准库
226
+ ├── test/provider.test.mjs # provider 的 Node 测试
227
+ ├── tests/test_zotero.py # 模拟 API 的 Python 单元测试
228
+ ├── tests/test_literature.py # 术语抽取、分类计划、脉络、查重、空组合
229
+ ├── install.py # 复制式安装器(方式 B)
230
+ ├── LICENSE
231
+ └── NOTICE.md
232
+ ```
233
+
234
+ 装成插件后,`lib/index.js` 在 `apply()` 里注册一个 rank 600 的 provider,直接读包内的 `zotero/SKILL.md`。相比复制到 `~/.dsh/skills`,升级只需 `dsh plugin` 更新,也不会有两份副本不同步的问题。
235
+
236
+ ## 设计取舍
237
+
238
+ - **读写都走官方 API**:检索、导出、全文走 `/api/` 的 GET(免认证);打标签、归集、查重清理走同一套本地 API 的 PATCH/POST,需要本地 API key 与 `Zotero-Server-ID`,脚本用 `authorize` 一次性取得并缓存,绝不直接改 `zotero.sqlite`。
239
+ - **先预览再写入**:所有写命令都支持 `--dry-run`,Skill 要求先把计划给用户看,确认后才加 `--yes`。
240
+ - **规则可解释**:方法 / 数据集识别是正则词表,分歧能被逐条检查;空组合与停更标签都是可复算的统计,而不是模型编的结论。
241
+ - **写文件前备份**:`cite` 会先校验草稿和标记,再备份草稿与 `.bib`;`sync-bib` / `export-bibtex --out` 会覆盖目标文件,所以 Skill 要求先确认路径。
242
+ - **本机请求绕过代理**:`localhost`、`127.0.0.1`、`::1` 直连,避免系统代理吞掉本机请求;非回环地址才走代理配置。
243
+ - **UTF-8**:Windows 控制台下的中文 JSON 与路径不会乱码;`--input` 也能读 PowerShell 写出的 UTF-16 文件。
244
+ - **不强制结束 Zotero**:需要改配置时要求先正常退出,避免 `prefs.js` 被覆盖。
245
+
246
+ ## 测试
247
+
248
+ ```bash
249
+ python -m unittest discover -s tests -v # Python 侧
250
+ node --test test/*.test.mjs # 插件 provider
251
+ ```
252
+
253
+ 测试用临时文件和模拟 API,不读写真实文献库。GitHub Actions 会在 Ubuntu / Windows 与 Python 3.10 / 3.13 上跑 Python 测试,并在 Node 20 / 22 上跑 provider 测试。
254
+
255
+ ## 已知限制
256
+
257
+ - Word 里的 Zotero 动态引用域不在能力范围内;脚本不会用替换文本的方式伪装成已插入引用。
258
+ - 远端 / WSL / 容器里 `127.0.0.1` 指向的是运行环境本身,不在同一台机器上时无法直接连到桌面 Zotero。
259
+ - `enable` / `disable` 只改本机 `prefs.js`,对远端 Zotero 无效。
260
+ - 本地 API 的能力随 Zotero 版本变化,遇到 403 / 404 先检查开关和版本。
261
+ - 写入需要你在 Zotero 弹窗里授权一次(选「Always Allow」才是持久的);选「Allow」的 key 只能写一次。
262
+ - 方法 / 数据集识别基于固定正则词表,冷门术语可能漏标;`gaps` 会把这些条目列成「标注缺失」供人工补。
263
+ - `landscape` / `gaps` 的结论来自题录与摘要的统计,不是全文精读;写进论文前需要回头看原文。
264
+ - 每日收录依赖 OpenAlex 的网络访问;离线或被限流时会跳过本次抓取,不会写坏文献库。
265
+ - 收录只导入题录与摘要,不下载 PDF;需要全文时再按需获取。
266
+ - 期刊指标来自 OpenAlex,与 JCR 影响因子不是同一套数据,仅用于相对排序。
267
+ - 定时任务只在该 Windows 用户登录时运行;机器关机或睡眠时当次会跳过。
268
+
269
+ ## 来源与许可
270
+
271
+ 本项目改编自 OpenAI 的 Zotero 插件 0.1.2(MIT):保留了其 `zotero.py` 与本地 API 路由说明,并针对独立运行、DeepSeek Harness、Windows 和本机代理环境做了修改。
272
+
273
+ - 原始版权与许可全文见 [LICENSE](LICENSE)
274
+ - 来源与改动范围见 [NOTICE.md](NOTICE.md)
275
+ - 本项目不是 OpenAI、DeepSeek 或 Zotero 官方发布的适配版本
276
+
277
+ ## English
278
+
279
+ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) skill that runs the whole literature loop against your local Zotero library: find papers on OpenAlex, track research fronts with saved watches, screen candidates into core / important / reference tiers, manage items (batch tags, collections, DOI and title de-duplication), generate batch reading packs, classify items into Zotero folders by type / method / dataset / year / venue / topic / project, chart the landscape of years / venues / methods / datasets, and surface candidate research gaps. It also exports BibTeX, inserts citations into LaTeX/Markdown drafts, and can run a ranked daily digest of new papers.
280
+
281
+ Install with `dsh plugin --profile <name> add dsh-zotero-skill` (or copy `zotero/` to `~/.dsh/skills/zotero`, or run `python install.py`), enable Zotero's local API, and run `zotero.py authorize` once if you want write operations. Adapted from OpenAI's Zotero plugin 0.1.2 under the MIT license.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-zotero-skill",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Zotero skill for DeepSeek Harness: find papers on OpenAlex, track research fronts, screen core papers, manage and classify the library, batch-read, map the landscape, and surface research gaps. Also exports BibTeX and cites into LaTeX/Markdown.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/zotero/SKILL.md CHANGED
@@ -93,7 +93,25 @@ description: 面向科研文献全流程的 Zotero 技能:找文献、追前
93
93
 
94
94
  ### 6. 分类整理 `classify`
95
95
 
96
- 从题名+摘要识别方法与数据集,生成标签/集合计划,可写入。
96
+ 两种模式:打标签(默认),或**按维度在 Zotero 里建文件夹**(`--by`)。两种都要先给用户看计划再写。
97
+
98
+ **A. 建文件夹(用户说「按论文类型/方法/数据集分类建目录」时用这个)**
99
+
100
+ ```text
101
+ <python> "<skill-dir>/scripts/zotero.py" classify --all --by type,method
102
+ <python> "<skill-dir>/scripts/zotero.py" classify --all --by type,method,dataset --rename "MI-EEG=AI脑电解码,BIN-PICKING=智能分拣"
103
+ <python> "<skill-dir>/scripts/zotero.py" classify --all --by method --group none --root 方法库 --apply --yes
104
+ ```
105
+
106
+ - 目录结构是 `方向 / 维度 / 取值` 三层:**方向**取自 `project:` 标签(用户的研究大方向),默认根目录为空,即直接在库里出现 `AI脑电解码 / 方法 / transformer`。
107
+ - `--group project`(默认)决定第一层用哪个维度;`--group topic|method|dataset` 可换,`--group none` 关掉第一层。整个选区都没有该维度标签时会自动省略第一层,不会产生「未分组」。
108
+ - `--rename "旧=新,旧=新"` 把标签值换成用户习惯的叫法(如 `MI-EEG → AI脑电解码`),只影响文件夹名,不改标签。
109
+ - `--root 名称` 在最外层再加一层(默认不加)。
110
+ - 可用维度:`type`(期刊论文 / 会议论文 / 预印本 / 学位论文 / 综述…)、`method`、`dataset`、`year`、`venue`、`topic`、`project`;逗号分开可一次建多层。
111
+ - 一个条目可以同时落在多个文件夹(Zotero 原生支持),不会互相覆盖;已有同名文件夹复用,重复运行幂等。
112
+ - `--include-unclassified` 把该维度缺失的条目并入 `未标注`。
113
+
114
+ **B. 打标签(默认)**
97
115
 
98
116
  ```text
99
117
  <python> "<skill-dir>/scripts/zotero.py" classify --all
@@ -1555,6 +1555,15 @@ def load_local_key() -> dict[str, Any]:
1555
1555
  return stored if isinstance(stored, dict) else {}
1556
1556
 
1557
1557
 
1558
+ def authorize_timeout() -> float:
1559
+ """How long to wait for the user to click Allow in Zotero."""
1560
+ try:
1561
+ value = float(os.environ.get("ZOTERO_AUTHORIZE_TIMEOUT", "900"))
1562
+ except ValueError:
1563
+ value = 900.0
1564
+ return max(30.0, value)
1565
+
1566
+
1558
1567
  def authorize_zotero(app_name: str = WRITE_APP_NAME) -> dict[str, Any]:
1559
1568
  sid = server_id()
1560
1569
  payload = json.dumps({"appName": app_name}).encode("utf-8")
@@ -1565,7 +1574,7 @@ def authorize_zotero(app_name: str = WRITE_APP_NAME) -> dict[str, Any]:
1565
1574
  }
1566
1575
  print(
1567
1576
  "Zotero is asking for permission: choose \"Always Allow\" / 「始终允许」 "
1568
- f"for {app_name}.",
1577
+ f"for {app_name}. Waiting up to {int(authorize_timeout())}s.",
1569
1578
  file=sys.stderr,
1570
1579
  )
1571
1580
  response = request(
@@ -1573,7 +1582,7 @@ def authorize_zotero(app_name: str = WRITE_APP_NAME) -> dict[str, Any]:
1573
1582
  method="POST",
1574
1583
  data=payload,
1575
1584
  headers=headers,
1576
- timeout=300.0,
1585
+ timeout=authorize_timeout(),
1577
1586
  )
1578
1587
  if not response.ok:
1579
1588
  detail = response.error or response.text[:TEXT_LIMIT] or "no response body"
@@ -1689,6 +1698,26 @@ def find_collection(name: str) -> dict[str, Any] | None:
1689
1698
  return None
1690
1699
 
1691
1700
 
1701
+ def created_key(body: Any) -> str | None:
1702
+ """Extract the new object key from a multi-write response.
1703
+
1704
+ Zotero keys the `successful` map by request index ("0"), not by object key,
1705
+ so the key has to be read from the entry body.
1706
+ """
1707
+ if not isinstance(body, dict):
1708
+ return None
1709
+ created = body.get("successful") or {}
1710
+ if not isinstance(created, dict) or not created:
1711
+ return None
1712
+ token = next(iter(created))
1713
+ entry = created[token]
1714
+ if isinstance(entry, dict) and entry.get("key"):
1715
+ return str(entry["key"])
1716
+ if re.fullmatch(r"[0-9A-Za-z]{8}", str(token)):
1717
+ return str(token)
1718
+ return None
1719
+
1720
+
1692
1721
  def create_collection(name: str, parent: str | None = None) -> dict[str, Any]:
1693
1722
  payload: dict[str, Any] = {"name": name}
1694
1723
  if parent:
@@ -1697,14 +1726,12 @@ def create_collection(name: str, parent: str | None = None) -> dict[str, Any]:
1697
1726
  exit_with(f"Parent collection not found: {parent}")
1698
1727
  data = parent_record.get("data", parent_record)
1699
1728
  payload["parentCollection"] = parent_record.get("key") or data.get("key")
1700
- response = local_write(f"{LOCAL_USER}/collections", payload, method="POST")
1729
+ else:
1730
+ payload["parentCollection"] = False
1731
+ response = local_write(f"{LOCAL_USER}/collections", [payload], method="POST")
1701
1732
  require_ok(response, "POST collection")
1702
- body = parse_body(response)
1703
- created = (body or {}).get("successful") or {}
1704
- if isinstance(created, dict) and created:
1705
- key = next(iter(created))
1706
- return {"key": key, "data": {"name": name}}
1707
- return {"key": None, "data": {"name": name}}
1733
+ key = created_key(parse_body(response))
1734
+ return {"key": key, "data": {"name": name}}
1708
1735
 
1709
1736
 
1710
1737
  # --- selection & records ----------------------------------------------------
@@ -2322,6 +2349,324 @@ def cmd_collect(args: argparse.Namespace) -> None:
2322
2349
  )
2323
2350
 
2324
2351
 
2352
+ # --- classification into collections (Zotero folders) -----------------------
2353
+
2354
+ DIMENSION_LABELS = {
2355
+ "method": "方法",
2356
+ "dataset": "数据集",
2357
+ "type": "类型",
2358
+ "year": "年份",
2359
+ "venue": "期刊",
2360
+ "topic": "主题",
2361
+ "project": "项目",
2362
+ }
2363
+
2364
+ DOCUMENT_TYPE_LABELS = {
2365
+ "journalArticle": "期刊论文",
2366
+ "conferencePaper": "会议论文",
2367
+ "preprint": "预印本",
2368
+ "thesis": "学位论文",
2369
+ "book": "图书",
2370
+ "bookSection": "图书章节",
2371
+ "report": "报告",
2372
+ "manuscript": "手稿",
2373
+ "dataset": "数据集",
2374
+ "webpage": "网页",
2375
+ }
2376
+
2377
+ REVIEW_TITLE_RE = re.compile(
2378
+ r"\b(review|survey|overview|tutorial|state[- ]of[- ]the[- ]art|systematic review|meta[- ]analysis)\b",
2379
+ re.IGNORECASE,
2380
+ )
2381
+
2382
+ DIMENSION_ALIASES = {
2383
+ "method": "method",
2384
+ "methods": "method",
2385
+ "technique": "method",
2386
+ "techniques": "method",
2387
+ "dataset": "dataset",
2388
+ "datasets": "dataset",
2389
+ "data": "dataset",
2390
+ "type": "type",
2391
+ "types": "type",
2392
+ "kind": "type",
2393
+ "kinds": "type",
2394
+ "document": "type",
2395
+ "documents": "type",
2396
+ "year": "year",
2397
+ "years": "year",
2398
+ "venue": "venue",
2399
+ "venues": "venue",
2400
+ "journal": "venue",
2401
+ "journals": "venue",
2402
+ "topic": "topic",
2403
+ "topics": "topic",
2404
+ "project": "project",
2405
+ "projects": "project",
2406
+ }
2407
+
2408
+ UNCLASSIFIED = "未标注"
2409
+ UNGROUPED = "未分组"
2410
+
2411
+
2412
+ def dimension_values(record: dict[str, Any], dimension: str) -> list[str]:
2413
+ """Return the folder names this record belongs to for one dimension."""
2414
+ if dimension == "method":
2415
+ return [str(name) for name in record.get("methods") or []]
2416
+ if dimension == "dataset":
2417
+ return [str(name) for name in record.get("datasets") or []]
2418
+ if dimension == "type":
2419
+ values = [
2420
+ DOCUMENT_TYPE_LABELS.get(
2421
+ str(record.get("itemType") or ""), "其他"
2422
+ )
2423
+ ]
2424
+ if REVIEW_TITLE_RE.search(str(record.get("title") or "")):
2425
+ values.append("综述")
2426
+ return values
2427
+ if dimension == "year":
2428
+ year = record.get("year")
2429
+ return [str(year)] if year else []
2430
+ if dimension == "venue":
2431
+ venue = str(record.get("venue") or "").strip()
2432
+ return [venue[:80]] if venue else []
2433
+ if dimension in ("topic", "project"):
2434
+ prefix = dimension + ":"
2435
+ values = []
2436
+ for tag in record.get("tags") or []:
2437
+ text = str(tag)
2438
+ if text.startswith(prefix):
2439
+ values.append(text[len(prefix):].strip())
2440
+ return [value for value in values if value]
2441
+ return []
2442
+
2443
+
2444
+ def parse_dimensions(raw: str | None) -> list[str]:
2445
+ """Turn `method,dataset` into `['method', 'dataset']`, rejecting typos."""
2446
+ dimensions: list[str] = []
2447
+ for item in split_csv(raw):
2448
+ name = DIMENSION_ALIASES.get(item.strip().lower())
2449
+ if name is None:
2450
+ exit_with(
2451
+ f"Unknown classification dimension: {item}. "
2452
+ f"Use one of: {', '.join(DIMENSION_LABELS)}"
2453
+ )
2454
+ if name not in dimensions:
2455
+ dimensions.append(name)
2456
+ return dimensions
2457
+
2458
+
2459
+ def parse_renames(raw: str | None) -> dict[str, str]:
2460
+ """Parse `MI-EEG=AI脑电解码,BIN-PICKING=智能分拣` into a rename table."""
2461
+ table: dict[str, str] = {}
2462
+ for item in split_csv(raw):
2463
+ if "=" not in item:
2464
+ exit_with(f"Bad --rename entry: {item} (expected old=new)")
2465
+ old, new = item.split("=", 1)
2466
+ old, new = old.strip(), new.strip()
2467
+ if old and new:
2468
+ table[old] = new
2469
+ return table
2470
+
2471
+
2472
+ def collection_plan(
2473
+ records: list[dict[str, Any]],
2474
+ dimensions: list[str],
2475
+ *,
2476
+ root: str = "",
2477
+ group: str = "project",
2478
+ include_unclassified: bool = False,
2479
+ rename: dict[str, str] | None = None,
2480
+ ) -> list[dict[str, Any]]:
2481
+ """Group records into `[root /] group / dimension-label / value` folders.
2482
+
2483
+ `group` is the top-level research direction (usually the `project:` tag);
2484
+ pass `"none"` to skip that level.
2485
+ """
2486
+ renames = dict(rename or {})
2487
+ use_group = bool(group) and group != "none"
2488
+ if use_group:
2489
+ # Drop the direction level when nothing in this selection carries it,
2490
+ # so an untagged library still yields a clean dimension/value tree.
2491
+ use_group = any(dimension_values(record, group) for record in records)
2492
+ buckets: dict[tuple[str | None, str, str], list[str]] = {}
2493
+ missing: dict[tuple[str | None, str], list[str]] = {}
2494
+
2495
+ for record in records:
2496
+ key = str(record.get("key") or "")
2497
+ group_values: list[str | None] = [None]
2498
+ if use_group:
2499
+ found = [value for value in dimension_values(record, group) if value]
2500
+ group_values = list(dict.fromkeys(found)) if found else [UNGROUPED]
2501
+ for group_value in group_values:
2502
+ for dimension in dimensions:
2503
+ values = [v for v in dimension_values(record, dimension) if v]
2504
+ if not values:
2505
+ missing.setdefault((group_value, dimension), []).append(key)
2506
+ continue
2507
+ for value in dict.fromkeys(values):
2508
+ buckets.setdefault((group_value, dimension, value), []).append(key)
2509
+
2510
+ dimension_order = {name: index for index, name in enumerate(dimensions)}
2511
+ groups: list[dict[str, Any]] = []
2512
+ for (group_value, dimension, value), keys in sorted(
2513
+ buckets.items(),
2514
+ key=lambda item: (
2515
+ str(item[0][0] or ""),
2516
+ dimension_order.get(item[0][1], 99),
2517
+ item[0][2],
2518
+ ),
2519
+ ):
2520
+ path: list[str] = []
2521
+ if root:
2522
+ path.append(root)
2523
+ if group_value:
2524
+ path.append(renames.get(group_value, group_value))
2525
+ path.append(DIMENSION_LABELS[dimension])
2526
+ path.append(renames.get(value, value))
2527
+ groups.append(
2528
+ {
2529
+ "group": group_value,
2530
+ "dimension": dimension,
2531
+ "label": DIMENSION_LABELS[dimension],
2532
+ "value": value,
2533
+ "path": path,
2534
+ "items": list(dict.fromkeys(keys)),
2535
+ }
2536
+ )
2537
+
2538
+ if include_unclassified:
2539
+ for (group_value, dimension), keys in sorted(
2540
+ missing.items(), key=lambda item: (str(item[0][0] or ""), item[0][1])
2541
+ ):
2542
+ path = []
2543
+ if root:
2544
+ path.append(root)
2545
+ if group_value:
2546
+ path.append(renames.get(group_value, group_value))
2547
+ path.extend([DIMENSION_LABELS[dimension], UNCLASSIFIED])
2548
+ groups.append(
2549
+ {
2550
+ "group": group_value,
2551
+ "dimension": dimension,
2552
+ "label": DIMENSION_LABELS[dimension],
2553
+ "value": UNCLASSIFIED,
2554
+ "path": path,
2555
+ "items": list(dict.fromkeys(keys)),
2556
+ }
2557
+ )
2558
+ return groups
2559
+
2560
+
2561
+ def collection_plan_markdown(
2562
+ groups: list[dict[str, Any]], total: int, *, root: str = "", group: str = ""
2563
+ ) -> str:
2564
+ folders = len(groups)
2565
+ touched = len({key for group_ in groups for key in group_["items"]})
2566
+ shape = " / ".join(
2567
+ [part for part in [root, "方向" if group and group != "none" else "", "维度", "取值"] if part]
2568
+ )
2569
+ lines = [
2570
+ f"# 分类整理计划 · 文件夹({total} 篇 → {folders} 个文件夹,覆盖 {touched} 篇)",
2571
+ "",
2572
+ f"结构:`{shape}`;同一个条目可以同时进多个文件夹。",
2573
+ "",
2574
+ "只预览,不写入。加 `--apply --yes` 才会在 Zotero 里真的建文件夹并归入条目。",
2575
+ "",
2576
+ "| 文件夹 | 篇数 | 示例条目 |",
2577
+ "| --- | --- | --- |",
2578
+ ]
2579
+ for group in groups:
2580
+ sample = ", ".join(f"`{key}`" for key in group["items"][:3])
2581
+ if len(group["items"]) > 3:
2582
+ sample += f" … +{len(group['items']) - 3}"
2583
+ lines.append(
2584
+ "| {path} | {count} | {sample} |".format(
2585
+ path=md_cell(" / ".join(group["path"])),
2586
+ count=len(group["items"]),
2587
+ sample=sample,
2588
+ )
2589
+ )
2590
+ return "\n".join(lines)
2591
+
2592
+
2593
+ def collection_index() -> dict[tuple[str, str], str]:
2594
+ """Map (parent key or '', normalized name) -> collection key."""
2595
+ index: dict[tuple[str, str], str] = {}
2596
+ for collection in all_collections():
2597
+ data = collection.get("data", collection)
2598
+ key = collection.get("key") or data.get("key")
2599
+ if not key:
2600
+ continue
2601
+ parent = data.get("parentCollection") or ""
2602
+ index[(str(parent), normalize_text(data.get("name")))] = str(key)
2603
+ return index
2604
+
2605
+
2606
+ def create_collection_under(name: str, parent_key: str | None) -> str | None:
2607
+ payload: dict[str, Any] = {"name": name}
2608
+ if parent_key:
2609
+ payload["parentCollection"] = parent_key
2610
+ else:
2611
+ payload["parentCollection"] = False
2612
+ response = local_write(f"{LOCAL_USER}/collections", [payload], method="POST")
2613
+ require_ok(response, f"POST collection {name}")
2614
+ return created_key(parse_body(response))
2615
+
2616
+
2617
+ def ensure_collection_path(
2618
+ path: list[str], index: dict[tuple[str, str], str]
2619
+ ) -> str | None:
2620
+ """Create every missing level of a folder path and return its key."""
2621
+ parent = ""
2622
+ key: str | None = None
2623
+ for name in path:
2624
+ lookup = (str(parent), normalize_text(name))
2625
+ found = index.get(lookup)
2626
+ if found is None:
2627
+ found = create_collection_under(name, parent or None)
2628
+ if not found:
2629
+ exit_with(f"Could not create collection: {name}")
2630
+ index[lookup] = found
2631
+ key = found
2632
+ parent = key
2633
+ return key
2634
+
2635
+
2636
+ def apply_collection_plan(
2637
+ groups: list[dict[str, Any]], *, dry_run: bool = False
2638
+ ) -> list[dict[str, Any]]:
2639
+ """Create the folders, then add each item to the folders it belongs to."""
2640
+ index = collection_index() if not dry_run else {}
2641
+ wanted: dict[str, dict[str, list[str]]] = {}
2642
+ for group in groups:
2643
+ collection_key = None if dry_run else ensure_collection_path(group["path"], index)
2644
+ for item_key in group["items"]:
2645
+ entry = wanted.setdefault(item_key, {"keys": [], "paths": []})
2646
+ if collection_key:
2647
+ entry["keys"].append(collection_key)
2648
+ entry["paths"].append(" / ".join(group["path"]))
2649
+
2650
+ results: list[dict[str, Any]] = []
2651
+ for item_key, entry in wanted.items():
2652
+ current_item = item_full(item_key)
2653
+ data = current_item.get("data", current_item)
2654
+ current = list(data.get("collections") or [])
2655
+ added = [key for key in dict.fromkeys(entry["keys"]) if key not in current]
2656
+ if not added:
2657
+ results.append(
2658
+ {"key": item_key, "changed": False, "collections": entry["paths"]}
2659
+ )
2660
+ continue
2661
+ outcome = patch_item(
2662
+ item_key, {"collections": current + added}, dry_run=dry_run
2663
+ )
2664
+ outcome["changed"] = True
2665
+ outcome["collections"] = entry["paths"]
2666
+ results.append(outcome)
2667
+ return results
2668
+
2669
+
2325
2670
  def classify_plan(
2326
2671
  records: list[dict[str, Any]],
2327
2672
  *,
@@ -2360,6 +2705,61 @@ def cmd_classify(args: argparse.Namespace) -> None:
2360
2705
  records = [record_from_item(item) for item in selected_items(args)]
2361
2706
  if not records:
2362
2707
  exit_with("No items matched the selection")
2708
+
2709
+ dimensions = parse_dimensions(getattr(args, "by", "") or "")
2710
+ if dimensions:
2711
+ root = getattr(args, "root", None) or ""
2712
+ group = getattr(args, "group", None) or "project"
2713
+ rename = parse_renames(getattr(args, "rename", None))
2714
+ groups = collection_plan(
2715
+ records,
2716
+ dimensions,
2717
+ root=root,
2718
+ group=group,
2719
+ include_unclassified=bool(getattr(args, "include_unclassified", False)),
2720
+ rename=rename,
2721
+ )
2722
+ if not groups:
2723
+ exit_with(
2724
+ "No folder could be derived from these items; "
2725
+ "try another --by dimension or add tags first"
2726
+ )
2727
+ if not args.apply:
2728
+ if args.json:
2729
+ dump_json(
2730
+ {
2731
+ "mode": "collections",
2732
+ "root": root,
2733
+ "group": group,
2734
+ "rename": rename,
2735
+ "items": len(records),
2736
+ "folders": len(groups),
2737
+ "groups": groups,
2738
+ }
2739
+ )
2740
+ return
2741
+ print(
2742
+ collection_plan_markdown(
2743
+ groups, len(records), root=root, group=group
2744
+ )
2745
+ )
2746
+ return
2747
+ ensure_yes(args, "create Zotero collections")
2748
+ results = apply_collection_plan(groups, dry_run=args.dry_run)
2749
+ dump_json(
2750
+ {
2751
+ "mode": "collections",
2752
+ "root": root,
2753
+ "group": group,
2754
+ "items": len(records),
2755
+ "folders": len(groups),
2756
+ "changed": sum(1 for row in results if row.get("changed")),
2757
+ "dryRun": bool(args.dry_run),
2758
+ "results": results,
2759
+ }
2760
+ )
2761
+ return
2762
+
2363
2763
  plan = classify_plan(
2364
2764
  records,
2365
2765
  by_method=not args.no_method,
@@ -3256,6 +3656,37 @@ def build_parser() -> argparse.ArgumentParser:
3256
3656
  "classify", help="Derive method/dataset/venue rules and apply them as tags or collections"
3257
3657
  )
3258
3658
  add_selection_args(classify)
3659
+ classify.add_argument(
3660
+ "--by",
3661
+ default="",
3662
+ help=(
3663
+ "Create Zotero folders by dimension instead of tags: "
3664
+ "method,dataset,year,venue,topic,project (comma separated)"
3665
+ ),
3666
+ )
3667
+ classify.add_argument(
3668
+ "--root",
3669
+ default="",
3670
+ help="Optional extra top folder above the research direction (default: none)",
3671
+ )
3672
+ classify.add_argument(
3673
+ "--group",
3674
+ default="project",
3675
+ help=(
3676
+ "Top-level research direction dimension: project (default), topic, "
3677
+ "method, dataset, or none"
3678
+ ),
3679
+ )
3680
+ classify.add_argument(
3681
+ "--rename",
3682
+ default="",
3683
+ help="Rename folder values, e.g. \"MI-EEG=AI脑电解码,BIN-PICKING=智能分拣\"",
3684
+ )
3685
+ classify.add_argument(
3686
+ "--include-unclassified",
3687
+ action="store_true",
3688
+ help="Also put items with no value for that dimension into a 未标注 folder",
3689
+ )
3259
3690
  classify.add_argument("--apply", action="store_true", help="Write the plan to Zotero")
3260
3691
  classify.add_argument("--by-year", action="store_true", help="Also tag year:<YYYY>")
3261
3692
  classify.add_argument("--by-venue", action="store_true", help="Also collect per venue")