dsh-plugin-litsearch 0.3.0 → 0.4.0

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.
Files changed (39) hide show
  1. package/README.md +158 -41
  2. package/client/client.js +2632 -926
  3. package/lib/host/events.d.ts +9 -1
  4. package/lib/host/events.js +50 -3
  5. package/lib/host/export/bibtex.js +1 -4
  6. package/lib/host/export/common.d.ts +0 -2
  7. package/lib/host/export/common.js +0 -7
  8. package/lib/host/export/csv.js +1 -5
  9. package/lib/host/export/markdown.d.ts +1 -1
  10. package/lib/host/export/markdown.js +9 -26
  11. package/lib/host/export/ris.js +1 -4
  12. package/lib/host/paths.d.ts +1 -1
  13. package/lib/host/paths.js +1 -1
  14. package/lib/host/pdf/store.d.ts +5 -3
  15. package/lib/host/pdf/store.js +50 -17
  16. package/lib/host/router.js +146 -79
  17. package/lib/host/tasks/download.d.ts +104 -0
  18. package/lib/host/tasks/download.js +274 -0
  19. package/lib/host/tasks/filter.d.ts +8 -1
  20. package/lib/host/tasks/filter.js +14 -0
  21. package/lib/host/tasks/service.d.ts +44 -11
  22. package/lib/host/tasks/service.js +216 -48
  23. package/lib/host/tasks/states.d.ts +1 -3
  24. package/lib/host/tasks/states.js +2 -66
  25. package/lib/host/tasks/stats.js +3 -0
  26. package/lib/host/tasks/summary.js +1 -1
  27. package/lib/host/tools.d.ts +5 -1
  28. package/lib/host/tools.js +813 -37
  29. package/lib/host/zotero/connector.d.ts +19 -10
  30. package/lib/host/zotero/connector.js +142 -39
  31. package/lib/host/zotero/match.d.ts +27 -0
  32. package/lib/host/zotero/match.js +59 -0
  33. package/lib/search/config.d.ts +0 -2
  34. package/lib/search/config.js +0 -2
  35. package/lib/search/oa.d.ts +8 -0
  36. package/lib/search/oa.js +53 -6
  37. package/lib/search/types.d.ts +154 -10
  38. package/lib/search/types.js +11 -0
  39. package/package.json +1 -1
@@ -4,11 +4,15 @@
4
4
  * 只允许回环地址:connector 是桌面端本地服务,绝不把用户数据发到外部主机。
5
5
  * 任何失败都转成可读的一句话,由调用方汇总成「未运行 / 条目保存失败 / 附件失败」。
6
6
  */
7
- import type { Paper, ZoteroProbeResult, ZoteroSyncResult } from '../../search/types.js';
7
+ import type { Paper, ZoteroProbeResult, ZoteroSyncResult, ZoteroTargetsResult } from '../../search/types.js';
8
8
  export interface ZoteroConnectorOptions {
9
9
  baseUrl: string;
10
10
  attachPdf: boolean;
11
11
  tag: string;
12
+ /** connector 的 treeViewID(如 `C15`);缺省沿用 Zotero 当前选中的目录。 */
13
+ targetId?: string;
14
+ /** 目标目录名,仅用于失败提示。 */
15
+ targetName?: string;
12
16
  }
