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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -2
  3. package/client/client.js +9036 -0
  4. package/cordis.patch.yml +10 -0
  5. package/lib/host/events.d.ts +19 -0
  6. package/lib/host/events.js +58 -0
  7. package/lib/host/export/bibtex.d.ts +3 -0
  8. package/lib/host/export/bibtex.js +42 -0
  9. package/lib/host/export/common.d.ts +18 -0
  10. package/lib/host/export/common.js +25 -0
  11. package/lib/host/export/csv.d.ts +8 -0
  12. package/lib/host/export/csv.js +68 -0
  13. package/lib/host/export/index.d.ts +5 -0
  14. package/lib/host/export/index.js +25 -0
  15. package/lib/host/export/markdown.d.ts +6 -0
  16. package/lib/host/export/markdown.js +120 -0
  17. package/lib/host/export/ris.d.ts +3 -0
  18. package/lib/host/export/ris.js +35 -0
  19. package/lib/host/paths.d.ts +43 -0
  20. package/lib/host/paths.js +128 -0
  21. package/lib/host/pdf/store.d.ts +57 -0
  22. package/lib/host/pdf/store.js +291 -0
  23. package/lib/host/router.d.ts +20 -0
  24. package/lib/host/router.js +852 -0
  25. package/lib/host/tasks/compare.d.ts +8 -0
  26. package/lib/host/tasks/compare.js +49 -0
  27. package/lib/host/tasks/filter.d.ts +42 -0
  28. package/lib/host/tasks/filter.js +100 -0
  29. package/lib/host/tasks/rounds.d.ts +28 -0
  30. package/lib/host/tasks/rounds.js +74 -0
  31. package/lib/host/tasks/runner.d.ts +29 -0
  32. package/lib/host/tasks/runner.js +208 -0
  33. package/lib/host/tasks/service.d.ts +171 -0
  34. package/lib/host/tasks/service.js +976 -0
  35. package/lib/host/tasks/states.d.ts +28 -0
  36. package/lib/host/tasks/states.js +223 -0
  37. package/lib/host/tasks/stats.d.ts +13 -0
  38. package/lib/host/tasks/stats.js +233 -0
  39. package/lib/host/tasks/store.d.ts +64 -0
  40. package/lib/host/tasks/store.js +280 -0
  41. package/lib/host/tasks/summary.d.ts +9 -0
  42. package/lib/host/tasks/summary.js +54 -0
  43. package/lib/host/tools.d.ts +47 -0
  44. package/lib/host/tools.js +1138 -0
  45. package/lib/host/zotero/connector.d.ts +46 -0
  46. package/lib/host/zotero/connector.js +263 -0
  47. package/lib/index.d.ts +107 -0
  48. package/lib/index.js +153 -0
  49. package/lib/search/aggregate.d.ts +17 -0
  50. package/lib/search/aggregate.js +60 -0
  51. package/lib/search/config.d.ts +96 -0
  52. package/lib/search/config.js +96 -0
  53. package/lib/search/errors.d.ts +22 -0
  54. package/lib/search/errors.js +24 -0
  55. package/lib/search/health.d.ts +13 -0
  56. package/lib/search/health.js +53 -0
  57. package/lib/search/http.d.ts +13 -0
  58. package/lib/search/http.js +81 -0
  59. package/lib/search/identity.d.ts +27 -0
  60. package/lib/search/identity.js +154 -0
  61. package/lib/search/normalize.d.ts +17 -0
  62. package/lib/search/normalize.js +47 -0
  63. package/lib/search/oa.d.ts +21 -0
  64. package/lib/search/oa.js +149 -0
  65. package/lib/search/providers/arxiv.d.ts +9 -0
  66. package/lib/search/providers/arxiv.js +99 -0
  67. package/lib/search/providers/crossref.d.ts +8 -0
  68. package/lib/search/providers/crossref.js +107 -0
  69. package/lib/search/providers/europepmc.d.ts +8 -0
  70. package/lib/search/providers/europepmc.js +105 -0
  71. package/lib/search/providers/index.d.ts +13 -0
  72. package/lib/search/providers/index.js +34 -0
  73. package/lib/search/providers/openalex.d.ts +8 -0
  74. package/lib/search/providers/openalex.js +109 -0
  75. package/lib/search/providers/pubmed.d.ts +10 -0
  76. package/lib/search/providers/pubmed.js +143 -0
  77. package/lib/search/providers/pubtator.d.ts +9 -0
  78. package/lib/search/providers/pubtator.js +79 -0
  79. package/lib/search/providers/semanticscholar.d.ts +9 -0
  80. package/lib/search/providers/semanticscholar.js +97 -0
  81. package/lib/search/pubtator-entities.d.ts +9 -0
  82. package/lib/search/pubtator-entities.js +19 -0
  83. package/lib/search/pubtator-parse.d.ts +3 -0
  84. package/lib/search/pubtator-parse.js +42 -0
  85. package/lib/search/query-adapter.d.ts +15 -0
  86. package/lib/search/query-adapter.js +69 -0
  87. package/lib/search/rate-limit.d.ts +31 -0
  88. package/lib/search/rate-limit.js +219 -0
  89. package/lib/search/types.d.ts +577 -0
  90. package/lib/search/types.js +15 -0
  91. package/lib/search/xml.d.ts +20 -0
  92. package/lib/search/xml.js +63 -0
  93. package/package.json +75 -4
