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.
@@ -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,7 +7,7 @@
7
7
  *
8
8
  * - 只复制、不删除,旧目录原样保留作备份;
9
9
  * - 不覆盖新目录里已有的文件,重复启动不会反复迁移;
10
- * - 复制失败或校验不通过时回退到旧目录读取,保证任务标注不丢。
10
+ * - 复制失败或校验不通过时回退到旧目录读取,保证任务数据不丢。
11
11
  *
12
12
  * PDF 的可读文件由设置项 `pdfRoot` 决定(默认落在当前会话工作目录下的
13
13
  * `dsh-litsearch/`),不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs`
package/lib/host/paths.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * - 只复制、不删除,旧目录原样保留作备份;
9
9
  * - 不覆盖新目录里已有的文件,重复启动不会反复迁移;
10
- * - 复制失败或校验不通过时回退到旧目录读取,保证任务标注不丢。
10
+ * - 复制失败或校验不通过时回退到旧目录读取,保证任务数据不丢。
11
11
  *
12
12
  * PDF 的可读文件由设置项 `pdfRoot` 决定(默认落在当前会话工作目录下的
13
13
  * `dsh-litsearch/`),不受本模块影响;只有插件内部的去重缓存 `<root>/pdfs`
@@ -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;
@@ -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 candidates = pdfCandidates(paper);
53
+ const fromMetadata = pdfCandidates(paper);
41
54
  const extra = await unpaywallPdfCandidates(paper.doi, settings.contactEmail, Math.min(settings.requestTimeoutMs, 8_000), signal);
42
- for (const url of extra)
43
- if (!candidates.includes(url))
44
- 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)];
45
64
  let last = null;
46
65
  let sawBlocked = false;
47
66
  let sawPaywalled = false;
48
- for (const url of candidates) {
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 error = last === null
68
- ? '没有可尝试的 PDF 地址'
69
- : `${last.message}${candidates.length > 1 ? `(已尝试 ${candidates.length} 个候选)` : ''}`;
70
- 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) };
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: { 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
+ },
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 === 403 || response.status === 429) {
101
- 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);
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);
@@ -6,11 +6,15 @@
6
6
  * `sec-fetch-site: cross-site`。写端点额外要求自定义头 + JSON content-type,
7
7
  * 两者一起让浏览器必须预检,堵掉跨站表单与简单请求。
8
8
  */
9
+ import { spawn } from 'node:child_process';
10
+ import { mkdir, stat } from 'node:fs/promises';
11
+ import { dirname, extname, isAbsolute, resolve } from 'node:path';
9
12
  import { MAX_LIMIT, ROUTE_PREFIX, SESSION_HEADER, WRITE_HEADER } from '../search/config.js';
10
13
  import { SearchError } from '../search/errors.js';
11
14
  import { asRecord } from '../search/normalize.js';
12
15
  import { isSourceId } from '../search/providers/index.js';
13
16
  import { TASK_LANGUAGES } from '../search/types.js';
17
+ import { resolvePdfRoot } from './pdf/store.js';
14
18
  import { resolveSessionWorkspace } from './workspace.js';
15
19
  const MAX_BODY_BYTES = 1_000_000;
16
20
  const SORT_MODES = ['relevance', 'citations', 'year'];
@@ -21,6 +25,7 @@ const PDF_STATUSES = [
21
25
  'unknown',
22
26
  'available',
23
27
  'downloaded',
28
+ 'missing',
24
29
  'html-only',
25
30
  'paywalled',
26
31
  'blocked',
@@ -81,48 +86,6 @@ function sendFile(res, file) {
81
86
  });
82
87
  res.end(body);
83
88
  }
84
- /** 内联 PDF,支持单段 Range(预览器会按需拉取)。 */
85
- function sendPdf(req, res, buffer, filename) {
86
- const total = buffer.length;
87
- const disposition = `inline; filename*=UTF-8''${encodeURIComponent(filename)}`;
88
- const range = req.headers.range;
89
- if (range === undefined) {
90
- res.writeHead(200, {
91
- 'content-type': 'application/pdf',
92
- 'content-length': total,
93
- 'content-disposition': disposition,
94
- 'accept-ranges': 'bytes',
95
- 'cache-control': 'no-store',
96
- });
97
- res.end(buffer);
98
- return;
99
- }
100
- const match = /^bytes=(\d*)-(\d*)$/u.exec(range.trim());
101
- const start = match?.[1] === '' ? Number.NaN : Number(match?.[1]);
102
- const end = match?.[2] === '' ? Number.NaN : Number(match?.[2]);
103
- if (match === null || (!Number.isFinite(start) && !Number.isFinite(end))) {
104
- res.writeHead(416, { 'content-range': `bytes */${total}`, 'cache-control': 'no-store' });
105
- res.end();
106
- return;
107
- }
108
- const from = Number.isFinite(start) ? Math.max(0, start) : Math.max(0, total - (end || 0));
109
- const to = Number.isFinite(end) ? Math.min(end, total - 1) : total - 1;
110
- if (from > to || from >= total) {
111
- res.writeHead(416, { 'content-range': `bytes */${total}`, 'cache-control': 'no-store' });
112
- res.end();
113
- return;
114
- }
115
- const chunk = buffer.subarray(from, to + 1);
116
- res.writeHead(206, {
117
- 'content-type': 'application/pdf',
118
- 'content-length': chunk.length,
119
- 'content-range': `bytes ${from}-${to}/${total}`,
120
- 'content-disposition': disposition,
121
- 'accept-ranges': 'bytes',
122
- 'cache-control': 'no-store',
123
- });
124
- res.end(chunk);
125
- }
126
89
  function sendHtml(res, status, html) {
127
90
  res.writeHead(status, {
128
91
  'content-type': 'text/html; charset=utf-8',
@@ -130,6 +93,52 @@ function sendHtml(res, status, html) {
130
93
  });
131
94
  res.end(html);
132
95
  }
96
+ /**
97
+ * 在系统文件管理器中定位文件:macOS Finder、Windows 资源管理器、Linux 桌面。
98
+ * 只接受已存在的 PDF 文件或目录(批量下载的「打开下载目录」用后者),
99
+ * 避免这个端点被当成任意路径探测。
100
+ */
101
+ async function revealInFileManager(rawPath, downloadRoot) {
102
+ const target = resolve(rawPath);
103
+ let info = await stat(target).catch(() => null);
104
+ // 下载目录可能一篇都还没下过:打开前补建,避免点「打开下载目录」毫无反应。
105
+ if (info === null && downloadRoot !== undefined && resolve(downloadRoot) === target) {
106
+ await mkdir(target, { recursive: true });
107
+ info = await stat(target).catch(() => null);
108
+ }
109
+ if (info === null)
110
+ throw new SearchError('not_found', '文件不存在或已被移动');
111
+ if (info.isDirectory()) {
112
+ const [command, args] = process.platform === 'darwin'
113
+ ? ['open', [target]]
114
+ : process.platform === 'win32'
115
+ ? ['explorer.exe', [target]]
116
+ : ['xdg-open', [target]];
117
+ await spawnDetached(command, args);
118
+ return { path: target };
119
+ }
120
+ if (!info.isFile() || extname(target).toLowerCase() !== '.pdf') {
121
+ throw new SearchError('bad_request', '只能定位 PDF 文件或下载目录');
122
+ }
123
+ const [command, args] = process.platform === 'darwin'
124
+ ? ['open', ['-R', target]]
125
+ : process.platform === 'win32'
126
+ ? ['explorer.exe', [`/select,${target}`]]
127
+ : ['xdg-open', [dirname(target)]];
128
+ await spawnDetached(command, args);
129
+ return { path: target };
130
+ }
131
+ /** 启动系统命令后立即脱离,不等待它的生命周期。 */
132
+ async function spawnDetached(command, args) {
133
+ await new Promise((settle, rejectSpawn) => {
134
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
135
+ child.once('error', rejectSpawn);
136
+ child.once('spawn', () => {
137
+ child.unref();
138
+ settle();
139
+ });
140
+ });
141
+ }
133
142
  function sendHtmlError(res, error) {
134
143
  const { status, error: apiError } = toApiError(error);
135
144
  sendHtml(res, status === 500 ? 502 : status, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>全文不可用</title></head><body style="font:14px/1.7 -apple-system,'PingFang SC',sans-serif;padding:16px"><p>全文不可用:${escapeHtml(apiError.message)}</p></body></html>`);
