dsh-plugin-litsearch 0.2.0 → 0.3.5

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 (47) hide show
  1. package/README.md +80 -38
  2. package/client/client.js +4750 -2342
  3. package/lib/host/export/bibtex.js +1 -4
  4. package/lib/host/export/common.d.ts +0 -2
  5. package/lib/host/export/common.js +0 -7
  6. package/lib/host/export/csv.js +1 -5
  7. package/lib/host/export/markdown.d.ts +1 -1
  8. package/lib/host/export/markdown.js +9 -26
  9. package/lib/host/export/ris.js +1 -4
  10. package/lib/host/paths.d.ts +4 -3
  11. package/lib/host/paths.js +4 -3
  12. package/lib/host/pdf/root.d.ts +11 -0
  13. package/lib/host/pdf/root.js +27 -0
  14. package/lib/host/pdf/store.d.ts +17 -7
  15. package/lib/host/pdf/store.js +110 -32
  16. package/lib/host/router.js +101 -87
  17. package/lib/host/tasks/filter.d.ts +3 -1
  18. package/lib/host/tasks/filter.js +45 -4
  19. package/lib/host/tasks/runner.js +1 -8
  20. package/lib/host/tasks/service.d.ts +21 -13
  21. package/lib/host/tasks/service.js +158 -47
  22. package/lib/host/tasks/states.d.ts +1 -3
  23. package/lib/host/tasks/states.js +2 -66
  24. package/lib/host/tasks/stats.js +9 -4
  25. package/lib/host/tasks/store.d.ts +1 -1
  26. package/lib/host/tasks/store.js +1 -1
  27. package/lib/host/tasks/summary.js +1 -1
  28. package/lib/host/tools.d.ts +1 -1
  29. package/lib/host/tools.js +25 -38
  30. package/lib/host/workspace.d.ts +20 -0
  31. package/lib/host/workspace.js +32 -0
  32. package/lib/index.d.ts +17 -8
  33. package/lib/index.js +33 -11
  34. package/lib/search/aggregate.js +1 -0
  35. package/lib/search/config.d.ts +17 -1
  36. package/lib/search/config.js +16 -1
  37. package/lib/search/identity.js +1 -0
  38. package/lib/search/oa.d.ts +8 -0
  39. package/lib/search/oa.js +53 -6
  40. package/lib/search/providers/googlescholar.d.ts +12 -0
  41. package/lib/search/providers/googlescholar.js +154 -0
  42. package/lib/search/providers/index.js +2 -0
  43. package/lib/search/query-adapter.js +3 -0
  44. package/lib/search/rate-limit.js +2 -1
  45. package/lib/search/types.d.ts +35 -13
  46. package/lib/search/types.js +25 -1
  47. package/package.json +1 -1
package/lib/host/tools.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * 都有效);只有用户明确要求新任务时才新建。等待最多 25 秒后把任务头部与前
7
7
  * 若干条结果交给模型,并明确要求本轮到此为止、只给完善建议、不发起下一轮。
8
8
  * - `literature_sources`:列出已接入的数据源与能力,便于模型决定后续策略。
9
- * - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读、分组与备注,
9
+ * - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读与分组,
10
10
  * 做新老对比与统计。
11
11
  * - `literature_export`:把任务(按筛选)导出为 CSV / RIS / BibTeX / Markdown 并落盘。
12
12
  *
@@ -16,7 +16,8 @@ import { MAX_LIMIT } from '../search/config.js';
16
16
  import { SearchError } from '../search/errors.js';
17
17
  import { asRecord } from '../search/normalize.js';
18
18
  import { PROVIDER_INFOS } from '../search/providers/index.js';
19
- import { SOURCE_IDS } from '../search/types.js';
19
+ import { SOURCE_IDS, TASK_LANGUAGES } from '../search/types.js';
20
+ import { resolveSessionWorkspace } from './workspace.js';
20
21
  /** 模型侧最多等多久;超时后任务继续跑,只返回当前进度与已有结果。 */
21
22
  const MODEL_WAIT_MS = 25_000;
22
23
  /** 交给模型的条数(UI 走分页端点,不受此限制)。 */
@@ -24,7 +25,7 @@ const TOOL_PAPER_LIMIT = 10;
24
25
  /** 复用任务时,最多等多久让上一轮跑完;超时直接返回提示,绝不新建任务。 */
25
26
  const WAIT_PREVIOUS_MS = 12_000;
26
27
  const SORT_MODES = ['relevance', 'citations', 'year'];
27
- const LANGUAGES = ['any', 'eng', 'chi'];
28
+ const LANGUAGES = TASK_LANGUAGES;
28
29
  const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
29
30
  const EXPORT_FORMATS = ['csv', 'ris', 'bibtex', 'md'];
