dsh-plugin-litsearch 0.2.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.
Files changed (47) hide show
  1. package/README.md +80 -38
  2. package/client/client.js +4750 -2342
  3. package/lib/host/export/bibtex.js +1 -4
  4. package/lib/host/export/common.d.ts +0 -2
  5. package/lib/host/export/common.js +0 -7
  6. package/lib/host/export/csv.js +1 -5
  7. package/lib/host/export/markdown.d.ts +1 -1
  8. package/lib/host/export/markdown.js +9 -26
  9. package/lib/host/export/ris.js +1 -4
  10. package/lib/host/paths.d.ts +4 -3
  11. package/lib/host/paths.js +4 -3
  12. package/lib/host/pdf/root.d.ts +11 -0
  13. package/lib/host/pdf/root.js +27 -0
  14. package/lib/host/pdf/store.d.ts +17 -7
  15. package/lib/host/pdf/store.js +110 -32
  16. package/lib/host/router.js +101 -87
  17. package/lib/host/tasks/filter.d.ts +3 -1
  18. package/lib/host/tasks/filter.js +45 -4
  19. package/lib/host/tasks/runner.js +1 -8
  20. package/lib/host/tasks/service.d.ts +21 -13
  21. package/lib/host/tasks/service.js +158 -47
  22. package/lib/host/tasks/states.d.ts +1 -3
  23. package/lib/host/tasks/states.js +2 -66
  24. package/lib/host/tasks/stats.js +9 -4
  25. package/lib/host/tasks/store.d.ts +1 -1
  26. package/lib/host/tasks/store.js +1 -1
  27. package/lib/host/tasks/summary.js +1 -1
  28. package/lib/host/tools.d.ts +1 -1
  29. package/lib/host/tools.js +25 -38
  30. package/lib/host/workspace.d.ts +20 -0
  31. package/lib/host/workspace.js +32 -0
  32. package/lib/index.d.ts +17 -8
  33. package/lib/index.js +33 -11
  34. package/lib/search/aggregate.js +1 -0
  35. package/lib/search/config.d.ts +17 -1
  36. package/lib/search/config.js +16 -1
  37. package/lib/search/identity.js +1 -0
  38. package/lib/search/oa.d.ts +8 -0
  39. package/lib/search/oa.js +53 -6
  40. package/lib/search/providers/googlescholar.d.ts +12 -0
  41. package/lib/search/providers/googlescholar.js +154 -0
  42. package/lib/search/providers/index.js +2 -0
  43. package/lib/search/query-adapter.js +3 -0
  44. package/lib/search/rate-limit.js +2 -1
  45. package/lib/search/types.d.ts +35 -13
  46. package/lib/search/types.js +25 -1
  47. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  /** BibTeX 导出。 */
2
- import { groupName, paperNote } from './common.js';
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
  }
@@ -4,7 +4,7 @@
4
4
  * 列顺序与第一版方案一致,后续全文能力只需填 `pdfStatus` / `pdfError`,不改列。
5
5
  * 带 UTF-8 BOM,方便 Excel 直接打开中文。
6
6
  */
7
- import { groupName, paperNote, queryName } from './common.js';
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,5 +1,5 @@
1
1
  /**
2
- * Markdown 检索报告:检索式组(可复现)、规模漏斗、分组阅读清单、必读路线、
2
+ * Markdown 检索报告:检索式组(可复现)、规模漏斗、分组阅读清单、
3
3
  * 对比差异与研究笔记。结构对齐两份真实产出。
4
4
  */
5
5
  import type { ExportContext } from './common.js';
@@ -1,12 +1,11 @@
1
1
  /**
2
- * Markdown 检索报告:检索式组(可复现)、规模漏斗、分组阅读清单、必读路线、
2
+ * Markdown 检索报告:检索式组(可复现)、规模漏斗、分组阅读清单、
3
3
  * 对比差异与研究笔记。结构对齐两份真实产出。
4
4
  */
5
5
  import { compareTasks } from '../tasks/compare.js';
