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 +281 -281
- package/package.json +1 -1
- package/zotero/SKILL.md +19 -1
- package/zotero/scripts/zotero.py +440 -9
package/README.md
CHANGED
|
@@ -1,281 +1,281 @@
|
|
|
1
|
-
# Zotero Skill for DeepSeek Harness
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/dsh-zotero-skill)
|
|
4
|
-

|
|
5
|
-

|
|
6
|
-

|
|
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
|
-
-
|
|
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` |
|
|
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
|
|
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
|
+
[](https://www.npmjs.com/package/dsh-zotero-skill)
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
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
|
+
"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
|
package/zotero/scripts/zotero.py
CHANGED
|
@@ -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=
|
|
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
|
-
|
|
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
|
-
|
|
1703
|
-
|
|
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")
|