13
17
  export interface ZoteroSyncInput {
14
18
  paper: Paper;
@@ -20,8 +24,13 @@ export declare class ZoteroConnector {
20
24
  private readonly baseUrl;
21
25
  constructor(baseUrl: string);
22
26
  probe(): Promise<ZoteroProbeResult>;
23
- /** 当前 Zotero 里选中的分类;未选中时返回 null。 */
24
- selectedCollection(): Promise<SelectedCollection | null>;
27
+ /** 所有可写入的库与分类,供同步弹窗选择目标。 */
28
+ listTargets(): Promise<ZoteroTargetsResult>;
29
+ /**
30
+ * `/connector/getSelectedCollection` 只接受 POST,返回当前目标与全部可写目标。
31
+ * 注意 GET 会直接 400,因此不能沿用 ping 的方式。
32
+ */
33
+ private fetchTargetInfo;
25
34
  /**
26
35
  * 保存条目并可选上传本地 PDF。
27
36
  *
@@ -29,6 +38,13 @@ export declare class ZoteroConnector {
29
38
  * messages,条目仍算保存成功。
30
39
  */
31
40
  sync(inputs: readonly ZoteroSyncInput[], options: ZoteroConnectorOptions): Promise<ZoteroSyncResult>;
41
+ /**
42
+ * 把本会话已保存的条目移动到指定目标。
43
+ *
44
+ * `tags` 传空数组:saveItems 用 `forceTagType: 1` 写入的标签是自动标签,
45
+ * updateSession 会保留自动标签;传空数组可避免同名标签重复写入。
46
+ */
47
+ private moveToTarget;
32
48
  /**
33
49
  * 保存条目。Zotero 返回 201 空响应,条目 id 由请求里的 `id` 字段决定,
34
50
  * 后续 `saveAttachment` 用它作为 parentItemID,并复用同一 sessionID。
@@ -37,10 +53,3 @@ export declare class ZoteroConnector {
37
53
  private saveAttachment;
38
54
  private request;
39
55
  }
40
- interface SelectedCollection {
41
- id: number | string;
42
- name: string;
43
- libraryID?: number;
44
- filesEditable?: boolean;
45
- }
46
- export {};
@@ -44,7 +44,8 @@ export class ZoteroConnector {
44
44
  return { ok: false, error: `connector 返回 HTTP ${response.status}` };
45
45
  }
46
46
  const version = response.headers.get('x-zotero-version') ?? undefined;
47
- const collection = await this.selectedCollection().catch(() => null);
47
+ const info = await this.fetchTargetInfo().catch(() => null);
48
+ const collection = info === null ? null : selectedOf(info);
48
49
  const result = { ok: true };
49
50
  if (version !== undefined)
50
51
  result.version = version;
@@ -64,29 +65,43 @@ export class ZoteroConnector {
64
65
  return { ok: false, error: describe(error) };
65
66
  }
66
67
  }
67
- /** 当前 Zotero 里选中的分类;未选中时返回 null。 */
68
- async selectedCollection() {
68
+ /** 所有可写入的库与分类,供同步弹窗选择目标。 */
69
+ async listTargets() {
70
+ try {
71
+ const info = await this.fetchTargetInfo();
72
+ const targets = [];
73
+ for (const raw of info.targets ?? []) {
74
+ const target = toTarget(raw);
75
+ if (target !== null)
76
+ targets.push(target);
77
+ }
78
+ const selected = selectedOf(info);
79
+ const result = { ok: true, targets };
80
+ if (selected !== null)
81
+ result.current = { id: selected.treeViewID, name: selected.name };
82
+ return result;
83
+ }
84
+ catch (error) {
85
+ return { ok: false, targets: [], error: describe(error) };
86
+ }
87
+ }
88
+ /**
89
+ * `/connector/getSelectedCollection` 只接受 POST,返回当前目标与全部可写目标。
90
+ * 注意 GET 会直接 400,因此不能沿用 ping 的方式。
91
+ */
92
+ async fetchTargetInfo() {
69
93
  const response = await this.request('/connector/getSelectedCollection', {
70
- method: 'GET',
94
+ method: 'POST',
71
95
  timeoutMs: PING_TIMEOUT_MS,
96
+ headers: { 'content-type': 'application/json' },
97
+ body: '{}',
72
98
  });
73
99
  if (!response.ok)
74
- return null;
100
+ throw new Error(`connector 返回 HTTP ${response.status}`);
75
101
  const body = (await response.json().catch(() => null));
76
- if (body === null || body === false || typeof body !== 'object')
77
- return null;
78
- const record = body;
79
- const id = record['id'];
80
- const name = record['name'];
81
- if ((typeof id !== 'number' && typeof id !== 'string') || typeof name !== 'string')
82
- return null;
83
- const collection = { id, name };
84
- if (typeof record['libraryID'] === 'number')
85
- collection.libraryID = record['libraryID'];
86
- if (typeof record['filesEditable'] === 'boolean') {
87
- collection.filesEditable = record['filesEditable'];
88
- }
89
- return collection;
102
+ if (body === null || typeof body !== 'object')
103
+ throw new Error('connector 返回空响应');
104
+ return body;
90
105
  }
91
106
  /**
92
107
  * 保存条目并可选上传本地 PDF。
@@ -95,49 +110,93 @@ export class ZoteroConnector {
95
110
  * messages,条目仍算保存成功。
96
111
  */
97
112
  async sync(inputs, options) {
98
- if (inputs.length === 0)
99
- return { ok: true, saved: 0, attached: 0, failed: 0, messages: [] };
100
- if (inputs.length > MAX_SYNC_ITEMS) {
101
- throw new Error(`一次最多同步 ${MAX_SYNC_ITEMS} 篇`);
102
- }
103
113
  const result = {
104
114
  ok: true,
105
115
  saved: 0,
106
116
  attached: 0,
107
117
  failed: 0,
108
118
  messages: [],
119
+ items: [],
109
120
  };
121
+ if (inputs.length === 0)
122
+ return result;
123
+ if (inputs.length > MAX_SYNC_ITEMS) {
124
+ throw new Error(`一次最多同步 ${MAX_SYNC_ITEMS} 篇`);
125
+ }
110
126
  for (const input of inputs) {
111
127
  const sessionID = newSessionID();
112
128
  const item = toZoteroItem(input.paper, input.tags, options.tag);
113
129
  try {
114
130
  await this.saveItem(item, sessionID);
115
- result.saved += 1;
116
131
  }
117
132
  catch (error) {
133
+ const reason = describe(error);
118
134
  result.failed += 1;
119
- result.messages.push(`${shortTitle(input.paper.title)}:条目保存失败(${describe(error)})`);
135
+ result.messages.push(`${shortTitle(input.paper.title)}:条目保存失败(${reason})`);
136
+ result.items.push({ paperId: input.paper.paperId, ok: false, attached: false, error: reason });
120
137
  continue;
121
138
  }
122
- if (!options.attachPdf || input.pdfPath === undefined)
123
- continue;
124
- const parentId = typeof item['id'] === 'string' ? item['id'] : null;
125
- if (parentId === null) {
126
- result.messages.push(`${shortTitle(input.paper.title)}:条目已保存,但缺少条目 id,未上传 PDF`);
127
- continue;
128
- }
129
- try {
130
- const buffer = await readFile(input.pdfPath);
131
- await this.saveAttachment(parentId, sessionID, input.paper, buffer);
132
- result.attached += 1;
139
+ // 条目先落到 Zotero 当前选中的目录,再用 updateSession 搬到用户选的目录。
140
+ if (options.targetId !== undefined) {
141
+ try {
142
+ await this.moveToTarget(sessionID, options.targetId);
143
+ }
144
+ catch (error) {
145
+ const targetName = options.targetName ?? options.targetId;
146
+ const reason = `未能移入「${targetName}」(${describe(error)})`;
147
+ result.failed += 1;
148
+ result.messages.push(`${shortTitle(input.paper.title)}:条目已保存,但${reason}`);
149
+ result.items.push({
150
+ paperId: input.paper.paperId,
151
+ ok: false,
152
+ attached: false,
153
+ error: reason,
154
+ });
155
+ continue;
156
+ }
133
157
  }
134
- catch (error) {
135
- result.messages.push(`${shortTitle(input.paper.title)}:PDF 附件失败(${describe(error)})`);
158
+ result.saved += 1;
159
+ let attached = false;
160
+ if (options.attachPdf && input.pdfPath !== undefined) {
161
+ const parentId = typeof item['id'] === 'string' ? item['id'] : null;
162
+ if (parentId === null) {
163
+ result.messages.push(`${shortTitle(input.paper.title)}:条目已保存,但缺少条目 id,未上传 PDF`);
164
+ }
165
+ else {
166
+ try {
167
+ const buffer = await readFile(input.pdfPath);
168
+ await this.saveAttachment(parentId, sessionID, input.paper, buffer);
169
+ result.attached += 1;
170
+ attached = true;
171
+ }
172
+ catch (error) {
173
+ result.messages.push(`${shortTitle(input.paper.title)}:PDF 附件失败(${describe(error)})`);
174
+ }
175
+ }
136
176
  }
177
+ result.items.push({ paperId: input.paper.paperId, ok: true, attached });
137
178
  }
138
179
  result.ok = result.failed === 0;
139
180
  return result;
140
181
  }
182
+ /**
183
+ * 把本会话已保存的条目移动到指定目标。
184
+ *
185
+ * `tags` 传空数组:saveItems 用 `forceTagType: 1` 写入的标签是自动标签,
186
+ * updateSession 会保留自动标签;传空数组可避免同名标签重复写入。
187
+ */
188
+ async moveToTarget(sessionID, target) {
189
+ const response = await this.request('/connector/updateSession', {
190
+ method: 'POST',
191
+ timeoutMs: SAVE_TIMEOUT_MS,
192
+ headers: { 'content-type': 'application/json' },
193
+ body: JSON.stringify({ sessionID, target, tags: [] }),
194
+ });
195
+ if (!response.ok) {
196
+ const detail = (await response.text().catch(() => '')).slice(0, 120);
197
+ throw new Error(`connector 返回 HTTP ${response.status}${detail === '' ? '' : `(${detail})`}`);
198
+ }
199
+ }
141
200
  /**
142
201
  * 保存条目。Zotero 返回 201 空响应,条目 id 由请求里的 `id` 字段决定,
143
202
  * 后续 `saveAttachment` 用它作为 parentItemID,并复用同一 sessionID。
@@ -201,6 +260,50 @@ export class ZoteroConnector {
201
260
  }
202
261
  }
203
262
  }
263
+ /** 当前选中目标。未选中分类时 `id` 为 null,用 libraryID 组成 `L<n>`。 */
264
+ function selectedOf(info) {
265
+ if (typeof info.name !== 'string')
266
+ return null;
267
+ const libraryID = typeof info.libraryID === 'number' ? info.libraryID : undefined;
268
+ let treeViewID;
269
+ if (typeof info.id === 'number')
270
+ treeViewID = `C${info.id}`;
271
+ else if (typeof info.id === 'string' && isTargetId(info.id))
272
+ treeViewID = info.id;
273
+ else if (libraryID !== undefined)
274
+ treeViewID = `L${libraryID}`;
275
+ else
276
+ return null;
277
+ const selected = {
278
+ id: typeof info.id === 'number' || typeof info.id === 'string' ? info.id : treeViewID,
279
+ name: info.name,
280
+ treeViewID,
281
+ };
282
+ if (libraryID !== undefined)
283
+ selected.libraryID = libraryID;
284
+ if (typeof info.filesEditable === 'boolean')
285
+ selected.filesEditable = info.filesEditable;
286
+ return selected;
287
+ }
288
+ function toTarget(raw) {
289
+ if (typeof raw.id !== 'string' || typeof raw.name !== 'string')
290
+ return null;
291
+ if (!isTargetId(raw.id))
292
+ return null;
293
+ const target = {
294
+ id: raw.id,
295
+ name: raw.name,
296
+ level: typeof raw.level === 'number' ? raw.level : 0,
297
+ };
298
+ if (typeof raw.filesEditable === 'boolean')
299
+ target.filesEditable = raw.filesEditable;
300
+ if (raw.recent === true)
301
+ target.recent = true;
302
+ return target;
303
+ }
304
+ function isTargetId(value) {
305
+ return /^[LC]\d+$/u.test(value);
306
+ }
204
307
  function toZoteroItem(paper, tags, defaultTag) {
205
308
  const item = {
206
309
  id: paper.paperId,
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 把模型从对话里听到的「存到 xx 目录」匹配到 Zotero connector 的真实目标。
3
+ *
4
+ * 纯函数、只做类型导入:`src` 源码可被 `node --test` 直接加载,Host 构建
5
+ * 产物仍走 `.js`。匹配失败或歧义时绝不猜一个目录就写入数据 —— 同步会真的
6
+ * 往 Zotero 里建条目,猜错代价太高。
7
+ */
8
+ import type { ZoteroTarget } from '../../search/types.js';
9
+ export type ZoteroMatch = {
10
+ kind: 'matched';
11
+ target: ZoteroTarget;
12
+ } | {
13
+ kind: 'ambiguous';
14
+ candidates: ZoteroTarget[];
15
+ } | {
16
+ kind: 'none';
17
+ candidates: ZoteroTarget[];
18
+ };
19
+ /**
20
+ * 匹配顺序:id 精确 → 名称精确(忽略大小写与空白)→ 名称包含。
21
+ *
22
+ * 任何一步命中多个都算歧义,返回候选让模型在对话里向用户确认;只有唯一
23
+ * 命中才返回 `matched`,由工具带上 `autoStart` 让右侧栏直接开跑。
24
+ */
25
+ export declare function matchZoteroTarget(targets: readonly ZoteroTarget[], query: string): ZoteroMatch;
26
+ /** 给模型的可用目录清单(工具返回文本里让用户/模型确认用)。 */
27
+ export declare function describeZoteroTargets(targets: readonly ZoteroTarget[], limit?: number): string;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * 把模型从对话里听到的「存到 xx 目录」匹配到 Zotero connector 的真实目标。
3
+ *
4
+ * 纯函数、只做类型导入:`src` 源码可被 `node --test` 直接加载,Host 构建
5
+ * 产物仍走 `.js`。匹配失败或歧义时绝不猜一个目录就写入数据 —— 同步会真的
6
+ * 往 Zotero 里建条目,猜错代价太高。
7
+ */
8
+ const MAX_CANDIDATES = 8;
9
+ /** 归一化:去首尾空白、合并连续空白(含全角空格)、忽略大小写。 */
10
+ function normalize(value) {
11
+ return value.trim().replace(/\s+/gu, ' ').toLowerCase();
12
+ }
13
+ function candidatesOf(targets) {
14
+ // 目录多时优先给用户常看的:最近使用过的排前面,其余保持 connector 原序。
15
+ return [
16
+ ...targets.filter((target) => target.recent === true),
17
+ ...targets.filter((target) => target.recent !== true),
18
+ ].slice(0, MAX_CANDIDATES);
19
+ }
20
+ /**
21
+ * 匹配顺序:id 精确 → 名称精确(忽略大小写与空白)→ 名称包含。
22
+ *
23
+ * 任何一步命中多个都算歧义,返回候选让模型在对话里向用户确认;只有唯一
24
+ * 命中才返回 `matched`,由工具带上 `autoStart` 让右侧栏直接开跑。
25
+ */
26
+ export function matchZoteroTarget(targets, query) {
27
+ const needle = normalize(query);
28
+ if (needle === '')
29
+ return { kind: 'none', candidates: candidatesOf(targets) };
30
+ const byId = targets.filter((target) => target.id.toLowerCase() === needle);
31
+ if (byId.length === 1)
32
+ return { kind: 'matched', target: byId[0] };
33
+ if (byId.length > 1)
34
+ return { kind: 'ambiguous', candidates: candidatesOf(byId) };
35
+ const byName = targets.filter((target) => normalize(target.name) === needle);
36
+ if (byName.length === 1)
37
+ return { kind: 'matched', target: byName[0] };
38
+ if (byName.length > 1)
39
+ return { kind: 'ambiguous', candidates: candidatesOf(byName) };
40
+ const partial = targets.filter((target) => normalize(target.name).includes(needle));
41
+ if (partial.length === 1)
42
+ return { kind: 'matched', target: partial[0] };
43
+ if (partial.length > 1)
44
+ return { kind: 'ambiguous', candidates: candidatesOf(partial) };
45
+ return { kind: 'none', candidates: candidatesOf(targets) };
46
+ }
47
+ /** 给模型的可用目录清单(工具返回文本里让用户/模型确认用)。 */
48
+ export function describeZoteroTargets(targets, limit = 12) {
49
+ if (targets.length === 0)
50
+ return '(Zotero 里没有可用目录)';
51
+ const lines = targets.slice(0, limit).map((target) => {
52
+ const indent = ' '.repeat(Math.max(0, target.level));
53
+ const suffix = target.filesEditable === false ? '(不可写附件)' : '';
54
+ return `- ${indent}${target.name}${suffix}(id: ${target.id})`;
55
+ });
56
+ if (targets.length > limit)
57
+ lines.push(`- ……还有 ${targets.length - limit} 个目录`);
58
+ return lines.join('\n');
59
+ }
@@ -5,8 +5,6 @@
5
5
  * 保存配置后无需重启即生效。
6
6
  */
7
7
  import type { SortMode, SourceId, TaskLanguage } from './types.js';
8
- /** 插件版本(与 package.json 同步;仅用于 /health 诊断展示)。 */
9
- export declare const PLUGIN_VERSION = "0.1.0";
10
8
  /**
11
9
  * 默认数据源。
12
10
  *
@@ -5,8 +5,6 @@
5
5
  * 保存配置后无需重启即生效。
6
6
  */
7
7
  import { SOURCE_IDS } from './types.js';
8
- /** 插件版本(与 package.json 同步;仅用于 /health 诊断展示)。 */
9
- export const PLUGIN_VERSION = '0.1.0';
10
8
  /**
11
9
  * 默认数据源。
12
10
  *
@@ -6,6 +6,14 @@
6
6
  import type { Paper } from './types.js';
7
7
  /** 去重后的 PDF 候选地址(顺序即尝试优先级)。 */
8
8
  export declare function pdfCandidates(paper: Paper): string[];
9
+ /**
10
+ * 仓储的 PDF 渲染页:浏览器能打开,后台请求却普遍被拦截。
11
+ *
12
+ * 实测 `europepmc.org/articles/...?pdf=render` 返回 403 Cloudflare 页、
13
+ * `ncbi.nlm.nih.gov/pmc/articles/.../pdf/` 返回 200 但内容是 HTML 落地页。
14
+ * 它们仍保留为最后兜底,但必须排在所有静态直链与 Unpaywall 结果之后。
15
+ */
16
+ export declare function isPdfRenderUrl(url: string): boolean;
9
17
  /** Europe PMC 的全文 XML 地址;没有 PMCID 时返回 null。 */
10
18
  export declare function epmcXmlUrl(paper: Paper): string | null;
11
19
  /** Unpaywall 查询(需要联系邮箱,遵守其 polite pool)。 */
package/lib/search/oa.js CHANGED
@@ -13,6 +13,9 @@ export function pdfCandidates(paper) {
13
13
  const candidates = new Set();
14
14
  if (paper.pdfUrl !== '')
15
15
  candidates.add(paper.pdfUrl);
16
+ const publisher = publisherPdfUrl(paper.doi);
17
+ if (publisher !== null)
18
+ candidates.add(publisher);
16
19
  const pmcid = normalizePmcid(paper.ids.pmcid);
17
20
  if (pmcid !== '') {
18
21
  candidates.add(`${EPMC_ARTICLE}/${pmcid}?pdf=render`);
@@ -22,6 +25,44 @@ export function pdfCandidates(paper) {
22
25
  candidates.add(`${ARXIV_PDF}/${paper.ids.arxivId}`);
23
26
  return [...candidates];
24
27
  }
28
+ /**
29
+ * 仓储的 PDF 渲染页:浏览器能打开,后台请求却普遍被拦截。
30
+ *
31
+ * 实测 `europepmc.org/articles/...?pdf=render` 返回 403 Cloudflare 页、
32
+ * `ncbi.nlm.nih.gov/pmc/articles/.../pdf/` 返回 200 但内容是 HTML 落地页。
33
+ * 它们仍保留为最后兜底,但必须排在所有静态直链与 Unpaywall 结果之后。
34
+ */
35
+ export function isPdfRenderUrl(url) {
36
+ return url.startsWith(`${EPMC_ARTICLE}/`) || url.startsWith(`${PMC_ARTICLE}/`);
37
+ }
38
+ /**
39
+ * 部分开放获取出版商的固定 PDF 地址规则。
40
+ *
41
+ * Europe PMC / PMC 的渲染页会对后台请求启用 Cloudflare 人机验证(浏览器能打开、
42
+ * Host 下载却拿到 HTML),因此优先补一条出版商原生 PDF 地址。
43
+ */
44
+ function publisherPdfUrl(doi) {
45
+ const value = doi.trim();
46
+ if (value === '')
47
+ return null;
48
+ // BMC / SpringerOpen:https://link.springer.com/content/pdf/<doi>.pdf
49
+ if (value.startsWith('10.1186/') || value.startsWith('10.1007/')) {
50
+ return `https://link.springer.com/content/pdf/${encodeURI(value)}.pdf`;
51
+ }
52
+ // Frontiers:https://www.frontiersin.org/articles/<doi>/pdf
53
+ if (value.startsWith('10.3389/')) {
54
+ return `https://www.frontiersin.org/articles/${encodeURI(value)}/pdf`;
55
+ }
56
+ // PLOS ONE
57
+ if (value.startsWith('10.1371/journal.pone.')) {
58
+ return `https://journals.plos.org/plosone/article/file?id=${encodeURI(value)}&type=printable`;
59
+ }
60
+ // Nature 系列(开放获取文章可下载,订阅文章会落到 HTML 页并继续尝试其它候选)
61
+ if (value.startsWith('10.1038/')) {
62
+ return `https://www.nature.com/articles/${encodeURI(value.slice('10.1038/'.length))}.pdf`;
63
+ }
64
+ return null;
65
+ }
25
66
  /** Europe PMC 的全文 XML 地址;没有 PMCID 时返回 null。 */
26
67
  export function epmcXmlUrl(paper) {
27
68
  const pmcid = normalizePmcid(paper.ids.pmcid);
@@ -43,13 +84,19 @@ export async function unpaywallPdfCandidates(doi, email, timeoutMs, signal) {
43
84
  const body = (await response.json());
44
85
  const urls = new Set();
45
86
  const best = asRecord(body['best_oa_location']);
46
- const bestPdf = asString(best['url_for_pdf']);
47
- if (bestPdf !== '')
48
- urls.add(bestPdf);
49
- for (const raw of asArray(body['oa_locations'])) {
50
- const pdf = asString(asRecord(raw)['url_for_pdf']);
51
- if (pdf !== '')
87
+ const locations = [best, ...asArray(body['oa_locations']).map(asRecord)];
88
+ for (const location of locations) {
89
+ const pdf = asString(location['url_for_pdf']);
90
+ if (pdf !== '') {
52
91
  urls.add(pdf);
92
+ continue;
93
+ }
94
+ // 少数仓储只给 `url`,且本身就是 PDF 直链;只收明显是 PDF 的地址,
95
+ // 避免把落地页塞进候选后又被判成「返回内容不是 PDF」。
96
+ const landing = asString(location['url']);
97
+ if (/\.pdf(?:[?#]|$)/iu.test(landing) || /\/pdf\/(?:[?#]|$)/iu.test(landing)) {
98
+ urls.add(landing);
99
+ }
53
100
  }
54
101
  return [...urls];
55
102
  }