6
- import { sortGroupPapers, stateOf } from '../tasks/states.js';
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
- sortGroupPapers(task, items).forEach((paper, index) => {
49
- lines.push(`| ${index + 1} | ${paperLabel(paper)} | ${metaLabel(paper)} | ${cell(stateOf(task, paper.paperId).note ?? '')} | ${stateOf(task, paper.paperId).screening} |`);
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)} | ${cell(stateOf(task, paper.paperId).note ?? '')} | ${stateOf(task, paper.paperId).screening} |`);
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;
@@ -1,5 +1,5 @@
1
1
  /** RIS 导出(供 Zotero / EndNote 导入)。 */
2
- import { groupName, paperNote, paperTags } from './common.js';
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 - ');
@@ -7,10 +7,11 @@
7
7
  *
8
8
  * - 只复制、不删除,旧目录原样保留作备份;
9
9
  * - 不覆盖新目录里已有的文件,重复启动不会反复迁移;
10
- * - 复制失败或校验不通过时回退到旧目录读取,保证任务标注不丢。
10
+ * - 复制失败或校验不通过时回退到旧目录读取,保证任务数据不丢。
11
11
  *
12
- * PDF 的可读文件由设置项 `pdfRoot` 决定(默认 `~/Downloads/dsh-litsearch`),
13
- * 不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs` 随数据目录一起搬迁。
12
+ * PDF 的可读文件由设置项 `pdfRoot` 决定(默认落在当前会话工作目录下的
13
+ * `dsh-litsearch/`),不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs`
14
+ * 随数据目录一起搬迁。
14
15
  */
15
16
  export declare const PLUGIN_ID = "dsh-plugin-litsearch";
16
17
  /** 历史版本使用的目录名(`$DSH_HOME` 下的一级目录)。 */
package/lib/host/paths.js CHANGED
@@ -7,10 +7,11 @@
7
7
  *
8
8
  * - 只复制、不删除,旧目录原样保留作备份;
9
9
  * - 不覆盖新目录里已有的文件,重复启动不会反复迁移;
10
- * - 复制失败或校验不通过时回退到旧目录读取,保证任务标注不丢。
10
+ * - 复制失败或校验不通过时回退到旧目录读取,保证任务数据不丢。
11
11
  *
12
- * PDF 的可读文件由设置项 `pdfRoot` 决定(默认 `~/Downloads/dsh-litsearch`),
13
- * 不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs` 随数据目录一起搬迁。
12
+ * PDF 的可读文件由设置项 `pdfRoot` 决定(默认落在当前会话工作目录下的
13
+ * `dsh-litsearch/`),不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs`
14
+ * 随数据目录一起搬迁。
14
15
  */
15
16
  import { copyFile, mkdir, readdir, rm, stat } from 'node:fs/promises';
16
17
  import { homedir } from 'node:os';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * PDF 下载目录的纯路径计算。
3
+ *
4
+ * 优先级:显式配置 `pdfRoot` → 会话工作目录下的 `dsh-litsearch/` →
5
+ * 历史默认 `~/Downloads/dsh-litsearch`。保持无相对导入,便于单测直接加载。
6
+ */
7
+ /** 工作目录(或 Downloads)下的固定子目录名。 */
8
+ export declare const PDF_SUBDIR = "dsh-litsearch";
9
+ /** 展开 `~` / `~/...`;其它路径原样返回。 */
10
+ export declare function expandHomePath(path: string, home?: string): string;
11
+ export declare function resolvePdfRootPath(configured: string, workspace: string | undefined, home?: string): string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * PDF 下载目录的纯路径计算。
3
+ *
4
+ * 优先级:显式配置 `pdfRoot` → 会话工作目录下的 `dsh-litsearch/` →
5
+ * 历史默认 `~/Downloads/dsh-litsearch`。保持无相对导入,便于单测直接加载。
6
+ */
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ /** 工作目录(或 Downloads)下的固定子目录名。 */
10
+ export const PDF_SUBDIR = 'dsh-litsearch';
11
+ /** 展开 `~` / `~/...`;其它路径原样返回。 */
12
+ export function expandHomePath(path, home = homedir()) {
13
+ if (path === '~')
14
+ return home;
15
+ if (path.startsWith('~/'))
16
+ return join(home, path.slice(2));
17
+ return path;
18
+ }
19
+ export function resolvePdfRootPath(configured, workspace, home = homedir()) {
20
+ const custom = configured.trim();
21
+ if (custom !== '')
22
+ return expandHomePath(custom, home);
23
+ const base = workspace?.trim() ?? '';
24
+ if (base !== '')
25
+ return join(base, PDF_SUBDIR);
26
+ return join(home, 'Downloads', PDF_SUBDIR);
27
+ }
@@ -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,9 +17,13 @@ export interface PdfResolution {
17
17
  buffer?: Buffer;
18
18
  url?: string;
19
19
  error?: string;
20
+ /** 失败根因;成功时缺省。 */
21
+ reason?: PdfFailureReason;
20
22
  }
21
- /** 设置里的根目录;空串回落到 ~/Downloads/dsh-litsearch。 */
22
- export declare function resolvePdfRoot(): string;
23
+ /** 把技术错误整理成「发生了什么 + 下一步做什么」,供界面直接展示。 */
24
+ export declare function pdfFailureMessage(reason: PdfFailureReason, detail: string, attempted: number): string;
25
+ /** 设置里的根目录;未配置时落在会话工作目录下的 dsh-litsearch/。 */
26
+ export declare function resolvePdfRoot(workspace?: string): string;
23
27
  export declare function pdfCacheDir(storageRoot: string): string;
24
28
  /** 尝试所有候选并返回第一个合法 PDF;失败时给出可展示的状态与原因。 */
25
29
  export declare function resolvePdf(paper: Paper, signal?: AbortSignal): Promise<PdfResolution>;
@@ -31,7 +35,7 @@ export interface FetchPdfOptions {
31
35
  /** 下载并校验单个 PDF;内部处理重定向,每一步都重新校验目标地址。 */
32
36
  export declare function fetchPdf(url: string, options?: FetchPdfOptions): Promise<Buffer>;
33
37
  /** 写入去重缓存与用户目录;返回两份路径。 */
34
- export declare function persistPdf(buffer: Buffer, storageRoot: string, paper: Paper): Promise<{
38
+ export declare function persistPdf(buffer: Buffer, storageRoot: string, paper: Paper, workspace?: string): Promise<{
35
39
  cachePath: string;
36
40
  filePath: string;
37
41
  filename: string;
@@ -40,15 +44,21 @@ 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): Promise<Buffer | null>;
45
47
  /** 清理 PDF 缓存目录。 */
46
48
  export declare function cleanupPdfCache(storageRoot: string): Promise<{
47
49
  removed: number;
48
50
  freedBytes: number;
49
51
  }>;
52
+ /**
53
+ * 按总量上限修剪去重缓存:超出时从最旧(mtime)开始删,直到不超过上限。
54
+ * `maxBytes <= 0` 表示关闭自动清理。
55
+ */
56
+ export declare function prunePdfCache(storageRoot: string, maxBytes: number): Promise<{
57
+ removed: number;
58
+ freedBytes: number;
59
+ }>;
50
60
  /** 测试下载目录是否可写(设置页)。 */
51
- export declare function probePdfRoot(): Promise<{
61
+ export declare function probePdfRoot(workspace?: string): Promise<{
52
62
  root: string;
53
63
  writable: boolean;
54
64
  error?: string;
@@ -7,11 +7,11 @@
7
7
  import { createHash } from 'node:crypto';
8
8
  import { lookup } from 'node:dns/promises';
9
9
  import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
10
- import { homedir } from 'node:os';
11
10
  import { isIP } from 'node:net';
12
11
  import { join } from 'node:path';
13
12
  import { getSettings } from '../../search/config.js';
14
- import { epmcXmlUrl, pdfCandidates, unpaywallPdfCandidates } from '../../search/oa.js';
13
+ import { epmcXmlUrl, isPdfRenderUrl, pdfCandidates, unpaywallPdfCandidates, } from '../../search/oa.js';
14
+ import { resolvePdfRootPath } from './root.js';
15
15
  const MAX_REDIRECTS = 5;
16
16
  const FETCH_TIMEOUT_MS = 20_000;
17
17
  const MAX_PDF_BYTES_DEFAULT = 30 * 1024 * 1024;
@@ -26,16 +26,22 @@ export class PdfFetchError extends Error {
26
26
  this.status = status;
27
27
  }
28
28
  }
29
- /** 设置里的根目录;空串回落到 ~/Downloads/dsh-litsearch。 */
30
- export function resolvePdfRoot() {
31
- const configured = getSettings().pdfRoot.trim();
32
- if (configured === '')
33
- return join(homedir(), 'Downloads', 'dsh-litsearch');
34
- if (configured === '~')
35
- return homedir();
36
- if (configured.startsWith('~/'))
37
- return join(homedir(), configured.slice(2));
38
- return configured;
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
+ }
42
+ /** 设置里的根目录;未配置时落在会话工作目录下的 dsh-litsearch/。 */
43
+ export function resolvePdfRoot(workspace) {
44
+ return resolvePdfRootPath(getSettings().pdfRoot, workspace);
39
45
  }
40
46
  export function pdfCacheDir(storageRoot) {
41
47
  return join(storageRoot, 'pdfs');
@@ -44,15 +50,21 @@ export function pdfCacheDir(storageRoot) {
44
50
  export async function resolvePdf(paper, signal) {
45
51
  const settings = getSettings();
46
52
  const maxBytes = settings.pdfMaxMb * 1024 * 1024;
47
- const candidates = pdfCandidates(paper);
53
+ const fromMetadata = pdfCandidates(paper);
48
54
  const extra = await unpaywallPdfCandidates(paper.doi, settings.contactEmail, Math.min(settings.requestTimeoutMs, 8_000), signal);
49
- for (const url of extra)
50
- if (!candidates.includes(url))
51
- candidates.push(url);
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)];
52
64
  let last = null;
53
65
  let sawBlocked = false;
54
66
  let sawPaywalled = false;
55
- for (const url of candidates) {
67
+ for (const url of unique) {
56
68
  try {
57
69
  const buffer = await fetchPdf(url, { maxBytes, signal });
58
70
  return { ok: true, status: 'available', buffer, url };
@@ -71,10 +83,17 @@ export async function resolvePdf(paper, signal) {
71
83
  }
72
84
  }
73
85
  const status = sawBlocked ? 'blocked' : sawPaywalled ? 'paywalled' : 'failed';
74
- const error = last === null
75
- ? '没有可尝试的 PDF 地址'
76
- : `${last.message}${candidates.length > 1 ? `(已尝试 ${candidates.length} 个候选)` : ''}`;
77
- return { ok: false, status, error };
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) };
78
97
  }
79
98
  /** 下载并校验单个 PDF;内部处理重定向,每一步都重新校验目标地址。 */
80
99
  export async function fetchPdf(url, options = {}) {
@@ -92,7 +111,11 @@ export async function fetchPdf(url, options = {}) {
92
111
  const response = await fetch(current, {
93
112
  redirect: 'manual',
94
113
  signal: controller.signal,
95
- headers: { accept: 'application/pdf,application/octet-stream;q=0.9,*/*;q=0.5' },
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
+ },
96
119
  });
97
120
  if (response.status >= 300 && response.status < 400) {
98
121
  const location = response.headers.get('location');
@@ -104,8 +127,11 @@ export async function fetchPdf(url, options = {}) {
104
127
  if (response.status === 401 || response.status === 402) {
105
128
  throw new PdfFetchError('paywalled', `需要订阅或登录(HTTP ${response.status})`, response.status);
106
129
  }
107
- if (response.status === 403 || response.status === 429) {
108
- throw new PdfFetchError('blocked', `出版商拒绝访问(HTTP ${response.status})`, response.status);
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);
109
135
  }
110
136
  if (!response.ok) {
111
137
  throw new PdfFetchError('failed', `下载失败(HTTP ${response.status})`, response.status);
@@ -116,6 +142,10 @@ export async function fetchPdf(url, options = {}) {
116
142
  }
117
143
  const buffer = await readCapped(response, maxBytes);
118
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
+ }
119
149
  throw new PdfFetchError('not_pdf', '返回内容不是 PDF(可能是 HTML 登录/摘要页)');
120
150
  }
121
151
  return buffer;
@@ -138,13 +168,13 @@ export async function fetchPdf(url, options = {}) {
138
168
  throw new PdfFetchError('failed', `重定向超过 ${MAX_REDIRECTS} 次`);
139
169
  }
140
170
  /** 写入去重缓存与用户目录;返回两份路径。 */
141
- export async function persistPdf(buffer, storageRoot, paper) {
171
+ export async function persistPdf(buffer, storageRoot, paper, workspace) {
142
172
  const hash = createHash('sha256').update(buffer).digest('hex');
143
173
  const cacheDir = pdfCacheDir(storageRoot);
144
174
  await mkdir(cacheDir, { recursive: true });
145
175
  const cachePath = join(cacheDir, `${hash}.pdf`);
146
176
  await writeFile(cachePath, buffer);
147
- const root = resolvePdfRoot();
177
+ const root = resolvePdfRoot(workspace);
148
178
  await mkdir(root, { recursive: true });
149
179
  const filename = readableFilename(paper);
150
180
  const filePath = join(root, filename);
@@ -177,10 +207,6 @@ export function readableFilename(paper) {
177
207
  const base = `${author}${year}_${title === '' ? 'untitled' : title}_${safeId}`;
178
208
  return `${base.slice(0, 120)}.pdf`;
179
209
  }
180
- /** 预览时读取已保存的可读文件;没有则返回 null(由调用方决定是否联网)。 */
181
- export async function readLocalPdf(paper) {
182
- return readSavedPdf(join(resolvePdfRoot(), readableFilename(paper)));
183
- }
184
210
  /** 清理 PDF 缓存目录。 */
185
211
  export async function cleanupPdfCache(storageRoot) {
186
212
  const dir = pdfCacheDir(storageRoot);
@@ -206,9 +232,61 @@ export async function cleanupPdfCache(storageRoot) {
206
232
  }
207
233
  return { removed, freedBytes };
208
234
  }
235
+ /**
236
+ * 按总量上限修剪去重缓存:超出时从最旧(mtime)开始删,直到不超过上限。
237
+ * `maxBytes <= 0` 表示关闭自动清理。
238
+ */
239
+ export async function prunePdfCache(storageRoot, maxBytes) {
240
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0)
241
+ return { removed: 0, freedBytes: 0 };
242
+ const dir = pdfCacheDir(storageRoot);
243
+ let names;
244
+ try {
245
+ names = await readdir(dir);
246
+ }
247
+ catch {
248
+ return { removed: 0, freedBytes: 0 };
249
+ }
250
+ const files = [];
251
+ let total = 0;
252
+ for (const name of names) {
253
+ if (!name.endsWith('.pdf'))
254
+ continue;
255
+ const path = join(dir, name);
256
+ try {
257
+ const info = await stat(path);
258
+ if (!info.isFile())
259
+ continue;
260
+ files.push({ path, size: info.size, mtimeMs: info.mtimeMs });
261
+ total += info.size;
262
+ }
263
+ catch {
264
+ // 单个缓存文件读不到元信息时跳过,不影响修剪其余文件。
265
+ }
266
+ }
267
+ if (total <= maxBytes)
268
+ return { removed: 0, freedBytes: 0 };
269
+ files.sort((a, b) => a.mtimeMs - b.mtimeMs);
270
+ let removed = 0;
271
+ let freedBytes = 0;
272
+ for (const file of files) {
273
+ if (total <= maxBytes)
274
+ break;
275
+ try {
276
+ await rm(file.path, { force: true });
277
+ total -= file.size;
278
+ freedBytes += file.size;
279
+ removed += 1;
280
+ }
281
+ catch {
282
+ // 删除失败继续尝试更近的文件。
283
+ }
284
+ }
285
+ return { removed, freedBytes };
286
+ }
209
287
  /** 测试下载目录是否可写(设置页)。 */
210
- export async function probePdfRoot() {
211
- const root = resolvePdfRoot();
288
+ export async function probePdfRoot(workspace) {
289
+ const root = resolvePdfRoot(workspace);
212
290
  const probe = join(root, `.write-test-${process.pid}-${Date.now()}`);
213
291
  try {
214
292
  await mkdir(root, { recursive: true });