dsh-plugin-litsearch 0.1.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/README.md +35 -13
- package/client/client.js +4397 -1953
- package/lib/host/events.d.ts +3 -0
- package/lib/host/events.js +25 -1
- package/lib/host/paths.d.ts +44 -0
- package/lib/host/paths.js +129 -0
- package/lib/host/pdf/root.d.ts +11 -0
- package/lib/host/pdf/root.js +27 -0
- package/lib/host/pdf/store.d.ts +13 -5
- package/lib/host/pdf/store.js +62 -17
- package/lib/host/router.js +35 -11
- package/lib/host/tasks/filter.d.ts +5 -1
- package/lib/host/tasks/filter.js +49 -4
- package/lib/host/tasks/runner.js +1 -8
- package/lib/host/tasks/service.d.ts +34 -15
- package/lib/host/tasks/service.js +180 -53
- package/lib/host/tasks/stats.js +6 -4
- package/lib/host/tasks/store.d.ts +24 -4
- package/lib/host/tasks/store.js +64 -6
- package/lib/host/tools.d.ts +13 -6
- package/lib/host/tools.js +73 -29
- package/lib/host/workspace.d.ts +20 -0
- package/lib/host/workspace.js +32 -0
- package/lib/index.d.ts +17 -8
- package/lib/index.js +33 -11
- package/lib/search/aggregate.js +1 -0
- package/lib/search/config.d.ts +17 -8
- package/lib/search/config.js +16 -8
- package/lib/search/identity.js +1 -0
- package/lib/search/providers/googlescholar.d.ts +12 -0
- package/lib/search/providers/googlescholar.js +154 -0
- package/lib/search/providers/index.js +2 -0
- package/lib/search/query-adapter.js +3 -0
- package/lib/search/rate-limit.js +2 -1
- package/lib/search/types.d.ts +27 -5
- package/lib/search/types.js +21 -1
- package/package.json +1 -1
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 {
|
|
22
|
-
import {
|
|
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([...
|
|
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(
|
|
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(
|
|
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()
|
|
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 ? [...
|
|
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
|
|
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
|
+
}
|
package/lib/search/aggregate.js
CHANGED
package/lib/search/config.d.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -33,19 +40,16 @@ export declare const DEFAULT_RETENTION_COUNT = 120;
|
|
|
33
40
|
export declare const DEFAULT_TASK_MAX_PAPERS = 1500;
|
|
34
41
|
/** 单任务保留的检索轮数上限;只记元数据,超出后淘汰最旧的轮次。 */
|
|
35
42
|
export declare const DEFAULT_TASK_MAX_ROUNDS = 30;
|
|
36
|
-
/**
|
|
37
|
-
* Host 侧「当前任务」记忆有效期(毫秒)。
|
|
38
|
-
*
|
|
39
|
-
* 工具执行上下文拿不到会话 id,只能用进程内最近使用的任务做降级;
|
|
40
|
-
* 超过该时长没有再使用就自动失效,避免隔了很久的新主题被误并进旧任务。
|
|
41
|
-
*/
|
|
42
|
-
export declare const CURRENT_TASK_TTL_MS: number;
|
|
43
43
|
/** 各源默认最小请求间隔(毫秒);PubMed/S2/arXiv 可由设置覆盖。 */
|
|
44
44
|
export declare const DEFAULT_PUBMED_INTERVAL_MS = 350;
|
|
45
45
|
export declare const DEFAULT_S2_INTERVAL_MS = 3000;
|
|
46
46
|
export declare const DEFAULT_ARXIV_INTERVAL_MS = 3000;
|
|
47
|
+
/** SerpAPI 按次计费,保守限制为 1 次/秒。 */
|
|
48
|
+
export declare const DEFAULT_SERPAPI_INTERVAL_MS = 1000;
|
|
47
49
|
/** 全文下载默认设置。 */
|
|
48
50
|
export declare const DEFAULT_PDF_MAX_MB = 30;
|
|
51
|
+
/** PDF 去重缓存总量上限(MB);0 表示关闭自动清理。 */
|
|
52
|
+
export declare const DEFAULT_PDF_CACHE_MAX_MB = 512;
|
|
49
53
|
export declare const DEFAULT_AUTO_DOWNLOAD_PDF = false;
|
|
50
54
|
export declare const DEFAULT_ENABLE_EPMC_FULLTEXT = true;
|
|
51
55
|
/** Zotero connector 默认设置。 */
|
|
@@ -57,6 +61,8 @@ export declare const DEFAULT_ZOTERO_TAG = "dsh-litsearch";
|
|
|
57
61
|
export declare const MAX_ABSTRACT_CHARS = 800;
|
|
58
62
|
/** 写端点的自定义请求头(与 JSON content-type 一起构成非简单请求)。 */
|
|
59
63
|
export declare const WRITE_HEADER = "x-dsh-litsearch-write";
|
|
64
|
+
/** 面板请求携带的会话 id;Host 用它解析会话工作目录。 */
|
|
65
|
+
export declare const SESSION_HEADER = "x-dsh-litsearch-session";
|
|
60
66
|
/** Host 路由前缀(Client fetch 路径与 handler 的剥离基准,带尾斜杠)。 */
|
|
61
67
|
export declare const ROUTE_PREFIX = "/dsh-litsearch/api/";
|
|
62
68
|
/**
|
|
@@ -83,13 +89,16 @@ export interface LitsearchSettings {
|
|
|
83
89
|
ncbiApiKey: string;
|
|
84
90
|
/** 可选 Semantic Scholar API Key;提高配额。 */
|
|
85
91
|
s2ApiKey: string;
|
|
92
|
+
/** 可选 SerpAPI Key;配置后启用 Google Scholar 数据源。 */
|
|
93
|
+
serpApiKey: string;
|
|
86
94
|
/** 各源最小请求间隔覆盖值。 */
|
|
87
95
|
pubmedIntervalMs: number;
|
|
88
96
|
s2IntervalMs: number;
|
|
89
97
|
arxivIntervalMs: number;
|
|
90
|
-
/** PDF 下载根目录;空串由 Host
|
|
98
|
+
/** PDF 下载根目录;空串由 Host 回落到会话工作目录下的 dsh-litsearch/。 */
|
|
91
99
|
pdfRoot: string;
|
|
92
100
|
pdfMaxMb: number;
|
|
101
|
+
pdfCacheMaxMb: number;
|
|
93
102
|
autoDownloadPdf: boolean;
|
|
94
103
|
enableEpmcFulltext: boolean;
|
|
95
104
|
zoteroEnabled: boolean;
|
package/lib/search/config.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -33,19 +40,16 @@ export const DEFAULT_RETENTION_COUNT = 120;
|
|
|
33
40
|
export const DEFAULT_TASK_MAX_PAPERS = 1500;
|
|
34
41
|
/** 单任务保留的检索轮数上限;只记元数据,超出后淘汰最旧的轮次。 */
|
|
35
42
|
export const DEFAULT_TASK_MAX_ROUNDS = 30;
|
|
36
|
-
/**
|
|
37
|
-
* Host 侧「当前任务」记忆有效期(毫秒)。
|
|
38
|
-
*
|
|
39
|
-
* 工具执行上下文拿不到会话 id,只能用进程内最近使用的任务做降级;
|
|
40
|
-
* 超过该时长没有再使用就自动失效,避免隔了很久的新主题被误并进旧任务。
|
|
41
|
-
*/
|
|
42
|
-
export const CURRENT_TASK_TTL_MS = 15 * 60_000;
|
|
43
43
|
/** 各源默认最小请求间隔(毫秒);PubMed/S2/arXiv 可由设置覆盖。 */
|
|
44
44
|
export const DEFAULT_PUBMED_INTERVAL_MS = 350;
|
|
45
45
|
export const DEFAULT_S2_INTERVAL_MS = 3000;
|
|
46
46
|
export const DEFAULT_ARXIV_INTERVAL_MS = 3000;
|
|
47
|
+
/** SerpAPI 按次计费,保守限制为 1 次/秒。 */
|
|
48
|
+
export const DEFAULT_SERPAPI_INTERVAL_MS = 1000;
|
|
47
49
|
/** 全文下载默认设置。 */
|
|
48
50
|
export const DEFAULT_PDF_MAX_MB = 30;
|
|
51
|
+
/** PDF 去重缓存总量上限(MB);0 表示关闭自动清理。 */
|
|
52
|
+
export const DEFAULT_PDF_CACHE_MAX_MB = 512;
|
|
49
53
|
export const DEFAULT_AUTO_DOWNLOAD_PDF = false;
|
|
50
54
|
export const DEFAULT_ENABLE_EPMC_FULLTEXT = true;
|
|
51
55
|
/** Zotero connector 默认设置。 */
|
|
@@ -57,6 +61,8 @@ export const DEFAULT_ZOTERO_TAG = 'dsh-litsearch';
|
|
|
57
61
|
export const MAX_ABSTRACT_CHARS = 800;
|
|
58
62
|
/** 写端点的自定义请求头(与 JSON content-type 一起构成非简单请求)。 */
|
|
59
63
|
export const WRITE_HEADER = 'x-dsh-litsearch-write';
|
|
64
|
+
/** 面板请求携带的会话 id;Host 用它解析会话工作目录。 */
|
|
65
|
+
export const SESSION_HEADER = 'x-dsh-litsearch-session';
|
|
60
66
|
/** Host 路由前缀(Client fetch 路径与 handler 的剥离基准,带尾斜杠)。 */
|
|
61
67
|
export const ROUTE_PREFIX = '/dsh-litsearch/api/';
|
|
62
68
|
/**
|
|
@@ -71,7 +77,7 @@ let readSettings = () => ({
|
|
|
71
77
|
contactEmail: '',
|
|
72
78
|
defaultPageSize: DEFAULT_PAGE_SIZE,
|
|
73
79
|
requestTimeoutMs: REQUEST_TIMEOUT_MS,
|
|
74
|
-
defaultSources: [...
|
|
80
|
+
defaultSources: [...DEFAULT_SOURCE_IDS],
|
|
75
81
|
defaultSort: DEFAULT_SORT,
|
|
76
82
|
defaultLanguage: DEFAULT_LANGUAGE,
|
|
77
83
|
autoOpenTaskTab: DEFAULT_AUTO_OPEN,
|
|
@@ -81,11 +87,13 @@ let readSettings = () => ({
|
|
|
81
87
|
taskMaxPapers: DEFAULT_TASK_MAX_PAPERS,
|
|
82
88
|
ncbiApiKey: '',
|
|
83
89
|
s2ApiKey: '',
|
|
90
|
+
serpApiKey: '',
|
|
84
91
|
pubmedIntervalMs: DEFAULT_PUBMED_INTERVAL_MS,
|
|
85
92
|
s2IntervalMs: DEFAULT_S2_INTERVAL_MS,
|
|
86
93
|
arxivIntervalMs: DEFAULT_ARXIV_INTERVAL_MS,
|
|
87
94
|
pdfRoot: '',
|
|
88
95
|
pdfMaxMb: DEFAULT_PDF_MAX_MB,
|
|
96
|
+
pdfCacheMaxMb: DEFAULT_PDF_CACHE_MAX_MB,
|
|
89
97
|
autoDownloadPdf: DEFAULT_AUTO_DOWNLOAD_PDF,
|
|
90
98
|
enableEpmcFulltext: DEFAULT_ENABLE_EPMC_FULLTEXT,
|
|
91
99
|
zoteroEnabled: DEFAULT_ZOTERO_ENABLED,
|
package/lib/search/identity.js
CHANGED
|
@@ -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]` 一类字段标签与通配符,保留引号短语与布尔词。 */
|
package/lib/search/rate-limit.js
CHANGED
|
@@ -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;
|
package/lib/search/types.d.ts
CHANGED
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 本文件同时被 Host 与 Client 引用,因此不得引入任何 Node 内置模块。
|
|
5
5
|
*/
|
|
6
|
-
/**
|
|
7
|
-
|
|
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;
|
|
@@ -222,7 +230,6 @@ export interface SearchTask {
|
|
|
222
230
|
conditions: TaskConditions;
|
|
223
231
|
status: TaskStatus;
|
|
224
232
|
origin: TaskOrigin;
|
|
225
|
-
sessionId?: string;
|
|
226
233
|
fromTaskId?: string;
|
|
227
234
|
/** 每次状态/条件变化自增。 */
|
|
228
235
|
rev: number;
|
|
@@ -234,6 +241,8 @@ export interface SearchTask {
|
|
|
234
241
|
/** 任务级研究笔记。 */
|
|
235
242
|
notes: string;
|
|
236
243
|
groups: TaskGroup[];
|
|
244
|
+
/** 关联的会话工作目录;PDF 默认下载到其下的 dsh-litsearch/。 */
|
|
245
|
+
workspace?: string;
|
|
237
246
|
progress?: TaskProgress;
|
|
238
247
|
stats: TaskStats;
|
|
239
248
|
result: TaskResult;
|
|
@@ -336,7 +345,9 @@ export interface TaskStatsReport {
|
|
|
336
345
|
byYear: YearStat[];
|
|
337
346
|
byType: CountEntry[];
|
|
338
347
|
byLanguage: CountEntry[];
|
|
348
|
+
/** 出现过的出处,按命中数降序;筛选项直接用这份清单(最多 500 条)。 */
|
|
339
349
|
topVenues: CountEntry[];
|
|
350
|
+
/** 出现过的作者,按命中数降序;筛选项直接用这份清单(最多 500 条)。 */
|
|
340
351
|
topAuthors: CountEntry[];
|
|
341
352
|
metrics: {
|
|
342
353
|
oaRate: number;
|
|
@@ -512,11 +523,21 @@ export interface ProbeResult {
|
|
|
512
523
|
error?: string;
|
|
513
524
|
cooldownUntil?: string;
|
|
514
525
|
}
|
|
526
|
+
/** 客户端需要打开/聚焦的任务(模型刚在当前任务里发起了一轮检索)。 */
|
|
527
|
+
export interface TaskFocus {
|
|
528
|
+
taskId: string;
|
|
529
|
+
title: string;
|
|
530
|
+
/** 请求时间(epoch ms);客户端据此丢弃过期的聚焦请求。 */
|
|
531
|
+
at: number;
|
|
532
|
+
/** 发起检索的会话;客户端只在该会话可见时打开,避免抢占其他对话的面板。 */
|
|
533
|
+
sessionId?: string;
|
|
534
|
+
}
|
|
515
535
|
/** `/events` 的响应;`reset` 表示客户端的 sinceRev 比服务端新(宿主重启)。 */
|
|
516
536
|
export interface EventsPage {
|
|
517
537
|
rev: number;
|
|
518
538
|
reset: boolean;
|
|
519
539
|
tasks: TaskSummary[];
|
|
540
|
+
focus?: TaskFocus;
|
|
520
541
|
}
|
|
521
542
|
/** `/health` 的响应:数据源能力 + 客户端需要的默认设置(不含密钥)。 */
|
|
522
543
|
export interface HealthInfo {
|
|
@@ -528,6 +549,7 @@ export interface HealthInfo {
|
|
|
528
549
|
keys: {
|
|
529
550
|
ncbi: boolean;
|
|
530
551
|
s2: boolean;
|
|
552
|
+
serpapi: boolean;
|
|
531
553
|
};
|
|
532
554
|
settings: {
|
|
533
555
|
defaultSources: SourceId[];
|
package/lib/search/types.js
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 本文件同时被 Host 与 Client 引用,因此不得引入任何 Node 内置模块。
|
|
5
5
|
*/
|
|
6
|
-
/**
|
|
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.
|
|
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",
|