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,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务对比:按稳定的 `paperId` 做差分,标出新增 / 共有 / 缺失。
|
|
3
|
+
*
|
|
4
|
+
* 对比只读两份已加载结果,不触发重新检索;新增结果可回到结果页用 `new=1`
|
|
5
|
+
* 筛选,或由模型/面板批量写入筛读状态。
|
|
6
|
+
*/
|
|
7
|
+
import type { CompareResult, SearchTask } from '../../search/types.js';
|
|
8
|
+
export declare function compareTasks(task: SearchTask, base: SearchTask): CompareResult;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务对比:按稳定的 `paperId` 做差分,标出新增 / 共有 / 缺失。
|
|
3
|
+
*
|
|
4
|
+
* 对比只读两份已加载结果,不触发重新检索;新增结果可回到结果页用 `new=1`
|
|
5
|
+
* 筛选,或由模型/面板批量写入筛读状态。
|
|
6
|
+
*/
|
|
7
|
+
export function compareTasks(task, base) {
|
|
8
|
+
const currentIds = new Set(task.result.papers.map((paper) => paper.paperId));
|
|
9
|
+
const baseIds = new Set(base.result.papers.map((paper) => paper.paperId));
|
|
10
|
+
let common = 0;
|
|
11
|
+
const added = [];
|
|
12
|
+
const missing = [];
|
|
13
|
+
for (const paper of task.result.papers) {
|
|
14
|
+
if (baseIds.has(paper.paperId))
|
|
15
|
+
common += 1;
|
|
16
|
+
else
|
|
17
|
+
added.push(toComparePaper(paper));
|
|
18
|
+
}
|
|
19
|
+
for (const paper of base.result.papers) {
|
|
20
|
+
if (!currentIds.has(paper.paperId))
|
|
21
|
+
missing.push(toComparePaper(paper));
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
taskId: task.id,
|
|
25
|
+
rev: task.rev,
|
|
26
|
+
withTaskId: base.id,
|
|
27
|
+
withTitle: base.title,
|
|
28
|
+
counts: {
|
|
29
|
+
added: added.length,
|
|
30
|
+
common,
|
|
31
|
+
missing: missing.length,
|
|
32
|
+
total: task.result.papers.length,
|
|
33
|
+
baseTotal: base.result.papers.length,
|
|
34
|
+
},
|
|
35
|
+
added,
|
|
36
|
+
missing,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function toComparePaper(paper) {
|
|
40
|
+
return {
|
|
41
|
+
paperId: paper.paperId,
|
|
42
|
+
title: paper.title,
|
|
43
|
+
authors: paper.authors.slice(0, 6),
|
|
44
|
+
year: paper.year,
|
|
45
|
+
venue: paper.venue,
|
|
46
|
+
doi: paper.doi,
|
|
47
|
+
openAccess: paper.openAccess,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 结果筛选:`/results` 与 `/stats` 共用同一份口径。
|
|
3
|
+
*
|
|
4
|
+
* 任何新筛选条件都必须先加到这里,避免列表与统计出现两套过滤逻辑。
|
|
5
|
+
*/
|
|
6
|
+
import type { Paper, PdfStatus, Screening, SearchTask, TaskLanguage } from '../../search/types.js';
|
|
7
|
+
export interface PaperFilter {
|
|
8
|
+
source?: string;
|
|
9
|
+
yearFrom?: number;
|
|
10
|
+
yearTo?: number;
|
|
11
|
+
openAccessOnly?: boolean;
|
|
12
|
+
q?: string;
|
|
13
|
+
screening?: Screening;
|
|
14
|
+
/** 只保留命中该检索式的论文。 */
|
|
15
|
+
queryId?: string;
|
|
16
|
+
/** 文献类型(精确匹配,不分大小写)。 */
|
|
17
|
+
type?: string;
|
|
18
|
+
language?: TaskLanguage;
|
|
19
|
+
/** 只保留已有 PDF 直链或已下载的论文。 */
|
|
20
|
+
hasPdf?: boolean;
|
|
21
|
+
/** 只保留尚无可用全文(PDF 或 EPMC HTML)的论文。 */
|
|
22
|
+
missingFullText?: boolean;
|
|
23
|
+
/** 只保留有效全文状态恰为该值的论文(与覆盖率的 pdfStatusOf 同一口径)。 */
|
|
24
|
+
pdfStatus?: PdfStatus;
|
|
25
|
+
/** 只保留归入该分组的论文。 */
|
|
26
|
+
groupId?: string;
|
|
27
|
+
/** 期刊/出处(精确匹配,不分大小写)。 */
|
|
28
|
+
venue?: string;
|
|
29
|
+
/** 作者(仅匹配 authors 中的完整姓名,不分大小写)。 */
|
|
30
|
+
author?: string;
|
|
31
|
+
/** 只保留相对 `fromTaskId` 新增的论文。 */
|
|
32
|
+
newOnly?: boolean;
|
|
33
|
+
/** 由服务层按基准任务解析出的新增集合;不参与指纹。 */
|
|
34
|
+
newPaperIds?: ReadonlySet<string>;
|
|
35
|
+
}
|
|
36
|
+
export declare function filterPapers(task: SearchTask, filter?: PaperFilter): Paper[];
|
|
37
|
+
export declare function screeningOf(task: SearchTask, paper: Paper): Screening;
|
|
38
|
+
/** 有效全文状态:没有人工/下载记录时,上游给的 PDF 直链视为可用。 */
|
|
39
|
+
export declare function pdfStatusOf(task: SearchTask, paper: Paper): PdfStatus;
|
|
40
|
+
export declare function matchesLanguage(paper: Paper, language: TaskLanguage): boolean;
|
|
41
|
+
/** 稳定的筛选指纹(统计缓存 key 的一部分)。 */
|
|
42
|
+
export declare function filterKey(filter: PaperFilter): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 结果筛选:`/results` 与 `/stats` 共用同一份口径。
|
|
3
|
+
*
|
|
4
|
+
* 任何新筛选条件都必须先加到这里,避免列表与统计出现两套过滤逻辑。
|
|
5
|
+
*/
|
|
6
|
+
export function filterPapers(task, filter = {}) {
|
|
7
|
+
const needle = filter.q?.trim().toLowerCase() ?? '';
|
|
8
|
+
const type = filter.type?.trim().toLowerCase() ?? '';
|
|
9
|
+
const venue = filter.venue?.trim().toLowerCase() ?? '';
|
|
10
|
+
const author = filter.author?.trim().toLowerCase() ?? '';
|
|
11
|
+
return task.result.papers.filter((paper) => {
|
|
12
|
+
if (filter.source !== undefined && !paper.hitSources.some((hit) => hit.source === filter.source)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
if (filter.yearFrom !== undefined && (paper.year ?? 0) < filter.yearFrom)
|
|
16
|
+
return false;
|
|
17
|
+
if (filter.yearTo !== undefined && (paper.year ?? Number.MAX_SAFE_INTEGER) > filter.yearTo) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (filter.openAccessOnly === true && !paper.openAccess)
|
|
21
|
+
return false;
|
|
22
|
+
if (filter.queryId !== undefined && !paper.matchedQueries.includes(filter.queryId))
|
|
23
|
+
return false;
|
|
24
|
+
if (type !== '' && paper.type.toLowerCase() !== type)
|
|
25
|
+
return false;
|
|
26
|
+
if (venue !== '' && paper.venue.trim().toLowerCase() !== venue)
|
|
27
|
+
return false;
|
|
28
|
+
if (author !== '' && !paper.authors.some((name) => name.trim().toLowerCase() === author)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (filter.language !== undefined && !matchesLanguage(paper, filter.language))
|
|
32
|
+
return false;
|
|
33
|
+
if (filter.hasPdf === true && pdfStatusOf(task, paper) !== 'downloaded' && paper.pdfUrl === '') {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (filter.missingFullText === true) {
|
|
37
|
+
const status = pdfStatusOf(task, paper);
|
|
38
|
+
if (status === 'downloaded' || status === 'available' || status === 'html-only')
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (filter.pdfStatus !== undefined && pdfStatusOf(task, paper) !== filter.pdfStatus) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
if (filter.groupId !== undefined && task.states[paper.paperId]?.groupId !== filter.groupId) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (filter.newOnly === true) {
|
|
48
|
+
const base = filter.newPaperIds;
|
|
49
|
+
if (base !== undefined && base.has(paper.paperId))
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (filter.screening !== undefined && screeningOf(task, paper) !== filter.screening)
|
|
53
|
+
return false;
|
|
54
|
+
if (needle !== '') {
|
|
55
|
+
const haystack = `${paper.title}\n${paper.abstract}`.toLowerCase();
|
|
56
|
+
if (!haystack.includes(needle))
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function screeningOf(task, paper) {
|
|
63
|
+
return task.states[paper.paperId]?.screening ?? 'unread';
|
|
64
|
+
}
|
|
65
|
+
/** 有效全文状态:没有人工/下载记录时,上游给的 PDF 直链视为可用。 */
|
|
66
|
+
export function pdfStatusOf(task, paper) {
|
|
67
|
+
const recorded = task.states[paper.paperId]?.pdfStatus;
|
|
68
|
+
if (recorded !== undefined)
|
|
69
|
+
return recorded;
|
|
70
|
+
return paper.pdfUrl === '' ? 'unknown' : 'available';
|
|
71
|
+
}
|
|
72
|
+
export function matchesLanguage(paper, language) {
|
|
73
|
+
if (language === 'any')
|
|
74
|
+
return true;
|
|
75
|
+
const value = (paper.language ?? '').toLowerCase();
|
|
76
|
+
if (language === 'chi')
|
|
77
|
+
return value.startsWith('chi') || value.startsWith('zh');
|
|
78
|
+
return value.startsWith('eng') || value.startsWith('en');
|
|
79
|
+
}
|
|
80
|
+
/** 稳定的筛选指纹(统计缓存 key 的一部分)。 */
|
|
81
|
+
export function filterKey(filter) {
|
|
82
|
+
return [
|
|
83
|
+
filter.source ?? '',
|
|
84
|
+
filter.yearFrom ?? '',
|
|
85
|
+
filter.yearTo ?? '',
|
|
86
|
+
filter.openAccessOnly === true ? '1' : '',
|
|
87
|
+
filter.q ?? '',
|
|
88
|
+
filter.screening ?? '',
|
|
89
|
+
filter.queryId ?? '',
|
|
90
|
+
filter.type ?? '',
|
|
91
|
+
filter.language ?? '',
|
|
92
|
+
filter.hasPdf === true ? '1' : '',
|
|
93
|
+
filter.missingFullText === true ? '1' : '',
|
|
94
|
+
filter.pdfStatus ?? '',
|
|
95
|
+
filter.groupId ?? '',
|
|
96
|
+
filter.venue ?? '',
|
|
97
|
+
filter.author ?? '',
|
|
98
|
+
filter.newOnly === true ? '1' : '',
|
|
99
|
+
].join('|');
|
|
100
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务内检索轮次的纯逻辑。
|
|
3
|
+
*
|
|
4
|
+
* 这里只依赖类型(type-only import),不引入任何运行时模块,因此单测可以
|
|
5
|
+
* 直接 import 源码,不必先编译出 lib/。轮数上限由调用方从配置注入。
|
|
6
|
+
*/
|
|
7
|
+
import type { SearchRound, SearchTask, TaskConditions, TaskOrigin } from '../../search/types.js';
|
|
8
|
+
/** 旧版本落盘的任务没有 rounds;所有读取路径统一按空数组兼容。 */
|
|
9
|
+
export declare function roundsOf(task: SearchTask): SearchRound[];
|
|
10
|
+
/**
|
|
11
|
+
* 条件深拷贝。
|
|
12
|
+
*
|
|
13
|
+
* 轮次快照必须使用它:`queries[].perSource`、`types`、`sources` 都是可变引用,
|
|
14
|
+
* 浅拷贝会让历史轮次的条件跟着后续编辑一起变。
|
|
15
|
+
*/
|
|
16
|
+
export declare function cloneConditions(conditions: TaskConditions): TaskConditions;
|
|
17
|
+
/** 轮次深拷贝,避免投影对象与内存任务共享条件引用。 */
|
|
18
|
+
export declare function cloneRound(round: SearchRound): SearchRound;
|
|
19
|
+
/** 只保留最近 max 轮;返回新数组(调用方负责写回)。 */
|
|
20
|
+
export declare function trimRoundList(rounds: SearchRound[], max: number): SearchRound[];
|
|
21
|
+
/**
|
|
22
|
+
* 开跑前挂一条轮次快照,返回该轮对象供结束时回填。
|
|
23
|
+
*
|
|
24
|
+
* 序号取「上一轮 index + 1」,即使旧轮次被裁剪也不会复用旧号。
|
|
25
|
+
*/
|
|
26
|
+
export declare function startRound(task: SearchTask, origin: TaskOrigin, max: number, now?: string): SearchRound;
|
|
27
|
+
/** 无论成功 / 取消 / 异常都回填本轮元数据,时间轴不会留下「永远运行中」。 */
|
|
28
|
+
export declare function finishRound(round: SearchRound, task: SearchTask, now?: string): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务内检索轮次的纯逻辑。
|
|
3
|
+
*
|
|
4
|
+
* 这里只依赖类型(type-only import),不引入任何运行时模块,因此单测可以
|
|
5
|
+
* 直接 import 源码,不必先编译出 lib/。轮数上限由调用方从配置注入。
|
|
6
|
+
*/
|
|
7
|
+
/** 旧版本落盘的任务没有 rounds;所有读取路径统一按空数组兼容。 */
|
|
8
|
+
export function roundsOf(task) {
|
|
9
|
+
return Array.isArray(task.rounds) ? task.rounds : [];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* 条件深拷贝。
|
|
13
|
+
*
|
|
14
|
+
* 轮次快照必须使用它:`queries[].perSource`、`types`、`sources` 都是可变引用,
|
|
15
|
+
* 浅拷贝会让历史轮次的条件跟着后续编辑一起变。
|
|
16
|
+
*/
|
|
17
|
+
export function cloneConditions(conditions) {
|
|
18
|
+
return {
|
|
19
|
+
...conditions,
|
|
20
|
+
queries: conditions.queries.map((query) => ({
|
|
21
|
+
...query,
|
|
22
|
+
...(query.perSource === undefined ? {} : { perSource: { ...query.perSource } }),
|
|
23
|
+
})),
|
|
24
|
+
sources: [...conditions.sources],
|
|
25
|
+
...(conditions.types === undefined ? {} : { types: [...conditions.types] }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** 轮次深拷贝,避免投影对象与内存任务共享条件引用。 */
|
|
29
|
+
export function cloneRound(round) {
|
|
30
|
+
return {
|
|
31
|
+
...round,
|
|
32
|
+
conditions: cloneConditions(round.conditions),
|
|
33
|
+
...(round.stats === undefined
|
|
34
|
+
? {}
|
|
35
|
+
: { stats: { ...round.stats, perQuery: { ...round.stats.perQuery } } }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** 只保留最近 max 轮;返回新数组(调用方负责写回)。 */
|
|
39
|
+
export function trimRoundList(rounds, max) {
|
|
40
|
+
return rounds.length <= max ? rounds : rounds.slice(rounds.length - max);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 开跑前挂一条轮次快照,返回该轮对象供结束时回填。
|
|
44
|
+
*
|
|
45
|
+
* 序号取「上一轮 index + 1」,即使旧轮次被裁剪也不会复用旧号。
|
|
46
|
+
*/
|
|
47
|
+
export function startRound(task, origin, max, now = new Date().toISOString()) {
|
|
48
|
+
const previous = roundsOf(task);
|
|
49
|
+
const index = previous.length > 0 ? previous[previous.length - 1].index + 1 : 1;
|
|
50
|
+
const round = {
|
|
51
|
+
id: `r${index}`,
|
|
52
|
+
index,
|
|
53
|
+
createdAt: now,
|
|
54
|
+
origin,
|
|
55
|
+
conditions: cloneConditions(task.conditions),
|
|
56
|
+
};
|
|
57
|
+
task.rounds = trimRoundList([...previous, round], max);
|
|
58
|
+
return round;
|
|
59
|
+
}
|
|
60
|
+
/** 无论成功 / 取消 / 异常都回填本轮元数据,时间轴不会留下「永远运行中」。 */
|
|
61
|
+
export function finishRound(round, task, now = new Date().toISOString()) {
|
|
62
|
+
round.finishedAt = now;
|
|
63
|
+
round.status = task.status;
|
|
64
|
+
round.stats = {
|
|
65
|
+
rawHits: task.stats.rawHits,
|
|
66
|
+
deduped: task.stats.deduped,
|
|
67
|
+
loaded: task.result.papers.length,
|
|
68
|
+
perQuery: { ...task.stats.perQuery },
|
|
69
|
+
};
|
|
70
|
+
round.skippedSources = task.result.sources.filter((source) => source.skipped === true).length;
|
|
71
|
+
round.failedSources = task.result.sources.filter((source) => !source.ok && source.skipped !== true).length;
|
|
72
|
+
if (task.error !== undefined)
|
|
73
|
+
round.error = task.error;
|
|
74
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务执行器。
|
|
3
|
+
*
|
|
4
|
+
* 多检索式按「先 query 后 source」串行扇出(每条式内部并发各源),每完成一条
|
|
5
|
+
* 式就自增 rev 并持久化,客户端据此显示 i/N 进度。取消通过 AbortSignal 透传到
|
|
6
|
+
* 每个上游请求;已拿到的结果保留,状态标 `canceled`。
|
|
7
|
+
*/
|
|
8
|
+
import type { SearchTask, TaskOrigin } from '../../search/types.js';
|
|
9
|
+
export interface RunnerHooks {
|
|
10
|
+
persist(task: SearchTask): Promise<void>;
|
|
11
|
+
publish(task: SearchTask): void;
|
|
12
|
+
}
|
|
13
|
+
export declare class TaskRunner {
|
|
14
|
+
private readonly controllers;
|
|
15
|
+
/** 正在执行的那一轮的完成 Promise,供「等待上一轮结束」使用。 */
|
|
16
|
+
private readonly running;
|
|
17
|
+
isRunning(id: string): boolean;
|
|
18
|
+
/** 返回当前这一轮的完成 Promise;空闲时立即 resolve。 */
|
|
19
|
+
wait(id: string): Promise<void>;
|
|
20
|
+
/** 中止运行;返回是否确实有任务在跑。 */
|
|
21
|
+
cancel(id: string): boolean;
|
|
22
|
+
cancelAll(): void;
|
|
23
|
+
/**
|
|
24
|
+
* 启动一次执行(= 任务内新开一轮);controller 同步登记,因此调用方返回时
|
|
25
|
+
* `isRunning` 已为 true。`origin` 记录这一轮是谁发起的,用于历史时间轴。
|
|
26
|
+
*/
|
|
27
|
+
start(task: SearchTask, hooks: RunnerHooks, origin?: TaskOrigin): Promise<void>;
|
|
28
|
+
private execute;
|
|
29
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务执行器。
|
|
3
|
+
*
|
|
4
|
+
* 多检索式按「先 query 后 source」串行扇出(每条式内部并发各源),每完成一条
|
|
5
|
+
* 式就自增 rev 并持久化,客户端据此显示 i/N 进度。取消通过 AbortSignal 透传到
|
|
6
|
+
* 每个上游请求;已拿到的结果保留,状态标 `canceled`。
|
|
7
|
+
*/
|
|
8
|
+
import { DEFAULT_TASK_MAX_ROUNDS, MAX_LIMIT, getSettings } from '../../search/config.js';
|
|
9
|
+
import { searchSources } from '../../search/aggregate.js';
|
|
10
|
+
import { mergeHits } from '../../search/identity.js';
|
|
11
|
+
import { finishRound, startRound } from './rounds.js';
|
|
12
|
+
import { countScreening } from './states.js';
|
|
13
|
+
class RunCanceled extends Error {
|
|
14
|
+
constructor() {
|
|
15
|
+
super('检索已取消');
|
|
16
|
+
this.name = 'RunCanceled';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class TaskRunner {
|
|
20
|
+
controllers = new Map();
|
|
21
|
+
/** 正在执行的那一轮的完成 Promise,供「等待上一轮结束」使用。 */
|
|
22
|
+
running = new Map();
|
|
23
|
+
isRunning(id) {
|
|
24
|
+
return this.controllers.has(id);
|
|
25
|
+
}
|
|
26
|
+
/** 返回当前这一轮的完成 Promise;空闲时立即 resolve。 */
|
|
27
|
+
wait(id) {
|
|
28
|
+
return this.running.get(id) ?? Promise.resolve();
|
|
29
|
+
}
|
|
30
|
+
/** 中止运行;返回是否确实有任务在跑。 */
|
|
31
|
+
cancel(id) {
|
|
32
|
+
const controller = this.controllers.get(id);
|
|
33
|
+
if (controller === undefined)
|
|
34
|
+
return false;
|
|
35
|
+
controller.abort();
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
cancelAll() {
|
|
39
|
+
for (const controller of this.controllers.values())
|
|
40
|
+
controller.abort();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 启动一次执行(= 任务内新开一轮);controller 同步登记,因此调用方返回时
|
|
44
|
+
* `isRunning` 已为 true。`origin` 记录这一轮是谁发起的,用于历史时间轴。
|
|
45
|
+
*/
|
|
46
|
+
start(task, hooks, origin = task.origin) {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
this.controllers.set(task.id, controller);
|
|
49
|
+
const done = this.execute(task, hooks, controller, origin).finally(() => {
|
|
50
|
+
this.controllers.delete(task.id);
|
|
51
|
+
this.running.delete(task.id);
|
|
52
|
+
});
|
|
53
|
+
this.running.set(task.id, done);
|
|
54
|
+
return done;
|
|
55
|
+
}
|
|
56
|
+
async execute(task, hooks, controller, origin) {
|
|
57
|
+
const queries = task.conditions.queries.filter((query) => query.enabled && query.text.trim() !== '');
|
|
58
|
+
if (queries.length === 0)
|
|
59
|
+
throw new Error('没有启用的检索式');
|
|
60
|
+
// 开跑前先落一条轮次快照:条件是深拷贝,之后改条件不会影响历史。
|
|
61
|
+
const round = startRound(task, origin, DEFAULT_TASK_MAX_ROUNDS);
|
|
62
|
+
task.status = 'running';
|
|
63
|
+
task.error = undefined;
|
|
64
|
+
task.result.papers = [];
|
|
65
|
+
task.result.sources = [];
|
|
66
|
+
task.result.fetchedAt = undefined;
|
|
67
|
+
task.stats = {
|
|
68
|
+
perQuery: {},
|
|
69
|
+
rawHits: 0,
|
|
70
|
+
deduped: 0,
|
|
71
|
+
screening: { unread: 0, read: 0, included: 0, excluded: 0 },
|
|
72
|
+
};
|
|
73
|
+
task.progress = { queryIndex: 0, queryTotal: queries.length };
|
|
74
|
+
touch(task, hooks);
|
|
75
|
+
await hooks.persist(task);
|
|
76
|
+
const hits = [];
|
|
77
|
+
const statusById = new Map();
|
|
78
|
+
const perSourceLimit = clampRequestLimit(task.conditions.pageSize);
|
|
79
|
+
try {
|
|
80
|
+
for (const [index, query] of queries.entries()) {
|
|
81
|
+
task.progress = { queryIndex: index + 1, queryTotal: queries.length };
|
|
82
|
+
touch(task, hooks);
|
|
83
|
+
await hooks.persist(task);
|
|
84
|
+
const results = await searchSources({
|
|
85
|
+
query: query.text,
|
|
86
|
+
perSource: query.perSource,
|
|
87
|
+
sources: task.conditions.sources,
|
|
88
|
+
limit: perSourceLimit,
|
|
89
|
+
yearFrom: task.conditions.yearFrom,
|
|
90
|
+
yearTo: task.conditions.yearTo,
|
|
91
|
+
openAccessOnly: task.conditions.openAccessOnly,
|
|
92
|
+
contactEmail: '',
|
|
93
|
+
timeoutMs: 0,
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
});
|
|
96
|
+
if (controller.signal.aborted)
|
|
97
|
+
throw new RunCanceled();
|
|
98
|
+
for (const result of results) {
|
|
99
|
+
const records = result.records.filter((record) => matchesConditions(record, task.conditions));
|
|
100
|
+
const status = statusById.get(result.source) ?? {
|
|
101
|
+
id: result.source,
|
|
102
|
+
ok: true,
|
|
103
|
+
count: 0,
|
|
104
|
+
};
|
|
105
|
+
if (records.length < result.records.length)
|
|
106
|
+
status.filtered = true;
|
|
107
|
+
status.count += records.length;
|
|
108
|
+
if (result.skipped === true) {
|
|
109
|
+
// 限流跳过不是故障:不标 failed、不进 partial,只留中性说明。
|
|
110
|
+
status.skipped = true;
|
|
111
|
+
status.note = result.error;
|
|
112
|
+
}
|
|
113
|
+
else if (!result.ok) {
|
|
114
|
+
status.ok = false;
|
|
115
|
+
status.error = result.error;
|
|
116
|
+
}
|
|
117
|
+
statusById.set(result.source, status);
|
|
118
|
+
task.stats.perQuery[query.id] =
|
|
119
|
+
(task.stats.perQuery[query.id] ?? 0) + records.length;
|
|
120
|
+
records.forEach((record, position) => {
|
|
121
|
+
hits.push({
|
|
122
|
+
record,
|
|
123
|
+
source: result.source,
|
|
124
|
+
rank: position + 1,
|
|
125
|
+
queryId: query.id,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const merged = mergeHits(hits);
|
|
131
|
+
const papers = merged
|
|
132
|
+
.filter((paper) => matchesConditions(paper, task.conditions))
|
|
133
|
+
.slice(0, getSettings().taskMaxPapers);
|
|
134
|
+
task.result.papers = papers;
|
|
135
|
+
task.result.sources = [...statusById.values()];
|
|
136
|
+
task.result.fetchedAt = new Date().toISOString();
|
|
137
|
+
task.stats.rawHits = hits.length;
|
|
138
|
+
task.stats.deduped = merged.length;
|
|
139
|
+
task.stats.screening = countScreening(papers, task.states);
|
|
140
|
+
const failed = task.result.sources.filter((source) => !source.ok && !source.skipped);
|
|
141
|
+
const succeeded = task.result.sources.filter((source) => source.ok && !source.skipped);
|
|
142
|
+
const skipped = task.result.sources.filter((source) => source.skipped);
|
|
143
|
+
// 全被限流跳过且结果为空时仍标 partial 提示重跑,但不显示任何红色失败。
|
|
144
|
+
task.status =
|
|
145
|
+
succeeded.length === 0 && failed.length > 0
|
|
146
|
+
? 'error'
|
|
147
|
+
: failed.length > 0 || (succeeded.length === 0 && skipped.length > 0)
|
|
148
|
+
? 'partial'
|
|
149
|
+
: 'done';
|
|
150
|
+
task.progress = undefined;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (controller.signal.aborted || error instanceof RunCanceled) {
|
|
154
|
+
task.status = 'canceled';
|
|
155
|
+
task.error = '检索已取消';
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
task.status = 'error';
|
|
159
|
+
task.error = error instanceof Error ? error.message : String(error);
|
|
160
|
+
}
|
|
161
|
+
task.result.sources = [...statusById.values()];
|
|
162
|
+
task.stats.screening = countScreening(task.result.papers, task.states);
|
|
163
|
+
task.progress = undefined;
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
// 无论成功 / 取消 / 异常都回填本轮元数据,时间轴不会留下「永远运行中」的轮次。
|
|
167
|
+
finishRound(round, task);
|
|
168
|
+
touch(task, hooks);
|
|
169
|
+
await hooks.persist(task);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function touch(task, hooks) {
|
|
174
|
+
task.rev += 1;
|
|
175
|
+
task.updatedAt = new Date().toISOString();
|
|
176
|
+
hooks.publish(task);
|
|
177
|
+
}
|
|
178
|
+
/** 每个源多取一些给跨源/跨式去重留余量,但不放大到打爆上游。 */
|
|
179
|
+
function clampRequestLimit(pageSize) {
|
|
180
|
+
const base = Number.isFinite(pageSize) ? pageSize : 20;
|
|
181
|
+
return Math.min(Math.max(Math.floor(base) * 2, 10), MAX_LIMIT);
|
|
182
|
+
}
|
|
183
|
+
function matchesConditions(paper, conditions) {
|
|
184
|
+
if (conditions.yearFrom !== undefined && (paper.year ?? 0) < conditions.yearFrom)
|
|
185
|
+
return false;
|
|
186
|
+
if (conditions.yearTo !== undefined &&
|
|
187
|
+
(paper.year ?? Number.MAX_SAFE_INTEGER) > conditions.yearTo) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
if (conditions.openAccessOnly && !paper.openAccess)
|
|
191
|
+
return false;
|
|
192
|
+
if (!matchesLanguage(paper, conditions.language))
|
|
193
|
+
return false;
|
|
194
|
+
if (conditions.types !== undefined && conditions.types.length > 0) {
|
|
195
|
+
const type = paper.type.toLowerCase();
|
|
196
|
+
if (!conditions.types.some((wanted) => wanted.toLowerCase() === type))
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
function matchesLanguage(paper, language) {
|
|
202
|
+
if (language === 'any')
|
|
203
|
+
return true;
|
|
204
|
+
const value = (paper.language ?? '').toLowerCase();
|
|
205
|
+
if (language === 'chi')
|
|
206
|
+
return value.startsWith('chi') || value.startsWith('zh');
|
|
207
|
+
return value.startsWith('eng') || value.startsWith('en');
|
|
208
|
+
}
|