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,976 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TaskService:任务工作台的唯一事实源。
|
|
3
|
+
*
|
|
4
|
+
* 负责校验/规范化任务条件、维护内存与磁盘状态、串行化执行并广播 rev。
|
|
5
|
+
* 模型工具与面板 HTTP 路由都只调用这里,保证两边操作同一份状态。
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { MAX_LIMIT, PLUGIN_VERSION, getSettings } from '../../search/config.js';
|
|
11
|
+
import { SearchError } from '../../search/errors.js';
|
|
12
|
+
import { cooldownUntil, sourceHealthList } from '../../search/health.js';
|
|
13
|
+
import { PROVIDER_INFOS } from '../../search/providers/index.js';
|
|
14
|
+
import { searchSources } from '../../search/aggregate.js';
|
|
15
|
+
import { fullTextHtml, jatsToHtml, epmcXmlUrl } from '../../search/oa.js';
|
|
16
|
+
import { fetchPubtatorEntities } from '../../search/pubtator-entities.js';
|
|
17
|
+
import { SOURCE_IDS } from '../../search/types.js';
|
|
18
|
+
import { TaskEvents } from '../events.js';
|
|
19
|
+
import { buildExport } from '../export/index.js';
|
|
20
|
+
import { cleanupPdfCache, epmcFullTextAvailable, persistPdf, probePdfRoot, readLocalPdf, readableFilename, resolvePdf, resolvePdfRoot, } from '../pdf/store.js';
|
|
21
|
+
import { ZoteroConnector } from '../zotero/connector.js';
|
|
22
|
+
import { compareTasks } from './compare.js';
|
|
23
|
+
import { filterPapers } from './filter.js';
|
|
24
|
+
import { TaskRunner } from './runner.js';
|
|
25
|
+
import { computeStats } from './stats.js';
|
|
26
|
+
import { applyAnnotations, countScreening, emptyState, normalizeGroups } from './states.js';
|
|
27
|
+
import { TaskStore } from './store.js';
|
|
28
|
+
import { cloneRound, roundsOf, toDetail, toSummary } from './summary.js';
|
|
29
|
+
const MAX_QUERIES = 20;
|
|
30
|
+
const MAX_TITLE = 120;
|
|
31
|
+
const MAX_PAGE_LIMIT = 200;
|
|
32
|
+
const MAX_TYPES = 20;
|
|
33
|
+
const PROBE_QUERY = 'machine learning';
|
|
34
|
+
export class TaskService {
|
|
35
|
+
ready;
|
|
36
|
+
store = new TaskStore();
|
|
37
|
+
events = new TaskEvents();
|
|
38
|
+
runner = new TaskRunner();
|
|
39
|
+
/**
|
|
40
|
+
* 全局「当前任务」:最后一次使用或打开的任务。
|
|
41
|
+
*
|
|
42
|
+
* 不按对话、也不按时间过期——同一主题的后续检索必须在原任务里加一轮,
|
|
43
|
+
* 跨对话、宿主重启都成立。只有用户显式新建任务或点开别的任务时才会改变。
|
|
44
|
+
*/
|
|
45
|
+
currentTaskId;
|
|
46
|
+
currentTaskAt = 0;
|
|
47
|
+
constructor() {
|
|
48
|
+
this.ready = this.restore();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 先加载任务索引,再恢复「当前任务」快照。
|
|
52
|
+
*
|
|
53
|
+
* 快照缺失或指向已删除任务时,自举为最近更新的任务,避免下一轮检索
|
|
54
|
+
* 又新建一个同名任务。
|
|
55
|
+
*/
|
|
56
|
+
async restore() {
|
|
57
|
+
try {
|
|
58
|
+
await this.store.load({
|
|
59
|
+
retentionDays: getSettings().taskRetentionDays,
|
|
60
|
+
retentionCount: getSettings().taskRetentionCount,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.warn('[dsh-plugin-litsearch] 任务目录加载失败:', error);
|
|
65
|
+
}
|
|
66
|
+
let restored = false;
|
|
67
|
+
try {
|
|
68
|
+
const snapshot = await this.store.loadCurrent();
|
|
69
|
+
if (snapshot?.current !== undefined &&
|
|
70
|
+
this.store.summary(snapshot.current.taskId) !== undefined) {
|
|
71
|
+
this.currentTaskId = snapshot.current.taskId;
|
|
72
|
+
this.currentTaskAt = snapshot.current.at;
|
|
73
|
+
restored = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.warn('[dsh-plugin-litsearch] 当前任务快照加载失败:', error);
|
|
78
|
+
}
|
|
79
|
+
if (!restored)
|
|
80
|
+
this.bootstrapCurrentTask();
|
|
81
|
+
}
|
|
82
|
+
/** 没有可用快照时,把最近更新的任务设为当前任务并落盘。 */
|
|
83
|
+
bootstrapCurrentTask() {
|
|
84
|
+
const latest = this.store.list()[0];
|
|
85
|
+
if (latest === undefined) {
|
|
86
|
+
// 任务已全部删除:清掉快照里的旧 id,避免 current.json 指向不存在的任务。
|
|
87
|
+
this.currentTaskId = undefined;
|
|
88
|
+
this.currentTaskAt = 0;
|
|
89
|
+
this.persistCurrent();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.currentTaskId = latest.id;
|
|
93
|
+
this.currentTaskAt = Date.parse(latest.updatedAt) || Date.now();
|
|
94
|
+
this.persistCurrent();
|
|
95
|
+
}
|
|
96
|
+
get globalRev() {
|
|
97
|
+
return this.events.currentRev;
|
|
98
|
+
}
|
|
99
|
+
get root() {
|
|
100
|
+
return this.store.root;
|
|
101
|
+
}
|
|
102
|
+
isRunning(id) {
|
|
103
|
+
return this.runner.isRunning(id);
|
|
104
|
+
}
|
|
105
|
+
health() {
|
|
106
|
+
const settings = getSettings();
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
globalRev: this.events.currentRev,
|
|
110
|
+
sources: [...PROVIDER_INFOS],
|
|
111
|
+
sourceHealth: sourceHealthList(),
|
|
112
|
+
keys: { ncbi: settings.ncbiApiKey !== '', s2: settings.s2ApiKey !== '' },
|
|
113
|
+
settings: {
|
|
114
|
+
defaultSources: [...settings.defaultSources],
|
|
115
|
+
defaultPageSize: settings.defaultPageSize,
|
|
116
|
+
defaultSort: settings.defaultSort,
|
|
117
|
+
defaultLanguage: settings.defaultLanguage,
|
|
118
|
+
autoOpenTaskTab: settings.autoOpenTaskTab,
|
|
119
|
+
maxTaskTabs: settings.maxTaskTabs,
|
|
120
|
+
},
|
|
121
|
+
fullText: {
|
|
122
|
+
pdfRoot: resolvePdfRoot(),
|
|
123
|
+
pdfMaxMb: settings.pdfMaxMb,
|
|
124
|
+
autoDownload: settings.autoDownloadPdf,
|
|
125
|
+
epmcFulltext: settings.enableEpmcFulltext,
|
|
126
|
+
},
|
|
127
|
+
zotero: {
|
|
128
|
+
enabled: settings.zoteroEnabled,
|
|
129
|
+
baseUrl: settings.zoteroBaseUrl,
|
|
130
|
+
attachPdf: settings.zoteroAttachPdf,
|
|
131
|
+
tag: settings.zoteroTag,
|
|
132
|
+
},
|
|
133
|
+
storageDir: this.store.root,
|
|
134
|
+
version: PLUGIN_VERSION,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async eventsSince(sinceRev) {
|
|
138
|
+
await this.ready;
|
|
139
|
+
return this.events.since(sinceRev);
|
|
140
|
+
}
|
|
141
|
+
async list(filter = {}) {
|
|
142
|
+
await this.ready;
|
|
143
|
+
const needle = filter.q?.trim().toLowerCase() ?? '';
|
|
144
|
+
return this.store.list().filter((task) => {
|
|
145
|
+
if (filter.origin !== undefined && task.origin !== filter.origin)
|
|
146
|
+
return false;
|
|
147
|
+
if (filter.pinned !== undefined && task.pinned !== filter.pinned)
|
|
148
|
+
return false;
|
|
149
|
+
if (needle !== '' && !task.title.toLowerCase().includes(needle))
|
|
150
|
+
return false;
|
|
151
|
+
return true;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
async get(id) {
|
|
155
|
+
await this.ready;
|
|
156
|
+
const task = this.store.cached(id) ?? (await this.store.loadTask(id));
|
|
157
|
+
if (task === undefined)
|
|
158
|
+
throw new SearchError('not_found', `任务不存在:${id}`);
|
|
159
|
+
// 旧版本落盘的任务没有 rounds,所有读路径统一补成数组。
|
|
160
|
+
if (!Array.isArray(task.rounds))
|
|
161
|
+
task.rounds = [];
|
|
162
|
+
return task;
|
|
163
|
+
}
|
|
164
|
+
async getDetail(id) {
|
|
165
|
+
return toDetail(await this.get(id));
|
|
166
|
+
}
|
|
167
|
+
/** 任务内轮次历史(时间升序、深拷贝)。 */
|
|
168
|
+
async getRounds(id) {
|
|
169
|
+
return roundsOf(await this.get(id)).map(cloneRound);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 当前任务:最近一次使用或打开的任务。
|
|
173
|
+
*
|
|
174
|
+
* 没有时间窗口、也不区分对话;只要任务还在就一直有效。任务被全部删除后
|
|
175
|
+
* 返回 undefined,由调用方决定是否新建。
|
|
176
|
+
*/
|
|
177
|
+
async currentTask() {
|
|
178
|
+
await this.ready;
|
|
179
|
+
if (this.currentTaskId === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
const task = await this.tryGet(this.currentTaskId);
|
|
182
|
+
if (task !== undefined)
|
|
183
|
+
return task;
|
|
184
|
+
this.currentTaskId = undefined;
|
|
185
|
+
this.bootstrapCurrentTask();
|
|
186
|
+
if (this.currentTaskId === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
return this.tryGet(this.currentTaskId);
|
|
189
|
+
}
|
|
190
|
+
/** 记住最近使用的任务(下一次模型检索默认落在这里)。 */
|
|
191
|
+
setCurrentTask(id) {
|
|
192
|
+
this.currentTaskId = id;
|
|
193
|
+
this.currentTaskAt = Date.now();
|
|
194
|
+
this.persistCurrent();
|
|
195
|
+
}
|
|
196
|
+
/** 请求客户端打开/聚焦某任务 tab(面板被关掉时重新打开)。 */
|
|
197
|
+
focusTask(id, title, sessionId) {
|
|
198
|
+
this.events.requestFocus(id, title, sessionId);
|
|
199
|
+
}
|
|
200
|
+
persistCurrent() {
|
|
201
|
+
void this.store.saveCurrent({
|
|
202
|
+
version: 1,
|
|
203
|
+
...(this.currentTaskId === undefined
|
|
204
|
+
? {}
|
|
205
|
+
: { current: { taskId: this.currentTaskId, at: this.currentTaskAt } }),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** 用户从任务列表点开某任务 = 进入该任务,之后的模型检索默认落在这里。 */
|
|
209
|
+
async activate(id) {
|
|
210
|
+
const task = await this.get(id);
|
|
211
|
+
this.setCurrentTask(task.id);
|
|
212
|
+
return task;
|
|
213
|
+
}
|
|
214
|
+
/** 等待该任务当前这一轮结束;返回是否确实已空闲。 */
|
|
215
|
+
async waitUntilIdle(id, timeoutMs, signal) {
|
|
216
|
+
if (!this.runner.isRunning(id))
|
|
217
|
+
return true;
|
|
218
|
+
if (signal?.aborted === true)
|
|
219
|
+
return false;
|
|
220
|
+
let timer;
|
|
221
|
+
let onAbort;
|
|
222
|
+
try {
|
|
223
|
+
await Promise.race([
|
|
224
|
+
this.runner.wait(id),
|
|
225
|
+
new Promise((resolve) => {
|
|
226
|
+
timer = setTimeout(resolve, Math.max(0, timeoutMs));
|
|
227
|
+
}),
|
|
228
|
+
...(signal === undefined
|
|
229
|
+
? []
|
|
230
|
+
: [
|
|
231
|
+
new Promise((resolve) => {
|
|
232
|
+
onAbort = () => resolve();
|
|
233
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
234
|
+
}),
|
|
235
|
+
]),
|
|
236
|
+
]);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
if (timer !== undefined)
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
if (signal !== undefined && onAbort !== undefined) {
|
|
242
|
+
signal.removeEventListener('abort', onAbort);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return !this.runner.isRunning(id);
|
|
246
|
+
}
|
|
247
|
+
async create(input) {
|
|
248
|
+
await this.ready;
|
|
249
|
+
const settings = getSettings();
|
|
250
|
+
const conditions = this.readConditions(input, settings);
|
|
251
|
+
const title = readTitle(input.title, conditions);
|
|
252
|
+
const inherited = input.fromTaskId === undefined ? undefined : await this.get(input.fromTaskId);
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
const task = {
|
|
255
|
+
id: `t${Date.now().toString(36)}${randomUUID().slice(0, 6)}`,
|
|
256
|
+
title,
|
|
257
|
+
conditions,
|
|
258
|
+
status: 'draft',
|
|
259
|
+
origin: input.origin,
|
|
260
|
+
rev: 1,
|
|
261
|
+
createdAt: now,
|
|
262
|
+
updatedAt: now,
|
|
263
|
+
pinned: false,
|
|
264
|
+
notes: '',
|
|
265
|
+
groups: [],
|
|
266
|
+
stats: {
|
|
267
|
+
perQuery: {},
|
|
268
|
+
rawHits: 0,
|
|
269
|
+
deduped: 0,
|
|
270
|
+
screening: { unread: 0, read: 0, included: 0, excluded: 0 },
|
|
271
|
+
},
|
|
272
|
+
result: { papers: [], sources: [] },
|
|
273
|
+
rounds: [],
|
|
274
|
+
states: inherited === undefined ? {} : inheritStates(inherited),
|
|
275
|
+
...(input.fromTaskId === undefined ? {} : { fromTaskId: input.fromTaskId }),
|
|
276
|
+
};
|
|
277
|
+
this.store.put(task);
|
|
278
|
+
await this.store.save(task);
|
|
279
|
+
// 新建的任务就是「当前任务」:后续检索默认在它里面加轮次。
|
|
280
|
+
this.setCurrentTask(task.id);
|
|
281
|
+
this.events.publish(toSummary(task));
|
|
282
|
+
if (input.runNow !== false) {
|
|
283
|
+
void this.begin(task, 'new').catch((error) => {
|
|
284
|
+
console.warn('[dsh-plugin-litsearch] 任务执行失败:', error);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return task;
|
|
288
|
+
}
|
|
289
|
+
async update(id, patch, expectedRev) {
|
|
290
|
+
const task = await this.get(id);
|
|
291
|
+
if (expectedRev !== undefined && expectedRev !== task.rev) {
|
|
292
|
+
throw new SearchError('conflict', `任务已被更新(当前 rev=${task.rev}),请刷新后重试`);
|
|
293
|
+
}
|
|
294
|
+
if (this.runner.isRunning(id)) {
|
|
295
|
+
throw new SearchError('conflict', '任务正在运行,请等待完成或先取消');
|
|
296
|
+
}
|
|
297
|
+
if (patch.title !== undefined) {
|
|
298
|
+
const title = patch.title.trim();
|
|
299
|
+
if (title === '')
|
|
300
|
+
throw new SearchError('bad_request', '任务标题不能为空');
|
|
301
|
+
task.title = title.slice(0, MAX_TITLE);
|
|
302
|
+
}
|
|
303
|
+
if (patch.notes !== undefined)
|
|
304
|
+
task.notes = patch.notes;
|
|
305
|
+
if (patch.pinned !== undefined)
|
|
306
|
+
task.pinned = patch.pinned;
|
|
307
|
+
if (patch.groups !== undefined) {
|
|
308
|
+
const groups = normalizeGroups(patch.groups) ?? [];
|
|
309
|
+
task.groups = groups;
|
|
310
|
+
// 删除分组后,原来归入它的论文回到未分组,而不是指向不存在的 id。
|
|
311
|
+
const ids = new Set(groups.map((group) => group.id));
|
|
312
|
+
for (const state of Object.values(task.states)) {
|
|
313
|
+
if (state.groupId !== undefined && !ids.has(state.groupId))
|
|
314
|
+
delete state.groupId;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (patch.conditions !== undefined) {
|
|
318
|
+
task.conditions = mergeConditions(task.conditions, patch.conditions);
|
|
319
|
+
}
|
|
320
|
+
task.rev += 1;
|
|
321
|
+
task.updatedAt = new Date().toISOString();
|
|
322
|
+
await this.store.save(task);
|
|
323
|
+
this.events.publish(toSummary(task));
|
|
324
|
+
return task;
|
|
325
|
+
}
|
|
326
|
+
async remove(id) {
|
|
327
|
+
const task = await this.get(id);
|
|
328
|
+
// 先等这一轮收尾(finally 里还会落一次盘),再删文件,避免任务被写回。
|
|
329
|
+
if (this.runner.cancel(task.id))
|
|
330
|
+
await this.runner.wait(task.id);
|
|
331
|
+
await this.store.remove(task.id);
|
|
332
|
+
// 模型工具导出到 <storage>/out/<taskId>/ 的文件随任务一起清掉。
|
|
333
|
+
await rm(join(this.store.root, 'out', task.id), { recursive: true, force: true });
|
|
334
|
+
this.events.remove(task.id);
|
|
335
|
+
if (this.currentTaskId !== task.id)
|
|
336
|
+
return;
|
|
337
|
+
this.currentTaskId = undefined;
|
|
338
|
+
this.bootstrapCurrentTask();
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* 启动一次执行(任务内新一轮);返回的 Promise 在运行结束后 resolve 为最新任务。
|
|
342
|
+
*
|
|
343
|
+
* 任务已缓存时同步登记运行态,调用方紧接着调 `waitUntilIdle` 也不会误判为空闲。
|
|
344
|
+
*/
|
|
345
|
+
run(id, mode = 'new', origin = 'panel') {
|
|
346
|
+
const cached = this.store.cached(id);
|
|
347
|
+
if (cached !== undefined) {
|
|
348
|
+
try {
|
|
349
|
+
return this.begin(cached, mode, origin);
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
return Promise.reject(error);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return this.get(id).then((task) => this.begin(task, mode, origin));
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* 在同一任务内开新一轮:全量替换检索条件后执行,**不新建任务、不新开 tab**。
|
|
359
|
+
*
|
|
360
|
+
* 与 `update` 的增量 patch 不同,这里按 CreateTaskInput 重算条件,缺省项回落到
|
|
361
|
+
* 当前设置而不是沿用旧值,保证「提交完整新条件」的语义明确。
|
|
362
|
+
*/
|
|
363
|
+
async rerun(id, input, origin) {
|
|
364
|
+
const task = await this.get(id);
|
|
365
|
+
if (this.runner.isRunning(id)) {
|
|
366
|
+
throw new SearchError('conflict', '任务正在运行,请等待当前轮次结束');
|
|
367
|
+
}
|
|
368
|
+
const nextTitle = input.title?.trim() ?? '';
|
|
369
|
+
if (nextTitle !== '')
|
|
370
|
+
task.title = nextTitle.slice(0, MAX_TITLE);
|
|
371
|
+
task.conditions = this.readConditions(input, getSettings());
|
|
372
|
+
task.rev += 1;
|
|
373
|
+
task.updatedAt = new Date().toISOString();
|
|
374
|
+
await this.store.save(task);
|
|
375
|
+
this.events.publish(toSummary(task));
|
|
376
|
+
this.setCurrentTask(task.id);
|
|
377
|
+
return this.begin(task, 'new', origin);
|
|
378
|
+
}
|
|
379
|
+
async cancel(id) {
|
|
380
|
+
const task = await this.get(id);
|
|
381
|
+
if (!this.runner.cancel(id))
|
|
382
|
+
throw new SearchError('conflict', '任务未在运行');
|
|
383
|
+
return task;
|
|
384
|
+
}
|
|
385
|
+
async results(id, filter = {}) {
|
|
386
|
+
const task = await this.get(id);
|
|
387
|
+
const limit = clampInt(filter.limit ?? task.conditions.pageSize, 1, MAX_PAGE_LIMIT);
|
|
388
|
+
const offset = clampInt(filter.offset ?? 0, 0, Number.MAX_SAFE_INTEGER);
|
|
389
|
+
const papers = filterPapers(task, await this.withBaseFilter(task, filter));
|
|
390
|
+
const sort = filter.sort ?? task.conditions.sort;
|
|
391
|
+
if (sort === 'citations') {
|
|
392
|
+
papers.sort((a, b) => (b.citedBy ?? -1) - (a.citedBy ?? -1));
|
|
393
|
+
}
|
|
394
|
+
else if (sort === 'year') {
|
|
395
|
+
papers.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
taskId: task.id,
|
|
399
|
+
rev: task.rev,
|
|
400
|
+
total: papers.length,
|
|
401
|
+
offset,
|
|
402
|
+
limit,
|
|
403
|
+
papers: papers.slice(offset, offset + limit),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
async stats(id, filter = {}, scope = 'task') {
|
|
407
|
+
const task = await this.get(id);
|
|
408
|
+
const base = await this.baseInfo(task);
|
|
409
|
+
const resolved = base === undefined ? filter : await this.withBaseFilter(task, filter);
|
|
410
|
+
return computeStats(task, resolved, scope, base);
|
|
411
|
+
}
|
|
412
|
+
/** 写入筛读 / 分组 / 必读 / 备注;返回新的 rev 与命中条数。 */
|
|
413
|
+
async annotate(id, inputs, expectedRev) {
|
|
414
|
+
const task = await this.get(id);
|
|
415
|
+
if (expectedRev !== undefined && expectedRev !== task.rev) {
|
|
416
|
+
throw new SearchError('conflict', `任务已被更新(当前 rev=${task.rev}),请刷新后重试`);
|
|
417
|
+
}
|
|
418
|
+
const outcome = applyAnnotations(task, inputs);
|
|
419
|
+
task.states = outcome.states;
|
|
420
|
+
task.stats.screening = countScreening(task.result.papers, task.states);
|
|
421
|
+
task.rev += 1;
|
|
422
|
+
task.updatedAt = new Date().toISOString();
|
|
423
|
+
await this.store.save(task);
|
|
424
|
+
this.events.publish(toSummary(task));
|
|
425
|
+
return { taskId: task.id, rev: task.rev, updated: outcome.updated };
|
|
426
|
+
}
|
|
427
|
+
async compare(id, withTaskId) {
|
|
428
|
+
if (withTaskId.trim() === '')
|
|
429
|
+
throw new SearchError('bad_request', '缺少对比任务 id');
|
|
430
|
+
if (withTaskId === id)
|
|
431
|
+
throw new SearchError('bad_request', '不能与自身对比');
|
|
432
|
+
const task = await this.get(id);
|
|
433
|
+
const base = await this.get(withTaskId);
|
|
434
|
+
return compareTasks(task, base);
|
|
435
|
+
}
|
|
436
|
+
/** 生成导出内容;`paperIds` 为空表示按当前筛选导出全部。 */
|
|
437
|
+
async export(id, format, filter = {}, paperIds) {
|
|
438
|
+
const task = await this.get(id);
|
|
439
|
+
const resolved = await this.withBaseFilter(task, filter);
|
|
440
|
+
let papers = filterPapers(task, resolved);
|
|
441
|
+
if (paperIds !== undefined && paperIds.length > 0) {
|
|
442
|
+
const wanted = new Set(paperIds);
|
|
443
|
+
papers = papers.filter((paper) => wanted.has(paper.paperId));
|
|
444
|
+
}
|
|
445
|
+
const base = task.fromTaskId === undefined ? undefined : await this.tryGet(task.fromTaskId);
|
|
446
|
+
const context = { task, papers, generatedAt: new Date() };
|
|
447
|
+
if (base !== undefined)
|
|
448
|
+
context.base = base;
|
|
449
|
+
return buildExport(context, format);
|
|
450
|
+
}
|
|
451
|
+
/** 把导出内容写到 `<storage>/out/<taskId>/`,供模型工具返回落盘路径。 */
|
|
452
|
+
async exportToDisk(id, format, filter = {}, paperIds) {
|
|
453
|
+
const task = await this.get(id);
|
|
454
|
+
const file = await this.export(id, format, filter, paperIds);
|
|
455
|
+
const dir = join(this.store.root, 'out', task.id);
|
|
456
|
+
await mkdir(dir, { recursive: true });
|
|
457
|
+
const path = join(dir, file.filename);
|
|
458
|
+
await writeFile(path, file.content, 'utf8');
|
|
459
|
+
let papers = filterPapers(task, await this.withBaseFilter(task, filter));
|
|
460
|
+
if (paperIds !== undefined && paperIds.length > 0) {
|
|
461
|
+
const wanted = new Set(paperIds);
|
|
462
|
+
papers = papers.filter((paper) => wanted.has(paper.paperId));
|
|
463
|
+
}
|
|
464
|
+
return { path, file, count: papers.length };
|
|
465
|
+
}
|
|
466
|
+
/** 设置页清理:任务文件与 PDF 缓存。 */
|
|
467
|
+
async cleanup(scope) {
|
|
468
|
+
if (scope === 'pdf') {
|
|
469
|
+
const outcome = await cleanupPdfCache(this.store.root);
|
|
470
|
+
return {
|
|
471
|
+
scope,
|
|
472
|
+
removed: outcome.removed,
|
|
473
|
+
freedBytes: outcome.freedBytes,
|
|
474
|
+
remaining: this.store.list().length,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
const tasks = await this.store.cleanupUnpinned();
|
|
478
|
+
const pdf = scope === 'all' ? await cleanupPdfCache(this.store.root) : { removed: 0, freedBytes: 0 };
|
|
479
|
+
return {
|
|
480
|
+
scope,
|
|
481
|
+
removed: tasks.removed + pdf.removed,
|
|
482
|
+
freedBytes: tasks.freedBytes + pdf.freedBytes,
|
|
483
|
+
remaining: this.store.list().length,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/** 下载单篇 PDF;失败时写入失败状态,若有 EPMC 全文则降级为 html-only。 */
|
|
487
|
+
async downloadPdf(id, paperId) {
|
|
488
|
+
const task = await this.get(id);
|
|
489
|
+
const paper = findPaper(task, paperId);
|
|
490
|
+
const settings = getSettings();
|
|
491
|
+
const resolution = await resolvePdf(paper);
|
|
492
|
+
if (!resolution.ok || resolution.buffer === undefined) {
|
|
493
|
+
const fallback = settings.enableEpmcFulltext && epmcFullTextAvailable(paper) ? 'html-only' : resolution.status;
|
|
494
|
+
await this.setPdfState(task, paperId, {
|
|
495
|
+
pdfStatus: fallback,
|
|
496
|
+
pdfError: resolution.error ?? '下载失败',
|
|
497
|
+
});
|
|
498
|
+
const result = {
|
|
499
|
+
paperId,
|
|
500
|
+
status: fallback,
|
|
501
|
+
bytes: 0,
|
|
502
|
+
error: resolution.error ?? '下载失败',
|
|
503
|
+
};
|
|
504
|
+
return result;
|
|
505
|
+
}
|
|
506
|
+
const saved = await persistPdf(resolution.buffer, this.store.root, paper);
|
|
507
|
+
await this.setPdfState(task, paperId, {
|
|
508
|
+
pdfStatus: 'downloaded',
|
|
509
|
+
pdfFile: saved.filePath,
|
|
510
|
+
});
|
|
511
|
+
const result = {
|
|
512
|
+
paperId,
|
|
513
|
+
status: 'downloaded',
|
|
514
|
+
bytes: resolution.buffer.length,
|
|
515
|
+
filename: saved.filename,
|
|
516
|
+
path: saved.filePath,
|
|
517
|
+
};
|
|
518
|
+
if (resolution.url !== undefined)
|
|
519
|
+
result.url = resolution.url;
|
|
520
|
+
return result;
|
|
521
|
+
}
|
|
522
|
+
/** PDF 预览:优先用已落地文件;没有则临时抓取一次但不落盘。 */
|
|
523
|
+
async previewPdf(id, paperId) {
|
|
524
|
+
const task = await this.get(id);
|
|
525
|
+
const paper = findPaper(task, paperId);
|
|
526
|
+
const filename = readableFilename(paper);
|
|
527
|
+
const local = await readLocalPdf(paper);
|
|
528
|
+
if (local !== null)
|
|
529
|
+
return { buffer: local, filename };
|
|
530
|
+
const resolution = await resolvePdf(paper);
|
|
531
|
+
if (!resolution.ok || resolution.buffer === undefined) {
|
|
532
|
+
throw new SearchError('not_found', resolution.error ?? '没有可预览的 PDF');
|
|
533
|
+
}
|
|
534
|
+
return { buffer: resolution.buffer, filename };
|
|
535
|
+
}
|
|
536
|
+
/** Europe PMC 全文(JATS → 只含安全标签的 HTML)。 */
|
|
537
|
+
async fullText(id, paperId) {
|
|
538
|
+
const task = await this.get(id);
|
|
539
|
+
const paper = findPaper(task, paperId);
|
|
540
|
+
const settings = getSettings();
|
|
541
|
+
if (!settings.enableEpmcFulltext) {
|
|
542
|
+
throw new SearchError('not_found', 'Europe PMC 全文兜底已在设置中关闭');
|
|
543
|
+
}
|
|
544
|
+
const url = epmcXmlUrl(paper);
|
|
545
|
+
if (url === null)
|
|
546
|
+
throw new SearchError('not_found', '这篇文献没有 Europe PMC 全文');
|
|
547
|
+
// EPMC 的 JATS 全文经常 10s+,通用搜索超时对单篇阅读过紧。
|
|
548
|
+
const xml = await fetchEpmcXml(url, Math.max(settings.requestTimeoutMs, 30_000));
|
|
549
|
+
const html = fullTextHtml(paper, jatsToHtml(xml), 'Europe PMC');
|
|
550
|
+
const recorded = task.states[paperId]?.pdfStatus;
|
|
551
|
+
if (paper.pdfUrl === '' && (recorded === undefined || recorded === 'unknown' || recorded === 'failed')) {
|
|
552
|
+
await this.setPdfState(task, paperId, { pdfStatus: 'html-only' }).catch(() => undefined);
|
|
553
|
+
}
|
|
554
|
+
return html;
|
|
555
|
+
}
|
|
556
|
+
/** 单篇论文详情:独立论文页使用,含任务标题与分组。 */
|
|
557
|
+
async paperDetail(id, paperId) {
|
|
558
|
+
const task = await this.get(id);
|
|
559
|
+
const paper = findPaper(task, paperId);
|
|
560
|
+
const detail = {
|
|
561
|
+
taskId: task.id,
|
|
562
|
+
taskTitle: task.title,
|
|
563
|
+
rev: task.rev,
|
|
564
|
+
paper,
|
|
565
|
+
groups: task.groups,
|
|
566
|
+
};
|
|
567
|
+
const state = task.states[paperId];
|
|
568
|
+
if (state !== undefined)
|
|
569
|
+
detail.state = state;
|
|
570
|
+
return detail;
|
|
571
|
+
}
|
|
572
|
+
/** PubTator3 实体标注;无 PMID 时明确告知不可标注。 */
|
|
573
|
+
async entities(id, paperId, signal) {
|
|
574
|
+
const task = await this.get(id);
|
|
575
|
+
const paper = findPaper(task, paperId);
|
|
576
|
+
const pmid = paper.ids.pmid;
|
|
577
|
+
if (pmid === '') {
|
|
578
|
+
throw new SearchError('not_found', '这篇文献没有 PMID,PubTator 无法标注');
|
|
579
|
+
}
|
|
580
|
+
const settings = getSettings();
|
|
581
|
+
// 全文标注的 BioC JSON 可能较大,沿用全文的 30s 下限。
|
|
582
|
+
const entities = await fetchPubtatorEntities(pmid, Math.max(settings.requestTimeoutMs, 30_000), signal);
|
|
583
|
+
return { paperId, pmid, entities, fetchedAt: new Date().toISOString() };
|
|
584
|
+
}
|
|
585
|
+
/** 测试 Zotero connector 是否可达,并返回当前选中的分类。 */
|
|
586
|
+
async zoteroProbe() {
|
|
587
|
+
const settings = getSettings();
|
|
588
|
+
try {
|
|
589
|
+
return await new ZoteroConnector(settings.zoteroBaseUrl).probe();
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/** 把选中论文同步到 Zotero(含本地 PDF 附件)。 */
|
|
596
|
+
async zoteroSync(id, paperIds) {
|
|
597
|
+
const settings = getSettings();
|
|
598
|
+
if (!settings.zoteroEnabled)
|
|
599
|
+
throw new SearchError('bad_request', 'Zotero 同步已在设置中关闭');
|
|
600
|
+
const task = await this.get(id);
|
|
601
|
+
const wanted = paperIds === undefined || paperIds.length === 0
|
|
602
|
+
? task.result.papers.slice(0, 50)
|
|
603
|
+
: paperIds.map((paperId) => findPaper(task, paperId));
|
|
604
|
+
if (wanted.length === 0)
|
|
605
|
+
throw new SearchError('bad_request', '没有可同步的文献');
|
|
606
|
+
const connector = new ZoteroConnector(settings.zoteroBaseUrl);
|
|
607
|
+
const probe = await connector.probe();
|
|
608
|
+
if (!probe.ok) {
|
|
609
|
+
throw new SearchError('network_error', `Zotero 未运行或 connector 不可达:${probe.error ?? settings.zoteroBaseUrl}`);
|
|
610
|
+
}
|
|
611
|
+
const readOnlyAttachments = settings.zoteroAttachPdf && probe.collection?.filesEditable === false;
|
|
612
|
+
const attachPdf = settings.zoteroAttachPdf && !readOnlyAttachments;
|
|
613
|
+
const inputs = [];
|
|
614
|
+
for (const paper of wanted) {
|
|
615
|
+
const tags = task.states[paper.paperId]?.tags ?? [];
|
|
616
|
+
const pdfPath = attachPdf ? await this.ensureLocalPdf(paper) : undefined;
|
|
617
|
+
inputs.push({ paper, tags, ...(pdfPath === undefined ? {} : { pdfPath }) });
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
const result = await connector.sync(inputs, {
|
|
621
|
+
baseUrl: settings.zoteroBaseUrl,
|
|
622
|
+
attachPdf,
|
|
623
|
+
tag: settings.zoteroTag,
|
|
624
|
+
});
|
|
625
|
+
if (probe.collection === undefined) {
|
|
626
|
+
result.messages.unshift('Zotero 当前未选择分类,条目已保存到「我的文库」');
|
|
627
|
+
}
|
|
628
|
+
else if (readOnlyAttachments) {
|
|
629
|
+
result.messages.unshift('当前分类不可写附件,已只保存条目;可在 Zotero 中换一个可写分类后重试');
|
|
630
|
+
}
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
throw new SearchError('upstream_error', error instanceof Error ? error.message : String(error));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** 下载目录可写性测试(设置页)。 */
|
|
638
|
+
async storageProbe() {
|
|
639
|
+
const outcome = await probePdfRoot();
|
|
640
|
+
if (outcome.writable)
|
|
641
|
+
return { ok: true, root: outcome.root, writable: true };
|
|
642
|
+
return {
|
|
643
|
+
ok: false,
|
|
644
|
+
root: outcome.root,
|
|
645
|
+
writable: false,
|
|
646
|
+
...(outcome.error === undefined ? {} : { error: outcome.error }),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
async ensureLocalPdf(paper) {
|
|
650
|
+
const local = await readLocalPdf(paper);
|
|
651
|
+
if (local !== null)
|
|
652
|
+
return join(resolvePdfRoot(), readableFilename(paper));
|
|
653
|
+
const resolution = await resolvePdf(paper).catch(() => null);
|
|
654
|
+
if (resolution === null || !resolution.ok || resolution.buffer === undefined)
|
|
655
|
+
return undefined;
|
|
656
|
+
const saved = await persistPdf(resolution.buffer, this.store.root, paper);
|
|
657
|
+
return saved.filePath;
|
|
658
|
+
}
|
|
659
|
+
async setPdfState(task, paperId, patch) {
|
|
660
|
+
// 下载进行中任务被删除:不再写回,也不向客户端广播已删除的任务。
|
|
661
|
+
if (this.store.summary(task.id) === undefined)
|
|
662
|
+
return;
|
|
663
|
+
const current = task.states[paperId] ?? emptyState();
|
|
664
|
+
const next = { ...current, pdfStatus: patch.pdfStatus };
|
|
665
|
+
if (patch.pdfError === undefined)
|
|
666
|
+
delete next.pdfError;
|
|
667
|
+
else
|
|
668
|
+
next.pdfError = patch.pdfError.slice(0, 300);
|
|
669
|
+
if (patch.pdfFile === undefined)
|
|
670
|
+
delete next.pdfFile;
|
|
671
|
+
else
|
|
672
|
+
next.pdfFile = patch.pdfFile.slice(0, 1024);
|
|
673
|
+
task.states[paperId] = next;
|
|
674
|
+
task.rev += 1;
|
|
675
|
+
task.updatedAt = new Date().toISOString();
|
|
676
|
+
await this.store.save(task);
|
|
677
|
+
this.events.publish(toSummary(task));
|
|
678
|
+
}
|
|
679
|
+
/** 单源诊断:用固定短查询测一次连通性与延迟(不受冷却影响)。 */
|
|
680
|
+
async probe(source) {
|
|
681
|
+
if (!SOURCE_IDS.includes(source)) {
|
|
682
|
+
throw new SearchError('bad_request', `未知数据源:${source}`);
|
|
683
|
+
}
|
|
684
|
+
const settings = getSettings();
|
|
685
|
+
const started = Date.now();
|
|
686
|
+
const result = { target: source, ok: false, latencyMs: 0, count: 0 };
|
|
687
|
+
try {
|
|
688
|
+
const [outcome] = await searchSources({
|
|
689
|
+
query: PROBE_QUERY,
|
|
690
|
+
sources: [source],
|
|
691
|
+
limit: 1,
|
|
692
|
+
openAccessOnly: false,
|
|
693
|
+
contactEmail: settings.contactEmail,
|
|
694
|
+
timeoutMs: Math.min(settings.requestTimeoutMs, 20_000),
|
|
695
|
+
bypassCooldown: true,
|
|
696
|
+
});
|
|
697
|
+
result.latencyMs = Date.now() - started;
|
|
698
|
+
if (outcome === undefined || !outcome.ok) {
|
|
699
|
+
result.error = outcome?.error ?? '未返回结果';
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
result.ok = true;
|
|
703
|
+
result.count = outcome.records.length;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
result.latencyMs = Date.now() - started;
|
|
708
|
+
result.error = error instanceof Error ? error.message : String(error);
|
|
709
|
+
}
|
|
710
|
+
const until = cooldownUntil(source);
|
|
711
|
+
if (until > Date.now())
|
|
712
|
+
result.cooldownUntil = new Date(until).toISOString();
|
|
713
|
+
return result;
|
|
714
|
+
}
|
|
715
|
+
async tryGet(id) {
|
|
716
|
+
try {
|
|
717
|
+
return await this.get(id);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async baseInfo(task) {
|
|
724
|
+
if (task.fromTaskId === undefined)
|
|
725
|
+
return undefined;
|
|
726
|
+
const base = await this.tryGet(task.fromTaskId);
|
|
727
|
+
if (base === undefined)
|
|
728
|
+
return undefined;
|
|
729
|
+
return { paperIds: new Set(base.result.papers.map((paper) => paper.paperId)), rev: base.rev };
|
|
730
|
+
}
|
|
731
|
+
/** `newOnly` 需要基准任务的论文集合;基准已删除时忽略该条件。 */
|
|
732
|
+
async withBaseFilter(task, filter) {
|
|
733
|
+
if (filter.newOnly !== true || task.fromTaskId === undefined)
|
|
734
|
+
return filter;
|
|
735
|
+
const base = await this.tryGet(task.fromTaskId);
|
|
736
|
+
if (base === undefined)
|
|
737
|
+
return filter;
|
|
738
|
+
return {
|
|
739
|
+
...filter,
|
|
740
|
+
newPaperIds: new Set(base.result.papers.map((paper) => paper.paperId)),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
dispose() {
|
|
744
|
+
this.runner.cancelAll();
|
|
745
|
+
void this.store.flush();
|
|
746
|
+
}
|
|
747
|
+
begin(task, mode, origin = task.origin) {
|
|
748
|
+
if (this.runner.isRunning(task.id)) {
|
|
749
|
+
throw new SearchError('conflict', '任务正在运行');
|
|
750
|
+
}
|
|
751
|
+
if (mode === 'more') {
|
|
752
|
+
throw new SearchError('bad_request', '续页加载将在后续版本提供,请先重跑任务');
|
|
753
|
+
}
|
|
754
|
+
const queries = task.conditions.queries.filter((query) => query.enabled && query.text.trim() !== '');
|
|
755
|
+
if (queries.length === 0)
|
|
756
|
+
throw new SearchError('bad_request', '没有启用的检索式');
|
|
757
|
+
const done = this.runner.start(task, {
|
|
758
|
+
persist: (current) => this.store.save(current),
|
|
759
|
+
publish: (current) => {
|
|
760
|
+
// 删除后这一轮收尾仍会 publish,跳过已删除任务,避免客户端又去打开它。
|
|
761
|
+
if (this.store.summary(current.id) !== undefined) {
|
|
762
|
+
this.events.publish(toSummary(current));
|
|
763
|
+
}
|
|
764
|
+
},
|
|
765
|
+
}, origin);
|
|
766
|
+
return done.then(() => {
|
|
767
|
+
if (getSettings().autoDownloadPdf) {
|
|
768
|
+
void this.autoDownloadPdfs(task).catch((error) => {
|
|
769
|
+
console.warn('[dsh-plugin-litsearch] 自动下载 PDF 失败:', error);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
return task;
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
/** 任务完成后按设置自动下载少量开放获取 PDF(上限 10 篇,避免长时间占用网络)。 */
|
|
776
|
+
async autoDownloadPdfs(task) {
|
|
777
|
+
const targets = task.result.papers
|
|
778
|
+
.filter((paper) => paper.pdfUrl !== '' || paper.ids.pmcid !== '' || paper.ids.arxivId !== '')
|
|
779
|
+
.slice(0, 10);
|
|
780
|
+
for (const paper of targets) {
|
|
781
|
+
// 任务中途被删除时停止后续下载,避免继续往用户下载目录写文件。
|
|
782
|
+
if (this.store.summary(task.id) === undefined)
|
|
783
|
+
return;
|
|
784
|
+
if (task.states[paper.paperId]?.pdfStatus === 'downloaded')
|
|
785
|
+
continue;
|
|
786
|
+
const resolution = await resolvePdf(paper).catch(() => null);
|
|
787
|
+
if (resolution === null || !resolution.ok || resolution.buffer === undefined) {
|
|
788
|
+
if (resolution !== null) {
|
|
789
|
+
const fallback = getSettings().enableEpmcFulltext && epmcFullTextAvailable(paper)
|
|
790
|
+
? 'html-only'
|
|
791
|
+
: resolution.status;
|
|
792
|
+
await this.setPdfState(task, paper.paperId, {
|
|
793
|
+
pdfStatus: fallback,
|
|
794
|
+
pdfError: resolution.error ?? '下载失败',
|
|
795
|
+
}).catch(() => undefined);
|
|
796
|
+
}
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const saved = await persistPdf(resolution.buffer, this.store.root, paper);
|
|
800
|
+
await this.setPdfState(task, paper.paperId, {
|
|
801
|
+
pdfStatus: 'downloaded',
|
|
802
|
+
pdfFile: saved.filePath,
|
|
803
|
+
}).catch(() => undefined);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
readConditions(input, settings) {
|
|
807
|
+
const queries = normalizeQueries(input.query, input.queries);
|
|
808
|
+
const sources = normalizeSources(input.sources, settings.defaultSources);
|
|
809
|
+
const condition = {
|
|
810
|
+
queries,
|
|
811
|
+
sources,
|
|
812
|
+
openAccessOnly: input.openAccessOnly === true,
|
|
813
|
+
language: input.language ?? settings.defaultLanguage,
|
|
814
|
+
sort: input.sort ?? settings.defaultSort,
|
|
815
|
+
pageSize: clampInt(input.pageSize ?? settings.defaultPageSize, 1, MAX_LIMIT),
|
|
816
|
+
};
|
|
817
|
+
if (input.yearFrom !== undefined)
|
|
818
|
+
condition.yearFrom = input.yearFrom;
|
|
819
|
+
if (input.yearTo !== undefined)
|
|
820
|
+
condition.yearTo = input.yearTo;
|
|
821
|
+
const types = normalizeTypes(input.types);
|
|
822
|
+
if (types !== undefined)
|
|
823
|
+
condition.types = types;
|
|
824
|
+
if (condition.yearFrom !== undefined && condition.yearTo !== undefined && condition.yearFrom > condition.yearTo) {
|
|
825
|
+
throw new SearchError('bad_request', '起始年份不能晚于结束年份');
|
|
826
|
+
}
|
|
827
|
+
return condition;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function mergeConditions(current, patch) {
|
|
831
|
+
const next = {
|
|
832
|
+
...current,
|
|
833
|
+
queries: patch.queries === undefined ? current.queries : normalizeQueries(undefined, patch.queries),
|
|
834
|
+
sources: normalizeSources(patch.sources, current.sources),
|
|
835
|
+
pageSize: clampInt(patch.pageSize ?? current.pageSize, 1, MAX_LIMIT),
|
|
836
|
+
openAccessOnly: patch.openAccessOnly ?? current.openAccessOnly,
|
|
837
|
+
language: patch.language ?? current.language,
|
|
838
|
+
sort: patch.sort ?? current.sort,
|
|
839
|
+
};
|
|
840
|
+
if (patch.yearFrom === null)
|
|
841
|
+
delete next.yearFrom;
|
|
842
|
+
else if (patch.yearFrom !== undefined)
|
|
843
|
+
next.yearFrom = patch.yearFrom;
|
|
844
|
+
if (patch.yearTo === null)
|
|
845
|
+
delete next.yearTo;
|
|
846
|
+
else if (patch.yearTo !== undefined)
|
|
847
|
+
next.yearTo = patch.yearTo;
|
|
848
|
+
if (patch.types === null)
|
|
849
|
+
delete next.types;
|
|
850
|
+
else if (patch.types !== undefined) {
|
|
851
|
+
const types = normalizeTypes(patch.types);
|
|
852
|
+
if (types === undefined)
|
|
853
|
+
delete next.types;
|
|
854
|
+
else
|
|
855
|
+
next.types = types;
|
|
856
|
+
}
|
|
857
|
+
if (next.yearFrom !== undefined && next.yearTo !== undefined && next.yearFrom > next.yearTo) {
|
|
858
|
+
throw new SearchError('bad_request', '起始年份不能晚于结束年份');
|
|
859
|
+
}
|
|
860
|
+
return next;
|
|
861
|
+
}
|
|
862
|
+
function normalizeQueries(query, queries) {
|
|
863
|
+
const raw = [];
|
|
864
|
+
if (queries !== undefined && queries.length > 0)
|
|
865
|
+
raw.push(...queries);
|
|
866
|
+
else if (query !== undefined && query.trim() !== '')
|
|
867
|
+
raw.push({ text: query });
|
|
868
|
+
if (raw.length === 0)
|
|
869
|
+
throw new SearchError('bad_request', '请至少提供一条检索式');
|
|
870
|
+
if (raw.length > MAX_QUERIES) {
|
|
871
|
+
throw new SearchError('bad_request', `检索式最多 ${MAX_QUERIES} 条`);
|
|
872
|
+
}
|
|
873
|
+
return raw.map((input, index) => {
|
|
874
|
+
const text = input.text.trim();
|
|
875
|
+
if (text === '')
|
|
876
|
+
throw new SearchError('bad_request', `第 ${index + 1} 条检索式为空`);
|
|
877
|
+
const name = input.name?.trim() ?? '';
|
|
878
|
+
return {
|
|
879
|
+
id: `q${index + 1}`,
|
|
880
|
+
name: name === '' ? `检索式 ${index + 1}` : name,
|
|
881
|
+
text,
|
|
882
|
+
enabled: input.enabled !== false,
|
|
883
|
+
...(input.perSource === undefined ? {} : { perSource: { ...input.perSource } }),
|
|
884
|
+
};
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
function normalizeSources(requested, fallback) {
|
|
888
|
+
const wanted = requested === undefined || requested.length === 0 ? fallback : requested;
|
|
889
|
+
const valid = SOURCE_IDS.filter((id) => wanted.includes(id));
|
|
890
|
+
return valid.length === 0 ? [...SOURCE_IDS] : valid;
|
|
891
|
+
}
|
|
892
|
+
function normalizeTypes(values) {
|
|
893
|
+
if (values === undefined)
|
|
894
|
+
return undefined;
|
|
895
|
+
const seen = new Set();
|
|
896
|
+
for (const raw of values) {
|
|
897
|
+
const value = raw.trim().slice(0, 40);
|
|
898
|
+
if (value !== '')
|
|
899
|
+
seen.add(value);
|
|
900
|
+
if (seen.size >= MAX_TYPES)
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
return seen.size === 0 ? undefined : [...seen];
|
|
904
|
+
}
|
|
905
|
+
function readTitle(title, conditions) {
|
|
906
|
+
const explicit = title?.trim() ?? '';
|
|
907
|
+
if (explicit !== '')
|
|
908
|
+
return explicit.slice(0, MAX_TITLE);
|
|
909
|
+
const first = conditions.queries[0]?.text ?? '未命名检索';
|
|
910
|
+
const suffix = conditions.queries.length > 1 ? ` 等 ${conditions.queries.length} 条` : '';
|
|
911
|
+
const base = first.length > 60 ? `${first.slice(0, 60)}…` : first;
|
|
912
|
+
return `${base}${suffix}`.slice(0, MAX_TITLE);
|
|
913
|
+
}
|
|
914
|
+
function inheritStates(base) {
|
|
915
|
+
const states = {};
|
|
916
|
+
for (const [paperId, state] of Object.entries(base.states)) {
|
|
917
|
+
// 分组是任务内概念,继承时只带筛读、必读顺序、备注与标签。
|
|
918
|
+
const next = { screening: state.screening };
|
|
919
|
+
if (state.shortlistOrder !== undefined)
|
|
920
|
+
next.shortlistOrder = state.shortlistOrder;
|
|
921
|
+
if (state.note !== undefined)
|
|
922
|
+
next.note = state.note;
|
|
923
|
+
if (state.tags !== undefined)
|
|
924
|
+
next.tags = [...state.tags];
|
|
925
|
+
states[paperId] = next;
|
|
926
|
+
}
|
|
927
|
+
return states;
|
|
928
|
+
}
|
|
929
|
+
function clampInt(value, min, max) {
|
|
930
|
+
if (!Number.isFinite(value))
|
|
931
|
+
return min;
|
|
932
|
+
return Math.min(Math.max(Math.floor(value), min), max);
|
|
933
|
+
}
|
|
934
|
+
function findPaper(task, paperId) {
|
|
935
|
+
const paper = task.result.papers.find((entry) => entry.paperId === paperId);
|
|
936
|
+
if (paper === undefined)
|
|
937
|
+
throw new SearchError('not_found', `任务里没有这篇文献:${paperId}`);
|
|
938
|
+
return paper;
|
|
939
|
+
}
|
|
940
|
+
async function fetchEpmcXml(url, timeoutMs) {
|
|
941
|
+
const controller = new AbortController();
|
|
942
|
+
const timer = setTimeout(() => controller.abort(), Math.max(5_000, timeoutMs));
|
|
943
|
+
try {
|
|
944
|
+
const response = await fetch(url, {
|
|
945
|
+
signal: controller.signal,
|
|
946
|
+
headers: { accept: 'application/xml,text/xml;q=0.9,*/*;q=0.5' },
|
|
947
|
+
});
|
|
948
|
+
if (!response.ok) {
|
|
949
|
+
const code = response.status === 404 ? 'not_found' : 'upstream_error';
|
|
950
|
+
throw new SearchError(code, `Europe PMC 返回 HTTP ${response.status}`);
|
|
951
|
+
}
|
|
952
|
+
const declared = Number(response.headers.get('content-length') ?? '0');
|
|
953
|
+
if (Number.isFinite(declared) && declared > 6 * 1024 * 1024) {
|
|
954
|
+
throw new SearchError('bad_request', 'Europe PMC 全文超过 6 MB,已跳过');
|
|
955
|
+
}
|
|
956
|
+
const text = await response.text();
|
|
957
|
+
if (text.length > 6 * 1024 * 1024) {
|
|
958
|
+
throw new SearchError('bad_request', 'Europe PMC 全文超过 6 MB,已跳过');
|
|
959
|
+
}
|
|
960
|
+
if (!text.includes('<body')) {
|
|
961
|
+
throw new SearchError('not_found', 'Europe PMC 没有可用的全文 XML');
|
|
962
|
+
}
|
|
963
|
+
return text;
|
|
964
|
+
}
|
|
965
|
+
catch (error) {
|
|
966
|
+
if (error instanceof SearchError)
|
|
967
|
+
throw error;
|
|
968
|
+
if (controller.signal.aborted) {
|
|
969
|
+
throw new SearchError('timeout', 'Europe PMC 全文请求超时');
|
|
970
|
+
}
|
|
971
|
+
throw new SearchError('network_error', error instanceof Error ? error.message : String(error));
|
|
972
|
+
}
|
|
973
|
+
finally {
|
|
974
|
+
clearTimeout(timer);
|
|
975
|
+
}
|
|
976
|
+
}
|