dsh-plugin-litsearch 0.3.0 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -34
- package/client/client.js +1382 -760
- package/lib/host/export/bibtex.js +1 -4
- package/lib/host/export/common.d.ts +0 -2
- package/lib/host/export/common.js +0 -7
- package/lib/host/export/csv.js +1 -5
- package/lib/host/export/markdown.d.ts +1 -1
- package/lib/host/export/markdown.js +9 -26
- package/lib/host/export/ris.js +1 -4
- package/lib/host/paths.d.ts +1 -1
- package/lib/host/paths.js +1 -1
- package/lib/host/pdf/store.d.ts +5 -3
- package/lib/host/pdf/store.js +50 -17
- package/lib/host/router.js +80 -78
- package/lib/host/tasks/service.d.ts +10 -8
- package/lib/host/tasks/service.js +86 -31
- package/lib/host/tasks/states.d.ts +1 -3
- package/lib/host/tasks/states.js +2 -66
- package/lib/host/tasks/stats.js +3 -0
- package/lib/host/tasks/summary.js +1 -1
- package/lib/host/tools.d.ts +1 -1
- package/lib/host/tools.js +6 -29
- package/lib/search/oa.d.ts +8 -0
- package/lib/search/oa.js +53 -6
- package/lib/search/types.d.ts +18 -9
- package/lib/search/types.js +4 -0
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 模型工具与面板 HTTP 路由都只调用这里,保证两边操作同一份状态。
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { MAX_LIMIT, PLUGIN_VERSION, getSettings } from '../../search/config.js';
|
|
11
11
|
import { SearchError } from '../../search/errors.js';
|
|
@@ -17,7 +17,7 @@ import { fetchPubtatorEntities } from '../../search/pubtator-entities.js';
|
|
|
17
17
|
import { SOURCE_IDS } from '../../search/types.js';
|
|
18
18
|
import { TaskEvents } from '../events.js';
|
|
19
19
|
import { buildExport } from '../export/index.js';
|
|
20
|
-
import { cleanupPdfCache, epmcFullTextAvailable, persistPdf, probePdfRoot, prunePdfCache,
|
|
20
|
+
import { cleanupPdfCache, epmcFullTextAvailable, persistPdf, probePdfRoot, prunePdfCache, readSavedPdf, readableFilename, resolvePdf, resolvePdfRoot, } from '../pdf/store.js';
|
|
21
21
|
import { ZoteroConnector } from '../zotero/connector.js';
|
|
22
22
|
import { normalizeWorkspace } from '../workspace.js';
|
|
23
23
|
import { compareTasks } from './compare.js';
|
|
@@ -32,6 +32,48 @@ const MAX_TITLE = 120;
|
|
|
32
32
|
const MAX_PAGE_LIMIT = 200;
|
|
33
33
|
const MAX_TYPES = 20;
|
|
34
34
|
const PROBE_QUERY = 'machine learning';
|
|
35
|
+
/** 已下载文件存在性检查的短缓存:详情轮询时不必反复 stat 同一批文件。 */
|
|
36
|
+
const PDF_EXISTS_TTL_MS = 10_000;
|
|
37
|
+
const pdfExistsCache = new Map();
|
|
38
|
+
async function pdfFileExists(filePath) {
|
|
39
|
+
const cached = pdfExistsCache.get(filePath);
|
|
40
|
+
if (cached !== undefined && Date.now() - cached.at < PDF_EXISTS_TTL_MS)
|
|
41
|
+
return cached.exists;
|
|
42
|
+
const exists = await stat(filePath)
|
|
43
|
+
.then((info) => info.isFile())
|
|
44
|
+
.catch(() => false);
|
|
45
|
+
pdfExistsCache.set(filePath, { exists, at: Date.now() });
|
|
46
|
+
return exists;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 已下载的 PDF 可能被用户在文件管理器里删掉(也可能从废纸篓恢复):
|
|
50
|
+
* 每次读取任务时校准状态,删除后回到 `missing`,点击即可重新下载。
|
|
51
|
+
*/
|
|
52
|
+
async function reconcilePdfFiles(task) {
|
|
53
|
+
const downloads = Object.entries(task.states).filter(([, state]) => state.pdfFile !== undefined &&
|
|
54
|
+
(state.pdfStatus === 'downloaded' || state.pdfStatus === 'missing'));
|
|
55
|
+
if (downloads.length === 0)
|
|
56
|
+
return false;
|
|
57
|
+
const checks = await Promise.all(downloads.map(async ([paperId, state]) => ({
|
|
58
|
+
paperId,
|
|
59
|
+
exists: await pdfFileExists(state.pdfFile ?? ''),
|
|
60
|
+
})));
|
|
61
|
+
let changed = false;
|
|
62
|
+
for (const { paperId, exists } of checks) {
|
|
63
|
+
const current = task.states[paperId];
|
|
64
|
+
if (current === undefined)
|
|
65
|
+
continue;
|
|
66
|
+
if (current.pdfStatus === 'downloaded' && !exists) {
|
|
67
|
+
task.states[paperId] = { ...current, pdfStatus: 'missing' };
|
|
68
|
+
changed = true;
|
|
69
|
+
}
|
|
70
|
+
else if (current.pdfStatus === 'missing' && exists) {
|
|
71
|
+
task.states[paperId] = { ...current, pdfStatus: 'downloaded' };
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return changed;
|
|
76
|
+
}
|
|
35
77
|
export class TaskService {
|
|
36
78
|
ready;
|
|
37
79
|
store = new TaskStore();
|
|
@@ -183,8 +225,12 @@ export class TaskService {
|
|
|
183
225
|
task.rounds = [];
|
|
184
226
|
return task;
|
|
185
227
|
}
|
|
186
|
-
async getDetail(id) {
|
|
187
|
-
|
|
228
|
+
async getDetail(id, workspace) {
|
|
229
|
+
const task = await this.get(id);
|
|
230
|
+
if (await reconcilePdfFiles(task))
|
|
231
|
+
await this.store.save(task);
|
|
232
|
+
const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
|
|
233
|
+
return { ...toDetail(task), downloadDir: resolvePdfRoot(target) };
|
|
188
234
|
}
|
|
189
235
|
/** 任务内轮次历史(时间升序、深拷贝)。 */
|
|
190
236
|
async getRounds(id) {
|
|
@@ -445,7 +491,7 @@ export class TaskService {
|
|
|
445
491
|
const resolved = base === undefined ? filter : await this.withBaseFilter(task, filter);
|
|
446
492
|
return computeStats(task, resolved, scope, base);
|
|
447
493
|
}
|
|
448
|
-
/** 写入筛读 /
|
|
494
|
+
/** 写入筛读 / 分组;返回新的 rev 与命中条数。 */
|
|
449
495
|
async annotate(id, inputs, expectedRev) {
|
|
450
496
|
const task = await this.get(id);
|
|
451
497
|
if (expectedRev !== undefined && expectedRev !== task.rev) {
|
|
@@ -519,6 +565,27 @@ export class TaskService {
|
|
|
519
565
|
remaining: this.store.list().length,
|
|
520
566
|
};
|
|
521
567
|
}
|
|
568
|
+
/**
|
|
569
|
+
* 「查看已下载」前的存在性检查:页面加载后文件可能被删除或移走。
|
|
570
|
+
* 缺失时把状态改为 `missing` 并广播,所有面板随即显示「下载文件已删除」。
|
|
571
|
+
*/
|
|
572
|
+
async recheckPdf(id, paperId) {
|
|
573
|
+
const task = await this.get(id);
|
|
574
|
+
findPaper(task, paperId);
|
|
575
|
+
const state = task.states[paperId];
|
|
576
|
+
const filePath = state?.pdfFile;
|
|
577
|
+
if (state === undefined || filePath === undefined)
|
|
578
|
+
return { exists: false };
|
|
579
|
+
pdfExistsCache.delete(filePath);
|
|
580
|
+
const exists = await pdfFileExists(filePath);
|
|
581
|
+
if (exists && state.pdfStatus !== 'downloaded') {
|
|
582
|
+
await this.setPdfState(task, paperId, { pdfStatus: 'downloaded', pdfFile: filePath });
|
|
583
|
+
}
|
|
584
|
+
else if (!exists && state.pdfStatus !== 'missing') {
|
|
585
|
+
await this.setPdfState(task, paperId, { pdfStatus: 'missing', pdfFile: filePath });
|
|
586
|
+
}
|
|
587
|
+
return { exists };
|
|
588
|
+
}
|
|
522
589
|
/** 下载单篇 PDF;失败时写入失败状态,若有 EPMC 全文则降级为 html-only。 */
|
|
523
590
|
async downloadPdf(id, paperId, workspace) {
|
|
524
591
|
const task = await this.get(id);
|
|
@@ -539,6 +606,7 @@ export class TaskService {
|
|
|
539
606
|
status: fallback,
|
|
540
607
|
bytes: 0,
|
|
541
608
|
error: resolution.error ?? '下载失败',
|
|
609
|
+
...(resolution.reason === undefined ? {} : { reason: resolution.reason }),
|
|
542
610
|
};
|
|
543
611
|
return result;
|
|
544
612
|
}
|
|
@@ -558,23 +626,6 @@ export class TaskService {
|
|
|
558
626
|
result.url = resolution.url;
|
|
559
627
|
return result;
|
|
560
628
|
}
|
|
561
|
-
/** PDF 预览:优先用已落地文件;没有则临时抓取一次但不落盘。 */
|
|
562
|
-
async previewPdf(id, paperId, workspace) {
|
|
563
|
-
const task = await this.get(id);
|
|
564
|
-
const paper = findPaper(task, paperId);
|
|
565
|
-
const filename = readableFilename(paper);
|
|
566
|
-
const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
|
|
567
|
-
const recorded = task.states[paperId]?.pdfFile;
|
|
568
|
-
const saved = recorded === undefined ? null : await readSavedPdf(recorded);
|
|
569
|
-
const local = saved ?? (await readLocalPdf(paper, target));
|
|
570
|
-
if (local !== null)
|
|
571
|
-
return { buffer: local, filename };
|
|
572
|
-
const resolution = await resolvePdf(paper);
|
|
573
|
-
if (!resolution.ok || resolution.buffer === undefined) {
|
|
574
|
-
throw new SearchError('not_found', resolution.error ?? '没有可预览的 PDF');
|
|
575
|
-
}
|
|
576
|
-
return { buffer: resolution.buffer, filename };
|
|
577
|
-
}
|
|
578
629
|
/** Europe PMC 全文(JATS → 只含安全标签的 HTML)。 */
|
|
579
630
|
async fullText(id, paperId) {
|
|
580
631
|
const task = await this.get(id);
|
|
@@ -596,15 +647,19 @@ export class TaskService {
|
|
|
596
647
|
return html;
|
|
597
648
|
}
|
|
598
649
|
/** 单篇论文详情:独立论文页使用,含任务标题与分组。 */
|
|
599
|
-
async paperDetail(id, paperId) {
|
|
650
|
+
async paperDetail(id, paperId, workspace) {
|
|
600
651
|
const task = await this.get(id);
|
|
652
|
+
if (await reconcilePdfFiles(task))
|
|
653
|
+
await this.store.save(task);
|
|
601
654
|
const paper = findPaper(task, paperId);
|
|
655
|
+
const target = normalizeWorkspace(workspace) ?? normalizeWorkspace(task.workspace);
|
|
602
656
|
const detail = {
|
|
603
657
|
taskId: task.id,
|
|
604
658
|
taskTitle: task.title,
|
|
605
659
|
rev: task.rev,
|
|
606
660
|
paper,
|
|
607
661
|
groups: task.groups,
|
|
662
|
+
downloadDir: resolvePdfRoot(target),
|
|
608
663
|
};
|
|
609
664
|
const state = task.states[paperId];
|
|
610
665
|
if (state !== undefined)
|
|
@@ -656,7 +711,9 @@ export class TaskService {
|
|
|
656
711
|
const inputs = [];
|
|
657
712
|
for (const paper of wanted) {
|
|
658
713
|
const state = task.states[paper.paperId];
|
|
659
|
-
|
|
714
|
+
// 标签已移除,用任务内的分组名承载 Zotero 侧的分类信息。
|
|
715
|
+
const group = task.groups.find((entry) => entry.id === state?.groupId);
|
|
716
|
+
const tags = group === undefined ? [] : [group.name];
|
|
660
717
|
const pdfPath = attachPdf
|
|
661
718
|
? await this.ensureLocalPdf(paper, target, state?.pdfFile)
|
|
662
719
|
: undefined;
|
|
@@ -721,6 +778,10 @@ export class TaskService {
|
|
|
721
778
|
delete next.pdfFile;
|
|
722
779
|
else
|
|
723
780
|
next.pdfFile = patch.pdfFile.slice(0, 1024);
|
|
781
|
+
// 刚写入的文件立即视为存在,避免存在性缓存把状态又翻回 `missing`。
|
|
782
|
+
if (patch.pdfStatus === 'downloaded' && next.pdfFile !== undefined) {
|
|
783
|
+
pdfExistsCache.set(next.pdfFile, { exists: true, at: Date.now() });
|
|
784
|
+
}
|
|
724
785
|
task.states[paperId] = next;
|
|
725
786
|
task.rev += 1;
|
|
726
787
|
task.updatedAt = new Date().toISOString();
|
|
@@ -970,14 +1031,8 @@ function readTitle(title, conditions) {
|
|
|
970
1031
|
function inheritStates(base) {
|
|
971
1032
|
const states = {};
|
|
972
1033
|
for (const [paperId, state] of Object.entries(base.states)) {
|
|
973
|
-
//
|
|
1034
|
+
// 分组是任务内概念,继承时只带筛读状态。
|
|
974
1035
|
const next = { screening: state.screening };
|
|
975
|
-
if (state.shortlistOrder !== undefined)
|
|
976
|
-
next.shortlistOrder = state.shortlistOrder;
|
|
977
|
-
if (state.note !== undefined)
|
|
978
|
-
next.note = state.note;
|
|
979
|
-
if (state.tags !== undefined)
|
|
980
|
-
next.tags = [...state.tags];
|
|
981
1036
|
states[paperId] = next;
|
|
982
1037
|
}
|
|
983
1038
|
return states;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 筛读台账:论文级状态(四态筛读 /
|
|
2
|
+
* 筛读台账:论文级状态(四态筛读 / 分组)与任务分组。
|
|
3
3
|
*
|
|
4
4
|
* 所有写入口(面板注解、模型 `literature_task annotate`、分组编辑)都先经过
|
|
5
5
|
* 这里的校验,确保状态结构稳定;`states` 以稳定的 `paperId` 为键,因此跨任务
|
|
@@ -24,5 +24,3 @@ export declare function applyAnnotations(task: SearchTask, inputs: readonly Anno
|
|
|
24
24
|
export declare function normalizeGroups(values: readonly TaskGroup[] | undefined): TaskGroup[] | undefined;
|
|
25
25
|
/** 分组若仍有论文引用,删除前提醒调用方(调用方决定是否允许)。 */
|
|
26
26
|
export declare function groupUsage(task: SearchTask, groupId: string): number;
|
|
27
|
-
/** 按必读顺序、年份、标题排序某组的论文。 */
|
|
28
|
-
export declare function sortGroupPapers(task: SearchTask, papers: readonly Paper[]): Paper[];
|
package/lib/host/tasks/states.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 筛读台账:论文级状态(四态筛读 /
|
|
2
|
+
* 筛读台账:论文级状态(四态筛读 / 分组)与任务分组。
|
|
3
3
|
*
|
|
4
4
|
* 所有写入口(面板注解、模型 `literature_task annotate`、分组编辑)都先经过
|
|
5
5
|
* 这里的校验,确保状态结构稳定;`states` 以稳定的 `paperId` 为键,因此跨任务
|
|
@@ -10,17 +10,14 @@ const MAX_ANNOTATIONS = 50;
|
|
|
10
10
|
const MAX_GROUPS = 50;
|
|
11
11
|
const MAX_GROUP_NAME = 60;
|
|
12
12
|
const MAX_GROUP_DESCRIPTION = 500;
|
|
13
|
-
const MAX_NOTE = 4000;
|
|
14
|
-
const MAX_TAGS = 20;
|
|
15
|
-
const MAX_TAG = 40;
|
|
16
13
|
const MAX_PDF_ERROR = 300;
|
|
17
14
|
const MAX_PDF_FILE = 1024;
|
|
18
|
-
const MAX_SHORTLIST_ORDER = 100_000;
|
|
19
15
|
export const SCREENINGS = ['unread', 'read', 'included', 'excluded'];
|
|
20
16
|
export const PDF_STATUSES = [
|
|
21
17
|
'unknown',
|
|
22
18
|
'available',
|
|
23
19
|
'downloaded',
|
|
20
|
+
'missing',
|
|
24
21
|
'html-only',
|
|
25
22
|
'paywalled',
|
|
26
23
|
'blocked',
|
|
@@ -86,40 +83,6 @@ export function applyAnnotations(task, inputs) {
|
|
|
86
83
|
}
|
|
87
84
|
touched = true;
|
|
88
85
|
}
|
|
89
|
-
if (input.shortlistOrder !== undefined) {
|
|
90
|
-
if (input.shortlistOrder === null) {
|
|
91
|
-
delete next.shortlistOrder;
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
if (!Number.isInteger(input.shortlistOrder) ||
|
|
95
|
-
input.shortlistOrder < 0 ||
|
|
96
|
-
input.shortlistOrder > MAX_SHORTLIST_ORDER) {
|
|
97
|
-
throw new SearchError('bad_request', `shortlistOrder 必须是 0..${MAX_SHORTLIST_ORDER} 的整数`);
|
|
98
|
-
}
|
|
99
|
-
next.shortlistOrder = input.shortlistOrder;
|
|
100
|
-
}
|
|
101
|
-
touched = true;
|
|
102
|
-
}
|
|
103
|
-
if (input.note !== undefined) {
|
|
104
|
-
if (typeof input.note !== 'string')
|
|
105
|
-
throw new SearchError('bad_request', 'note 必须是字符串');
|
|
106
|
-
const note = input.note.trim();
|
|
107
|
-
if (note === '')
|
|
108
|
-
delete next.note;
|
|
109
|
-
else
|
|
110
|
-
next.note = note.slice(0, MAX_NOTE);
|
|
111
|
-
touched = true;
|
|
112
|
-
}
|
|
113
|
-
if (input.tags !== undefined) {
|
|
114
|
-
if (!Array.isArray(input.tags))
|
|
115
|
-
throw new SearchError('bad_request', 'tags 必须是字符串数组');
|
|
116
|
-
const tags = normalizeTags(input.tags);
|
|
117
|
-
if (tags.length === 0)
|
|
118
|
-
delete next.tags;
|
|
119
|
-
else
|
|
120
|
-
next.tags = tags;
|
|
121
|
-
touched = true;
|
|
122
|
-
}
|
|
123
86
|
if (input.pdfStatus !== undefined) {
|
|
124
87
|
if (!isPdfStatus(input.pdfStatus)) {
|
|
125
88
|
throw new SearchError('bad_request', `pdfStatus 只能是 ${PDF_STATUSES.join(' / ')}`);
|
|
@@ -194,30 +157,3 @@ export function normalizeGroups(values) {
|
|
|
194
157
|
export function groupUsage(task, groupId) {
|
|
195
158
|
return Object.values(task.states).filter((state) => state.groupId === groupId).length;
|
|
196
159
|
}
|
|
197
|
-
/** 按必读顺序、年份、标题排序某组的论文。 */
|
|
198
|
-
export function sortGroupPapers(task, papers) {
|
|
199
|
-
return [...papers].sort((a, b) => {
|
|
200
|
-
const left = stateOf(task, a.paperId).shortlistOrder;
|
|
201
|
-
const right = stateOf(task, b.paperId).shortlistOrder;
|
|
202
|
-
if (left !== undefined || right !== undefined) {
|
|
203
|
-
if (left === undefined)
|
|
204
|
-
return 1;
|
|
205
|
-
if (right === undefined)
|
|
206
|
-
return -1;
|
|
207
|
-
if (left !== right)
|
|
208
|
-
return left - right;
|
|
209
|
-
}
|
|
210
|
-
return (b.year ?? 0) - (a.year ?? 0) || a.title.localeCompare(b.title);
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
function normalizeTags(values) {
|
|
214
|
-
const seen = new Set();
|
|
215
|
-
for (const raw of values) {
|
|
216
|
-
const tag = raw.trim().slice(0, MAX_TAG);
|
|
217
|
-
if (tag !== '')
|
|
218
|
-
seen.add(tag);
|
|
219
|
-
if (seen.size >= MAX_TAGS)
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
|
-
return [...seen];
|
|
223
|
-
}
|
package/lib/host/tasks/stats.js
CHANGED
|
@@ -72,6 +72,7 @@ function countFullText(task, papers) {
|
|
|
72
72
|
unknown: 0,
|
|
73
73
|
available: 0,
|
|
74
74
|
downloaded: 0,
|
|
75
|
+
missing: 0,
|
|
75
76
|
htmlOnly: 0,
|
|
76
77
|
paywalled: 0,
|
|
77
78
|
blocked: 0,
|
|
@@ -85,6 +86,8 @@ function countFullText(task, papers) {
|
|
|
85
86
|
coverage.available += 1;
|
|
86
87
|
else if (status === 'downloaded')
|
|
87
88
|
coverage.downloaded += 1;
|
|
89
|
+
else if (status === 'missing')
|
|
90
|
+
coverage.missing += 1;
|
|
88
91
|
else if (status === 'paywalled')
|
|
89
92
|
coverage.paywalled += 1;
|
|
90
93
|
else if (status === 'blocked')
|
|
@@ -27,7 +27,7 @@ export function toSummary(task) {
|
|
|
27
27
|
export function toDetail(task) {
|
|
28
28
|
const states = {};
|
|
29
29
|
for (const [paperId, state] of Object.entries(task.states)) {
|
|
30
|
-
states[paperId] = { ...state
|
|
30
|
+
states[paperId] = { ...state };
|
|
31
31
|
}
|
|
32
32
|
return {
|
|
33
33
|
...toSummary(task),
|
package/lib/host/tools.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 都有效);只有用户明确要求新任务时才新建。等待最多 25 秒后把任务头部与前
|
|
7
7
|
* 若干条结果交给模型,并明确要求本轮到此为止、只给完善建议、不发起下一轮。
|
|
8
8
|
* - `literature_sources`:列出已接入的数据源与能力,便于模型决定后续策略。
|
|
9
|
-
* - `literature_task
|
|
9
|
+
* - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读与分组,
|
|
10
10
|
* 做新老对比与统计。
|
|
11
11
|
* - `literature_export`:把任务(按筛选)导出为 CSV / RIS / BibTeX / Markdown 并落盘。
|
|
12
12
|
*
|
package/lib/host/tools.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 都有效);只有用户明确要求新任务时才新建。等待最多 25 秒后把任务头部与前
|
|
7
7
|
* 若干条结果交给模型,并明确要求本轮到此为止、只给完善建议、不发起下一轮。
|
|
8
8
|
* - `literature_sources`:列出已接入的数据源与能力,便于模型决定后续策略。
|
|
9
|
-
* - `literature_task
|
|
9
|
+
* - `literature_task`:读取/修改已保存任务(含轮次历史),写入筛读与分组,
|
|
10
10
|
* 做新老对比与统计。
|
|
11
11
|
* - `literature_export`:把任务(按筛选)导出为 CSV / RIS / BibTeX / Markdown 并落盘。
|
|
12
12
|
*
|
|
@@ -139,7 +139,7 @@ const TASK_PARAMETERS = {
|
|
|
139
139
|
action: {
|
|
140
140
|
type: 'string',
|
|
141
141
|
enum: [...TASK_ACTIONS],
|
|
142
|
-
description: 'current 读「当前任务」(推荐在执行检索/任务操作前先调用,确认这个对话正在用哪个任务,无需 id);list 列任务;get 读条件与结果;update 改条件/标题/笔记/分组;run 重跑(仅在用户明确要求进入下一轮时使用);rounds 读该任务的检索轮次历史;annotate
|
|
142
|
+
description: 'current 读「当前任务」(推荐在执行检索/任务操作前先调用,确认这个对话正在用哪个任务,无需 id);list 列任务;get 读条件与结果;update 改条件/标题/笔记/分组;run 重跑(仅在用户明确要求进入下一轮时使用);rounds 读该任务的检索轮次历史;annotate 写筛读/分组;compare 与旧任务对比;stats 读语料统计。',
|
|
143
143
|
},
|
|
144
144
|
id: { type: 'string', description: '任务 id;除 current / list 外必填。' },
|
|
145
145
|
withTaskId: { type: 'string', description: 'compare 时的基准任务 id。' },
|
|
@@ -201,16 +201,13 @@ const TASK_PARAMETERS = {
|
|
|
201
201
|
papers: {
|
|
202
202
|
type: 'array',
|
|
203
203
|
maxItems: 50,
|
|
204
|
-
description: 'annotate 时的逐篇写入(筛读 /
|
|
204
|
+
description: 'annotate 时的逐篇写入(筛读 / 分组)。',
|
|
205
205
|
items: {
|
|
206
206
|
type: 'object',
|
|
207
207
|
properties: {
|
|
208
208
|
paperId: { type: 'string' },
|
|
209
209
|
screening: { type: 'string', enum: [...SCREENINGS] },
|
|
210
210
|
groupId: { type: ['string', 'null'] },
|
|
211
|
-
shortlistOrder: { type: ['integer', 'null'] },
|
|
212
|
-
note: { type: 'string' },
|
|
213
|
-
tags: { type: 'array', items: { type: 'string' } },
|
|
214
211
|
},
|
|
215
212
|
required: ['paperId'],
|
|
216
213
|
additionalProperties: false,
|
|
@@ -331,7 +328,7 @@ export function literatureTools(service) {
|
|
|
331
328
|
},
|
|
332
329
|
{
|
|
333
330
|
name: 'literature_task',
|
|
334
|
-
description: '读取与推进已保存的检索任务。current 读「当前任务」(推荐在执行任何检索/任务操作前先调用,确认这个对话正在用哪个任务;无需 id);list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词过滤);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate
|
|
331
|
+
description: '读取与推进已保存的检索任务。current 读「当前任务」(推荐在执行任何检索/任务操作前先调用,确认这个对话正在用哪个任务;无需 id);list 列出任务;get 读条件、分组、笔记与结果(可按筛读/分组/检索式/关键词过滤);update 改标题、条件、任务笔记与分组;rounds 读该任务的检索轮次历史(每轮时间、条件、发起方、命中数与状态);annotate 批量写入筛读状态与分组;compare 与旧任务按论文身份对比新增/缺失;stats 读语料统计(年份、来源、期刊、作者、类型、语言)。' +
|
|
335
332
|
'注意:一个对话只保留一个任务 tab,切换任务由用户在任务列表里完成;run 会在指定任务内开新一轮检索(并把它设为当前任务),与 literature_search 一样受「用户查看结果并明确要求后才能进入下一轮」约束,不能用它绕过该约束自行加搜;也不要用 update 改完条件就立刻 run。适合在检索之后做筛选、整理与汇报。',
|
|
336
333
|
parameters: TASK_PARAMETERS,
|
|
337
334
|
output: TEXT_OUTPUT,
|
|
@@ -343,7 +340,7 @@ export function literatureTools(service) {
|
|
|
343
340
|
},
|
|
344
341
|
{
|
|
345
342
|
name: 'literature_export',
|
|
346
|
-
description: '把检索任务导出为文件并返回落盘路径。csv
|
|
343
|
+
description: '把检索任务导出为文件并返回落盘路径。csv 是全量筛读台账(含命中检索式、筛读状态、分组);ris / bibtex 供 Zotero、EndNote 等文献管理器;md 是分层检索报告(漏斗、按分组的阅读清单、开放获取与 PDF 覆盖率、对比新增)。可按筛读状态、分组、检索式或指定 paperIds 限定范围。',
|
|
347
344
|
parameters: EXPORT_PARAMETERS,
|
|
348
345
|
output: TEXT_OUTPUT,
|
|
349
346
|
async execute(args) {
|
|
@@ -694,23 +691,6 @@ function readAnnotations(value) {
|
|
|
694
691
|
}
|
|
695
692
|
input.groupId = groupId;
|
|
696
693
|
}
|
|
697
|
-
if (record['shortlistOrder'] !== undefined) {
|
|
698
|
-
const order = record['shortlistOrder'];
|
|
699
|
-
if (order !== null && (typeof order !== 'number' || !Number.isFinite(order))) {
|
|
700
|
-
throw new Error(`papers[${index}].shortlistOrder 必须是数字或 null`);
|
|
701
|
-
}
|
|
702
|
-
input.shortlistOrder = order;
|
|
703
|
-
}
|
|
704
|
-
const note = readText(record['note'], `papers[${index}].note`, true);
|
|
705
|
-
if (note !== undefined)
|
|
706
|
-
input.note = note;
|
|
707
|
-
if (record['tags'] !== undefined) {
|
|
708
|
-
const tags = record['tags'];
|
|
709
|
-
if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === 'string')) {
|
|
710
|
-
throw new Error(`papers[${index}].tags 必须是字符串数组`);
|
|
711
|
-
}
|
|
712
|
-
input.tags = tags;
|
|
713
|
-
}
|
|
714
694
|
return input;
|
|
715
695
|
});
|
|
716
696
|
}
|
|
@@ -798,7 +778,7 @@ async function runTaskAction(service, args, signal, sessionId) {
|
|
|
798
778
|
}
|
|
799
779
|
case 'annotate': {
|
|
800
780
|
const result = await service.annotate(requireId(args), args.papers ?? []);
|
|
801
|
-
return `已写入 ${result.updated}
|
|
781
|
+
return `已写入 ${result.updated} 篇的筛读/分组(任务 id: ${result.taskId},rev=${result.rev})。`;
|
|
802
782
|
}
|
|
803
783
|
case 'compare': {
|
|
804
784
|
const report = await service.compare(requireId(args), args.withTaskId ?? '');
|
|
@@ -926,7 +906,6 @@ function formatTaskDetail(detail, page, states, groups, outcome) {
|
|
|
926
906
|
const marks = [
|
|
927
907
|
state?.screening ?? 'unread',
|
|
928
908
|
state?.groupId === undefined ? '' : groupNames.get(state.groupId) ?? state.groupId,
|
|
929
|
-
state?.shortlistOrder === undefined ? '' : `必读#${state.shortlistOrder}`,
|
|
930
909
|
].filter((part) => part !== '');
|
|
931
910
|
const meta = [
|
|
932
911
|
paper.authors.slice(0, 4).join(', '),
|
|
@@ -938,8 +917,6 @@ function formatTaskDetail(detail, page, states, groups, outcome) {
|
|
|
938
917
|
lines.push(`${page.offset + index + 1}. [${marks.join('|')}] ${paper.title}`);
|
|
939
918
|
if (meta.length > 0)
|
|
940
919
|
lines.push(` ${meta.join(' | ')}`);
|
|
941
|
-
if (state?.note !== undefined && state.note !== '')
|
|
942
|
-
lines.push(` 备注:${state.note}`);
|
|
943
920
|
if (paper.abstract !== '')
|
|
944
921
|
lines.push(` 摘要:${truncate(paper.abstract, 300)}`);
|
|
945
922
|
});
|
package/lib/search/oa.d.ts
CHANGED
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
import type { Paper } from './types.js';
|
|
7
7
|
/** 去重后的 PDF 候选地址(顺序即尝试优先级)。 */
|
|
8
8
|
export declare function pdfCandidates(paper: Paper): string[];
|
|
9
|
+
/**
|
|
10
|
+
* 仓储的 PDF 渲染页:浏览器能打开,后台请求却普遍被拦截。
|
|
11
|
+
*
|
|
12
|
+
* 实测 `europepmc.org/articles/...?pdf=render` 返回 403 Cloudflare 页、
|
|
13
|
+
* `ncbi.nlm.nih.gov/pmc/articles/.../pdf/` 返回 200 但内容是 HTML 落地页。
|
|
14
|
+
* 它们仍保留为最后兜底,但必须排在所有静态直链与 Unpaywall 结果之后。
|
|
15
|
+
*/
|
|
16
|
+
export declare function isPdfRenderUrl(url: string): boolean;
|
|
9
17
|
/** Europe PMC 的全文 XML 地址;没有 PMCID 时返回 null。 */
|
|
10
18
|
export declare function epmcXmlUrl(paper: Paper): string | null;
|
|
11
19
|
/** Unpaywall 查询(需要联系邮箱,遵守其 polite pool)。 */
|
package/lib/search/oa.js
CHANGED
|
@@ -13,6 +13,9 @@ export function pdfCandidates(paper) {
|
|
|
13
13
|
const candidates = new Set();
|
|
14
14
|
if (paper.pdfUrl !== '')
|
|
15
15
|
candidates.add(paper.pdfUrl);
|
|
16
|
+
const publisher = publisherPdfUrl(paper.doi);
|
|
17
|
+
if (publisher !== null)
|
|
18
|
+
candidates.add(publisher);
|
|
16
19
|
const pmcid = normalizePmcid(paper.ids.pmcid);
|
|
17
20
|
if (pmcid !== '') {
|
|
18
21
|
candidates.add(`${EPMC_ARTICLE}/${pmcid}?pdf=render`);
|
|
@@ -22,6 +25,44 @@ export function pdfCandidates(paper) {
|
|
|
22
25
|
candidates.add(`${ARXIV_PDF}/${paper.ids.arxivId}`);
|
|
23
26
|
return [...candidates];
|
|
24
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* 仓储的 PDF 渲染页:浏览器能打开,后台请求却普遍被拦截。
|
|
30
|
+
*
|
|
31
|
+
* 实测 `europepmc.org/articles/...?pdf=render` 返回 403 Cloudflare 页、
|
|
32
|
+
* `ncbi.nlm.nih.gov/pmc/articles/.../pdf/` 返回 200 但内容是 HTML 落地页。
|
|
33
|
+
* 它们仍保留为最后兜底,但必须排在所有静态直链与 Unpaywall 结果之后。
|
|
34
|
+
*/
|
|
35
|
+
export function isPdfRenderUrl(url) {
|
|
36
|
+
return url.startsWith(`${EPMC_ARTICLE}/`) || url.startsWith(`${PMC_ARTICLE}/`);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 部分开放获取出版商的固定 PDF 地址规则。
|
|
40
|
+
*
|
|
41
|
+
* Europe PMC / PMC 的渲染页会对后台请求启用 Cloudflare 人机验证(浏览器能打开、
|
|
42
|
+
* Host 下载却拿到 HTML),因此优先补一条出版商原生 PDF 地址。
|
|
43
|
+
*/
|
|
44
|
+
function publisherPdfUrl(doi) {
|
|
45
|
+
const value = doi.trim();
|
|
46
|
+
if (value === '')
|
|
47
|
+
return null;
|
|
48
|
+
// BMC / SpringerOpen:https://link.springer.com/content/pdf/<doi>.pdf
|
|
49
|
+
if (value.startsWith('10.1186/') || value.startsWith('10.1007/')) {
|
|
50
|
+
return `https://link.springer.com/content/pdf/${encodeURI(value)}.pdf`;
|
|
51
|
+
}
|
|
52
|
+
// Frontiers:https://www.frontiersin.org/articles/<doi>/pdf
|
|
53
|
+
if (value.startsWith('10.3389/')) {
|
|
54
|
+
return `https://www.frontiersin.org/articles/${encodeURI(value)}/pdf`;
|
|
55
|
+
}
|
|
56
|
+
// PLOS ONE
|
|
57
|
+
if (value.startsWith('10.1371/journal.pone.')) {
|
|
58
|
+
return `https://journals.plos.org/plosone/article/file?id=${encodeURI(value)}&type=printable`;
|
|
59
|
+
}
|
|
60
|
+
// Nature 系列(开放获取文章可下载,订阅文章会落到 HTML 页并继续尝试其它候选)
|
|
61
|
+
if (value.startsWith('10.1038/')) {
|
|
62
|
+
return `https://www.nature.com/articles/${encodeURI(value.slice('10.1038/'.length))}.pdf`;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
25
66
|
/** Europe PMC 的全文 XML 地址;没有 PMCID 时返回 null。 */
|
|
26
67
|
export function epmcXmlUrl(paper) {
|
|
27
68
|
const pmcid = normalizePmcid(paper.ids.pmcid);
|
|
@@ -43,13 +84,19 @@ export async function unpaywallPdfCandidates(doi, email, timeoutMs, signal) {
|
|
|
43
84
|
const body = (await response.json());
|
|
44
85
|
const urls = new Set();
|
|
45
86
|
const best = asRecord(body['best_oa_location']);
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const pdf = asString(asRecord(raw)['url_for_pdf']);
|
|
51
|
-
if (pdf !== '')
|
|
87
|
+
const locations = [best, ...asArray(body['oa_locations']).map(asRecord)];
|
|
88
|
+
for (const location of locations) {
|
|
89
|
+
const pdf = asString(location['url_for_pdf']);
|
|
90
|
+
if (pdf !== '') {
|
|
52
91
|
urls.add(pdf);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// 少数仓储只给 `url`,且本身就是 PDF 直链;只收明显是 PDF 的地址,
|
|
95
|
+
// 避免把落地页塞进候选后又被判成「返回内容不是 PDF」。
|
|
96
|
+
const landing = asString(location['url']);
|
|
97
|
+
if (/\.pdf(?:[?#]|$)/iu.test(landing) || /\/pdf\/(?:[?#]|$)/iu.test(landing)) {
|
|
98
|
+
urls.add(landing);
|
|
99
|
+
}
|
|
53
100
|
}
|
|
54
101
|
return [...urls];
|
|
55
102
|
}
|
package/lib/search/types.d.ts
CHANGED
|
@@ -153,14 +153,20 @@ export interface TaskConditions {
|
|
|
153
153
|
pageSize: number;
|
|
154
154
|
}
|
|
155
155
|
/** 全文获取状态;`unknown` 表示尚未尝试。 */
|
|
156
|
-
export type PdfStatus = 'unknown' | 'available' | 'downloaded' | 'html-only' | 'paywalled' | 'blocked' | 'failed';
|
|
156
|
+
export type PdfStatus = 'unknown' | 'available' | 'downloaded' | 'missing' | 'html-only' | 'paywalled' | 'blocked' | 'failed';
|
|
157
|
+
/**
|
|
158
|
+
* PDF 下载失败的根因分类。
|
|
159
|
+
*
|
|
160
|
+
* 除了 `failed`(网络/超时/体积等程序故障)之外,其余都属于「后台拿不到、
|
|
161
|
+
* 但浏览器可能能打开」,界面按此分流,避免把订阅受限误报成功能失败。
|
|
162
|
+
*/
|
|
163
|
+
export type PdfFailureReason = 'no-candidate' | 'blocked' | 'paywalled' | 'not-pdf' | 'failed';
|
|
164
|
+
/** 是否需要引导用户到浏览器手动保存。 */
|
|
165
|
+
export declare function isManualPdfFailure(reason: PdfFailureReason | undefined): boolean;
|
|
157
166
|
/** 论文级人工筛选状态。 */
|
|
158
167
|
export interface PaperState {
|
|
159
168
|
screening: Screening;
|
|
160
169
|
groupId?: string;
|
|
161
|
-
shortlistOrder?: number;
|
|
162
|
-
note?: string;
|
|
163
|
-
tags?: string[];
|
|
164
170
|
pdfStatus?: PdfStatus;
|
|
165
171
|
/** 失败原因(面向用户的一句话)。 */
|
|
166
172
|
pdfError?: string;
|
|
@@ -194,7 +200,7 @@ export interface TaskResult {
|
|
|
194
200
|
* 任务内一次检索(一轮)的元数据快照。
|
|
195
201
|
*
|
|
196
202
|
* 只留「这一轮用什么条件、什么时候跑、跑出多少、结果如何」,**不保存文献列表**:
|
|
197
|
-
* 当前结果永远是最新一轮的 `result.papers
|
|
203
|
+
* 当前结果永远是最新一轮的 `result.papers`,筛读状态按 paperId 跨轮保留。
|
|
198
204
|
* 历史轮次用于时间轴回看与「回填条件重跑」。
|
|
199
205
|
*/
|
|
200
206
|
export interface SearchRound {
|
|
@@ -281,6 +287,8 @@ export interface TaskDetail extends TaskSummary {
|
|
|
281
287
|
states: Record<string, PaperState>;
|
|
282
288
|
/** 建立任务时继承筛读状态的来源任务。 */
|
|
283
289
|
fromTaskId?: string;
|
|
290
|
+
/** PDF 实际保存目录(供面板悬停提示)。 */
|
|
291
|
+
downloadDir?: string;
|
|
284
292
|
result: {
|
|
285
293
|
totalLoaded: number;
|
|
286
294
|
sources: SourceStatus[];
|
|
@@ -377,6 +385,7 @@ export interface FullTextCoverage {
|
|
|
377
385
|
unknown: number;
|
|
378
386
|
available: number;
|
|
379
387
|
downloaded: number;
|
|
388
|
+
missing: number;
|
|
380
389
|
htmlOnly: number;
|
|
381
390
|
paywalled: number;
|
|
382
391
|
blocked: number;
|
|
@@ -388,10 +397,6 @@ export interface AnnotationInput {
|
|
|
388
397
|
screening?: Screening;
|
|
389
398
|
/** `null` 表示移出分组。 */
|
|
390
399
|
groupId?: string | null;
|
|
391
|
-
/** `null` 表示清除必读顺序。 */
|
|
392
|
-
shortlistOrder?: number | null;
|
|
393
|
-
note?: string;
|
|
394
|
-
tags?: string[];
|
|
395
400
|
/** 全文状态由 Host 下载/同步流程写入,面板也可显示与筛选。 */
|
|
396
401
|
pdfStatus?: PdfStatus;
|
|
397
402
|
pdfError?: string;
|
|
@@ -407,6 +412,8 @@ export interface PdfDownloadResult {
|
|
|
407
412
|
path?: string;
|
|
408
413
|
url?: string;
|
|
409
414
|
error?: string;
|
|
415
|
+
/** 失败根因;成功时缺省。 */
|
|
416
|
+
reason?: PdfFailureReason;
|
|
410
417
|
}
|
|
411
418
|
/** Zotero connector 探测结果。 */
|
|
412
419
|
export interface ZoteroProbeResult {
|
|
@@ -448,6 +455,8 @@ export interface PaperDetail {
|
|
|
448
455
|
paper: Paper;
|
|
449
456
|
state?: PaperState;
|
|
450
457
|
groups: TaskGroup[];
|
|
458
|
+
/** PDF 实际保存目录(供面板悬停提示)。 */
|
|
459
|
+
downloadDir?: string;
|
|
451
460
|
}
|
|
452
461
|
/** PubTator 标注的单个实体(按标识符与原文去重)。 */
|
|
453
462
|
export interface PaperEntity {
|
package/lib/search/types.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-litsearch",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Search free scholarly literature sources from DSH. The model can search OpenAlex, Crossref and more with native tools, while you can run the same searches in a sidebar panel and send any paper into the conversation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|