@@ -300,26 +309,6 @@ function readAnnotations(value) {
300
309
  if (groupId !== undefined)
301
310
  input.groupId = groupId;
302
311
  }
303
- if (record['shortlistOrder'] === null)
304
- input.shortlistOrder = null;
305
- else if (record['shortlistOrder'] !== undefined) {
306
- const order = readInt(record['shortlistOrder'], 'shortlistOrder', 0, 100_000);
307
- if (order !== undefined)
308
- input.shortlistOrder = order;
309
- }
310
- if (record['note'] !== undefined) {
311
- if (typeof record['note'] !== 'string') {
312
- throw new SearchError('bad_request', 'note 必须是字符串');
313
- }
314
- input.note = record['note'].slice(0, 4_000);
315
- }
316
- if (record['tags'] !== undefined) {
317
- const tags = readStringArray(record['tags'], 'tags', 20, 40);
318
- if (tags !== undefined && tags !== null)
319
- input.tags = tags;
320
- else if (tags === null)
321
- input.tags = [];
322
- }
323
312
  return input;
324
313
  });
325
314
  }
@@ -590,7 +579,7 @@ async function handleTasks(service, req, res, seg, params, sessionId) {
590
579
  const action = seg[2];
591
580
  if (action === undefined) {
592
581
  if (req.method === 'GET') {
593
- ok(res, await service.getDetail(id));
582
+ ok(res, await service.getDetail(id, await resolveSessionWorkspace(sessionId)));
594
583
  return;
595
584
  }
596
585
  if (req.method === 'PATCH') {
@@ -664,7 +653,7 @@ async function handleTasks(service, req, res, seg, params, sessionId) {
664
653
  fail(res, 405, 'bad_request', '论文详情仅支持 GET');
665
654
  return;
666
655
  }
667
- ok(res, await service.paperDetail(id, paperId));
656
+ ok(res, await service.paperDetail(id, paperId, await resolveSessionWorkspace(sessionId)));
668
657
  return;
669
658
  }
670
659
  if (op === 'entities') {
@@ -675,27 +664,20 @@ async function handleTasks(service, req, res, seg, params, sessionId) {
675
664
  ok(res, await service.entities(id, paperId));
676
665
  return;
677
666
  }
678
- if (op === 'download') {
667
+ if (op === 'recheck') {
679
668
  if (req.method !== 'POST') {
680
- fail(res, 405, 'bad_request', 'download 仅支持 POST');
669
+ fail(res, 405, 'bad_request', 'recheck 仅支持 POST');
681
670
  return;
682
671
  }
683
- ok(res, await service.downloadPdf(id, paperId, await resolveSessionWorkspace(sessionId)));
672
+ ok(res, await service.recheckPdf(id, paperId));
684
673
  return;
685
674
  }
686
- if (op === 'pdf') {
687
- if (req.method !== 'GET') {
688
- fail(res, 405, 'bad_request', 'pdf 仅支持 GET');
675
+ if (op === 'download') {
676
+ if (req.method !== 'POST') {
677
+ fail(res, 405, 'bad_request', 'download 仅支持 POST');
689
678
  return;
690
679
  }
691
- try {
692
- const file = await service.previewPdf(id, paperId, await resolveSessionWorkspace(sessionId));
693
- sendPdf(req, res, file.buffer, file.filename);
694
- }
695
- catch (error) {
696
- const { status, error: apiError } = toApiError(error);
697
- fail(res, status === 500 ? 502 : status, apiError.code, apiError.message);
698
- }
680
+ ok(res, await service.downloadPdf(id, paperId, await resolveSessionWorkspace(sessionId)));
699
681
  return;
700
682
  }
701
683
  if (op === 'fulltext') {
@@ -802,6 +784,26 @@ export function createHandler(service) {
802
784
  ok(res, await service.eventsSince(Number.isFinite(sinceRev) ? sinceRev : 0));
803
785
  return;
804
786
  }
787
+ if (head === 'files') {
788
+ if (seg[1] !== 'reveal') {
789
+ fail(res, 404, 'not_found', `未知文件操作 ${seg[1] ?? ''}`);
790
+ return;
791
+ }
792
+ if (req.method !== 'POST') {
793
+ fail(res, 405, 'bad_request', 'reveal 仅支持 POST');
794
+ return;
795
+ }
796
+ const body = await readJsonBody(req);
797
+ const path = readString(body['path'], 'path', 4096);
798
+ if (path === undefined || path === '') {
799
+ throw new SearchError('bad_request', '缺少 path 参数');
800
+ }
801
+ if (!isAbsolute(path))
802
+ throw new SearchError('bad_request', 'path 必须是绝对路径');
803
+ const workspace = await resolveSessionWorkspace(readSessionId(req, url.searchParams));
804
+ ok(res, await revealInFileManager(path, resolvePdfRoot(workspace)));
805
+ return;
806
+ }
805
807
  if (head === 'settings') {
806
808
  if (seg[1] === 'probe' && req.method === 'POST') {
807
809
  const source = readString((await readJsonBody(req))['source'], 'source', 40);
@@ -89,7 +89,7 @@ export declare class TaskService {
89
89
  eventsSince(sinceRev: number): Promise<EventsPage>;
90
90
  list(filter?: TaskListFilter): Promise<TaskSummary[]>;
91
91
  get(id: string): Promise<SearchTask>;
92
- getDetail(id: string): Promise<TaskDetail>;
92
+ getDetail(id: string, workspace?: string): Promise<TaskDetail>;
93
93
  /** 任务内轮次历史(时间升序、深拷贝)。 */
94
94
  getRounds(id: string): Promise<SearchRound[]>;
95
95
  /**
@@ -129,7 +129,7 @@ export declare class TaskService {
129
129
  cancel(id: string): Promise<SearchTask>;
130
130
  results(id: string, filter?: ResultsFilter): Promise<ResultsPage>;
131
131
  stats(id: string, filter?: PaperFilter, scope?: 'task' | 'filtered'): Promise<TaskStatsReport>;
132
- /** 写入筛读 / 分组 / 必读 / 备注;返回新的 rev 与命中条数。 */
132
+ /** 写入筛读 / 分组;返回新的 rev 与命中条数。 */
133
133
  annotate(id: string, inputs: readonly AnnotationInput[], expectedRev?: number): Promise<AnnotationResult>;
134
134
  compare(id: string, withTaskId: string): Promise<CompareResult>;
135
135
  /** 生成导出内容;`paperIds` 为空表示按当前筛选导出全部。 */
@@ -142,17 +142,19 @@ export declare class TaskService {
142
142
  }>;
143
143
  /** 设置页清理:任务文件与 PDF 缓存。 */
144
144
  cleanup(scope: CleanupScope): Promise<CleanupResult>;
145
+ /**
146
+ * 「查看已下载」前的存在性检查:页面加载后文件可能被删除或移走。
147
+ * 缺失时把状态改为 `missing` 并广播,所有面板随即显示「下载文件已删除」。
148
+ */
149
+ recheckPdf(id: string, paperId: string): Promise<{
150
+ exists: boolean;
151
+ }>;
145
152
  /** 下载单篇 PDF;失败时写入失败状态,若有 EPMC 全文则降级为 html-only。 */
146
153
  downloadPdf(id: string, paperId: string, workspace?: string): Promise<PdfDownloadResult>;
147
- /** PDF 预览:优先用已落地文件;没有则临时抓取一次但不落盘。 */
148
- previewPdf(id: string, paperId: string, workspace?: string): Promise<{
149
- buffer: Buffer;
150
- filename: string;
151
- }>;
152
154
  /** Europe PMC 全文(JATS → 只含安全标签的 HTML)。 */
153
155
  fullText(id: string, paperId: string): Promise<string>;
154
156
  /** 单篇论文详情:独立论文页使用,含任务标题与分组。 */
155
- paperDetail(id: string, paperId: string): Promise<PaperDetail>;
157
+ paperDetail(id: string, paperId: string, workspace?: string): Promise<PaperDetail>;
156
158
  /** PubTator3 实体标注;无 PMID 时明确告知不可标注。 */
157
159
  entities(id: string, paperId: string, signal?: AbortSignal): Promise<PaperEntities>;
158
160
  /** 测试 Zotero connector 是否可达,并返回当前选中的分类。 */