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/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
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Google Scholar provider —— 通过 SerpAPI 的 `google_scholar` 引擎检索。
3
+ *
4
+ * Google 不提供官方 Scholar API,且禁止程序化抓取;这里只走第三方 SerpAPI,
5
+ * 由用户自备 Key。未配置 Key 时直接按「限流跳过」处理,不发起请求,也不计入
6
+ * 失败。文档见 `docs/谷歌学术接入调研.md`。
7
+ *
8
+ * 限制:SerpAPI 单页最多 20 条,`limit > 20` 时按 `start` 翻页;结果普遍不带
9
+ * DOI/PMID,跨源去重主要依赖规范化标题键。
10
+ */
11
+ import type { Provider } from '../types.js';
12
+ export declare const googlescholar: Provider;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Google Scholar provider —— 通过 SerpAPI 的 `google_scholar` 引擎检索。
3
+ *
4
+ * Google 不提供官方 Scholar API,且禁止程序化抓取;这里只走第三方 SerpAPI,
5
+ * 由用户自备 Key。未配置 Key 时直接按「限流跳过」处理,不发起请求,也不计入
6
+ * 失败。文档见 `docs/谷歌学术接入调研.md`。
7
+ *
8
+ * 限制:SerpAPI 单页最多 20 条,`limit > 20` 时按 `start` 翻页;结果普遍不带
9
+ * DOI/PMID,跨源去重主要依赖规范化标题键。
10
+ */
11
+ import { SearchError } from '../errors.js';
12
+ import { asArray, asNumber, asRecord, asString, normalizeDoi } from '../normalize.js';
13
+ import { limitedJson, SourceSkipped } from '../rate-limit.js';
14
+ const ENDPOINT = 'https://serpapi.com/search.json';
15
+ /** SerpAPI 允许的单页条数上限。 */
16
+ const PAGE_SIZE = 20;
17
+ export const googlescholar = {
18
+ info: {
19
+ id: 'googlescholar',
20
+ label: 'Google Scholar',
21
+ homepage: 'https://scholar.google.com',
22
+ capabilities: { abstract: true, citationCount: true, openAccess: true, pdf: true },
23
+ rateLimit: '1 req/s 自限;受 SerpAPI 套餐配额约束(免费 250 次/月)',
24
+ syntax: '自然语言;支持引号短语、author:/source:,不解析 PubMed 字段标签',
25
+ optionalKey: true,
26
+ },
27
+ async search(query, options) {
28
+ const apiKey = options.serpApiKey?.trim() ?? '';
29
+ if (apiKey === '') {
30
+ throw new SourceSkipped('未配置 SerpAPI 密钥,Google Scholar 已跳过');
31
+ }
32
+ const records = [];
33
+ const seen = new Set();
34
+ for (let start = 0; start < options.limit; start += PAGE_SIZE) {
35
+ const num = Math.min(PAGE_SIZE, options.limit - start);
36
+ const params = new URLSearchParams({
37
+ engine: 'google_scholar',
38
+ q: query,
39
+ num: String(num),
40
+ start: String(start),
41
+ hl: 'en',
42
+ api_key: apiKey,
43
+ });
44
+ if (options.yearFrom !== undefined)
45
+ params.set('as_ylo', String(options.yearFrom));
46
+ if (options.yearTo !== undefined)
47
+ params.set('as_yhi', String(options.yearTo));
48
+ const payload = await limitedJson('googlescholar', `${ENDPOINT}?${params.toString()}`, {
49
+ timeoutMs: options.timeoutMs,
50
+ signal: options.signal,
51
+ bypassCooldown: options.bypassCooldown === true,
52
+ headers: { accept: 'application/json' },
53
+ }).catch((error) => {
54
+ if (error instanceof SearchError && (error.status === 401 || error.status === 403)) {
55
+ throw new SearchError('upstream_error', 'SerpAPI 密钥无效或无权限,请在设置页检查');
56
+ }
57
+ throw error;
58
+ });
59
+ const response = asRecord(payload);
60
+ const error = asString(response['error']).trim();
61
+ if (error !== '') {
62
+ throw new SearchError('upstream_error', `SerpAPI:${error}`);
63
+ }
64
+ const items = asArray(response['organic_results']);
65
+ for (const item of items) {
66
+ const record = mapItem(item);
67
+ if (record.title === '')
68
+ continue;
69
+ const key = record.sourceId === '' ? record.title : record.sourceId;
70
+ if (seen.has(key))
71
+ continue;
72
+ seen.add(key);
73
+ records.push(record);
74
+ }
75
+ // 上游返回不足一页,说明没有更多结果。
76
+ if (items.length < num)
77
+ break;
78
+ }
79
+ return records;
80
+ },
81
+ };
82
+ function mapItem(raw) {
83
+ const item = asRecord(raw);
84
+ const publication = asRecord(item['publication_info']);
85
+ const inline = asRecord(item['inline_links']);
86
+ const versions = asRecord(inline['versions']);
87
+ const citedBy = asRecord(inline['cited_by']);
88
+ const title = asString(item['title']).trim();
89
+ const link = asString(item['link']).trim();
90
+ const summary = asString(publication['summary']).trim();
91
+ const clusterId = asString(versions['cluster_id']).trim();
92
+ const resultId = asString(item['result_id']).trim();
93
+ const doi = readDoi(link);
94
+ const pdfUrl = readPdfUrl(item['resources']);
95
+ return {
96
+ source: 'googlescholar',
97
+ sourceId: clusterId !== '' ? clusterId : resultId !== '' ? resultId : link,
98
+ ids: { doi, pmid: '', pmcid: '', arxivId: '' },
99
+ title,
100
+ authors: readAuthors(publication['authors'], summary),
101
+ year: readYear(summary),
102
+ venue: readVenue(summary),
103
+ doi,
104
+ url: link,
105
+ abstract: asString(item['snippet']).trim(),
106
+ citedBy: asNumber(citedBy['total']),
107
+ openAccess: pdfUrl !== '',
108
+ pdfUrl,
109
+ type: '',
110
+ };
111
+ }
112
+ /** 结果页不直接给 DOI;只有链接里出现 DOI 时才提取,避免误判普通 URL。 */
113
+ function readDoi(link) {
114
+ if (!/10\.\d{4,9}\//u.test(link))
115
+ return '';
116
+ const match = /10\.\d{4,9}\/[^\s"<>]+/u.exec(link);
117
+ return match === null ? '' : normalizeDoi(match[0]);
118
+ }
119
+ function readPdfUrl(value) {
120
+ for (const entry of asArray(value)) {
121
+ const resource = asRecord(entry);
122
+ if (asString(resource['file_format']).toUpperCase() !== 'PDF')
123
+ continue;
124
+ const link = asString(resource['link']).trim();
125
+ if (link !== '')
126
+ return link;
127
+ }
128
+ return '';
129
+ }
130
+ function readAuthors(value, summary) {
131
+ const names = asArray(value)
132
+ .map((entry) => asString(asRecord(entry)['name']).trim())
133
+ .filter((name) => name !== '');
134
+ if (names.length > 0)
135
+ return names;
136
+ // 兜底:summary 形如「作者列表 - 标题, 年份 - 来源」。
137
+ const head = summary.split(' - ')[0] ?? '';
138
+ return head
139
+ .split(',')
140
+ .map((name) => name.trim())
141
+ .filter((name) => name !== '' && name.length <= 80);
142
+ }
143
+ /** summary 中的最后一个四位年份即出版年(标题里的年份通常更早)。 */
144
+ function readYear(summary) {
145
+ const matches = [...summary.matchAll(/\b(?:19|20)\d{2}\b/gu)];
146
+ if (matches.length === 0)
147
+ return null;
148
+ return asNumber(Number(matches[matches.length - 1][0]));
149
+ }
150
+ /** summary 末段通常是来源站点(如 `cabdirect.org`)。 */
151
+ function readVenue(summary) {
152
+ const parts = summary.split(' - ');
153
+ return parts.length < 2 ? '' : (parts[parts.length - 1] ?? '').trim();
154
+ }
@@ -8,6 +8,7 @@ import { SOURCE_IDS } from '../types.js';
8
8
  import { arxiv } from './arxiv.js';
9
9
  import { crossref } from './crossref.js';
10
10
  import { europepmc } from './europepmc.js';
11
+ import { googlescholar } from './googlescholar.js';
11
12
  import { openalex } from './openalex.js';
12
13
  import { pubmed } from './pubmed.js';
13
14
  import { pubtator } from './pubtator.js';
@@ -20,6 +21,7 @@ export const PROVIDERS = {
20
21
  semanticscholar,
21
22
  arxiv,
22
23
  pubtator,
24
+ googlescholar,
23
25
  };
24
26
  /** 注册顺序即聚合展示顺序。 */
25
27
  export const PROVIDER_INFOS = SOURCE_IDS.map((id) => PROVIDERS[id].info);
@@ -22,6 +22,9 @@ export function adaptQuery(source, block) {
22
22
  return toArxivQuery(text);
23
23
  case 'pubtator':
24
24
  return toPlainText(text);
25
+ case 'googlescholar':
26
+ // 支持引号短语与 author:/source:,但不认 PubMed 字段标签与布尔式。
27
+ return toPlainText(text);
25
28
  }
26
29
  }
27
30
  /** 去掉 `[tiab]` 一类字段标签与通配符,保留引号短语与布尔词。 */
@@ -11,7 +11,7 @@
11
11
  * - 连续成功若干次后间隔逐步回落;超时 / 临时网络故障也重试一次。
12
12
  * - 诊断探活(bypassCooldown)忽略冷却,429 仍按原始错误返回,便于定位。
13
13
  */
14
- import { DEFAULT_ARXIV_INTERVAL_MS, DEFAULT_PUBMED_INTERVAL_MS, DEFAULT_S2_INTERVAL_MS, getSettings, } from './config.js';
14
+ import { DEFAULT_ARXIV_INTERVAL_MS, DEFAULT_PUBMED_INTERVAL_MS, DEFAULT_S2_INTERVAL_MS, DEFAULT_SERPAPI_INTERVAL_MS, getSettings, } from './config.js';
15
15
  import { SearchError } from './errors.js';
16
16
  import { cooldownUntil, recordSourceCooldown, recordSourceError, recordSourceOk, } from './health.js';
17
17
  import { fetchJson, fetchText } from './http.js';
@@ -23,6 +23,7 @@ const BASE_INTERVALS = {
23
23
  semanticscholar: DEFAULT_S2_INTERVAL_MS,
24
24
  arxiv: DEFAULT_ARXIV_INTERVAL_MS,
25
25
  pubtator: 350,
26
+ googlescholar: DEFAULT_SERPAPI_INTERVAL_MS,
26
27
  };
27
28
  /** 429 未带 Retry-After 时的冷却时长(原为 30s,过久会拖垮多检索式任务)。 */
28
29
  const COOLDOWN_FALLBACK_MS = 10_000;
@@ -3,13 +3,20 @@
3
3
  *
4
4
  * 本文件同时被 Host 与 Client 引用,因此不得引入任何 Node 内置模块。
5
5
  */
6
- /** 已接入的免费数据源 id;顺序即聚合展示顺序。 */
7
- export declare const SOURCE_IDS: readonly ["pubmed", "europepmc", "openalex", "crossref", "semanticscholar", "arxiv", "pubtator"];
6
+ /**
7
+ * 已接入的数据源 id;顺序即聚合展示顺序。
8
+ *
9
+ * 除 `googlescholar` 外均为免费公开 API;`googlescholar` 依赖用户自备的
10
+ * SerpAPI Key,未配置时默认不参与检索。
11
+ */
12
+ export declare const SOURCE_IDS: readonly ["pubmed", "europepmc", "openalex", "crossref", "semanticscholar", "arxiv", "pubtator", "googlescholar"];
8
13
  export type SourceId = (typeof SOURCE_IDS)[number];
9
14
  /** 排序方式。 */
10
15
  export type SortMode = 'relevance' | 'citations' | 'year';
11
- /** 语言筛选。 */
12
- export type TaskLanguage = 'any' | 'eng' | 'chi';
16
+ /** 语言筛选;取值沿用 PubMed 的三字母语言码。 */
17
+ export type TaskLanguage = 'any' | 'eng' | 'chi' | 'jpn' | 'kor' | 'fre' | 'ger' | 'spa' | 'rus' | 'ita' | 'por';
18
+ /** 语言候选项;顺序即下拉与胶囊的展示顺序。 */
19
+ export declare const TASK_LANGUAGES: readonly TaskLanguage[];
13
20
  /** 论文的持久标识集合(裸值,不带 URL 前缀)。 */
14
21
  export interface PaperIds {
15
22
  doi: string;
@@ -101,6 +108,7 @@ export interface ProviderSearchOptions {
101
108
  /** 可选密钥;provider 只用于请求参数/请求头,不得写日志。 */
102
109
  ncbiApiKey?: string;
103
110
  s2ApiKey?: string;
111
+ serpApiKey?: string;
104
112
  }
105
113
  export interface Provider {
106
114
  info: ProviderInfo;
@@ -145,14 +153,20 @@ export interface TaskConditions {
145
153
  pageSize: number;
146
154
  }
147
155
  /** 全文获取状态;`unknown` 表示尚未尝试。 */
148
- export type PdfStatus = 'unknown' | 'available' | 'downloaded' | 'html-only' | 'paywalled' | 'blocked' | 'failed';
156
+ export type PdfStatus = 'unknown' | 'available' | 'downloaded' | 'missing' | 'html-only' | 'paywalled' | 'blocked' | 'failed';
157
+ /**
158
+ * PDF 下载失败的根因分类。
159
+ *
160
+ * 除了 `failed`(网络/超时/体积等程序故障)之外,其余都属于「后台拿不到、
161
+ * 但浏览器可能能打开」,界面按此分流,避免把订阅受限误报成功能失败。
162
+ */
163
+ export type PdfFailureReason = 'no-candidate' | 'blocked' | 'paywalled' | 'not-pdf' | 'failed';
164
+ /** 是否需要引导用户到浏览器手动保存。 */
165
+ export declare function isManualPdfFailure(reason: PdfFailureReason | undefined): boolean;
149
166
  /** 论文级人工筛选状态。 */
150
167
  export interface PaperState {
151
168
  screening: Screening;
152
169
  groupId?: string;
153
- shortlistOrder?: number;
154
- note?: string;
155
- tags?: string[];
156
170
  pdfStatus?: PdfStatus;
157
171
  /** 失败原因(面向用户的一句话)。 */
158
172
  pdfError?: string;
@@ -186,7 +200,7 @@ export interface TaskResult {
186
200
  * 任务内一次检索(一轮)的元数据快照。
187
201
  *
188
202
  * 只留「这一轮用什么条件、什么时候跑、跑出多少、结果如何」,**不保存文献列表**:
189
- * 当前结果永远是最新一轮的 `result.papers`,人工标注按 paperId 跨轮保留。
203
+ * 当前结果永远是最新一轮的 `result.papers`,筛读状态按 paperId 跨轮保留。
190
204
  * 历史轮次用于时间轴回看与「回填条件重跑」。
191
205
  */
192
206
  export interface SearchRound {
@@ -233,6 +247,8 @@ export interface SearchTask {
233
247
  /** 任务级研究笔记。 */
234
248
  notes: string;
235
249
  groups: TaskGroup[];
250
+ /** 关联的会话工作目录;PDF 默认下载到其下的 dsh-litsearch/。 */
251
+ workspace?: string;
236
252
  progress?: TaskProgress;
237
253
  stats: TaskStats;
238
254
  result: TaskResult;
@@ -271,6 +287,8 @@ export interface TaskDetail extends TaskSummary {
271
287
  states: Record<string, PaperState>;
272
288
  /** 建立任务时继承筛读状态的来源任务。 */
273
289
  fromTaskId?: string;
290
+ /** PDF 实际保存目录(供面板悬停提示)。 */
291
+ downloadDir?: string;
274
292
  result: {
275
293
  totalLoaded: number;
276
294
  sources: SourceStatus[];
@@ -335,7 +353,9 @@ export interface TaskStatsReport {
335
353
  byYear: YearStat[];
336
354
  byType: CountEntry[];
337
355
  byLanguage: CountEntry[];
356
+ /** 出现过的出处,按命中数降序;筛选项直接用这份清单(最多 500 条)。 */
338
357
  topVenues: CountEntry[];
358
+ /** 出现过的作者,按命中数降序;筛选项直接用这份清单(最多 500 条)。 */
339
359
  topAuthors: CountEntry[];
340
360
  metrics: {
341
361
  oaRate: number;
@@ -365,6 +385,7 @@ export interface FullTextCoverage {
365
385
  unknown: number;
366
386
  available: number;
367
387
  downloaded: number;
388
+ missing: number;
368
389
  htmlOnly: number;
369
390
  paywalled: number;
370
391
  blocked: number;
@@ -376,10 +397,6 @@ export interface AnnotationInput {
376
397
  screening?: Screening;
377
398
  /** `null` 表示移出分组。 */
378
399
  groupId?: string | null;
379
- /** `null` 表示清除必读顺序。 */
380
- shortlistOrder?: number | null;
381
- note?: string;
382
- tags?: string[];
383
400
  /** 全文状态由 Host 下载/同步流程写入,面板也可显示与筛选。 */
384
401
  pdfStatus?: PdfStatus;
385
402
  pdfError?: string;
@@ -395,6 +412,8 @@ export interface PdfDownloadResult {
395
412
  path?: string;
396
413
  url?: string;
397
414
  error?: string;
415
+ /** 失败根因;成功时缺省。 */
416
+ reason?: PdfFailureReason;
398
417
  }
399
418
  /** Zotero connector 探测结果。 */
400
419
  export interface ZoteroProbeResult {
@@ -436,6 +455,8 @@ export interface PaperDetail {
436
455
  paper: Paper;
437
456
  state?: PaperState;
438
457
  groups: TaskGroup[];
458
+ /** PDF 实际保存目录(供面板悬停提示)。 */
459
+ downloadDir?: string;
439
460
  }
440
461
  /** PubTator 标注的单个实体(按标识符与原文去重)。 */
441
462
  export interface PaperEntity {
@@ -537,6 +558,7 @@ export interface HealthInfo {
537
558
  keys: {
538
559
  ncbi: boolean;
539
560
  s2: boolean;
561
+ serpapi: boolean;
540
562
  };
541
563
  settings: {
542
564
  defaultSources: SourceId[];
@@ -3,7 +3,12 @@
3
3
  *
4
4
  * 本文件同时被 Host 与 Client 引用,因此不得引入任何 Node 内置模块。
5
5
  */
6
- /** 已接入的免费数据源 id;顺序即聚合展示顺序。 */
6
+ /**
7
+ * 已接入的数据源 id;顺序即聚合展示顺序。
8
+ *
9
+ * 除 `googlescholar` 外均为免费公开 API;`googlescholar` 依赖用户自备的
10
+ * SerpAPI Key,未配置时默认不参与检索。
11
+ */
7
12
  export const SOURCE_IDS = [
8
13
  'pubmed',
9
14
  'europepmc',
@@ -12,4 +17,23 @@ export const SOURCE_IDS = [
12
17
  'semanticscholar',
13
18
  'arxiv',
14
19
  'pubtator',
20
+ 'googlescholar',
21
+ ];
22
+ /** 语言候选项;顺序即下拉与胶囊的展示顺序。 */
23
+ export const TASK_LANGUAGES = [
24
+ 'any',
25
+ 'eng',
26
+ 'chi',
27
+ 'jpn',
28
+ 'kor',
29
+ 'fre',
30
+ 'ger',
31
+ 'spa',
32
+ 'rus',
33
+ 'ita',
34
+ 'por',
15
35
  ];
36
+ /** 是否需要引导用户到浏览器手动保存。 */
37
+ export function isManualPdfFailure(reason) {
38
+ return reason !== undefined && reason !== 'failed';
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-litsearch",
3
- "version": "0.2.0",
3
+ "version": "0.3.5",
4
4
  "description": "Search free scholarly literature sources from DSH. The model can search OpenAlex, Crossref and more with native tools, while you can run the same searches in a sidebar panel and send any paper into the conversation.",
5
5
  "keywords": [
6
6
  "dsh",