@@ -0,0 +1,852 @@
1
+ /**
2
+ * Host HTTP 路由:`/dsh-litsearch/api/*`。
3
+ *
4
+ * 面板与设置页都从这里取数;统一信封 `{ok:true,data}` / `{ok:false,error}`。
5
+ * 信任栅栏:Host 头必须是回环地址(防 DNS rebinding),并拒绝
6
+ * `sec-fetch-site: cross-site`。写端点额外要求自定义头 + JSON content-type,
7
+ * 两者一起让浏览器必须预检,堵掉跨站表单与简单请求。
8
+ */
9
+ import { MAX_LIMIT, ROUTE_PREFIX, WRITE_HEADER } from '../search/config.js';
10
+ import { SearchError } from '../search/errors.js';
11
+ import { asRecord } from '../search/normalize.js';
12
+ import { isSourceId } from '../search/providers/index.js';
13
+ const MAX_BODY_BYTES = 1_000_000;
14
+ const SORT_MODES = ['relevance', 'citations', 'year'];
15
+ const LANGUAGES = ['any', 'eng', 'chi'];
16
+ const ORIGINS = ['model', 'panel', 'history', 'api'];
17
+ const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
18
+ const PDF_STATUSES = [
19
+ 'unknown',
20
+ 'available',
21
+ 'downloaded',
22
+ 'html-only',
23
+ 'paywalled',
24
+ 'blocked',
25
+ 'failed',
26
+ ];
27
+ const EXPORT_FORMATS = ['csv', 'ris', 'bibtex', 'md'];
28
+ const CLEANUP_SCOPES = ['tasks', 'pdf', 'all'];
29
+ const MAX_ANNOTATIONS = 50;
30
+ function isLoopbackHost(hostHeader) {
31
+ if (!hostHeader)
32
+ return false;
33
+ const host = hostHeader.replace(/:\d+$/u, '').toLowerCase();
34
+ return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1';
35
+ }
36
+ function isTrusted(req) {
37
+ const site = req.headers['sec-fetch-site'];
38
+ if (typeof site === 'string' && site.toLowerCase() === 'cross-site')
39
+ return false;
40
+ return isLoopbackHost(req.headers.host);
41
+ }
42
+ /** 写端点要求自定义头 + JSON:跨站请求无法在不触发预检的情况下伪造。 */
43
+ function hasWriteIntent(req) {
44
+ if (req.headers[WRITE_HEADER] !== '1')
45
+ return false;
46
+ const type = req.headers['content-type'];
47
+ return typeof type === 'string' && type.toLowerCase().includes('application/json');
48
+ }
49
+ function sendJson(res, status, payload) {
50
+ const body = JSON.stringify(payload);
51
+ res.writeHead(status, {
52
+ 'content-type': 'application/json; charset=utf-8',
53
+ 'cache-control': 'no-store',
54
+ 'content-length': Buffer.byteLength(body),
55
+ });
56
+ res.end(body);
57
+ }
58
+ function fail(res, status, code, message) {
59
+ const error = { code, message };
60
+ sendJson(res, status, { ok: false, error });
61
+ }
62
+ function ok(res, data, status = 200) {
63
+ sendJson(res, status, { ok: true, data });
64
+ }
65
+ function sendFile(res, file) {
66
+ const body = Buffer.from(file.content, 'utf8');
67
+ res.writeHead(200, {
68
+ 'content-type': file.mime,
69
+ 'content-length': body.length,
70
+ 'content-disposition': `attachment; filename="${file.filename}"`,
71
+ 'cache-control': 'no-store',
72
+ });
73
+ res.end(body);
74
+ }
75
+ /** 内联 PDF,支持单段 Range(预览器会按需拉取)。 */
76
+ function sendPdf(req, res, buffer, filename) {
77
+ const total = buffer.length;
78
+ const disposition = `inline; filename*=UTF-8''${encodeURIComponent(filename)}`;
79
+ const range = req.headers.range;
80
+ if (range === undefined) {
81
+ res.writeHead(200, {
82
+ 'content-type': 'application/pdf',
83
+ 'content-length': total,
84
+ 'content-disposition': disposition,
85
+ 'accept-ranges': 'bytes',
86
+ 'cache-control': 'no-store',
87
+ });
88
+ res.end(buffer);
89
+ return;
90
+ }
91
+ const match = /^bytes=(\d*)-(\d*)$/u.exec(range.trim());
92
+ const start = match?.[1] === '' ? Number.NaN : Number(match?.[1]);
93
+ const end = match?.[2] === '' ? Number.NaN : Number(match?.[2]);
94
+ if (match === null || (!Number.isFinite(start) && !Number.isFinite(end))) {
95
+ res.writeHead(416, { 'content-range': `bytes */${total}`, 'cache-control': 'no-store' });
96
+ res.end();
97
+ return;
98
+ }
99
+ const from = Number.isFinite(start) ? Math.max(0, start) : Math.max(0, total - (end || 0));
100
+ const to = Number.isFinite(end) ? Math.min(end, total - 1) : total - 1;
101
+ if (from > to || from >= total) {
102
+ res.writeHead(416, { 'content-range': `bytes */${total}`, 'cache-control': 'no-store' });
103
+ res.end();
104
+ return;
105
+ }
106
+ const chunk = buffer.subarray(from, to + 1);
107
+ res.writeHead(206, {
108
+ 'content-type': 'application/pdf',
109
+ 'content-length': chunk.length,
110
+ 'content-range': `bytes ${from}-${to}/${total}`,
111
+ 'content-disposition': disposition,
112
+ 'accept-ranges': 'bytes',
113
+ 'cache-control': 'no-store',
114
+ });
115
+ res.end(chunk);
116
+ }
117
+ function sendHtml(res, status, html) {
118
+ res.writeHead(status, {
119
+ 'content-type': 'text/html; charset=utf-8',
120
+ 'cache-control': 'no-store',
121
+ });
122
+ res.end(html);
123
+ }
124
+ function sendHtmlError(res, error) {
125
+ const { status, error: apiError } = toApiError(error);
126
+ sendHtml(res, status === 500 ? 502 : status, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>全文不可用</title></head><body style="font:14px/1.7 -apple-system,'PingFang SC',sans-serif;padding:16px"><p>全文不可用:${escapeHtml(apiError.message)}</p></body></html>`);
127
+ }
128
+ function escapeHtml(value) {
129
+ return value
130
+ .replace(/&/gu, '&amp;')
131
+ .replace(/</gu, '&lt;')
132
+ .replace(/>/gu, '&gt;')
133
+ .replace(/"/gu, '&quot;');
134
+ }
135
+ function statusFor(code) {
136
+ switch (code) {
137
+ case 'bad_request':
138
+ return 400;
139
+ case 'not_found':
140
+ return 404;
141
+ case 'conflict':
142
+ return 409;
143
+ case 'timeout':
144
+ return 504;
145
+ case 'network_error':
146
+ case 'upstream_error':
147
+ return 502;
148
+ default:
149
+ return 500;
150
+ }
151
+ }
152
+ function toApiError(error) {
153
+ if (error instanceof SearchError) {
154
+ return { status: statusFor(error.code), error: { code: error.code, message: error.message } };
155
+ }
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ return { status: 500, error: { code: 'internal_error', message } };
158
+ }
159
+ function readBody(req) {
160
+ return new Promise((resolve, reject) => {
161
+ const chunks = [];
162
+ let size = 0;
163
+ req.on('data', (chunk) => {
164
+ size += chunk.length;
165
+ if (size > MAX_BODY_BYTES) {
166
+ reject(new SearchError('bad_request', '请求体过大'));
167
+ req.destroy();
168
+ return;
169
+ }
170
+ chunks.push(chunk);
171
+ });
172
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
173
+ req.on('error', reject);
174
+ });
175
+ }
176
+ async function readJsonBody(req) {
177
+ const raw = await readBody(req);
178
+ if (raw.trim() === '')
179
+ return {};
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(raw);
183
+ }
184
+ catch {
185
+ throw new SearchError('bad_request', '请求体不是合法 JSON');
186
+ }
187
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
188
+ throw new SearchError('bad_request', '请求体必须是 JSON 对象');
189
+ }
190
+ return asRecord(parsed);
191
+ }
192
+ function readString(value, name, max) {
193
+ if (value === undefined || value === null)
194
+ return undefined;
195
+ if (typeof value !== 'string')
196
+ throw new SearchError('bad_request', `${name} 必须是字符串`);
197
+ return value.trim().slice(0, max);
198
+ }
199
+ function readInt(value, name, min, max) {
200
+ if (value === undefined || value === null)
201
+ return undefined;
202
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
203
+ throw new SearchError('bad_request', `${name} 必须是 ${min}..${max} 的整数`);
204
+ }
205
+ return value;
206
+ }
207
+ function readSourcesValue(value) {
208
+ if (value === undefined || value === null)
209
+ return undefined;
210
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
211
+ throw new SearchError('bad_request', 'sources 必须是字符串数组');
212
+ }
213
+ const invalid = value.filter((item) => !isSourceId(item));
214
+ if (invalid.length > 0) {
215
+ throw new SearchError('bad_request', `未知数据源:${invalid.join(', ')}`);
216
+ }
217
+ return value;
218
+ }
219
+ function readStringArray(value, name, maxItems, maxLength) {
220
+ if (value === undefined)
221
+ return undefined;
222
+ if (value === null)
223
+ return null;
224
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
225
+ throw new SearchError('bad_request', `${name} 必须是字符串数组`);
226
+ }
227
+ if (value.length > maxItems) {
228
+ throw new SearchError('bad_request', `${name} 最多 ${maxItems} 项`);
229
+ }
230
+ return value.map((item) => item.trim().slice(0, maxLength)).filter((item) => item !== '');
231
+ }
232
+ function readQueriesValue(value) {
233
+ if (value === undefined || value === null)
234
+ return undefined;
235
+ if (!Array.isArray(value))
236
+ throw new SearchError('bad_request', 'queries 必须是数组');
237
+ return value.map((entry, index) => {
238
+ const record = asRecord(entry);
239
+ const text = record['text'];
240
+ if (typeof text !== 'string' || text.trim() === '') {
241
+ throw new SearchError('bad_request', `第 ${index + 1} 条检索式为空`);
242
+ }
243
+ const input = { text };
244
+ const name = readString(record['name'], 'name', 80);
245
+ if (name !== undefined)
246
+ input.name = name;
247
+ if (record['enabled'] !== undefined) {
248
+ if (typeof record['enabled'] !== 'boolean') {
249
+ throw new SearchError('bad_request', 'enabled 必须是布尔值');
250
+ }
251
+ input.enabled = record['enabled'];
252
+ }
253
+ const perSource = record['perSource'];
254
+ if (perSource !== undefined && perSource !== null) {
255
+ const overrides = {};
256
+ for (const [key, raw] of Object.entries(asRecord(perSource))) {
257
+ if (!isSourceId(key))
258
+ throw new SearchError('bad_request', `未知数据源:${key}`);
259
+ if (typeof raw !== 'string') {
260
+ throw new SearchError('bad_request', `perSource.${key} 必须是字符串`);
261
+ }
262
+ overrides[key] = raw;
263
+ }
264
+ input.perSource = overrides;
265
+ }
266
+ return input;
267
+ });
268
+ }
269
+ function readAnnotations(value) {
270
+ if (!Array.isArray(value))
271
+ throw new SearchError('bad_request', 'papers 必须是数组');
272
+ if (value.length === 0)
273
+ throw new SearchError('bad_request', 'papers 不能为空');
274
+ if (value.length > MAX_ANNOTATIONS) {
275
+ throw new SearchError('bad_request', `一次最多注解 ${MAX_ANNOTATIONS} 篇`);
276
+ }
277
+ return value.map((entry, index) => {
278
+ const record = asRecord(entry);
279
+ const paperId = readString(record['paperId'], 'paperId', 80);
280
+ if (paperId === undefined || paperId === '') {
281
+ throw new SearchError('bad_request', `第 ${index + 1} 条缺少 paperId`);
282
+ }
283
+ const input = { paperId };
284
+ const screening = readEnum(record['screening'], 'screening', SCREENINGS);
285
+ if (screening !== undefined)
286
+ input.screening = screening;
287
+ if (record['groupId'] === null)
288
+ input.groupId = null;
289
+ else {
290
+ const groupId = readString(record['groupId'], 'groupId', 40);
291
+ if (groupId !== undefined)
292
+ input.groupId = groupId;
293
+ }
294
+ if (record['shortlistOrder'] === null)
295
+ input.shortlistOrder = null;
296
+ else if (record['shortlistOrder'] !== undefined) {
297
+ const order = readInt(record['shortlistOrder'], 'shortlistOrder', 0, 100_000);
298
+ if (order !== undefined)
299
+ input.shortlistOrder = order;
300
+ }
301
+ if (record['note'] !== undefined) {
302
+ if (typeof record['note'] !== 'string') {
303
+ throw new SearchError('bad_request', 'note 必须是字符串');
304
+ }
305
+ input.note = record['note'].slice(0, 4_000);
306
+ }
307
+ if (record['tags'] !== undefined) {
308
+ const tags = readStringArray(record['tags'], 'tags', 20, 40);
309
+ if (tags !== undefined && tags !== null)
310
+ input.tags = tags;
311
+ else if (tags === null)
312
+ input.tags = [];
313
+ }
314
+ return input;
315
+ });
316
+ }
317
+ function readGroups(value) {
318
+ if (value === undefined || value === null)
319
+ return undefined;
320
+ if (!Array.isArray(value))
321
+ throw new SearchError('bad_request', 'groups 必须是数组');
322
+ return value.map((entry, index) => {
323
+ const record = asRecord(entry);
324
+ const id = readString(record['id'], 'id', 40);
325
+ const name = readString(record['name'], 'name', 60);
326
+ if (id === undefined || id === '') {
327
+ throw new SearchError('bad_request', `第 ${index + 1} 个分组缺少 id`);
328
+ }
329
+ if (name === undefined || name === '') {
330
+ throw new SearchError('bad_request', `第 ${index + 1} 个分组缺少名称`);
331
+ }
332
+ const group = { id, name, order: index };
333
+ const description = readString(record['description'], 'description', 500);
334
+ if (description !== undefined && description !== '')
335
+ group.description = description;
336
+ return group;
337
+ });
338
+ }
339
+ function readEnum(value, name, allowed) {
340
+ if (value === undefined || value === null)
341
+ return undefined;
342
+ if (typeof value !== 'string' || !allowed.includes(value)) {
343
+ throw new SearchError('bad_request', `${name} 只能是 ${allowed.join(' / ')}`);
344
+ }
345
+ return value;
346
+ }
347
+ function readCreateInput(body) {
348
+ const query = readString(body['query'], 'query', 500);
349
+ const queries = readQueriesValue(body['queries']);
350
+ if ((query === undefined || query === '') && queries === undefined) {
351
+ throw new SearchError('bad_request', '缺少必填参数 query 或 queries');
352
+ }
353
+ const input = {};
354
+ if (query !== undefined && query !== '')
355
+ input.query = query;
356
+ if (queries !== undefined)
357
+ input.queries = queries;
358
+ const title = readString(body['title'], 'title', 120);
359
+ if (title !== undefined && title !== '')
360
+ input.title = title;
361
+ const sources = readSourcesValue(body['sources']);
362
+ if (sources !== undefined)
363
+ input.sources = sources;
364
+ const pageSize = readInt(body['pageSize'], 'pageSize', 1, MAX_LIMIT);
365
+ if (pageSize !== undefined)
366
+ input.pageSize = pageSize;
367
+ const yearFrom = readInt(body['yearFrom'], 'yearFrom', 1, 9999);
368
+ if (yearFrom !== undefined)
369
+ input.yearFrom = yearFrom;
370
+ const yearTo = readInt(body['yearTo'], 'yearTo', 1, 9999);
371
+ if (yearTo !== undefined)
372
+ input.yearTo = yearTo;
373
+ if (body['openAccessOnly'] !== undefined) {
374
+ if (typeof body['openAccessOnly'] !== 'boolean') {
375
+ throw new SearchError('bad_request', 'openAccessOnly 必须是布尔值');
376
+ }
377
+ input.openAccessOnly = body['openAccessOnly'];
378
+ }
379
+ const language = readEnum(body['language'], 'language', LANGUAGES);
380
+ if (language !== undefined)
381
+ input.language = language;
382
+ const types = readStringArray(body['types'], 'types', 20, 40);
383
+ if (types !== undefined && types !== null)
384
+ input.types = types;
385
+ const sort = readEnum(body['sort'], 'sort', SORT_MODES);
386
+ if (sort !== undefined)
387
+ input.sort = sort;
388
+ if (body['runNow'] !== undefined) {
389
+ if (typeof body['runNow'] !== 'boolean') {
390
+ throw new SearchError('bad_request', 'runNow 必须是布尔值');
391
+ }
392
+ input.runNow = body['runNow'];
393
+ }
394
+ return input;
395
+ }
396
+ function readPatch(body) {
397
+ const patch = {};
398
+ const title = readString(body['title'], 'title', 120);
399
+ if (title !== undefined)
400
+ patch.title = title;
401
+ if (body['notes'] !== undefined) {
402
+ if (typeof body['notes'] !== 'string') {
403
+ throw new SearchError('bad_request', 'notes 必须是字符串');
404
+ }
405
+ patch.notes = body['notes'];
406
+ }
407
+ if (body['pinned'] !== undefined) {
408
+ if (typeof body['pinned'] !== 'boolean') {
409
+ throw new SearchError('bad_request', 'pinned 必须是布尔值');
410
+ }
411
+ patch.pinned = body['pinned'];
412
+ }
413
+ const groups = readGroups(body['groups']);
414
+ if (groups !== undefined)
415
+ patch.groups = groups;
416
+ const rawConditions = body['conditions'];
417
+ if (rawConditions !== undefined) {
418
+ const record = asRecord(rawConditions);
419
+ const conditions = {};
420
+ const queries = readQueriesValue(record['queries']);
421
+ if (queries !== undefined)
422
+ conditions.queries = queries;
423
+ const sources = readSourcesValue(record['sources']);
424
+ if (sources !== undefined)
425
+ conditions.sources = sources;
426
+ const pageSize = readInt(record['pageSize'], 'pageSize', 1, MAX_LIMIT);
427
+ if (pageSize !== undefined)
428
+ conditions.pageSize = pageSize;
429
+ if (record['yearFrom'] === null)
430
+ conditions.yearFrom = null;
431
+ else {
432
+ const yearFrom = readInt(record['yearFrom'], 'yearFrom', 1, 9999);
433
+ if (yearFrom !== undefined)
434
+ conditions.yearFrom = yearFrom;
435
+ }
436
+ if (record['yearTo'] === null)
437
+ conditions.yearTo = null;
438
+ else {
439
+ const yearTo = readInt(record['yearTo'], 'yearTo', 1, 9999);
440
+ if (yearTo !== undefined)
441
+ conditions.yearTo = yearTo;
442
+ }
443
+ if (record['openAccessOnly'] !== undefined) {
444
+ if (typeof record['openAccessOnly'] !== 'boolean') {
445
+ throw new SearchError('bad_request', 'openAccessOnly 必须是布尔值');
446
+ }
447
+ conditions.openAccessOnly = record['openAccessOnly'];
448
+ }
449
+ const language = readEnum(record['language'], 'language', LANGUAGES);
450
+ if (language !== undefined)
451
+ conditions.language = language;
452
+ const types = readStringArray(record['types'], 'types', 20, 40);
453
+ if (types !== undefined)
454
+ conditions.types = types;
455
+ const sort = readEnum(record['sort'], 'sort', SORT_MODES);
456
+ if (sort !== undefined)
457
+ conditions.sort = sort;
458
+ patch.conditions = conditions;
459
+ }
460
+ return patch;
461
+ }
462
+ function readListFilter(params) {
463
+ const filter = {};
464
+ const q = params.get('q')?.trim() ?? '';
465
+ if (q !== '')
466
+ filter.q = q;
467
+ const origin = readEnum(params.get('origin') ?? undefined, 'origin', ORIGINS);
468
+ if (origin !== undefined)
469
+ filter.origin = origin;
470
+ const pinned = params.get('pinned');
471
+ if (pinned === '1' || pinned === 'true')
472
+ filter.pinned = true;
473
+ else if (pinned === '0' || pinned === 'false')
474
+ filter.pinned = false;
475
+ return filter;
476
+ }
477
+ function readQueryInt(params, name, min, max) {
478
+ const raw = params.get(name);
479
+ if (raw === null || raw.trim() === '')
480
+ return undefined;
481
+ const value = Number(raw);
482
+ if (!Number.isFinite(value))
483
+ throw new SearchError('bad_request', `${name} 必须是整数`);
484
+ return readInt(value, name, min, max);
485
+ }
486
+ function readResultsFilter(params) {
487
+ const filter = {};
488
+ const offset = readQueryInt(params, 'offset', 0, Number.MAX_SAFE_INTEGER);
489
+ if (offset !== undefined)
490
+ filter.offset = offset;
491
+ const limit = readQueryInt(params, 'limit', 1, 200);
492
+ if (limit !== undefined)
493
+ filter.limit = limit;
494
+ const sort = readEnum(params.get('sort') ?? undefined, 'sort', SORT_MODES);
495
+ if (sort !== undefined)
496
+ filter.sort = sort;
497
+ const source = params.get('source') ?? '';
498
+ if (source !== '') {
499
+ if (!isSourceId(source))
500
+ throw new SearchError('bad_request', `未知数据源:${source}`);
501
+ filter.source = source;
502
+ }
503
+ const yearFrom = readQueryInt(params, 'yearFrom', 1, 9999);
504
+ if (yearFrom !== undefined)
505
+ filter.yearFrom = yearFrom;
506
+ const yearTo = readQueryInt(params, 'yearTo', 1, 9999);
507
+ if (yearTo !== undefined)
508
+ filter.yearTo = yearTo;
509
+ if (params.get('openAccessOnly') === '1' || params.get('openAccessOnly') === 'true') {
510
+ filter.openAccessOnly = true;
511
+ }
512
+ const q = params.get('q')?.trim() ?? '';
513
+ if (q !== '')
514
+ filter.q = q;
515
+ const screening = readEnum(params.get('screening') ?? undefined, 'screening', SCREENINGS);
516
+ if (screening !== undefined)
517
+ filter.screening = screening;
518
+ const queryId = params.get('queryId')?.trim() ?? '';
519
+ if (queryId !== '')
520
+ filter.queryId = queryId;
521
+ const type = params.get('type')?.trim() ?? '';
522
+ if (type !== '')
523
+ filter.type = type;
524
+ const language = readEnum(params.get('language') ?? undefined, 'language', LANGUAGES);
525
+ if (language !== undefined)
526
+ filter.language = language;
527
+ const hasPdf = params.get('hasPdf');
528
+ if (hasPdf === '1' || hasPdf === 'true')
529
+ filter.hasPdf = true;
530
+ const missingFullText = params.get('missingFullText');
531
+ if (missingFullText === '1' || missingFullText === 'true')
532
+ filter.missingFullText = true;
533
+ const pdfStatus = readEnum(params.get('pdfStatus') ?? undefined, 'pdfStatus', PDF_STATUSES);
534
+ if (pdfStatus !== undefined)
535
+ filter.pdfStatus = pdfStatus;
536
+ const groupId = params.get('groupId')?.trim() ?? '';
537
+ if (groupId !== '')
538
+ filter.groupId = groupId;
539
+ const venue = params.get('venue')?.trim() ?? '';
540
+ if (venue !== '')
541
+ filter.venue = venue;
542
+ const author = params.get('author')?.trim() ?? '';
543
+ if (author !== '')
544
+ filter.author = author;
545
+ const newOnly = params.get('new');
546
+ if (newOnly === '1' || newOnly === 'true')
547
+ filter.newOnly = true;
548
+ return filter;
549
+ }
550
+ function parseSegments(pathname) {
551
+ return pathname
552
+ .slice(ROUTE_PREFIX.length)
553
+ .split('/')
554
+ .filter((segment) => segment !== '')
555
+ .map((segment) => {
556
+ try {
557
+ return decodeURIComponent(segment);
558
+ }
559
+ catch {
560
+ throw new SearchError('bad_request', `路径段编码非法:${segment}`);
561
+ }
562
+ });
563
+ }
564
+ async function handleTasks(service, req, res, seg, params) {
565
+ const id = seg[1];
566
+ if (id === undefined) {
567
+ if (req.method === 'GET') {
568
+ ok(res, await service.list(readListFilter(params)));
569
+ return;
570
+ }
571
+ if (req.method === 'POST') {
572
+ const input = readCreateInput(await readJsonBody(req));
573
+ const task = await service.create({ ...input, origin: 'panel' });
574
+ ok(res, await service.getDetail(task.id), 201);
575
+ return;
576
+ }
577
+ fail(res, 405, 'bad_request', `任务集合不支持 ${req.method ?? '未知'} 方法`);
578
+ return;
579
+ }
580
+ const action = seg[2];
581
+ if (action === undefined) {
582
+ if (req.method === 'GET') {
583
+ ok(res, await service.getDetail(id));
584
+ return;
585
+ }
586
+ if (req.method === 'PATCH') {
587
+ const body = await readJsonBody(req);
588
+ const expectedRev = readInt(body['rev'], 'rev', 1, Number.MAX_SAFE_INTEGER);
589
+ const task = await service.update(id, readPatch(body), expectedRev);
590
+ ok(res, await service.getDetail(task.id));
591
+ return;
592
+ }
593
+ if (req.method === 'DELETE') {
594
+ await service.remove(id);
595
+ ok(res, { id });
596
+ return;
597
+ }
598
+ fail(res, 405, 'bad_request', `任务不支持 ${req.method ?? '未知'} 方法`);
599
+ return;
600
+ }
601
+ if (action === 'results') {
602
+ if (req.method !== 'GET') {
603
+ fail(res, 405, 'bad_request', 'results 仅支持 GET');
604
+ return;
605
+ }
606
+ ok(res, await service.results(id, readResultsFilter(params)));
607
+ return;
608
+ }
609
+ if (action === 'stats') {
610
+ if (req.method !== 'GET') {
611
+ fail(res, 405, 'bad_request', 'stats 仅支持 GET');
612
+ return;
613
+ }
614
+ const scope = params.get('scope') === 'filtered' ? 'filtered' : 'task';
615
+ ok(res, await service.stats(id, readResultsFilter(params), scope));
616
+ return;
617
+ }
618
+ if (action === 'rounds') {
619
+ if (req.method !== 'GET') {
620
+ fail(res, 405, 'bad_request', 'rounds 仅支持 GET');
621
+ return;
622
+ }
623
+ ok(res, await service.getRounds(id));
624
+ return;
625
+ }
626
+ if (action === 'activate') {
627
+ // 用户从任务列表点开某任务 = 进入该任务,之后的模型检索默认落在这里。
628
+ if (req.method !== 'POST') {
629
+ fail(res, 405, 'bad_request', 'activate 仅支持 POST');
630
+ return;
631
+ }
632
+ const task = await service.activate(id);
633
+ ok(res, await service.getDetail(task.id));
634
+ return;
635
+ }
636
+ if (action === 'annotate') {
637
+ if (req.method !== 'POST') {
638
+ fail(res, 405, 'bad_request', 'annotate 仅支持 POST');
639
+ return;
640
+ }
641
+ const body = await readJsonBody(req);
642
+ const papers = readAnnotations(body['papers']);
643
+ const expectedRev = readInt(body['rev'], 'rev', 1, Number.MAX_SAFE_INTEGER);
644
+ ok(res, await service.annotate(id, papers, expectedRev));
645
+ return;
646
+ }
647
+ if (action === 'papers') {
648
+ const paperId = seg[3] ?? '';
649
+ const op = seg[4] ?? '';
650
+ if (paperId === '')
651
+ throw new SearchError('bad_request', '缺少 paperId');
652
+ if (op === '') {
653
+ if (req.method !== 'GET') {
654
+ fail(res, 405, 'bad_request', '论文详情仅支持 GET');
655
+ return;
656
+ }
657
+ ok(res, await service.paperDetail(id, paperId));
658
+ return;
659
+ }
660
+ if (op === 'entities') {
661
+ if (req.method !== 'GET') {
662
+ fail(res, 405, 'bad_request', 'entities 仅支持 GET');
663
+ return;
664
+ }
665
+ ok(res, await service.entities(id, paperId));
666
+ return;
667
+ }
668
+ if (op === 'download') {
669
+ if (req.method !== 'POST') {
670
+ fail(res, 405, 'bad_request', 'download 仅支持 POST');
671
+ return;
672
+ }
673
+ ok(res, await service.downloadPdf(id, paperId));
674
+ return;
675
+ }
676
+ if (op === 'pdf') {
677
+ if (req.method !== 'GET') {
678
+ fail(res, 405, 'bad_request', 'pdf 仅支持 GET');
679
+ return;
680
+ }
681
+ try {
682
+ const file = await service.previewPdf(id, paperId);
683
+ sendPdf(req, res, file.buffer, file.filename);
684
+ }
685
+ catch (error) {
686
+ const { status, error: apiError } = toApiError(error);
687
+ fail(res, status === 500 ? 502 : status, apiError.code, apiError.message);
688
+ }
689
+ return;
690
+ }
691
+ if (op === 'fulltext') {
692
+ if (req.method !== 'GET') {
693
+ fail(res, 405, 'bad_request', 'fulltext 仅支持 GET');
694
+ return;
695
+ }
696
+ try {
697
+ sendHtml(res, 200, await service.fullText(id, paperId));
698
+ }
699
+ catch (error) {
700
+ sendHtmlError(res, error);
701
+ }
702
+ return;
703
+ }
704
+ fail(res, 404, 'not_found', `未知全文操作 ${op}`);
705
+ return;
706
+ }
707
+ if (action === 'zotero') {
708
+ if (req.method !== 'POST') {
709
+ fail(res, 405, 'bad_request', 'zotero 仅支持 POST');
710
+ return;
711
+ }
712
+ const body = await readJsonBody(req);
713
+ const ids = readStringArray(body['paperIds'], 'paperIds', 50, 80);
714
+ ok(res, await service.zoteroSync(id, ids === undefined || ids === null ? undefined : ids));
715
+ return;
716
+ }
717
+ if (action === 'compare') {
718
+ if (req.method !== 'GET') {
719
+ fail(res, 405, 'bad_request', 'compare 仅支持 GET');
720
+ return;
721
+ }
722
+ const withTaskId = params.get('with')?.trim() ?? '';
723
+ if (withTaskId === '')
724
+ throw new SearchError('bad_request', '缺少 with 参数');
725
+ ok(res, await service.compare(id, withTaskId));
726
+ return;
727
+ }
728
+ if (action === 'run') {
729
+ if (req.method !== 'POST') {
730
+ fail(res, 405, 'bad_request', 'run 仅支持 POST');
731
+ return;
732
+ }
733
+ const body = await readJsonBody(req);
734
+ const mode = readEnum(body['mode'], 'mode', ['new', 'more']) ?? 'new';
735
+ await service.get(id);
736
+ if (service.isRunning(id))
737
+ throw new SearchError('conflict', '任务正在运行');
738
+ // 面板里主动重跑 = 用户正在这个任务里工作,把它设为「当前任务」。
739
+ service.setCurrentTask(id);
740
+ void service.run(id, mode, 'panel').catch((error) => {
741
+ console.warn('[dsh-plugin-litsearch] 任务执行失败:', error);
742
+ });
743
+ ok(res, await service.getDetail(id), 202);
744
+ return;
745
+ }
746
+ if (action === 'cancel') {
747
+ if (req.method !== 'POST') {
748
+ fail(res, 405, 'bad_request', 'cancel 仅支持 POST');
749
+ return;
750
+ }
751
+ await service.cancel(id);
752
+ ok(res, await service.getDetail(id), 202);
753
+ return;
754
+ }
755
+ fail(res, 404, 'not_found', `未知 action ${action}`);
756
+ }
757
+ /** 构造挂在 `webServer` 上的前缀路由处理器。 */
758
+ export function createHandler(service) {
759
+ return async (req, res) => {
760
+ try {
761
+ if (!isTrusted(req)) {
762
+ fail(res, 403, 'bad_request', '拒绝非回环来源的请求');
763
+ return;
764
+ }
765
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
766
+ if (!url.pathname.startsWith(ROUTE_PREFIX)) {
767
+ fail(res, 404, 'not_found', `未知路由 ${url.pathname}`);
768
+ return;
769
+ }
770
+ if (req.method !== 'GET' && !hasWriteIntent(req)) {
771
+ fail(res, 403, 'bad_request', '写请求缺少自定义标识头');
772
+ return;
773
+ }
774
+ const seg = parseSegments(url.pathname);
775
+ const head = seg[0];
776
+ if (head === 'health') {
777
+ if (req.method !== 'GET') {
778
+ fail(res, 405, 'bad_request', 'health 仅支持 GET');
779
+ return;
780
+ }
781
+ ok(res, service.health());
782
+ return;
783
+ }
784
+ if (head === 'events') {
785
+ if (req.method !== 'GET') {
786
+ fail(res, 405, 'bad_request', 'events 仅支持 GET');
787
+ return;
788
+ }
789
+ const raw = url.searchParams.get('sinceRev') ?? '0';
790
+ const sinceRev = Number(raw);
791
+ ok(res, await service.eventsSince(Number.isFinite(sinceRev) ? sinceRev : 0));
792
+ return;
793
+ }
794
+ if (head === 'settings') {
795
+ if (seg[1] === 'probe' && req.method === 'POST') {
796
+ const source = readString((await readJsonBody(req))['source'], 'source', 40);
797
+ if (source === undefined || !isSourceId(source)) {
798
+ throw new SearchError('bad_request', `未知数据源:${source ?? ''}`);
799
+ }
800
+ ok(res, await service.probe(source));
801
+ return;
802
+ }
803
+ if (seg[1] === 'cleanup' && req.method === 'POST') {
804
+ const scope = readEnum((await readJsonBody(req))['scope'], 'scope', CLEANUP_SCOPES) ?? 'tasks';
805
+ ok(res, await service.cleanup(scope));
806
+ return;
807
+ }
808
+ if (seg[1] === 'zotero' && req.method === 'POST') {
809
+ ok(res, await service.zoteroProbe());
810
+ return;
811
+ }
812
+ if (seg[1] === 'storage' && req.method === 'POST') {
813
+ ok(res, await service.storageProbe());
814
+ return;
815
+ }
816
+ fail(res, 404, 'not_found', `未知设置端点 ${seg[1] ?? ''}`);
817
+ return;
818
+ }
819
+ if (head === 'tasks') {
820
+ await handleTasks(service, req, res, seg, url.searchParams);
821
+ return;
822
+ }
823
+ if (head === 'export') {
824
+ if (req.method !== 'GET') {
825
+ fail(res, 405, 'bad_request', 'export 仅支持 GET');
826
+ return;
827
+ }
828
+ const format = readEnum(url.searchParams.get('format') ?? undefined, 'format', EXPORT_FORMATS);
829
+ if (format === undefined) {
830
+ throw new SearchError('bad_request', `format 只能是 ${EXPORT_FORMATS.join(' / ')}`);
831
+ }
832
+ const taskId = url.searchParams.get('taskId')?.trim() ?? '';
833
+ if (taskId === '')
834
+ throw new SearchError('bad_request', '缺少 taskId 参数');
835
+ const idsRaw = url.searchParams.get('ids') ?? '';
836
+ const ids = idsRaw.trim() === ''
837
+ ? undefined
838
+ : idsRaw
839
+ .split(',')
840
+ .map((id) => id.trim())
841
+ .filter((id) => id !== '');
842
+ sendFile(res, await service.export(taskId, format, readResultsFilter(url.searchParams), ids));
843
+ return;
844
+ }
845
+ fail(res, 404, 'not_found', `未知 action ${head ?? ''}`);
846
+ }
847
+ catch (error) {
848
+ const { status, error: apiError } = toApiError(error);
849
+ fail(res, status, apiError.code, apiError.message);
850
+ }
851
+ };
852
+ }