dsh-plugin-litsearch 0.2.0 → 0.3.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.
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;
@@ -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;
@@ -233,6 +241,8 @@ export interface SearchTask {
233
241
  /** 任务级研究笔记。 */
234
242
  notes: string;
235
243
  groups: TaskGroup[];
244
+ /** 关联的会话工作目录;PDF 默认下载到其下的 dsh-litsearch/。 */
245
+ workspace?: string;
236
246
  progress?: TaskProgress;
237
247
  stats: TaskStats;
238
248
  result: TaskResult;
@@ -335,7 +345,9 @@ export interface TaskStatsReport {
335
345
  byYear: YearStat[];
336
346
  byType: CountEntry[];
337
347
  byLanguage: CountEntry[];
348
+ /** 出现过的出处,按命中数降序;筛选项直接用这份清单(最多 500 条)。 */
338
349
  topVenues: CountEntry[];
350
+ /** 出现过的作者,按命中数降序;筛选项直接用这份清单(最多 500 条)。 */
339
351
  topAuthors: CountEntry[];
340
352
  metrics: {
341
353
  oaRate: number;
@@ -537,6 +549,7 @@ export interface HealthInfo {
537
549
  keys: {
538
550
  ncbi: boolean;
539
551
  s2: boolean;
552
+ serpapi: boolean;
540
553
  };
541
554
  settings: {
542
555
  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,19 @@ 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
  ];
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.0",
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",