30
31
  const TASK_ACTIONS = [
@@ -105,7 +106,7 @@ const SEARCH_PARAMETERS = {
105
106
  sources: {
106
107
  type: 'array',
107
108
  items: { type: 'string', enum: [...SOURCE_IDS] },
108
- description: `要查询的数据源:${SOURCE_IDS.join(', ')};省略表示全部。`,
109
+ description: `要查询的数据源:${SOURCE_IDS.join(', ')};省略表示默认数据源(googlescholar 需配置 SerpAPI Key,默认关闭)。`,
109
110
  },
110
111
  limit: {
111
112
  type: 'integer',
@@ -138,7 +139,7 @@ const TASK_PARAMETERS = {
138
139
  action: {
139
140
  type: 'string',
140
141
  enum: [...TASK_ACTIONS],
141
- description: 'current 读「当前任务」(推荐在执行检索/任务操作前先调用,确认这个对话正在用哪个任务,无需 id);list 列任务;get 读条件与结果;update 改条件/标题/笔记/分组;run 重跑(仅在用户明确要求进入下一轮时使用);rounds 读该任务的检索轮次历史;annotate 写筛读/备注;compare 与旧任务对比;stats 读语料统计。',
142
+ description: 'current 读「当前任务」(推荐在执行检索/任务操作前先调用,确认这个对话正在用哪个任务,无需 id);list 列任务;get 读条件与结果;update 改条件/标题/笔记/分组;run 重跑(仅在用户明确要求进入下一轮时使用);rounds 读该任务的检索轮次历史;annotate 写筛读/分组;compare 与旧任务对比;stats 读语料统计。',
142
143
  },
143
144
  id: { type: 'string', description: '任务 id;除 current / list 外必填。' },
144
145
  withTaskId: { type: 'string', description: 'compare 时的基准任务 id。' },
@@ -200,16 +201,13 @@ const TASK_PARAMETERS = {
200
201
  papers: {
201
202
  type: 'array',
202
203
  maxItems: 50,
203
- description: 'annotate 时的逐篇写入(筛读 / 分组 / 必读顺序 / 备注 / 标签)。',
204
+ description: 'annotate 时的逐篇写入(筛读 / 分组)。',
204
205
  items: {
205
206
  type: 'object',
206
207
  properties: {
207
208
  paperId: { type: 'string' },
208
209
  screening: { type: 'string', enum: [...SCREENINGS] },
209
210
  groupId: { type: ['string', 'null'] },
210
- shortlistOrder: { type: ['integer', 'null'] },
211
- note: { type: 'string' },
212
- tags: { type: 'array', items: { type: 'string' } },
213
211
  },
214
212
  required: ['paperId'],
215
213
  additionalProperties: false,
@@ -247,7 +245,7 @@ export function literatureTools(service) {
247
245
  {
248
246
  name: 'literature_search',
249
247
  description: '检索免费开放学术文献。并发查询 PubMed、Europe PMC、OpenAlex、Crossref、Semantic Scholar、arXiv、PubTator,跨源与跨检索式去重合并成一篇文献一条,返回标题、作者、年份、出处、DOI、被引次数、开放获取状态与摘要,支持一次提交多条检索式(queries)。' +
250
- '【任务模型】一个研究主题 = 一个任务 = 一个右侧栏 tab;一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成。若不确定这个对话当前用哪个任务,先调用 literature_task(action=current)查清再执行。默认复用「当前任务」并在同一任务内开新一轮:任务 id 不变、tab 不变,结果替换为最新一轮,已有的已读/纳入/排除/备注按文献身份保留;绝不会新建任务或新开 tab。当前任务是持久化的「最近一次使用或打开的任务」,跨对话、宿主重启都有效;用户从右侧栏点开别的任务会切换当前任务。只有用户明确要求新建查询任务,或研究主题与当前任务明显不同时,才传 newTask=true 并用 title 给新任务起一个概括主题的名称;也可用 taskId 显式指定要复用的任务(先用 literature_task current/list 查 id,或让用户点右侧栏的「带入对话」把任务 id 带进对话)。每次调用请提交【完整】的新检索条件,缺省项回落到插件默认设置而非沿用旧条件。' +
248
+ '【任务模型】一个研究主题 = 一个任务 = 一个右侧栏 tab;一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成。若不确定这个对话当前用哪个任务,先调用 literature_task(action=current)查清再执行。默认复用「当前任务」并在同一任务内开新一轮:任务 id 不变、tab 不变,结果替换为最新一轮,已有的已读/纳入/排除/备注按文献身份保留;绝不会新建任务或新开 tab。当前任务是持久化的「最近一次使用或打开的任务」,跨对话、宿主重启都有效;用户从右侧栏点开别的任务会切换当前任务。只有用户明确要求新建查询任务,或研究主题与当前任务明显不同时,才传 newTask=true 并用 title 给新任务起一个概括主题的名称;也可用 taskId 显式指定要复用的任务(先用 literature_task current/list 查 id,或让用户点右侧栏的「加入对话」把任务 id 带进对话)。每次调用请提交【完整】的新检索条件,缺省项回落到插件默认设置而非沿用旧条件。' +
251
249
  '【轮次约束】本轮检索结束后,只给用户一份「如何完善检索」的建议,禁止自行发起下一轮、禁止重复调用本工具;必须等用户查看结果后,在右侧栏调整条件、或在对话里明确要求调整,才可在同一任务里再搜一次。' +
252
250
  '超过 25 秒仍在进行时会先返回当前进度,任务会继续在右侧栏完成。适合找论文、查某主题的代表作、补充参考文献。',
253
251
  parameters: SEARCH_PARAMETERS,
@@ -255,7 +253,13 @@ export function literatureTools(service) {
255
253
  async execute(args, exec) {
256
254
  const input = readSearchArgs(args);
257
255
  const { title, taskId, newTask, ...conditions } = input;
258
- const createInput = { ...conditions, origin: 'model', runNow: false };
256
+ const workspace = await resolveSessionWorkspace(exec.agent?.id);
257
+ const createInput = {
258
+ ...conditions,
259
+ origin: 'model',
260
+ runNow: false,
261
+ ...(workspace === undefined ? {} : { workspace }),
262
+ };
259
263
  let task;
260
264
  let reused = false;
261
265
  if (taskId !== undefined) {
@@ -315,7 +319,7 @@ export function literatureTools(service) {
315
319
  },
316
320
  {
317
321
  name: 'literature_sources',
318
- description: '列出本插件已接入的免费文献数据源:能力(摘要、被引次数、开放获取、PDF 链接)、限流额度、检索式语法与是否需要可选 API Key。',
322
+ description: '列出本插件已接入的文献数据源:能力(摘要、被引次数、开放获取、PDF 链接)、限流额度、检索式语法与是否需要可选 API Key。默认数据源均免费;Google Scholar 需在设置页配置 SerpAPI Key(有免费额度,超出付费),未配置时不参与检索。',
319
323
  parameters: { type: 'object', properties: {}, additionalProperties: false },
320
324
  output: TEXT_OUTPUT,
321
325
  async execute() {
@@ -324,7 +328,7 @@ export function literatureTools(service) {
324
328
  },
325
329
  {
326
330
  name: 'literature_task',
327
- description: '读取与推进已保存的检索任务。current 读「当前任务」(推荐在执行任何检索/任务操作前先调用,确认这个对话正在用哪个任务;无需 id);list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词过滤);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate 批量写入筛读状态、分组、必读顺序、备注与标签;compare 与旧任务按论文身份对比新增/缺失;stats 读语料统计(年份、来源、期刊、作者、类型、语言)。' +
331
+ description: '读取与推进已保存的检索任务。current 读「当前任务」(推荐在执行任何检索/任务操作前先调用,确认这个对话正在用哪个任务;无需 id);list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词过滤);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate 批量写入筛读状态与分组;compare 与旧任务按论文身份对比新增/缺失;stats 读语料统计(年份、来源、期刊、作者、类型、语言)。' +
328
332
  '注意:一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成;run 会在指定任务内开新一轮检索(并把它设为当前任务),与 literature_search 一样受「用户查看结果并明确要求后才能进入下一轮」约束,不能用它绕过该约束自行加搜;也不要用 update 改完条件就立刻 run。适合在检索之后做筛选、整理与汇报。',
329
333
  parameters: TASK_PARAMETERS,
330
334
  output: TEXT_OUTPUT,
@@ -336,7 +340,7 @@ export function literatureTools(service) {
336
340
  },
337
341
  {
338
342
  name: 'literature_export',
339
- description: '把检索任务导出为文件并返回落盘路径。csv 是全量筛读台账(含命中检索式、筛读状态、分组、备注);ris / bibtex 供 Zotero、EndNote 等文献管理器;md 是分层检索报告(漏斗、按分组的「为什么读」、必读清单、开放获取与 PDF 覆盖率、对比新增)。可按筛读状态、分组、检索式或指定 paperIds 限定范围。',
343
+ description: '把检索任务导出为文件并返回落盘路径。csv 是全量筛读台账(含命中检索式、筛读状态、分组);ris / bibtex 供 Zotero、EndNote 等文献管理器;md 是分层检索报告(漏斗、按分组的阅读清单、开放获取与 PDF 覆盖率、对比新增)。可按筛读状态、分组、检索式或指定 paperIds 限定范围。',
340
344
  parameters: EXPORT_PARAMETERS,
341
345
  output: TEXT_OUTPUT,
342
346
  async execute(args) {
@@ -687,23 +691,6 @@ function readAnnotations(value) {
687
691
  }
688
692
  input.groupId = groupId;
689
693
  }
690
- if (record['shortlistOrder'] !== undefined) {
691
- const order = record['shortlistOrder'];
692
- if (order !== null && (typeof order !== 'number' || !Number.isFinite(order))) {
693
- throw new Error(`papers[${index}].shortlistOrder 必须是数字或 null`);
694
- }
695
- input.shortlistOrder = order;
696
- }
697
- const note = readText(record['note'], `papers[${index}].note`, true);
698
- if (note !== undefined)
699
- input.note = note;
700
- if (record['tags'] !== undefined) {
701
- const tags = record['tags'];
702
- if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === 'string')) {
703
- throw new Error(`papers[${index}].tags 必须是字符串数组`);
704
- }
705
- input.tags = tags;
706
- }
707
694
  return input;
708
695
  });
709
696
  }
@@ -769,10 +756,11 @@ async function runTaskAction(service, args, signal, sessionId) {
769
756
  case 'run': {
770
757
  const id = requireId(args);
771
758
  const task = await service.get(id);
759
+ const workspace = await resolveSessionWorkspace(sessionId);
772
760
  // 用户明确要求推进这个任务:它成为当前任务,并让客户端打开/聚焦对应 tab。
773
761
  service.setCurrentTask(id);
774
762
  service.focusTask(task.id, task.title, sessionId);
775
- const outcome = await runWithin(service, id, signal, service.run(id, 'new', 'model'));
763
+ const outcome = await runWithin(service, id, signal, service.run(id, 'new', 'model', workspace));
776
764
  const latest = await service.get(id);
777
765
  const detail = await service.getDetail(id);
778
766
  const page = await service.results(id, {
@@ -790,7 +778,7 @@ async function runTaskAction(service, args, signal, sessionId) {
790
778
  }
791
779
  case 'annotate': {
792
780
  const result = await service.annotate(requireId(args), args.papers ?? []);
793
- return `已写入 ${result.updated} 篇的筛读/备注(任务 id: ${result.taskId},rev=${result.rev})。`;
781
+ return `已写入 ${result.updated} 篇的筛读/分组(任务 id: ${result.taskId},rev=${result.rev})。`;
794
782
  }
795
783
  case 'compare': {
796
784
  const report = await service.compare(requireId(args), args.withTaskId ?? '');
@@ -853,7 +841,7 @@ function formatCurrentTask(task) {
853
841
  if (task === undefined) {
854
842
  return [
855
843
  '当前没有任务:这个对话还没有任何检索任务。',
856
- '若用户要求检索,请直接调用 literature_search(不要传 newTask);若用户要继续某个已有任务,请让用户从右侧栏任务列表打开它,或点任务页的「带入对话」把任务 id 带进对话。',
844
+ '若用户要求检索,请直接调用 literature_search(不要传 newTask);若用户要继续某个已有任务,请让用户从右侧栏任务列表打开它,或点任务页的「加入对话」把任务 id 带进对话。',
857
845
  ].join('\n');
858
846
  }
859
847
  const conditions = task.conditions;
@@ -918,7 +906,6 @@ function formatTaskDetail(detail, page, states, groups, outcome) {
918
906
  const marks = [
919
907
  state?.screening ?? 'unread',
920
908
  state?.groupId === undefined ? '' : groupNames.get(state.groupId) ?? state.groupId,
921
- state?.shortlistOrder === undefined ? '' : `必读#${state.shortlistOrder}`,
922
909
  ].filter((part) => part !== '');
923
910
  const meta = [
924
911
  paper.authors.slice(0, 4).join(', '),
@@ -930,8 +917,6 @@ function formatTaskDetail(detail, page, states, groups, outcome) {
930
917
  lines.push(`${page.offset + index + 1}. [${marks.join('|')}] ${paper.title}`);
931
918
  if (meta.length > 0)
932
919
  lines.push(` ${meta.join(' | ')}`);
933
- if (state?.note !== undefined && state.note !== '')
934
- lines.push(` 备注:${state.note}`);
935
920
  if (paper.abstract !== '')
936
921
  lines.push(` 摘要:${truncate(paper.abstract, 300)}`);
937
922
  });
@@ -1002,7 +987,9 @@ function percent(value) {
1002
987
  return `${Math.round(value * 100)}%`;
1003
988
  }
1004
989
  function formatSources() {
1005
- const lines = ['已接入的免费文献数据源(无需付费;部分支持可选 Key 提高额度):'];
990
+ const lines = [
991
+ '已接入的文献数据源(默认均为免费;Google Scholar 需在设置页配置 SerpAPI Key,未配置时不参与检索):',
992
+ ];
1006
993
  for (const info of PROVIDER_INFOS) {
1007
994
  const caps = [
1008
995
  info.capabilities.abstract ? '摘要' : '',
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 会话工作目录解析。
3
+ *
4
+ * DSH 为每个会话保存一个绝对工作目录(SessionHeader.cwd)。PDF 默认落在
5
+ * 该目录下的 `dsh-litsearch/`,让下载物跟随项目而不是散落到 `~/Downloads`。
6
+ * 会话服务由宿主注入;未装配或读不到时返回 undefined,由调用方回落到旧默认。
7
+ */
8
+ /** `ctx.sessionPersistence` 中本插件用到的最小结构。 */
9
+ export interface SessionStoreLike {
10
+ stat(id: string): Promise<{
11
+ header: {
12
+ cwd?: string;
13
+ };
14
+ } | undefined>;
15
+ }
16
+ export declare function setSessionStore(store: SessionStoreLike | undefined): void;
17
+ /** 读取某个会话的工作目录;只接受绝对路径。 */
18
+ export declare function resolveSessionWorkspace(sessionId: string | undefined): Promise<string | undefined>;
19
+ /** 规范化工作目录:去空白、去尾斜杠,只接受绝对路径。 */
20
+ export declare function normalizeWorkspace(value: string | undefined): string | undefined;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 会话工作目录解析。
3
+ *
4
+ * DSH 为每个会话保存一个绝对工作目录(SessionHeader.cwd)。PDF 默认落在
5
+ * 该目录下的 `dsh-litsearch/`,让下载物跟随项目而不是散落到 `~/Downloads`。
6
+ * 会话服务由宿主注入;未装配或读不到时返回 undefined,由调用方回落到旧默认。
7
+ */
8
+ import { isAbsolute, resolve } from 'node:path';
9
+ let sessionStore;
10
+ export function setSessionStore(store) {
11
+ sessionStore = store;
12
+ }
13
+ /** 读取某个会话的工作目录;只接受绝对路径。 */
14
+ export async function resolveSessionWorkspace(sessionId) {
15
+ const id = sessionId?.trim() ?? '';
16
+ if (id === '' || sessionStore === undefined)
17
+ return undefined;
18
+ try {
19
+ const snapshot = await sessionStore.stat(id);
20
+ return normalizeWorkspace(snapshot?.header.cwd);
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ /** 规范化工作目录:去空白、去尾斜杠,只接受绝对路径。 */
27
+ export function normalizeWorkspace(value) {
28
+ const path = value?.trim() ?? '';
29
+ if (path === '' || !isAbsolute(path))
30
+ return undefined;
31
+ return resolve(path);
32
+ }
package/lib/index.d.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { Context } from '@deepseek-ai/cordis';
16
16
  import Schema from '@deepseek-ai/schemastery';
17
+ import type { TaskLanguage } from './search/types.js';
17
18
  export declare const name = "dsh-plugin-litsearch";
18
19
  /**
19
20
  * 插件配置 schema(逐字段 volatile)。
@@ -28,6 +29,8 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
28
29
  ncbiApiKey: Schema<string, string, "volatile-defined">;
29
30
  /** 可选 Semantic Scholar API Key。 */
30
31
  s2ApiKey: Schema<string, string, "volatile-defined">;
32
+ /** 可选 SerpAPI Key;配置后启用 Google Scholar 数据源(付费/有免费额度)。 */
33
+ serpApiKey: Schema<string, string, "volatile-defined">;
31
34
  /** 各源最小请求间隔(毫秒)。 */
32
35
  pubmedIntervalMs: Schema<number, number, "volatile-defined">;
33
36
  s2IntervalMs: Schema<number, number, "volatile-defined">;
@@ -37,22 +40,24 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
37
40
  /** 单次上游请求超时(毫秒)。 */
38
41
  requestTimeoutMs: Schema<number, number, "volatile-defined">;
39
42
  /** 默认数据源。 */
40
- defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, "volatile-defined">;
43
+ defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, "volatile-defined">;
41
44
  defaultSort: Schema<"relevance" | "citations" | "year", "relevance" | "citations" | "year", "volatile-defined">;
42
- defaultLanguage: Schema<"any" | "eng" | "chi", "any" | "eng" | "chi", "volatile-defined">;
45
+ defaultLanguage: Schema<TaskLanguage, TaskLanguage, "volatile-defined">;
43
46
  /** 模型发起检索后是否自动打开任务 tab。 */
44
47
  autoOpenTaskTab: Schema<boolean, boolean, "volatile-defined">;
45
48
  /** 同时保留的任务 tab 上限。 */
46
49
  maxTaskTabs: Schema<number, number, "volatile-defined">;
47
- /** 任务保留天数与条数(置顶任务不受限)。 */
50
+ /** 任务保留天数与条数(已保留任务不受限)。 */
48
51
  taskRetentionDays: Schema<number, number, "volatile-defined">;
49
52
  taskRetentionCount: Schema<number, number, "volatile-defined">;
50
53
  /** 单任务结果上限。 */
51
54
  taskMaxPapers: Schema<number, number, "volatile-defined">;
52
- /** PDF 下载根目录。 */
55
+ /** PDF 下载根目录;留空时跟随会话工作目录(其下的 dsh-litsearch/)。 */
53
56
  pdfRoot: Schema<string, string, "volatile-defined">;
54
57
  /** 单个 PDF 最大体积(MB)。 */
55
58
  pdfMaxMb: Schema<number, number, "volatile-defined">;
59
+ /** PDF 去重缓存总量上限(MB),启动时随任务清理一起修剪;0 表示关闭。 */
60
+ pdfCacheMaxMb: Schema<number, number, "volatile-defined">;
56
61
  /** 检索完成后自动下载缺失 PDF。 */
57
62
  autoDownloadPdf: Schema<boolean, boolean, "volatile-defined">;
58
63
  /** 无 PDF 时用 Europe PMC HTML 全文兜底。 */
@@ -69,6 +74,8 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
69
74
  ncbiApiKey: Schema<string, string, "volatile-defined">;
70
75
  /** 可选 Semantic Scholar API Key。 */
71
76
  s2ApiKey: Schema<string, string, "volatile-defined">;
77
+ /** 可选 SerpAPI Key;配置后启用 Google Scholar 数据源(付费/有免费额度)。 */
78
+ serpApiKey: Schema<string, string, "volatile-defined">;
72
79
  /** 各源最小请求间隔(毫秒)。 */
73
80
  pubmedIntervalMs: Schema<number, number, "volatile-defined">;
74
81
  s2IntervalMs: Schema<number, number, "volatile-defined">;
@@ -78,22 +85,24 @@ export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
78
85
  /** 单次上游请求超时(毫秒)。 */
79
86
  requestTimeoutMs: Schema<number, number, "volatile-defined">;
80
87
  /** 默认数据源。 */
81
- defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, "volatile-defined">;
88
+ defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator" | "googlescholar")[]>, "volatile-defined">;
82
89
  defaultSort: Schema<"relevance" | "citations" | "year", "relevance" | "citations" | "year", "volatile-defined">;
83
- defaultLanguage: Schema<"any" | "eng" | "chi", "any" | "eng" | "chi", "volatile-defined">;
90
+ defaultLanguage: Schema<TaskLanguage, TaskLanguage, "volatile-defined">;
84
91
  /** 模型发起检索后是否自动打开任务 tab。 */
85
92
  autoOpenTaskTab: Schema<boolean, boolean, "volatile-defined">;
86
93
  /** 同时保留的任务 tab 上限。 */
87
94
  maxTaskTabs: Schema<number, number, "volatile-defined">;
88
- /** 任务保留天数与条数(置顶任务不受限)。 */
95
+ /** 任务保留天数与条数(已保留任务不受限)。 */
89
96
  taskRetentionDays: Schema<number, number, "volatile-defined">;
90
97
  taskRetentionCount: Schema<number, number, "volatile-defined">;
91
98
  /** 单任务结果上限。 */
92
99
  taskMaxPapers: Schema<number, number, "volatile-defined">;
93
- /** PDF 下载根目录。 */
100
+ /** PDF 下载根目录;留空时跟随会话工作目录(其下的 dsh-litsearch/)。 */
94
101
  pdfRoot: Schema<string, string, "volatile-defined">;
95
102
  /** 单个 PDF 最大体积(MB)。 */
96
103
  pdfMaxMb: Schema<number, number, "volatile-defined">;
104
+ /** PDF 去重缓存总量上限(MB),启动时随任务清理一起修剪;0 表示关闭。 */
105
+ pdfCacheMaxMb: Schema<number, number, "volatile-defined">;
97
106
  /** 检索完成后自动下载缺失 PDF。 */
98
107
  autoDownloadPdf: Schema<boolean, boolean, "volatile-defined">;
99
108
  /** 无 PDF 时用 Europe PMC HTML 全文兜底。 */
package/lib/index.js CHANGED
@@ -18,9 +18,12 @@ import { join } from 'node:path';
18
18
  import { createHandler } from './host/router.js';
19
19
  import { literatureTools } from './host/tools.js';
20
20
  import { TaskService } from './host/tasks/service.js';
21
- import { DEFAULT_ARXIV_INTERVAL_MS, DEFAULT_AUTO_DOWNLOAD_PDF, DEFAULT_AUTO_OPEN, DEFAULT_ENABLE_EPMC_FULLTEXT, DEFAULT_LANGUAGE, DEFAULT_MAX_TASK_TABS, DEFAULT_PAGE_SIZE, DEFAULT_PDF_MAX_MB, DEFAULT_PUBMED_INTERVAL_MS, DEFAULT_RETENTION_COUNT, DEFAULT_RETENTION_DAYS, DEFAULT_S2_INTERVAL_MS, DEFAULT_SORT, DEFAULT_TASK_MAX_PAPERS, DEFAULT_ZOTERO_ATTACH_PDF, DEFAULT_ZOTERO_BASE_URL, DEFAULT_ZOTERO_ENABLED, DEFAULT_ZOTERO_TAG, MAX_LIMIT, REQUEST_TIMEOUT_MS, ROUTE_MATCH, setSettingsReader, } from './search/config.js';
22
- import { SOURCE_IDS } from './search/types.js';
21
+ import { setSessionStore } from './host/workspace.js';
22
+ import { DEFAULT_ARXIV_INTERVAL_MS, DEFAULT_AUTO_DOWNLOAD_PDF, DEFAULT_AUTO_OPEN, DEFAULT_ENABLE_EPMC_FULLTEXT, DEFAULT_LANGUAGE, DEFAULT_MAX_TASK_TABS, DEFAULT_PAGE_SIZE, DEFAULT_PDF_CACHE_MAX_MB, DEFAULT_PDF_MAX_MB, DEFAULT_PUBMED_INTERVAL_MS, DEFAULT_RETENTION_COUNT, DEFAULT_RETENTION_DAYS, DEFAULT_S2_INTERVAL_MS, DEFAULT_SOURCE_IDS, DEFAULT_SORT, DEFAULT_TASK_MAX_PAPERS, DEFAULT_ZOTERO_ATTACH_PDF, DEFAULT_ZOTERO_BASE_URL, DEFAULT_ZOTERO_ENABLED, DEFAULT_ZOTERO_TAG, MAX_LIMIT, REQUEST_TIMEOUT_MS, ROUTE_MATCH, setSettingsReader, } from './search/config.js';
23
+ import { SOURCE_IDS, TASK_LANGUAGES } from './search/types.js';
23
24
  export const name = 'dsh-plugin-litsearch';
25
+ /** 旧版默认下载目录;读取时视为「未配置」,让默认落点切换到工作目录。 */
26
+ const LEGACY_PDF_ROOT = join(homedir(), 'Downloads', 'dsh-litsearch');
24
27
  /**
25
28
  * 插件配置 schema(逐字段 volatile)。
26
29
  *
@@ -34,6 +37,8 @@ export const Config = Schema.object({
34
37
  ncbiApiKey: Schema.string().default('').role('secret').volatile(),
35
38
  /** 可选 Semantic Scholar API Key。 */
36
39
  s2ApiKey: Schema.string().default('').role('secret').volatile(),
40
+ /** 可选 SerpAPI Key;配置后启用 Google Scholar 数据源(付费/有免费额度)。 */
41
+ serpApiKey: Schema.string().default('').role('secret').volatile(),
37
42
  /** 各源最小请求间隔(毫秒)。 */
38
43
  pubmedIntervalMs: Schema.number().default(DEFAULT_PUBMED_INTERVAL_MS).volatile(),
39
44
  s2IntervalMs: Schema.number().default(DEFAULT_S2_INTERVAL_MS).volatile(),
@@ -51,8 +56,9 @@ export const Config = Schema.object({
51
56
  Schema.const('semanticscholar'),
52
57
  Schema.const('arxiv'),
53
58
  Schema.const('pubtator'),
59
+ Schema.const('googlescholar'),
54
60
  ]))
55
- .default([...SOURCE_IDS])
61
+ .default([...DEFAULT_SOURCE_IDS])
56
62
  .volatile(),
57
63
  defaultSort: Schema.union([
58
64
  Schema.const('relevance'),
@@ -61,22 +67,24 @@ export const Config = Schema.object({
61
67
  ])
62
68
  .default(DEFAULT_SORT)
63
69
  .volatile(),
64
- defaultLanguage: Schema.union([Schema.const('any'), Schema.const('eng'), Schema.const('chi')])
70
+ defaultLanguage: Schema.union(TASK_LANGUAGES.map((code) => Schema.const(code)))
65
71
  .default(DEFAULT_LANGUAGE)
66
72
  .volatile(),
67
73
  /** 模型发起检索后是否自动打开任务 tab。 */
68
74
  autoOpenTaskTab: Schema.boolean().default(DEFAULT_AUTO_OPEN).volatile(),
69
75
  /** 同时保留的任务 tab 上限。 */
70
76
  maxTaskTabs: Schema.number().default(DEFAULT_MAX_TASK_TABS).volatile(),
71
- /** 任务保留天数与条数(置顶任务不受限)。 */
77
+ /** 任务保留天数与条数(已保留任务不受限)。 */
72
78
  taskRetentionDays: Schema.number().default(DEFAULT_RETENTION_DAYS).volatile(),
73
79
  taskRetentionCount: Schema.number().default(DEFAULT_RETENTION_COUNT).volatile(),
74
80
  /** 单任务结果上限。 */
75
81
  taskMaxPapers: Schema.number().default(DEFAULT_TASK_MAX_PAPERS).volatile(),
76
- /** PDF 下载根目录。 */
77
- pdfRoot: Schema.string().default(join(homedir(), 'Downloads', 'dsh-litsearch')).volatile(),
82
+ /** PDF 下载根目录;留空时跟随会话工作目录(其下的 dsh-litsearch/)。 */
83
+ pdfRoot: Schema.string().default('').volatile(),
78
84
  /** 单个 PDF 最大体积(MB)。 */
79
85
  pdfMaxMb: Schema.number().default(DEFAULT_PDF_MAX_MB).volatile(),
86
+ /** PDF 去重缓存总量上限(MB),启动时随任务清理一起修剪;0 表示关闭。 */
87
+ pdfCacheMaxMb: Schema.number().default(DEFAULT_PDF_CACHE_MAX_MB).volatile(),
80
88
  /** 检索完成后自动下载缺失 PDF。 */
81
89
  autoDownloadPdf: Schema.boolean().default(DEFAULT_AUTO_DOWNLOAD_PDF).volatile(),
82
90
  /** 无 PDF 时用 Europe PMC HTML 全文兜底。 */
@@ -93,12 +101,13 @@ export function apply(ctx, config) {
93
101
  contactEmail: config.contactEmail.get().trim(),
94
102
  ncbiApiKey: config.ncbiApiKey.get().trim(),
95
103
  s2ApiKey: config.s2ApiKey.get().trim(),
104
+ serpApiKey: config.serpApiKey.get().trim(),
96
105
  pubmedIntervalMs: clampInt(config.pubmedIntervalMs.get(), 50, 10_000, DEFAULT_PUBMED_INTERVAL_MS),
97
106
  s2IntervalMs: clampInt(config.s2IntervalMs.get(), 50, 60_000, DEFAULT_S2_INTERVAL_MS),
98
107
  arxivIntervalMs: clampInt(config.arxivIntervalMs.get(), 50, 60_000, DEFAULT_ARXIV_INTERVAL_MS),
99
108
  defaultPageSize: clampInt(config.defaultPageSize.get(), 1, MAX_LIMIT, DEFAULT_PAGE_SIZE),
100
109
  requestTimeoutMs: clampInt(config.requestTimeoutMs.get(), 1_000, 120_000, REQUEST_TIMEOUT_MS),
101
- defaultSources: normalizeSources(config.defaultSources.get()),
110
+ defaultSources: normalizeSources(config.defaultSources.get()).filter((id) => id !== 'googlescholar' || config.serpApiKey.get().trim() !== ''),
102
111
  defaultSort: normalizeSort(config.defaultSort.get()),
103
112
  defaultLanguage: normalizeLanguage(config.defaultLanguage.get()),
104
113
  autoOpenTaskTab: config.autoOpenTaskTab.get(),
@@ -106,8 +115,9 @@ export function apply(ctx, config) {
106
115
  taskRetentionDays: clampInt(config.taskRetentionDays.get(), 1, 3_650, DEFAULT_RETENTION_DAYS),
107
116
  taskRetentionCount: clampInt(config.taskRetentionCount.get(), 1, 10_000, DEFAULT_RETENTION_COUNT),
108
117
  taskMaxPapers: clampInt(config.taskMaxPapers.get(), 10, 10_000, DEFAULT_TASK_MAX_PAPERS),
109
- pdfRoot: config.pdfRoot.get().trim(),
118
+ pdfRoot: normalizePdfRoot(config.pdfRoot.get()),
110
119
  pdfMaxMb: clampInt(config.pdfMaxMb.get(), 1, 200, DEFAULT_PDF_MAX_MB),
120
+ pdfCacheMaxMb: clampInt(config.pdfCacheMaxMb.get(), 0, 102_400, DEFAULT_PDF_CACHE_MAX_MB),
111
121
  autoDownloadPdf: config.autoDownloadPdf.get(),
112
122
  enableEpmcFulltext: config.enableEpmcFulltext.get(),
113
123
  zoteroEnabled: config.zoteroEnabled.get(),
@@ -118,6 +128,13 @@ export function apply(ctx, config) {
118
128
  // 任务状态是模型工具与 HTTP 路由的唯一事实源。
119
129
  const service = new TaskService();
120
130
  ctx.effect(() => () => service.dispose(), 'dsh-plugin-litsearch: task service');
131
+ // 会话工作目录:PDF 默认落在其中,跟随用户当前项目。
132
+ ctx.inject(['sessionPersistence'], (sessionCtx) => {
133
+ const store = sessionCtx
134
+ .sessionPersistence;
135
+ setSessionStore(store);
136
+ sessionCtx.effect(() => () => setSessionStore(undefined), 'dsh-plugin-litsearch: session workspace');
137
+ });
121
138
  // 模型侧:原生注册,不依赖外部 MCP。
122
139
  ctx.inject(['tools'], (toolCtx) => {
123
140
  const toolsService = toolCtx.tools;
@@ -139,15 +156,20 @@ function clampInt(value, min, max, fallback) {
139
156
  }
140
157
  function normalizeSources(value) {
141
158
  const valid = SOURCE_IDS.filter((id) => value.includes(id));
142
- return valid.length === 0 ? [...SOURCE_IDS] : valid;
159
+ return valid.length === 0 ? [...DEFAULT_SOURCE_IDS] : valid;
143
160
  }
144
161
  function normalizeSort(value) {
145
162
  return value === 'citations' || value === 'year' ? value : 'relevance';
146
163
  }
147
164
  function normalizeLanguage(value) {
148
- return value === 'eng' || value === 'chi' ? value : 'any';
165
+ return TASK_LANGUAGES.includes(value) ? value : 'any';
149
166
  }
150
167
  function normalizeBaseUrl(value) {
151
168
  const trimmed = value.trim().replace(/\/+$/u, '');
152
169
  return /^https?:\/\//u.test(trimmed) ? trimmed : DEFAULT_ZOTERO_BASE_URL;
153
170
  }
171
+ /** 旧默认值等同未配置:改由会话工作目录决定下载落点。 */
172
+ function normalizePdfRoot(value) {
173
+ const trimmed = value.trim();
174
+ return trimmed === LEGACY_PDF_ROOT ? '' : trimmed;
175
+ }
@@ -39,6 +39,7 @@ export async function searchSources(options) {
39
39
  bypassCooldown: options.bypassCooldown === true,
40
40
  ncbiApiKey: settings.ncbiApiKey,
41
41
  s2ApiKey: settings.s2ApiKey,
42
+ serpApiKey: settings.serpApiKey,
42
43
  });
43
44
  }));
44
45
  return settled.map((outcome, index) => {
@@ -7,6 +7,13 @@
7
7
  import type { SortMode, SourceId, TaskLanguage } from './types.js';
8
8
  /** 插件版本(与 package.json 同步;仅用于 /health 诊断展示)。 */
9
9
  export declare const PLUGIN_VERSION = "0.1.0";
10
+ /**
11
+ * 默认数据源。
12
+ *
13
+ * Google Scholar 依赖用户自备的 SerpAPI Key,未配置时默认关闭,
14
+ * 因此不进入默认勾选列表。
15
+ */
16
+ export declare const DEFAULT_SOURCE_IDS: readonly SourceId[];
10
17
  /** 默认每次加载条数。 */
11
18
  export declare const DEFAULT_PAGE_SIZE = 20;
12
19
  /**
@@ -37,8 +44,12 @@ export declare const DEFAULT_TASK_MAX_ROUNDS = 30;
37
44
  export declare const DEFAULT_PUBMED_INTERVAL_MS = 350;
38
45
  export declare const DEFAULT_S2_INTERVAL_MS = 3000;
39
46
  export declare const DEFAULT_ARXIV_INTERVAL_MS = 3000;
47
+ /** SerpAPI 按次计费,保守限制为 1 次/秒。 */
48
+ export declare const DEFAULT_SERPAPI_INTERVAL_MS = 1000;
40
49
  /** 全文下载默认设置。 */
41
50
  export declare const DEFAULT_PDF_MAX_MB = 30;
51
+ /** PDF 去重缓存总量上限(MB);0 表示关闭自动清理。 */
52
+ export declare const DEFAULT_PDF_CACHE_MAX_MB = 512;
42
53
  export declare const DEFAULT_AUTO_DOWNLOAD_PDF = false;
43
54
  export declare const DEFAULT_ENABLE_EPMC_FULLTEXT = true;
44
55
  /** Zotero connector 默认设置。 */
@@ -50,6 +61,8 @@ export declare const DEFAULT_ZOTERO_TAG = "dsh-litsearch";
50
61
  export declare const MAX_ABSTRACT_CHARS = 800;
51
62
  /** 写端点的自定义请求头(与 JSON content-type 一起构成非简单请求)。 */
52
63
  export declare const WRITE_HEADER = "x-dsh-litsearch-write";
64
+ /** 面板请求携带的会话 id;Host 用它解析会话工作目录。 */
65
+ export declare const SESSION_HEADER = "x-dsh-litsearch-session";
53
66
  /** Host 路由前缀(Client fetch 路径与 handler 的剥离基准,带尾斜杠)。 */
54
67
  export declare const ROUTE_PREFIX = "/dsh-litsearch/api/";
55
68
  /**
@@ -76,13 +89,16 @@ export interface LitsearchSettings {
76
89
  ncbiApiKey: string;
77
90
  /** 可选 Semantic Scholar API Key;提高配额。 */
78
91
  s2ApiKey: string;
92
+ /** 可选 SerpAPI Key;配置后启用 Google Scholar 数据源。 */
93
+ serpApiKey: string;
79
94
  /** 各源最小请求间隔覆盖值。 */
80
95
  pubmedIntervalMs: number;
81
96
  s2IntervalMs: number;
82
97
  arxivIntervalMs: number;
83
- /** PDF 下载根目录;空串由 Host 回落到 ~/Downloads/dsh-litsearch。 */
98
+ /** PDF 下载根目录;空串由 Host 回落到会话工作目录下的 dsh-litsearch/。 */
84
99
  pdfRoot: string;
85
100
  pdfMaxMb: number;
101
+ pdfCacheMaxMb: number;
86
102
  autoDownloadPdf: boolean;
87
103
  enableEpmcFulltext: boolean;
88
104
  zoteroEnabled: boolean;
@@ -7,6 +7,13 @@
7
7
  import { SOURCE_IDS } from './types.js';
8
8
  /** 插件版本(与 package.json 同步;仅用于 /health 诊断展示)。 */
9
9
  export const PLUGIN_VERSION = '0.1.0';
10
+ /**
11
+ * 默认数据源。
12
+ *
13
+ * Google Scholar 依赖用户自备的 SerpAPI Key,未配置时默认关闭,
14
+ * 因此不进入默认勾选列表。
15
+ */
16
+ export const DEFAULT_SOURCE_IDS = SOURCE_IDS.filter((id) => id !== 'googlescholar');
10
17
  /** 默认每次加载条数。 */
11
18
  export const DEFAULT_PAGE_SIZE = 20;
12
19
  /**
@@ -37,8 +44,12 @@ export const DEFAULT_TASK_MAX_ROUNDS = 30;
37
44
  export const DEFAULT_PUBMED_INTERVAL_MS = 350;
38
45
  export const DEFAULT_S2_INTERVAL_MS = 3000;
39
46
  export const DEFAULT_ARXIV_INTERVAL_MS = 3000;
47
+ /** SerpAPI 按次计费,保守限制为 1 次/秒。 */
48
+ export const DEFAULT_SERPAPI_INTERVAL_MS = 1000;
40
49
  /** 全文下载默认设置。 */
41
50
  export const DEFAULT_PDF_MAX_MB = 30;
51
+ /** PDF 去重缓存总量上限(MB);0 表示关闭自动清理。 */
52
+ export const DEFAULT_PDF_CACHE_MAX_MB = 512;
42
53
  export const DEFAULT_AUTO_DOWNLOAD_PDF = false;
43
54
  export const DEFAULT_ENABLE_EPMC_FULLTEXT = true;
44
55
  /** Zotero connector 默认设置。 */
@@ -50,6 +61,8 @@ export const DEFAULT_ZOTERO_TAG = 'dsh-litsearch';
50
61
  export const MAX_ABSTRACT_CHARS = 800;
51
62
  /** 写端点的自定义请求头(与 JSON content-type 一起构成非简单请求)。 */
52
63
  export const WRITE_HEADER = 'x-dsh-litsearch-write';
64
+ /** 面板请求携带的会话 id;Host 用它解析会话工作目录。 */
65
+ export const SESSION_HEADER = 'x-dsh-litsearch-session';
53
66
  /** Host 路由前缀(Client fetch 路径与 handler 的剥离基准,带尾斜杠)。 */
54
67
  export const ROUTE_PREFIX = '/dsh-litsearch/api/';
55
68
  /**
@@ -64,7 +77,7 @@ let readSettings = () => ({
64
77
  contactEmail: '',
65
78
  defaultPageSize: DEFAULT_PAGE_SIZE,
66
79
  requestTimeoutMs: REQUEST_TIMEOUT_MS,
67
- defaultSources: [...SOURCE_IDS],
80
+ defaultSources: [...DEFAULT_SOURCE_IDS],
68
81
  defaultSort: DEFAULT_SORT,
69
82
  defaultLanguage: DEFAULT_LANGUAGE,
70
83
  autoOpenTaskTab: DEFAULT_AUTO_OPEN,
@@ -74,11 +87,13 @@ let readSettings = () => ({
74
87
  taskMaxPapers: DEFAULT_TASK_MAX_PAPERS,
75
88
  ncbiApiKey: '',
76
89
  s2ApiKey: '',
90
+ serpApiKey: '',
77
91
  pubmedIntervalMs: DEFAULT_PUBMED_INTERVAL_MS,
78
92
  s2IntervalMs: DEFAULT_S2_INTERVAL_MS,
79
93
  arxivIntervalMs: DEFAULT_ARXIV_INTERVAL_MS,
80
94
  pdfRoot: '',
81
95
  pdfMaxMb: DEFAULT_PDF_MAX_MB,
96
+ pdfCacheMaxMb: DEFAULT_PDF_CACHE_MAX_MB,
82
97
  autoDownloadPdf: DEFAULT_AUTO_DOWNLOAD_PDF,
83
98
  enableEpmcFulltext: DEFAULT_ENABLE_EPMC_FULLTEXT,
84
99
  zoteroEnabled: DEFAULT_ZOTERO_ENABLED,
@@ -17,6 +17,7 @@ const RRF_WEIGHTS = {
17
17
  semanticscholar: 0.9,
18
18
  arxiv: 0.85,
19
19
  pubtator: 0.7,
20
+ googlescholar: 0.95,
20
21
  };
21
22
  /** RRF 平滑常数。 */
22
23
  const RRF_K = 60;
@@ -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)。 */