dsh-plugin-litsearch 0.3.0 → 0.4.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/README.md +158 -41
- package/client/client.js +2632 -926
- package/lib/host/events.d.ts +9 -1
- package/lib/host/events.js +50 -3
- 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 +146 -79
- package/lib/host/tasks/download.d.ts +104 -0
- package/lib/host/tasks/download.js +274 -0
- package/lib/host/tasks/filter.d.ts +8 -1
- package/lib/host/tasks/filter.js +14 -0
- package/lib/host/tasks/service.d.ts +44 -11
- package/lib/host/tasks/service.js +216 -48
- 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 +5 -1
- package/lib/host/tools.js +813 -37
- package/lib/host/zotero/connector.d.ts +19 -10
- package/lib/host/zotero/connector.js +142 -39
- package/lib/host/zotero/match.d.ts +27 -0
- package/lib/host/zotero/match.js +59 -0
- package/lib/search/config.d.ts +0 -2
- package/lib/search/config.js +0 -2
- package/lib/search/oa.d.ts +8 -0
- package/lib/search/oa.js +53 -6
- package/lib/search/types.d.ts +154 -10
- package/lib/search/types.js +11 -0
- package/package.json +1 -1
package/lib/host/events.d.ts
CHANGED
|
@@ -5,15 +5,23 @@
|
|
|
5
5
|
* 的 globalRev」日志;客户端按 `sinceRev` 轮询拿增量。globalRev 只在宿主进程内
|
|
6
6
|
* 单调递增,重启后归零 —— 客户端发现 `sinceRev` 比服务端大就以 `reset` 重新同步。
|
|
7
7
|
*/
|
|
8
|
-
import type { EventsPage, TaskSummary } from '../search/types.js';
|
|
8
|
+
import type { DownloadCommand, EventsPage, TaskSummary, ZoteroCommand } from '../search/types.js';
|
|
9
9
|
export declare class TaskEvents {
|
|
10
10
|
private rev;
|
|
11
11
|
private readonly entries;
|
|
12
12
|
private focus;
|
|
13
|
+
/** 下载指令日志:数组而非单值,两次轮询之间连发多条也不会互相覆盖。 */
|
|
14
|
+
private readonly downloadEntries;
|
|
15
|
+
/** Zotero 同步指令日志,与下载并列,互不覆盖。 */
|
|
16
|
+
private readonly zoteroEntries;
|
|
13
17
|
get currentRev(): number;
|
|
14
18
|
publish(summary: TaskSummary): void;
|
|
15
19
|
/** 请求客户端把某个任务 tab 打开/切到前台(面板关着时重新打开)。 */
|
|
16
20
|
requestFocus(taskId: string, title: string, sessionId?: string): void;
|
|
21
|
+
/** 下发一条模型批量下载指令;与 focus 一样推进 globalRev 走增量轮询。 */
|
|
22
|
+
publishDownload(command: DownloadCommand): void;
|
|
23
|
+
/** 下发一条模型 Zotero 同步指令;与下载指令一样推进 globalRev 走增量轮询。 */
|
|
24
|
+
publishZotero(command: ZoteroCommand): void;
|
|
17
25
|
remove(id: string): void;
|
|
18
26
|
since(sinceRev: number): EventsPage;
|
|
19
27
|
}
|
package/lib/host/events.js
CHANGED
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
* 的 globalRev」日志;客户端按 `sinceRev` 轮询拿增量。globalRev 只在宿主进程内
|
|
6
6
|
* 单调递增,重启后归零 —— 客户端发现 `sinceRev` 比服务端大就以 `reset` 重新同步。
|
|
7
7
|
*/
|
|
8
|
+
/** 事件日志里最多保留多少条模型指令,避免长时间运行无限增长。 */
|
|
9
|
+
const MAX_DOWNLOAD_ENTRIES = 50;
|
|
10
|
+
const MAX_ZOTERO_ENTRIES = 50;
|
|
8
11
|
export class TaskEvents {
|
|
9
12
|
rev = 0;
|
|
10
13
|
entries = new Map();
|
|
11
14
|
focus;
|
|
15
|
+
/** 下载指令日志:数组而非单值,两次轮询之间连发多条也不会互相覆盖。 */
|
|
16
|
+
downloadEntries = [];
|
|
17
|
+
/** Zotero 同步指令日志,与下载并列,互不覆盖。 */
|
|
18
|
+
zoteroEntries = [];
|
|
12
19
|
get currentRev() {
|
|
13
20
|
return this.rev;
|
|
14
21
|
}
|
|
@@ -27,17 +34,42 @@ export class TaskEvents {
|
|
|
27
34
|
...(sessionId === undefined ? {} : { sessionId }),
|
|
28
35
|
};
|
|
29
36
|
}
|
|
37
|
+
/** 下发一条模型批量下载指令;与 focus 一样推进 globalRev 走增量轮询。 */
|
|
38
|
+
publishDownload(command) {
|
|
39
|
+
this.rev += 1;
|
|
40
|
+
this.downloadEntries.push({ globalRev: this.rev, command });
|
|
41
|
+
if (this.downloadEntries.length > MAX_DOWNLOAD_ENTRIES) {
|
|
42
|
+
this.downloadEntries.splice(0, this.downloadEntries.length - MAX_DOWNLOAD_ENTRIES);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** 下发一条模型 Zotero 同步指令;与下载指令一样推进 globalRev 走增量轮询。 */
|
|
46
|
+
publishZotero(command) {
|
|
47
|
+
this.rev += 1;
|
|
48
|
+
this.zoteroEntries.push({ globalRev: this.rev, command });
|
|
49
|
+
if (this.zoteroEntries.length > MAX_ZOTERO_ENTRIES) {
|
|
50
|
+
this.zoteroEntries.splice(0, this.zoteroEntries.length - MAX_ZOTERO_ENTRIES);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
30
53
|
remove(id) {
|
|
31
54
|
this.entries.delete(id);
|
|
32
55
|
if (this.focus?.taskId === id)
|
|
33
56
|
this.focus = undefined;
|
|
57
|
+
for (let index = this.downloadEntries.length - 1; index >= 0; index -= 1) {
|
|
58
|
+
if (this.downloadEntries[index]?.command.taskId === id)
|
|
59
|
+
this.downloadEntries.splice(index, 1);
|
|
60
|
+
}
|
|
61
|
+
for (let index = this.zoteroEntries.length - 1; index >= 0; index -= 1) {
|
|
62
|
+
if (this.zoteroEntries[index]?.command.taskId === id)
|
|
63
|
+
this.zoteroEntries.splice(index, 1);
|
|
64
|
+
}
|
|
34
65
|
}
|
|
35
66
|
since(sinceRev) {
|
|
36
67
|
if (!Number.isFinite(sinceRev) || sinceRev < 0) {
|
|
37
|
-
|
|
68
|
+
// 宿主重启 / 客户端状态错乱:重新对齐,不重放历史模型指令。
|
|
69
|
+
return { rev: this.rev, reset: true, tasks: [], downloads: [], zotero: [] };
|
|
38
70
|
}
|
|
39
71
|
if (sinceRev > this.rev) {
|
|
40
|
-
return { rev: this.rev, reset: true, tasks: [] };
|
|
72
|
+
return { rev: this.rev, reset: true, tasks: [], downloads: [], zotero: [] };
|
|
41
73
|
}
|
|
42
74
|
const tasks = [...this.entries.values()]
|
|
43
75
|
.filter((entry) => entry.globalRev > sinceRev)
|
|
@@ -53,6 +85,21 @@ export class TaskEvents {
|
|
|
53
85
|
: { sessionId: this.focus.sessionId }),
|
|
54
86
|
}
|
|
55
87
|
: undefined;
|
|
56
|
-
|
|
88
|
+
const downloads = this.downloadEntries
|
|
89
|
+
.filter((entry) => entry.globalRev > sinceRev)
|
|
90
|
+
.sort((a, b) => a.globalRev - b.globalRev)
|
|
91
|
+
.map((entry) => entry.command);
|
|
92
|
+
const zotero = this.zoteroEntries
|
|
93
|
+
.filter((entry) => entry.globalRev > sinceRev)
|
|
94
|
+
.sort((a, b) => a.globalRev - b.globalRev)
|
|
95
|
+
.map((entry) => entry.command);
|
|
96
|
+
return {
|
|
97
|
+
rev: this.rev,
|
|
98
|
+
reset: false,
|
|
99
|
+
tasks,
|
|
100
|
+
...(focus === undefined ? {} : { focus }),
|
|
101
|
+
downloads,
|
|
102
|
+
zotero,
|
|
103
|
+
};
|
|
57
104
|
}
|
|
58
105
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** BibTeX 导出。 */
|
|
2
|
-
import { groupName
|
|
2
|
+
import { groupName } from './common.js';
|
|
3
3
|
import { stateOf } from '../tasks/states.js';
|
|
4
4
|
export function renderBibtex(context) {
|
|
5
5
|
return `${context.papers
|
|
@@ -22,9 +22,6 @@ export function renderBibtex(context) {
|
|
|
22
22
|
const group = groupName(context.task, state.groupId);
|
|
23
23
|
if (group !== '')
|
|
24
24
|
fields.push(['keywords', group]);
|
|
25
|
-
const note = paperNote(context.task, paper);
|
|
26
|
-
if (note !== '')
|
|
27
|
-
fields.push(['note', note]);
|
|
28
25
|
const body = fields
|
|
29
26
|
.map(([name, value]) => ` ${name} = {${escapeBibtex(value)}}`)
|
|
30
27
|
.join(',\n');
|
|
@@ -11,8 +11,6 @@ export interface ExportContext {
|
|
|
11
11
|
}
|
|
12
12
|
export declare function queryName(task: SearchTask, queryId: string): string;
|
|
13
13
|
export declare function groupName(task: SearchTask, groupId: string | undefined): string;
|
|
14
|
-
export declare function paperNote(task: SearchTask, paper: Paper): string;
|
|
15
|
-
export declare function paperTags(task: SearchTask, paper: Paper): string[];
|
|
16
14
|
export declare function isoDate(date: Date): string;
|
|
17
15
|
/** 文件名用的安全任务 id(导出统一按 id 命名,避免标题里的路径字符)。 */
|
|
18
16
|
export declare function exportStem(task: SearchTask, date: Date): string;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 导出共用上下文:把任务、待导出论文与可选的对比基准打包给各格式渲染器。
|
|
3
3
|
*/
|
|
4
|
-
import { stateOf } from '../tasks/states.js';
|
|
5
4
|
export function queryName(task, queryId) {
|
|
6
5
|
return task.conditions.queries.find((query) => query.id === queryId)?.name ?? queryId;
|
|
7
6
|
}
|
|
@@ -10,12 +9,6 @@ export function groupName(task, groupId) {
|
|
|
10
9
|
return '';
|
|
11
10
|
return task.groups.find((group) => group.id === groupId)?.name ?? '';
|
|
12
11
|
}
|
|
13
|
-
export function paperNote(task, paper) {
|
|
14
|
-
return stateOf(task, paper.paperId).note ?? '';
|
|
15
|
-
}
|
|
16
|
-
export function paperTags(task, paper) {
|
|
17
|
-
return stateOf(task, paper.paperId).tags ?? [];
|
|
18
|
-
}
|
|
19
12
|
export function isoDate(date) {
|
|
20
13
|
return date.toISOString().slice(0, 10);
|
|
21
14
|
}
|
package/lib/host/export/csv.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 列顺序与第一版方案一致,后续全文能力只需填 `pdfStatus` / `pdfError`,不改列。
|
|
5
5
|
* 带 UTF-8 BOM,方便 Excel 直接打开中文。
|
|
6
6
|
*/
|
|
7
|
-
import { groupName,
|
|
7
|
+
import { groupName, queryName } from './common.js';
|
|
8
8
|
import { pdfStatusOf } from '../tasks/filter.js';
|
|
9
9
|
import { stateOf } from '../tasks/states.js';
|
|
10
10
|
const COLUMNS = [
|
|
@@ -24,10 +24,8 @@ const COLUMNS = [
|
|
|
24
24
|
'language',
|
|
25
25
|
'screening',
|
|
26
26
|
'group',
|
|
27
|
-
'shortlistOrder',
|
|
28
27
|
'pdfStatus',
|
|
29
28
|
'pdfError',
|
|
30
|
-
'note',
|
|
31
29
|
];
|
|
32
30
|
export function renderCsv(context) {
|
|
33
31
|
const { task, papers } = context;
|
|
@@ -51,10 +49,8 @@ export function renderCsv(context) {
|
|
|
51
49
|
paper.language ?? '',
|
|
52
50
|
state.screening,
|
|
53
51
|
groupName(task, state.groupId),
|
|
54
|
-
state.shortlistOrder === undefined ? '' : String(state.shortlistOrder),
|
|
55
52
|
pdfStatusOf(task, paper),
|
|
56
53
|
state.pdfError ?? '',
|
|
57
|
-
paperNote(task, paper),
|
|
58
54
|
]
|
|
59
55
|
.map(csvCell)
|
|
60
56
|
.join(','));
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Markdown
|
|
2
|
+
* Markdown 检索报告:检索式组(可复现)、规模漏斗、分组阅读清单、
|
|
3
3
|
* 对比差异与研究笔记。结构对齐两份真实产出。
|
|
4
4
|
*/
|
|
5
5
|
import { compareTasks } from '../tasks/compare.js';
|
|
6
|
-
import {
|
|
6
|
+
import { stateOf } from '../tasks/states.js';
|
|
7
7
|
import { isoDate } from './common.js';
|
|
8
8
|
const TOP_COMPARE = 30;
|
|
9
|
-
const TOP_ROUTE = 30;
|
|
10
9
|
export function renderMarkdown(context) {
|
|
11
10
|
const { task, papers, base, generatedAt } = context;
|
|
12
11
|
const lines = [];
|
|
@@ -43,10 +42,10 @@ export function renderMarkdown(context) {
|
|
|
43
42
|
lines.push('_(暂无文献)_', '');
|
|
44
43
|
}
|
|
45
44
|
else {
|
|
46
|
-
lines.push('| # | 文献 | 年份 / 出处 |
|
|
47
|
-
lines.push('
|
|
48
|
-
|
|
49
|
-
lines.push(`| ${index + 1} | ${paperLabel(paper)} | ${metaLabel(paper)} | ${
|
|
45
|
+
lines.push('| # | 文献 | 年份 / 出处 | 状态 |');
|
|
46
|
+
lines.push('|---:|---|---|---|');
|
|
47
|
+
items.forEach((paper, index) => {
|
|
48
|
+
lines.push(`| ${index + 1} | ${paperLabel(paper)} | ${metaLabel(paper)} | ${stateOf(task, paper.paperId).screening} |`);
|
|
50
49
|
});
|
|
51
50
|
lines.push('');
|
|
52
51
|
}
|
|
@@ -54,31 +53,15 @@ export function renderMarkdown(context) {
|
|
|
54
53
|
const ungrouped = papers.filter((paper) => stateOf(task, paper.paperId).groupId === undefined);
|
|
55
54
|
if (ungrouped.length > 0) {
|
|
56
55
|
lines.push(`### 未分组(${ungrouped.length} 篇)`, '');
|
|
57
|
-
lines.push('| # | 文献 | 年份 / 出处 |
|
|
58
|
-
lines.push('
|
|
56
|
+
lines.push('| # | 文献 | 年份 / 出处 | 状态 |');
|
|
57
|
+
lines.push('|---:|---|---|---|');
|
|
59
58
|
ungrouped.slice(0, 120).forEach((paper, index) => {
|
|
60
|
-
lines.push(`| ${index + 1} | ${paperLabel(paper)} | ${metaLabel(paper)} | ${
|
|
59
|
+
lines.push(`| ${index + 1} | ${paperLabel(paper)} | ${metaLabel(paper)} | ${stateOf(task, paper.paperId).screening} |`);
|
|
61
60
|
});
|
|
62
61
|
if (ungrouped.length > 120)
|
|
63
62
|
lines.push('', `_(仅列出前 120 篇,完整清单见 CSV)_`);
|
|
64
63
|
lines.push('');
|
|
65
64
|
}
|
|
66
|
-
const route = papers
|
|
67
|
-
.filter((paper) => stateOf(task, paper.paperId).shortlistOrder !== undefined)
|
|
68
|
-
.sort((a, b) => (stateOf(task, a.paperId).shortlistOrder ?? 0) -
|
|
69
|
-
(stateOf(task, b.paperId).shortlistOrder ?? 0))
|
|
70
|
-
.slice(0, TOP_ROUTE);
|
|
71
|
-
lines.push('## 必读阅读路线', '');
|
|
72
|
-
if (route.length === 0) {
|
|
73
|
-
lines.push('_(尚未标记必读顺序)_', '');
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
route.forEach((paper, index) => {
|
|
77
|
-
const note = stateOf(task, paper.paperId).note;
|
|
78
|
-
lines.push(`${index + 1}. ${paperLabel(paper)}${note === undefined ? '' : ` — ${note}`}`);
|
|
79
|
-
});
|
|
80
|
-
lines.push('');
|
|
81
|
-
}
|
|
82
65
|
lines.push('## 全文获取情况', '');
|
|
83
66
|
const oa = papers.filter((paper) => paper.openAccess).length;
|
|
84
67
|
const pdf = papers.filter((paper) => paper.pdfUrl !== '').length;
|
package/lib/host/export/ris.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** RIS 导出(供 Zotero / EndNote 导入)。 */
|
|
2
|
-
import { groupName
|
|
2
|
+
import { groupName } from './common.js';
|
|
3
3
|
import { stateOf } from '../tasks/states.js';
|
|
4
4
|
export function renderRis(context) {
|
|
5
5
|
const blocks = context.papers.map((paper) => {
|
|
@@ -18,9 +18,6 @@ export function renderRis(context) {
|
|
|
18
18
|
const group = groupName(context.task, state.groupId);
|
|
19
19
|
if (group !== '')
|
|
20
20
|
push(lines, 'KW', group);
|
|
21
|
-
for (const tag of paperTags(context.task, paper))
|
|
22
|
-
push(lines, 'KW', tag);
|
|
23
|
-
push(lines, 'N1', paperNote(context.task, paper));
|
|
24
21
|
if (state.screening !== 'unread')
|
|
25
22
|
push(lines, 'N1', `screening: ${state.screening}`);
|
|
26
23
|
lines.push('ER - ');
|
package/lib/host/paths.d.ts
CHANGED
package/lib/host/paths.js
CHANGED
package/lib/host/pdf/store.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 安全约束:只允许 http/https、拒绝回环/私网/链路本地地址、最多 5 次重定向、
|
|
5
5
|
* 20 秒超时、体积上限可配;只有以 `%PDF-` 开头的内容才会被当成 PDF。
|
|
6
6
|
*/
|
|
7
|
-
import type { Paper, PdfStatus } from '../../search/types.js';
|
|
7
|
+
import type { Paper, PdfFailureReason, PdfStatus } from '../../search/types.js';
|
|
8
8
|
export type PdfFailure = 'paywalled' | 'blocked' | 'failed' | 'not_pdf' | 'too_large' | 'unsafe';
|
|
9
9
|
export declare class PdfFetchError extends Error {
|
|
10
10
|
readonly kind: PdfFailure;
|
|
@@ -17,7 +17,11 @@ export interface PdfResolution {
|
|
|
17
17
|
buffer?: Buffer;
|
|
18
18
|
url?: string;
|
|
19
19
|
error?: string;
|
|
20
|
+
/** 失败根因;成功时缺省。 */
|
|
21
|
+
reason?: PdfFailureReason;
|
|
20
22
|
}
|
|
23
|
+
/** 把技术错误整理成「发生了什么 + 下一步做什么」,供界面直接展示。 */
|
|
24
|
+
export declare function pdfFailureMessage(reason: PdfFailureReason, detail: string, attempted: number): string;
|
|
21
25
|
/** 设置里的根目录;未配置时落在会话工作目录下的 dsh-litsearch/。 */
|
|
22
26
|
export declare function resolvePdfRoot(workspace?: string): string;
|
|
23
27
|
export declare function pdfCacheDir(storageRoot: string): string;
|
|
@@ -40,8 +44,6 @@ export declare function persistPdf(buffer: Buffer, storageRoot: string, paper: P
|
|
|
40
44
|
export declare function readSavedPdf(filePath: string): Promise<Buffer | null>;
|
|
41
45
|
/** `<第一作者><年份>_<短标题>_<PMID|DOI>.pdf`,去掉路径分隔符。 */
|
|
42
46
|
export declare function readableFilename(paper: Paper): string;
|
|
43
|
-
/** 预览时读取已保存的可读文件;没有则返回 null(由调用方决定是否联网)。 */
|
|
44
|
-
export declare function readLocalPdf(paper: Paper, workspace?: string): Promise<Buffer | null>;
|
|
45
47
|
/** 清理 PDF 缓存目录。 */
|
|
46
48
|
export declare function cleanupPdfCache(storageRoot: string): Promise<{
|
|
47
49
|
removed: number;
|
package/lib/host/pdf/store.js
CHANGED
|
@@ -10,7 +10,7 @@ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
|
|
10
10
|
import { isIP } from 'node:net';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { getSettings } from '../../search/config.js';
|
|
13
|
-
import { epmcXmlUrl, pdfCandidates, unpaywallPdfCandidates } from '../../search/oa.js';
|
|
13
|
+
import { epmcXmlUrl, isPdfRenderUrl, pdfCandidates, unpaywallPdfCandidates, } from '../../search/oa.js';
|
|
14
14
|
import { resolvePdfRootPath } from './root.js';
|
|
15
15
|
const MAX_REDIRECTS = 5;
|
|
16
16
|
const FETCH_TIMEOUT_MS = 20_000;
|
|
@@ -26,6 +26,19 @@ export class PdfFetchError extends Error {
|
|
|
26
26
|
this.status = status;
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
/** 分类对应的下一步指引:后台拿不到时明确告诉用户去浏览器手动保存。 */
|
|
30
|
+
const FAILURE_HINTS = {
|
|
31
|
+
'no-candidate': '请在浏览器打开原文或 PDF 链接手动保存(订阅文献无法自动下载)',
|
|
32
|
+
blocked: '请在浏览器打开 PDF 链接手动保存(后台请求可能被要求登录、订阅或人机验证)',
|
|
33
|
+
paywalled: '该文献需要订阅,请在浏览器登录后手动保存',
|
|
34
|
+
'not-pdf': '请在浏览器打开 PDF 链接手动保存',
|
|
35
|
+
failed: '可稍后重试,若持续失败请在浏览器打开原文手动保存',
|
|
36
|
+
};
|
|
37
|
+
/** 把技术错误整理成「发生了什么 + 下一步做什么」,供界面直接展示。 */
|
|
38
|
+
export function pdfFailureMessage(reason, detail, attempted) {
|
|
39
|
+
const suffix = attempted > 1 ? `(已尝试 ${attempted} 个候选)` : '';
|
|
40
|
+
return `${detail}${suffix};${FAILURE_HINTS[reason]}`;
|
|
41
|
+
}
|
|
29
42
|
/** 设置里的根目录;未配置时落在会话工作目录下的 dsh-litsearch/。 */
|
|
30
43
|
export function resolvePdfRoot(workspace) {
|
|
31
44
|
return resolvePdfRootPath(getSettings().pdfRoot, workspace);
|
|
@@ -37,15 +50,21 @@ export function pdfCacheDir(storageRoot) {
|
|
|
37
50
|
export async function resolvePdf(paper, signal) {
|
|
38
51
|
const settings = getSettings();
|
|
39
52
|
const maxBytes = settings.pdfMaxMb * 1024 * 1024;
|
|
40
|
-
const
|
|
53
|
+
const fromMetadata = pdfCandidates(paper);
|
|
41
54
|
const extra = await unpaywallPdfCandidates(paper.doi, settings.contactEmail, Math.min(settings.requestTimeoutMs, 8_000), signal);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
// 静态直链优先、Unpaywall 补出的 OA 直链次之,渲染页放最后兜底:
|
|
56
|
+
// Europe PMC / PMC 的渲染页对后台请求几乎必然返回 403 或 HTML,
|
|
57
|
+
// 让它们排在优质候选前面只会拖慢下载并掩盖真正的失败原因。
|
|
58
|
+
const candidates = [
|
|
59
|
+
...fromMetadata.filter((url) => !isPdfRenderUrl(url)),
|
|
60
|
+
...extra,
|
|
61
|
+
...fromMetadata.filter(isPdfRenderUrl),
|
|
62
|
+
];
|
|
63
|
+
const unique = [...new Set(candidates)];
|
|
45
64
|
let last = null;
|
|
46
65
|
let sawBlocked = false;
|
|
47
66
|
let sawPaywalled = false;
|
|
48
|
-
for (const url of
|
|
67
|
+
for (const url of unique) {
|
|
49
68
|
try {
|
|
50
69
|
const buffer = await fetchPdf(url, { maxBytes, signal });
|
|
51
70
|
return { ok: true, status: 'available', buffer, url };
|
|
@@ -64,10 +83,17 @@ export async function resolvePdf(paper, signal) {
|
|
|
64
83
|
}
|
|
65
84
|
}
|
|
66
85
|
const status = sawBlocked ? 'blocked' : sawPaywalled ? 'paywalled' : 'failed';
|
|
67
|
-
const
|
|
68
|
-
? '
|
|
69
|
-
:
|
|
70
|
-
|
|
86
|
+
const reason = last === null
|
|
87
|
+
? 'no-candidate'
|
|
88
|
+
: sawBlocked
|
|
89
|
+
? 'blocked'
|
|
90
|
+
: sawPaywalled
|
|
91
|
+
? 'paywalled'
|
|
92
|
+
: last.kind === 'not_pdf'
|
|
93
|
+
? 'not-pdf'
|
|
94
|
+
: 'failed';
|
|
95
|
+
const detail = last === null ? '未找到可后台下载的开放获取 PDF' : last.message;
|
|
96
|
+
return { ok: false, status, reason, error: pdfFailureMessage(reason, detail, unique.length) };
|
|
71
97
|
}
|
|
72
98
|
/** 下载并校验单个 PDF;内部处理重定向,每一步都重新校验目标地址。 */
|
|
73
99
|
export async function fetchPdf(url, options = {}) {
|
|
@@ -85,7 +111,11 @@ export async function fetchPdf(url, options = {}) {
|
|
|
85
111
|
const response = await fetch(current, {
|
|
86
112
|
redirect: 'manual',
|
|
87
113
|
signal: controller.signal,
|
|
88
|
-
headers: {
|
|
114
|
+
headers: {
|
|
115
|
+
accept: 'application/pdf,application/octet-stream;q=0.9,*/*;q=0.5',
|
|
116
|
+
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
117
|
+
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
|
|
118
|
+
},
|
|
89
119
|
});
|
|
90
120
|
if (response.status >= 300 && response.status < 400) {
|
|
91
121
|
const location = response.headers.get('location');
|
|
@@ -97,8 +127,11 @@ export async function fetchPdf(url, options = {}) {
|
|
|
97
127
|
if (response.status === 401 || response.status === 402) {
|
|
98
128
|
throw new PdfFetchError('paywalled', `需要订阅或登录(HTTP ${response.status})`, response.status);
|
|
99
129
|
}
|
|
100
|
-
if (response.status ===
|
|
101
|
-
throw new PdfFetchError('blocked',
|
|
130
|
+
if (response.status === 429) {
|
|
131
|
+
throw new PdfFetchError('blocked', '请求过于频繁(HTTP 429),请稍后重试', response.status);
|
|
132
|
+
}
|
|
133
|
+
if (response.status === 403) {
|
|
134
|
+
throw new PdfFetchError('blocked', '出版商拒绝访问(HTTP 403)', response.status);
|
|
102
135
|
}
|
|
103
136
|
if (!response.ok) {
|
|
104
137
|
throw new PdfFetchError('failed', `下载失败(HTTP ${response.status})`, response.status);
|
|
@@ -109,6 +142,10 @@ export async function fetchPdf(url, options = {}) {
|
|
|
109
142
|
}
|
|
110
143
|
const buffer = await readCapped(response, maxBytes);
|
|
111
144
|
if (buffer.length < 5 || buffer.subarray(0, 5).toString('latin1') !== '%PDF-') {
|
|
145
|
+
const head = buffer.subarray(0, 2048).toString('latin1');
|
|
146
|
+
if (/Just a moment|cf-mitigated|Enable JavaScript and cookies/i.test(head)) {
|
|
147
|
+
throw new PdfFetchError('blocked', '出版商启用了人机验证,后台无法直接下载');
|
|
148
|
+
}
|
|
112
149
|
throw new PdfFetchError('not_pdf', '返回内容不是 PDF(可能是 HTML 登录/摘要页)');
|
|
113
150
|
}
|
|
114
151
|
return buffer;
|
|
@@ -170,10 +207,6 @@ export function readableFilename(paper) {
|
|
|
170
207
|
const base = `${author}${year}_${title === '' ? 'untitled' : title}_${safeId}`;
|
|
171
208
|
return `${base.slice(0, 120)}.pdf`;
|
|
172
209
|
}
|
|
173
|
-
/** 预览时读取已保存的可读文件;没有则返回 null(由调用方决定是否联网)。 */
|
|
174
|
-
export async function readLocalPdf(paper, workspace) {
|
|
175
|
-
return readSavedPdf(join(resolvePdfRoot(workspace), readableFilename(paper)));
|
|
176
|
-
}
|
|
177
210
|
/** 清理 PDF 缓存目录。 */
|
|
178
211
|
export async function cleanupPdfCache(storageRoot) {
|
|
179
212
|
const dir = pdfCacheDir(storageRoot);
|