dsh-plugin-litsearch 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +157 -2
- package/client/client.js +9036 -0
- package/cordis.patch.yml +10 -0
- package/lib/host/events.d.ts +19 -0
- package/lib/host/events.js +58 -0
- package/lib/host/export/bibtex.d.ts +3 -0
- package/lib/host/export/bibtex.js +42 -0
- package/lib/host/export/common.d.ts +18 -0
- package/lib/host/export/common.js +25 -0
- package/lib/host/export/csv.d.ts +8 -0
- package/lib/host/export/csv.js +68 -0
- package/lib/host/export/index.d.ts +5 -0
- package/lib/host/export/index.js +25 -0
- package/lib/host/export/markdown.d.ts +6 -0
- package/lib/host/export/markdown.js +120 -0
- package/lib/host/export/ris.d.ts +3 -0
- package/lib/host/export/ris.js +35 -0
- package/lib/host/paths.d.ts +43 -0
- package/lib/host/paths.js +128 -0
- package/lib/host/pdf/store.d.ts +57 -0
- package/lib/host/pdf/store.js +291 -0
- package/lib/host/router.d.ts +20 -0
- package/lib/host/router.js +852 -0
- package/lib/host/tasks/compare.d.ts +8 -0
- package/lib/host/tasks/compare.js +49 -0
- package/lib/host/tasks/filter.d.ts +42 -0
- package/lib/host/tasks/filter.js +100 -0
- package/lib/host/tasks/rounds.d.ts +28 -0
- package/lib/host/tasks/rounds.js +74 -0
- package/lib/host/tasks/runner.d.ts +29 -0
- package/lib/host/tasks/runner.js +208 -0
- package/lib/host/tasks/service.d.ts +171 -0
- package/lib/host/tasks/service.js +976 -0
- package/lib/host/tasks/states.d.ts +28 -0
- package/lib/host/tasks/states.js +223 -0
- package/lib/host/tasks/stats.d.ts +13 -0
- package/lib/host/tasks/stats.js +233 -0
- package/lib/host/tasks/store.d.ts +64 -0
- package/lib/host/tasks/store.js +280 -0
- package/lib/host/tasks/summary.d.ts +9 -0
- package/lib/host/tasks/summary.js +54 -0
- package/lib/host/tools.d.ts +47 -0
- package/lib/host/tools.js +1138 -0
- package/lib/host/zotero/connector.d.ts +46 -0
- package/lib/host/zotero/connector.js +263 -0
- package/lib/index.d.ts +107 -0
- package/lib/index.js +153 -0
- package/lib/search/aggregate.d.ts +17 -0
- package/lib/search/aggregate.js +60 -0
- package/lib/search/config.d.ts +96 -0
- package/lib/search/config.js +96 -0
- package/lib/search/errors.d.ts +22 -0
- package/lib/search/errors.js +24 -0
- package/lib/search/health.d.ts +13 -0
- package/lib/search/health.js +53 -0
- package/lib/search/http.d.ts +13 -0
- package/lib/search/http.js +81 -0
- package/lib/search/identity.d.ts +27 -0
- package/lib/search/identity.js +154 -0
- package/lib/search/normalize.d.ts +17 -0
- package/lib/search/normalize.js +47 -0
- package/lib/search/oa.d.ts +21 -0
- package/lib/search/oa.js +149 -0
- package/lib/search/providers/arxiv.d.ts +9 -0
- package/lib/search/providers/arxiv.js +99 -0
- package/lib/search/providers/crossref.d.ts +8 -0
- package/lib/search/providers/crossref.js +107 -0
- package/lib/search/providers/europepmc.d.ts +8 -0
- package/lib/search/providers/europepmc.js +105 -0
- package/lib/search/providers/index.d.ts +13 -0
- package/lib/search/providers/index.js +34 -0
- package/lib/search/providers/openalex.d.ts +8 -0
- package/lib/search/providers/openalex.js +109 -0
- package/lib/search/providers/pubmed.d.ts +10 -0
- package/lib/search/providers/pubmed.js +143 -0
- package/lib/search/providers/pubtator.d.ts +9 -0
- package/lib/search/providers/pubtator.js +79 -0
- package/lib/search/providers/semanticscholar.d.ts +9 -0
- package/lib/search/providers/semanticscholar.js +97 -0
- package/lib/search/pubtator-entities.d.ts +9 -0
- package/lib/search/pubtator-entities.js +19 -0
- package/lib/search/pubtator-parse.d.ts +3 -0
- package/lib/search/pubtator-parse.js +42 -0
- package/lib/search/query-adapter.d.ts +15 -0
- package/lib/search/query-adapter.js +69 -0
- package/lib/search/rate-limit.d.ts +31 -0
- package/lib/search/rate-limit.js +219 -0
- package/lib/search/types.d.ts +577 -0
- package/lib/search/types.js +15 -0
- package/lib/search/xml.d.ts +20 -0
- package/lib/search/xml.js +63 -0
- package/package.json +75 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zotero Connector 协议封装(本机 `http://127.0.0.1:23119`)。
|
|
3
|
+
*
|
|
4
|
+
* 只允许回环地址:connector 是桌面端本地服务,绝不把用户数据发到外部主机。
|
|
5
|
+
* 任何失败都转成可读的一句话,由调用方汇总成「未运行 / 条目保存失败 / 附件失败」。
|
|
6
|
+
*/
|
|
7
|
+
import type { Paper, ZoteroProbeResult, ZoteroSyncResult } from '../../search/types.js';
|
|
8
|
+
export interface ZoteroConnectorOptions {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
attachPdf: boolean;
|
|
11
|
+
tag: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ZoteroSyncInput {
|
|
14
|
+
paper: Paper;
|
|
15
|
+
tags: readonly string[];
|
|
16
|
+
/** 已下载的本地 PDF;缺省表示没有。 */
|
|
17
|
+
pdfPath?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare class ZoteroConnector {
|
|
20
|
+
private readonly baseUrl;
|
|
21
|
+
constructor(baseUrl: string);
|
|
22
|
+
probe(): Promise<ZoteroProbeResult>;
|
|
23
|
+
/** 当前 Zotero 里选中的分类;未选中时返回 null。 */
|
|
24
|
+
selectedCollection(): Promise<SelectedCollection | null>;
|
|
25
|
+
/**
|
|
26
|
+
* 保存条目并可选上传本地 PDF。
|
|
27
|
+
*
|
|
28
|
+
* 逐篇处理:单篇条目失败只记入 failed,不中断其余篇;附件失败单独计入
|
|
29
|
+
* messages,条目仍算保存成功。
|
|
30
|
+
*/
|
|
31
|
+
sync(inputs: readonly ZoteroSyncInput[], options: ZoteroConnectorOptions): Promise<ZoteroSyncResult>;
|
|
32
|
+
/**
|
|
33
|
+
* 保存条目。Zotero 返回 201 空响应,条目 id 由请求里的 `id` 字段决定,
|
|
34
|
+
* 后续 `saveAttachment` 用它作为 parentItemID,并复用同一 sessionID。
|
|
35
|
+
*/
|
|
36
|
+
private saveItem;
|
|
37
|
+
private saveAttachment;
|
|
38
|
+
private request;
|
|
39
|
+
}
|
|
40
|
+
interface SelectedCollection {
|
|
41
|
+
id: number | string;
|
|
42
|
+
name: string;
|
|
43
|
+
libraryID?: number;
|
|
44
|
+
filesEditable?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zotero Connector 协议封装(本机 `http://127.0.0.1:23119`)。
|
|
3
|
+
*
|
|
4
|
+
* 只允许回环地址:connector 是桌面端本地服务,绝不把用户数据发到外部主机。
|
|
5
|
+
* 任何失败都转成可读的一句话,由调用方汇总成「未运行 / 条目保存失败 / 附件失败」。
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { isIP } from 'node:net';
|
|
10
|
+
const PING_TIMEOUT_MS = 3_000;
|
|
11
|
+
const SAVE_TIMEOUT_MS = 20_000;
|
|
12
|
+
const ATTACH_TIMEOUT_MS = 60_000;
|
|
13
|
+
const MAX_SYNC_ITEMS = 50;
|
|
14
|
+
export class ZoteroConnector {
|
|
15
|
+
baseUrl;
|
|
16
|
+
constructor(baseUrl) {
|
|
17
|
+
// 允许 http://127.0.0.1:23119 / http://localhost:23119 / [::1]。
|
|
18
|
+
const trimmed = baseUrl.trim().replace(/\/+$/u, '');
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(trimmed);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error(`Zotero 地址不合法:${baseUrl}`);
|
|
25
|
+
}
|
|
26
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
27
|
+
throw new Error('Zotero 地址只支持 http/https');
|
|
28
|
+
}
|
|
29
|
+
const host = url.hostname.replace(/^\[|\]$/gu, '').toLowerCase();
|
|
30
|
+
const loopback = host === 'localhost' ||
|
|
31
|
+
host === '::1' ||
|
|
32
|
+
(isIP(host) === 4 && host.split('.')[0] === '127');
|
|
33
|
+
if (!loopback)
|
|
34
|
+
throw new Error('Zotero connector 只监听本机,地址必须是 127.0.0.1 或 localhost');
|
|
35
|
+
this.baseUrl = trimmed;
|
|
36
|
+
}
|
|
37
|
+
async probe() {
|
|
38
|
+
try {
|
|
39
|
+
const response = await this.request('/connector/ping', {
|
|
40
|
+
method: 'GET',
|
|
41
|
+
timeoutMs: PING_TIMEOUT_MS,
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
return { ok: false, error: `connector 返回 HTTP ${response.status}` };
|
|
45
|
+
}
|
|
46
|
+
const version = response.headers.get('x-zotero-version') ?? undefined;
|
|
47
|
+
const collection = await this.selectedCollection().catch(() => null);
|
|
48
|
+
const result = { ok: true };
|
|
49
|
+
if (version !== undefined)
|
|
50
|
+
result.version = version;
|
|
51
|
+
if (collection !== null) {
|
|
52
|
+
result.collection = {
|
|
53
|
+
id: collection.id,
|
|
54
|
+
name: collection.name,
|
|
55
|
+
...(collection.libraryID === undefined ? {} : { libraryID: collection.libraryID }),
|
|
56
|
+
...(collection.filesEditable === undefined
|
|
57
|
+
? {}
|
|
58
|
+
: { filesEditable: collection.filesEditable }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
return { ok: false, error: describe(error) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** 当前 Zotero 里选中的分类;未选中时返回 null。 */
|
|
68
|
+
async selectedCollection() {
|
|
69
|
+
const response = await this.request('/connector/getSelectedCollection', {
|
|
70
|
+
method: 'GET',
|
|
71
|
+
timeoutMs: PING_TIMEOUT_MS,
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok)
|
|
74
|
+
return null;
|
|
75
|
+
const body = (await response.json().catch(() => null));
|
|
76
|
+
if (body === null || body === false || typeof body !== 'object')
|
|
77
|
+
return null;
|
|
78
|
+
const record = body;
|
|
79
|
+
const id = record['id'];
|
|
80
|
+
const name = record['name'];
|
|
81
|
+
if ((typeof id !== 'number' && typeof id !== 'string') || typeof name !== 'string')
|
|
82
|
+
return null;
|
|
83
|
+
const collection = { id, name };
|
|
84
|
+
if (typeof record['libraryID'] === 'number')
|
|
85
|
+
collection.libraryID = record['libraryID'];
|
|
86
|
+
if (typeof record['filesEditable'] === 'boolean') {
|
|
87
|
+
collection.filesEditable = record['filesEditable'];
|
|
88
|
+
}
|
|
89
|
+
return collection;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 保存条目并可选上传本地 PDF。
|
|
93
|
+
*
|
|
94
|
+
* 逐篇处理:单篇条目失败只记入 failed,不中断其余篇;附件失败单独计入
|
|
95
|
+
* messages,条目仍算保存成功。
|
|
96
|
+
*/
|
|
97
|
+
async sync(inputs, options) {
|
|
98
|
+
if (inputs.length === 0)
|
|
99
|
+
return { ok: true, saved: 0, attached: 0, failed: 0, messages: [] };
|
|
100
|
+
if (inputs.length > MAX_SYNC_ITEMS) {
|
|
101
|
+
throw new Error(`一次最多同步 ${MAX_SYNC_ITEMS} 篇`);
|
|
102
|
+
}
|
|
103
|
+
const result = {
|
|
104
|
+
ok: true,
|
|
105
|
+
saved: 0,
|
|
106
|
+
attached: 0,
|
|
107
|
+
failed: 0,
|
|
108
|
+
messages: [],
|
|
109
|
+
};
|
|
110
|
+
for (const input of inputs) {
|
|
111
|
+
const sessionID = newSessionID();
|
|
112
|
+
const item = toZoteroItem(input.paper, input.tags, options.tag);
|
|
113
|
+
try {
|
|
114
|
+
await this.saveItem(item, sessionID);
|
|
115
|
+
result.saved += 1;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
result.failed += 1;
|
|
119
|
+
result.messages.push(`${shortTitle(input.paper.title)}:条目保存失败(${describe(error)})`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!options.attachPdf || input.pdfPath === undefined)
|
|
123
|
+
continue;
|
|
124
|
+
const parentId = typeof item['id'] === 'string' ? item['id'] : null;
|
|
125
|
+
if (parentId === null) {
|
|
126
|
+
result.messages.push(`${shortTitle(input.paper.title)}:条目已保存,但缺少条目 id,未上传 PDF`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const buffer = await readFile(input.pdfPath);
|
|
131
|
+
await this.saveAttachment(parentId, sessionID, input.paper, buffer);
|
|
132
|
+
result.attached += 1;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
result.messages.push(`${shortTitle(input.paper.title)}:PDF 附件失败(${describe(error)})`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
result.ok = result.failed === 0;
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 保存条目。Zotero 返回 201 空响应,条目 id 由请求里的 `id` 字段决定,
|
|
143
|
+
* 后续 `saveAttachment` 用它作为 parentItemID,并复用同一 sessionID。
|
|
144
|
+
*/
|
|
145
|
+
async saveItem(item, sessionID) {
|
|
146
|
+
const response = await this.request('/connector/saveItems', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
timeoutMs: SAVE_TIMEOUT_MS,
|
|
149
|
+
headers: {
|
|
150
|
+
'content-type': 'application/json',
|
|
151
|
+
'x-zotero-connector-api-version': '3',
|
|
152
|
+
},
|
|
153
|
+
body: JSON.stringify({
|
|
154
|
+
sessionID,
|
|
155
|
+
uri: `https://www.zotero.org/connector/${sessionID}`,
|
|
156
|
+
items: [item],
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
if (!response.ok)
|
|
160
|
+
throw new Error(`connector 返回 HTTP ${response.status}`);
|
|
161
|
+
}
|
|
162
|
+
async saveAttachment(parentId, sessionID, paper, buffer) {
|
|
163
|
+
const metadata = JSON.stringify({
|
|
164
|
+
sessionID,
|
|
165
|
+
parentItemID: parentId,
|
|
166
|
+
title: attachmentFilename(paper),
|
|
167
|
+
url: paper.pdfUrl !== '' ? paper.pdfUrl : paper.url,
|
|
168
|
+
});
|
|
169
|
+
const response = await this.request('/connector/saveAttachment', {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
timeoutMs: ATTACH_TIMEOUT_MS,
|
|
172
|
+
headers: {
|
|
173
|
+
'content-type': 'application/pdf',
|
|
174
|
+
'content-length': String(buffer.length),
|
|
175
|
+
'x-metadata': metadata,
|
|
176
|
+
},
|
|
177
|
+
body: Uint8Array.from(buffer),
|
|
178
|
+
});
|
|
179
|
+
if (!response.ok)
|
|
180
|
+
throw new Error(`connector 返回 HTTP ${response.status}`);
|
|
181
|
+
}
|
|
182
|
+
async request(path, init) {
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const timer = setTimeout(() => controller.abort(), init.timeoutMs);
|
|
185
|
+
try {
|
|
186
|
+
return await fetch(`${this.baseUrl}${path}`, {
|
|
187
|
+
method: init.method,
|
|
188
|
+
...(init.headers === undefined ? {} : { headers: init.headers }),
|
|
189
|
+
...(init.body === undefined ? {} : { body: init.body }),
|
|
190
|
+
signal: controller.signal,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (controller.signal.aborted) {
|
|
195
|
+
throw new Error('连接超时;请确认 Zotero 正在运行且已开启 connector 服务');
|
|
196
|
+
}
|
|
197
|
+
throw new Error(`无法连接 Zotero(${this.baseUrl});请确认 Zotero 桌面端已启动并允许 connector`);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function toZoteroItem(paper, tags, defaultTag) {
|
|
205
|
+
const item = {
|
|
206
|
+
id: paper.paperId,
|
|
207
|
+
itemType: paper.type === '' ? 'journalArticle' : mapItemType(paper.type),
|
|
208
|
+
title: paper.title,
|
|
209
|
+
creators: paper.authors.map((name) => {
|
|
210
|
+
const parts = name.trim().split(/\s+/u);
|
|
211
|
+
const lastName = parts.length > 1 ? parts.at(-1) ?? name : name;
|
|
212
|
+
const firstName = parts.length > 1 ? parts.slice(0, -1).join(' ') : '';
|
|
213
|
+
return { creatorType: 'author', firstName, lastName };
|
|
214
|
+
}),
|
|
215
|
+
date: paper.year === null ? '' : String(paper.year),
|
|
216
|
+
publicationTitle: paper.venue,
|
|
217
|
+
DOI: paper.doi,
|
|
218
|
+
url: paper.url,
|
|
219
|
+
abstractNote: paper.abstract,
|
|
220
|
+
extra: `dsh-litsearch:${paper.paperId}`,
|
|
221
|
+
};
|
|
222
|
+
const merged = new Set();
|
|
223
|
+
for (const tag of [...tags, defaultTag]) {
|
|
224
|
+
const value = tag.trim();
|
|
225
|
+
if (value !== '')
|
|
226
|
+
merged.add(value);
|
|
227
|
+
}
|
|
228
|
+
if (merged.size > 0)
|
|
229
|
+
item['tags'] = [...merged].map((tag) => ({ tag }));
|
|
230
|
+
return item;
|
|
231
|
+
}
|
|
232
|
+
function mapItemType(type) {
|
|
233
|
+
const value = type.toLowerCase();
|
|
234
|
+
if (value.includes('review'))
|
|
235
|
+
return 'journalArticle';
|
|
236
|
+
if (value.includes('preprint') || value.includes('arxiv'))
|
|
237
|
+
return 'preprint';
|
|
238
|
+
if (value.includes('conference') || value.includes('proceedings'))
|
|
239
|
+
return 'conferencePaper';
|
|
240
|
+
if (value.includes('book'))
|
|
241
|
+
return 'book';
|
|
242
|
+
if (value.includes('dataset'))
|
|
243
|
+
return 'dataset';
|
|
244
|
+
return 'journalArticle';
|
|
245
|
+
}
|
|
246
|
+
function newSessionID() {
|
|
247
|
+
return randomUUID().replace(/-/gu, '').slice(0, 16);
|
|
248
|
+
}
|
|
249
|
+
function attachmentFilename(paper) {
|
|
250
|
+
const base = paper.title
|
|
251
|
+
.normalize('NFKC')
|
|
252
|
+
.replace(/[\\/:*?"<>|\r\n]+/gu, ' ')
|
|
253
|
+
.replace(/\s+/gu, ' ')
|
|
254
|
+
.trim()
|
|
255
|
+
.slice(0, 80);
|
|
256
|
+
return `${base === '' ? paper.paperId : base}.pdf`;
|
|
257
|
+
}
|
|
258
|
+
function shortTitle(title) {
|
|
259
|
+
return title.length > 40 ? `${title.slice(0, 40)}…` : title;
|
|
260
|
+
}
|
|
261
|
+
function describe(error) {
|
|
262
|
+
return error instanceof Error ? error.message : String(error);
|
|
263
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-litsearch —— 插件入口(Host 半)。
|
|
3
|
+
*
|
|
4
|
+
* 做三件事:
|
|
5
|
+
* 1. 导出 Schemastery `Config`(逐字段 `.volatile()`),让 dsh「设置中心」能
|
|
6
|
+
* 渲染「文献检索」页;保存后无需重启即生效。
|
|
7
|
+
* 2. 装配 `TaskService`(任务持久化 / 执行 / 事件),并把检索能力注册进 dsh
|
|
8
|
+
* 工具表(`literature_search` / `literature_sources`)。
|
|
9
|
+
* 3. 在宿主 web server 上注册 `/dsh-litsearch/api/*` 端点,供右侧栏面板与
|
|
10
|
+
* 设置页取数。
|
|
11
|
+
*
|
|
12
|
+
* `webServer` / `tools` 的类型不在已安装的 dsh 包里,因此自定义最小结构接口并
|
|
13
|
+
* 强转(与 dsh-plugin-zotero 同法)。
|
|
14
|
+
*/
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
16
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
17
|
+
export declare const name = "dsh-plugin-litsearch";
|
|
18
|
+
/**
|
|
19
|
+
* 插件配置 schema(逐字段 volatile)。
|
|
20
|
+
*
|
|
21
|
+
* volatile 是 dsh 配置表单的硬前提;用 `.volatile()` 后保存只提交新值并
|
|
22
|
+
* 唤醒引用,条目本身不重挂,因此改配置无需重启 dsh。
|
|
23
|
+
*/
|
|
24
|
+
export declare const Config: Schema<Schemastery.ObjectS<NoInfer<{
|
|
25
|
+
/** 作为 OpenAlex / Crossref / NCBI 的联系邮箱;留空也可用。 */
|
|
26
|
+
contactEmail: Schema<string, string, "volatile-defined">;
|
|
27
|
+
/** 可选 NCBI API Key(E-utilities 额度从 3 req/s 提升到 10 req/s)。 */
|
|
28
|
+
ncbiApiKey: Schema<string, string, "volatile-defined">;
|
|
29
|
+
/** 可选 Semantic Scholar API Key。 */
|
|
30
|
+
s2ApiKey: Schema<string, string, "volatile-defined">;
|
|
31
|
+
/** 各源最小请求间隔(毫秒)。 */
|
|
32
|
+
pubmedIntervalMs: Schema<number, number, "volatile-defined">;
|
|
33
|
+
s2IntervalMs: Schema<number, number, "volatile-defined">;
|
|
34
|
+
arxivIntervalMs: Schema<number, number, "volatile-defined">;
|
|
35
|
+
/** 默认每次加载条数。 */
|
|
36
|
+
defaultPageSize: Schema<number, number, "volatile-defined">;
|
|
37
|
+
/** 单次上游请求超时(毫秒)。 */
|
|
38
|
+
requestTimeoutMs: Schema<number, number, "volatile-defined">;
|
|
39
|
+
/** 默认数据源。 */
|
|
40
|
+
defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, "volatile-defined">;
|
|
41
|
+
defaultSort: Schema<"relevance" | "citations" | "year", "relevance" | "citations" | "year", "volatile-defined">;
|
|
42
|
+
defaultLanguage: Schema<"any" | "eng" | "chi", "any" | "eng" | "chi", "volatile-defined">;
|
|
43
|
+
/** 模型发起检索后是否自动打开任务 tab。 */
|
|
44
|
+
autoOpenTaskTab: Schema<boolean, boolean, "volatile-defined">;
|
|
45
|
+
/** 同时保留的任务 tab 上限。 */
|
|
46
|
+
maxTaskTabs: Schema<number, number, "volatile-defined">;
|
|
47
|
+
/** 任务保留天数与条数(置顶任务不受限)。 */
|
|
48
|
+
taskRetentionDays: Schema<number, number, "volatile-defined">;
|
|
49
|
+
taskRetentionCount: Schema<number, number, "volatile-defined">;
|
|
50
|
+
/** 单任务结果上限。 */
|
|
51
|
+
taskMaxPapers: Schema<number, number, "volatile-defined">;
|
|
52
|
+
/** PDF 下载根目录。 */
|
|
53
|
+
pdfRoot: Schema<string, string, "volatile-defined">;
|
|
54
|
+
/** 单个 PDF 最大体积(MB)。 */
|
|
55
|
+
pdfMaxMb: Schema<number, number, "volatile-defined">;
|
|
56
|
+
/** 检索完成后自动下载缺失 PDF。 */
|
|
57
|
+
autoDownloadPdf: Schema<boolean, boolean, "volatile-defined">;
|
|
58
|
+
/** 无 PDF 时用 Europe PMC HTML 全文兜底。 */
|
|
59
|
+
enableEpmcFulltext: Schema<boolean, boolean, "volatile-defined">;
|
|
60
|
+
/** Zotero 入口开关。 */
|
|
61
|
+
zoteroEnabled: Schema<boolean, boolean, "volatile-defined">;
|
|
62
|
+
zoteroBaseUrl: Schema<string, string, "volatile-defined">;
|
|
63
|
+
zoteroAttachPdf: Schema<boolean, boolean, "volatile-defined">;
|
|
64
|
+
zoteroTag: Schema<string, string, "volatile-defined">;
|
|
65
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
66
|
+
/** 作为 OpenAlex / Crossref / NCBI 的联系邮箱;留空也可用。 */
|
|
67
|
+
contactEmail: Schema<string, string, "volatile-defined">;
|
|
68
|
+
/** 可选 NCBI API Key(E-utilities 额度从 3 req/s 提升到 10 req/s)。 */
|
|
69
|
+
ncbiApiKey: Schema<string, string, "volatile-defined">;
|
|
70
|
+
/** 可选 Semantic Scholar API Key。 */
|
|
71
|
+
s2ApiKey: Schema<string, string, "volatile-defined">;
|
|
72
|
+
/** 各源最小请求间隔(毫秒)。 */
|
|
73
|
+
pubmedIntervalMs: Schema<number, number, "volatile-defined">;
|
|
74
|
+
s2IntervalMs: Schema<number, number, "volatile-defined">;
|
|
75
|
+
arxivIntervalMs: Schema<number, number, "volatile-defined">;
|
|
76
|
+
/** 默认每次加载条数。 */
|
|
77
|
+
defaultPageSize: Schema<number, number, "volatile-defined">;
|
|
78
|
+
/** 单次上游请求超时(毫秒)。 */
|
|
79
|
+
requestTimeoutMs: Schema<number, number, "volatile-defined">;
|
|
80
|
+
/** 默认数据源。 */
|
|
81
|
+
defaultSources: Schema<NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, NoInfer<("pubmed" | "europepmc" | "openalex" | "crossref" | "semanticscholar" | "arxiv" | "pubtator")[]>, "volatile-defined">;
|
|
82
|
+
defaultSort: Schema<"relevance" | "citations" | "year", "relevance" | "citations" | "year", "volatile-defined">;
|
|
83
|
+
defaultLanguage: Schema<"any" | "eng" | "chi", "any" | "eng" | "chi", "volatile-defined">;
|
|
84
|
+
/** 模型发起检索后是否自动打开任务 tab。 */
|
|
85
|
+
autoOpenTaskTab: Schema<boolean, boolean, "volatile-defined">;
|
|
86
|
+
/** 同时保留的任务 tab 上限。 */
|
|
87
|
+
maxTaskTabs: Schema<number, number, "volatile-defined">;
|
|
88
|
+
/** 任务保留天数与条数(置顶任务不受限)。 */
|
|
89
|
+
taskRetentionDays: Schema<number, number, "volatile-defined">;
|
|
90
|
+
taskRetentionCount: Schema<number, number, "volatile-defined">;
|
|
91
|
+
/** 单任务结果上限。 */
|
|
92
|
+
taskMaxPapers: Schema<number, number, "volatile-defined">;
|
|
93
|
+
/** PDF 下载根目录。 */
|
|
94
|
+
pdfRoot: Schema<string, string, "volatile-defined">;
|
|
95
|
+
/** 单个 PDF 最大体积(MB)。 */
|
|
96
|
+
pdfMaxMb: Schema<number, number, "volatile-defined">;
|
|
97
|
+
/** 检索完成后自动下载缺失 PDF。 */
|
|
98
|
+
autoDownloadPdf: Schema<boolean, boolean, "volatile-defined">;
|
|
99
|
+
/** 无 PDF 时用 Europe PMC HTML 全文兜底。 */
|
|
100
|
+
enableEpmcFulltext: Schema<boolean, boolean, "volatile-defined">;
|
|
101
|
+
/** Zotero 入口开关。 */
|
|
102
|
+
zoteroEnabled: Schema<boolean, boolean, "volatile-defined">;
|
|
103
|
+
zoteroBaseUrl: Schema<string, string, "volatile-defined">;
|
|
104
|
+
zoteroAttachPdf: Schema<boolean, boolean, "volatile-defined">;
|
|
105
|
+
zoteroTag: Schema<string, string, "volatile-defined">;
|
|
106
|
+
}>>, "plain">;
|
|
107
|
+
export declare function apply(ctx: Context, config: Schemastery.TypeT<typeof Config>): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-litsearch —— 插件入口(Host 半)。
|
|
3
|
+
*
|
|
4
|
+
* 做三件事:
|
|
5
|
+
* 1. 导出 Schemastery `Config`(逐字段 `.volatile()`),让 dsh「设置中心」能
|
|
6
|
+
* 渲染「文献检索」页;保存后无需重启即生效。
|
|
7
|
+
* 2. 装配 `TaskService`(任务持久化 / 执行 / 事件),并把检索能力注册进 dsh
|
|
8
|
+
* 工具表(`literature_search` / `literature_sources`)。
|
|
9
|
+
* 3. 在宿主 web server 上注册 `/dsh-litsearch/api/*` 端点,供右侧栏面板与
|
|
10
|
+
* 设置页取数。
|
|
11
|
+
*
|
|
12
|
+
* `webServer` / `tools` 的类型不在已安装的 dsh 包里,因此自定义最小结构接口并
|
|
13
|
+
* 强转(与 dsh-plugin-zotero 同法)。
|
|
14
|
+
*/
|
|
15
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { createHandler } from './host/router.js';
|
|
19
|
+
import { literatureTools } from './host/tools.js';
|
|
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';
|
|
23
|
+
export const name = 'dsh-plugin-litsearch';
|
|
24
|
+
/**
|
|
25
|
+
* 插件配置 schema(逐字段 volatile)。
|
|
26
|
+
*
|
|
27
|
+
* volatile 是 dsh 配置表单的硬前提;用 `.volatile()` 后保存只提交新值并
|
|
28
|
+
* 唤醒引用,条目本身不重挂,因此改配置无需重启 dsh。
|
|
29
|
+
*/
|
|
30
|
+
export const Config = Schema.object({
|
|
31
|
+
/** 作为 OpenAlex / Crossref / NCBI 的联系邮箱;留空也可用。 */
|
|
32
|
+
contactEmail: Schema.string().default('').volatile(),
|
|
33
|
+
/** 可选 NCBI API Key(E-utilities 额度从 3 req/s 提升到 10 req/s)。 */
|
|
34
|
+
ncbiApiKey: Schema.string().default('').role('secret').volatile(),
|
|
35
|
+
/** 可选 Semantic Scholar API Key。 */
|
|
36
|
+
s2ApiKey: Schema.string().default('').role('secret').volatile(),
|
|
37
|
+
/** 各源最小请求间隔(毫秒)。 */
|
|
38
|
+
pubmedIntervalMs: Schema.number().default(DEFAULT_PUBMED_INTERVAL_MS).volatile(),
|
|
39
|
+
s2IntervalMs: Schema.number().default(DEFAULT_S2_INTERVAL_MS).volatile(),
|
|
40
|
+
arxivIntervalMs: Schema.number().default(DEFAULT_ARXIV_INTERVAL_MS).volatile(),
|
|
41
|
+
/** 默认每次加载条数。 */
|
|
42
|
+
defaultPageSize: Schema.number().default(DEFAULT_PAGE_SIZE).volatile(),
|
|
43
|
+
/** 单次上游请求超时(毫秒)。 */
|
|
44
|
+
requestTimeoutMs: Schema.number().default(REQUEST_TIMEOUT_MS).volatile(),
|
|
45
|
+
/** 默认数据源。 */
|
|
46
|
+
defaultSources: Schema.array(Schema.union([
|
|
47
|
+
Schema.const('pubmed'),
|
|
48
|
+
Schema.const('europepmc'),
|
|
49
|
+
Schema.const('openalex'),
|
|
50
|
+
Schema.const('crossref'),
|
|
51
|
+
Schema.const('semanticscholar'),
|
|
52
|
+
Schema.const('arxiv'),
|
|
53
|
+
Schema.const('pubtator'),
|
|
54
|
+
]))
|
|
55
|
+
.default([...SOURCE_IDS])
|
|
56
|
+
.volatile(),
|
|
57
|
+
defaultSort: Schema.union([
|
|
58
|
+
Schema.const('relevance'),
|
|
59
|
+
Schema.const('citations'),
|
|
60
|
+
Schema.const('year'),
|
|
61
|
+
])
|
|
62
|
+
.default(DEFAULT_SORT)
|
|
63
|
+
.volatile(),
|
|
64
|
+
defaultLanguage: Schema.union([Schema.const('any'), Schema.const('eng'), Schema.const('chi')])
|
|
65
|
+
.default(DEFAULT_LANGUAGE)
|
|
66
|
+
.volatile(),
|
|
67
|
+
/** 模型发起检索后是否自动打开任务 tab。 */
|
|
68
|
+
autoOpenTaskTab: Schema.boolean().default(DEFAULT_AUTO_OPEN).volatile(),
|
|
69
|
+
/** 同时保留的任务 tab 上限。 */
|
|
70
|
+
maxTaskTabs: Schema.number().default(DEFAULT_MAX_TASK_TABS).volatile(),
|
|
71
|
+
/** 任务保留天数与条数(置顶任务不受限)。 */
|
|
72
|
+
taskRetentionDays: Schema.number().default(DEFAULT_RETENTION_DAYS).volatile(),
|
|
73
|
+
taskRetentionCount: Schema.number().default(DEFAULT_RETENTION_COUNT).volatile(),
|
|
74
|
+
/** 单任务结果上限。 */
|
|
75
|
+
taskMaxPapers: Schema.number().default(DEFAULT_TASK_MAX_PAPERS).volatile(),
|
|
76
|
+
/** PDF 下载根目录。 */
|
|
77
|
+
pdfRoot: Schema.string().default(join(homedir(), 'Downloads', 'dsh-litsearch')).volatile(),
|
|
78
|
+
/** 单个 PDF 最大体积(MB)。 */
|
|
79
|
+
pdfMaxMb: Schema.number().default(DEFAULT_PDF_MAX_MB).volatile(),
|
|
80
|
+
/** 检索完成后自动下载缺失 PDF。 */
|
|
81
|
+
autoDownloadPdf: Schema.boolean().default(DEFAULT_AUTO_DOWNLOAD_PDF).volatile(),
|
|
82
|
+
/** 无 PDF 时用 Europe PMC HTML 全文兜底。 */
|
|
83
|
+
enableEpmcFulltext: Schema.boolean().default(DEFAULT_ENABLE_EPMC_FULLTEXT).volatile(),
|
|
84
|
+
/** Zotero 入口开关。 */
|
|
85
|
+
zoteroEnabled: Schema.boolean().default(DEFAULT_ZOTERO_ENABLED).volatile(),
|
|
86
|
+
zoteroBaseUrl: Schema.string().default(DEFAULT_ZOTERO_BASE_URL).volatile(),
|
|
87
|
+
zoteroAttachPdf: Schema.boolean().default(DEFAULT_ZOTERO_ATTACH_PDF).volatile(),
|
|
88
|
+
zoteroTag: Schema.string().default(DEFAULT_ZOTERO_TAG).volatile(),
|
|
89
|
+
});
|
|
90
|
+
export function apply(ctx, config) {
|
|
91
|
+
// 每次读取都走 volatile 引用,始终拿到最新值。
|
|
92
|
+
setSettingsReader(() => ({
|
|
93
|
+
contactEmail: config.contactEmail.get().trim(),
|
|
94
|
+
ncbiApiKey: config.ncbiApiKey.get().trim(),
|
|
95
|
+
s2ApiKey: config.s2ApiKey.get().trim(),
|
|
96
|
+
pubmedIntervalMs: clampInt(config.pubmedIntervalMs.get(), 50, 10_000, DEFAULT_PUBMED_INTERVAL_MS),
|
|
97
|
+
s2IntervalMs: clampInt(config.s2IntervalMs.get(), 50, 60_000, DEFAULT_S2_INTERVAL_MS),
|
|
98
|
+
arxivIntervalMs: clampInt(config.arxivIntervalMs.get(), 50, 60_000, DEFAULT_ARXIV_INTERVAL_MS),
|
|
99
|
+
defaultPageSize: clampInt(config.defaultPageSize.get(), 1, MAX_LIMIT, DEFAULT_PAGE_SIZE),
|
|
100
|
+
requestTimeoutMs: clampInt(config.requestTimeoutMs.get(), 1_000, 120_000, REQUEST_TIMEOUT_MS),
|
|
101
|
+
defaultSources: normalizeSources(config.defaultSources.get()),
|
|
102
|
+
defaultSort: normalizeSort(config.defaultSort.get()),
|
|
103
|
+
defaultLanguage: normalizeLanguage(config.defaultLanguage.get()),
|
|
104
|
+
autoOpenTaskTab: config.autoOpenTaskTab.get(),
|
|
105
|
+
maxTaskTabs: clampInt(config.maxTaskTabs.get(), 1, 20, DEFAULT_MAX_TASK_TABS),
|
|
106
|
+
taskRetentionDays: clampInt(config.taskRetentionDays.get(), 1, 3_650, DEFAULT_RETENTION_DAYS),
|
|
107
|
+
taskRetentionCount: clampInt(config.taskRetentionCount.get(), 1, 10_000, DEFAULT_RETENTION_COUNT),
|
|
108
|
+
taskMaxPapers: clampInt(config.taskMaxPapers.get(), 10, 10_000, DEFAULT_TASK_MAX_PAPERS),
|
|
109
|
+
pdfRoot: config.pdfRoot.get().trim(),
|
|
110
|
+
pdfMaxMb: clampInt(config.pdfMaxMb.get(), 1, 200, DEFAULT_PDF_MAX_MB),
|
|
111
|
+
autoDownloadPdf: config.autoDownloadPdf.get(),
|
|
112
|
+
enableEpmcFulltext: config.enableEpmcFulltext.get(),
|
|
113
|
+
zoteroEnabled: config.zoteroEnabled.get(),
|
|
114
|
+
zoteroBaseUrl: normalizeBaseUrl(config.zoteroBaseUrl.get()),
|
|
115
|
+
zoteroAttachPdf: config.zoteroAttachPdf.get(),
|
|
116
|
+
zoteroTag: config.zoteroTag.get().trim().slice(0, 40) || DEFAULT_ZOTERO_TAG,
|
|
117
|
+
}));
|
|
118
|
+
// 任务状态是模型工具与 HTTP 路由的唯一事实源。
|
|
119
|
+
const service = new TaskService();
|
|
120
|
+
ctx.effect(() => () => service.dispose(), 'dsh-plugin-litsearch: task service');
|
|
121
|
+
// 模型侧:原生注册,不依赖外部 MCP。
|
|
122
|
+
ctx.inject(['tools'], (toolCtx) => {
|
|
123
|
+
const toolsService = toolCtx.tools;
|
|
124
|
+
for (const definition of literatureTools(service)) {
|
|
125
|
+
toolCtx.effect(() => toolsService.register(definition), `dsh-plugin-litsearch: ${definition.name}`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
// 人侧:右侧栏面板与设置页通过回环端点读写任务。
|
|
129
|
+
ctx.inject(['webServer'], (hostCtx) => {
|
|
130
|
+
const webServer = hostCtx.webServer;
|
|
131
|
+
const handler = createHandler(service);
|
|
132
|
+
hostCtx.effect(() => webServer.register({ kind: 'prefix', path: ROUTE_MATCH, handler }), 'dsh-plugin-litsearch: http routes');
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function clampInt(value, min, max, fallback) {
|
|
136
|
+
if (!Number.isFinite(value))
|
|
137
|
+
return fallback;
|
|
138
|
+
return Math.min(Math.max(Math.floor(value), min), max);
|
|
139
|
+
}
|
|
140
|
+
function normalizeSources(value) {
|
|
141
|
+
const valid = SOURCE_IDS.filter((id) => value.includes(id));
|
|
142
|
+
return valid.length === 0 ? [...SOURCE_IDS] : valid;
|
|
143
|
+
}
|
|
144
|
+
function normalizeSort(value) {
|
|
145
|
+
return value === 'citations' || value === 'year' ? value : 'relevance';
|
|
146
|
+
}
|
|
147
|
+
function normalizeLanguage(value) {
|
|
148
|
+
return value === 'eng' || value === 'chi' ? value : 'any';
|
|
149
|
+
}
|
|
150
|
+
function normalizeBaseUrl(value) {
|
|
151
|
+
const trimmed = value.trim().replace(/\/+$/u, '');
|
|
152
|
+
return /^https?:\/\//u.test(trimmed) ? trimmed : DEFAULT_ZOTERO_BASE_URL;
|
|
153
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 多源检索扇出。
|
|
3
|
+
*
|
|
4
|
+
* 一次调用只跑一条检索式:并发请求选中的数据源,用 `allSettled` 隔离单源
|
|
5
|
+
* 故障,返回逐源的命中记录与状态。跨源去重、身份合并与排序交给 `identity.ts`,
|
|
6
|
+
* 任务执行器负责把多条检索式的结果拼在一起。
|
|
7
|
+
*/
|
|
8
|
+
import type { PaperRecord, SearchOptions, SourceId } from './types.js';
|
|
9
|
+
export interface SourceRunResult {
|
|
10
|
+
source: SourceId;
|
|
11
|
+
ok: boolean;
|
|
12
|
+
records: PaperRecord[];
|
|
13
|
+
error?: string;
|
|
14
|
+
/** 因遵守上游限流而跳过;不是失败,不计入 partial 与红色报错。 */
|
|
15
|
+
skipped?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function searchSources(options: SearchOptions): Promise<SourceRunResult[]>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 多源检索扇出。
|
|
3
|
+
*
|
|
4
|
+
* 一次调用只跑一条检索式:并发请求选中的数据源,用 `allSettled` 隔离单源
|
|
5
|
+
* 故障,返回逐源的命中记录与状态。跨源去重、身份合并与排序交给 `identity.ts`,
|
|
6
|
+
* 任务执行器负责把多条检索式的结果拼在一起。
|
|
7
|
+
*/
|
|
8
|
+
import { MAX_LIMIT, getSettings } from './config.js';
|
|
9
|
+
import { SearchError } from './errors.js';
|
|
10
|
+
import { PROVIDERS, resolveSources } from './providers/index.js';
|
|
11
|
+
import { adaptQuery } from './query-adapter.js';
|
|
12
|
+
import { SourceSkipped } from './rate-limit.js';
|
|
13
|
+
export async function searchSources(options) {
|
|
14
|
+
const query = options.query.trim();
|
|
15
|
+
if (query === '')
|
|
16
|
+
throw new SearchError('bad_request', '检索关键词不能为空');
|
|
17
|
+
if (options.yearFrom !== undefined &&
|
|
18
|
+
options.yearTo !== undefined &&
|
|
19
|
+
options.yearFrom > options.yearTo) {
|
|
20
|
+
throw new SearchError('bad_request', '起始年份不能晚于结束年份');
|
|
21
|
+
}
|
|
22
|
+
const settings = getSettings();
|
|
23
|
+
const limit = clampLimit(options.limit, settings.defaultPageSize);
|
|
24
|
+
const sources = resolveSources(options.sources);
|
|
25
|
+
const contactEmail = options.contactEmail !== '' ? options.contactEmail : settings.contactEmail;
|
|
26
|
+
const timeoutMs = options.timeoutMs > 0 ? options.timeoutMs : settings.requestTimeoutMs;
|
|
27
|
+
const settled = await Promise.allSettled(sources.map((id) => {
|
|
28
|
+
const adapted = adaptQuery(id, { text: query, perSource: options.perSource });
|
|
29
|
+
if (adapted === '')
|
|
30
|
+
return Promise.resolve([]);
|
|
31
|
+
return PROVIDERS[id].search(adapted, {
|
|
32
|
+
limit,
|
|
33
|
+
yearFrom: options.yearFrom,
|
|
34
|
+
yearTo: options.yearTo,
|
|
35
|
+
openAccessOnly: options.openAccessOnly,
|
|
36
|
+
contactEmail,
|
|
37
|
+
timeoutMs,
|
|
38
|
+
signal: options.signal,
|
|
39
|
+
bypassCooldown: options.bypassCooldown === true,
|
|
40
|
+
ncbiApiKey: settings.ncbiApiKey,
|
|
41
|
+
s2ApiKey: settings.s2ApiKey,
|
|
42
|
+
});
|
|
43
|
+
}));
|
|
44
|
+
return settled.map((outcome, index) => {
|
|
45
|
+
const source = sources[index];
|
|
46
|
+
if (outcome.status === 'fulfilled') {
|
|
47
|
+
return { source, ok: true, records: outcome.value };
|
|
48
|
+
}
|
|
49
|
+
const reason = outcome.reason;
|
|
50
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
51
|
+
if (reason instanceof SourceSkipped) {
|
|
52
|
+
return { source, ok: false, records: [], skipped: true, error: message };
|
|
53
|
+
}
|
|
54
|
+
return { source, ok: false, records: [], error: message };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function clampLimit(value, fallback) {
|
|
58
|
+
const base = Number.isFinite(value) ? value : fallback;
|
|
59
|
+
return Math.min(Math.max(Math.floor(base), 1), MAX_LIMIT);
|
|
60
|
+
}
|