dsh-plugin-litsearch 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +157 -2
  3. package/client/client.js +9036 -0
  4. package/cordis.patch.yml +10 -0
  5. package/lib/host/events.d.ts +19 -0
  6. package/lib/host/events.js +58 -0
  7. package/lib/host/export/bibtex.d.ts +3 -0
  8. package/lib/host/export/bibtex.js +42 -0
  9. package/lib/host/export/common.d.ts +18 -0
  10. package/lib/host/export/common.js +25 -0
  11. package/lib/host/export/csv.d.ts +8 -0
  12. package/lib/host/export/csv.js +68 -0
  13. package/lib/host/export/index.d.ts +5 -0
  14. package/lib/host/export/index.js +25 -0
  15. package/lib/host/export/markdown.d.ts +6 -0
  16. package/lib/host/export/markdown.js +120 -0
  17. package/lib/host/export/ris.d.ts +3 -0
  18. package/lib/host/export/ris.js +35 -0
  19. package/lib/host/paths.d.ts +43 -0
  20. package/lib/host/paths.js +128 -0
  21. package/lib/host/pdf/store.d.ts +57 -0
  22. package/lib/host/pdf/store.js +291 -0
  23. package/lib/host/router.d.ts +20 -0
  24. package/lib/host/router.js +852 -0
  25. package/lib/host/tasks/compare.d.ts +8 -0
  26. package/lib/host/tasks/compare.js +49 -0
  27. package/lib/host/tasks/filter.d.ts +42 -0
  28. package/lib/host/tasks/filter.js +100 -0
  29. package/lib/host/tasks/rounds.d.ts +28 -0
  30. package/lib/host/tasks/rounds.js +74 -0
  31. package/lib/host/tasks/runner.d.ts +29 -0
  32. package/lib/host/tasks/runner.js +208 -0
  33. package/lib/host/tasks/service.d.ts +171 -0
  34. package/lib/host/tasks/service.js +976 -0
  35. package/lib/host/tasks/states.d.ts +28 -0
  36. package/lib/host/tasks/states.js +223 -0
  37. package/lib/host/tasks/stats.d.ts +13 -0
  38. package/lib/host/tasks/stats.js +233 -0
  39. package/lib/host/tasks/store.d.ts +64 -0
  40. package/lib/host/tasks/store.js +280 -0
  41. package/lib/host/tasks/summary.d.ts +9 -0
  42. package/lib/host/tasks/summary.js +54 -0
  43. package/lib/host/tools.d.ts +47 -0
  44. package/lib/host/tools.js +1138 -0
  45. package/lib/host/zotero/connector.d.ts +46 -0
  46. package/lib/host/zotero/connector.js +263 -0
  47. package/lib/index.d.ts +107 -0
  48. package/lib/index.js +153 -0
  49. package/lib/search/aggregate.d.ts +17 -0
  50. package/lib/search/aggregate.js +60 -0
  51. package/lib/search/config.d.ts +96 -0
  52. package/lib/search/config.js +96 -0
  53. package/lib/search/errors.d.ts +22 -0
  54. package/lib/search/errors.js +24 -0
  55. package/lib/search/health.d.ts +13 -0
  56. package/lib/search/health.js +53 -0
  57. package/lib/search/http.d.ts +13 -0
  58. package/lib/search/http.js +81 -0
  59. package/lib/search/identity.d.ts +27 -0
  60. package/lib/search/identity.js +154 -0
  61. package/lib/search/normalize.d.ts +17 -0
  62. package/lib/search/normalize.js +47 -0
  63. package/lib/search/oa.d.ts +21 -0
  64. package/lib/search/oa.js +149 -0
  65. package/lib/search/providers/arxiv.d.ts +9 -0
  66. package/lib/search/providers/arxiv.js +99 -0
  67. package/lib/search/providers/crossref.d.ts +8 -0
  68. package/lib/search/providers/crossref.js +107 -0
  69. package/lib/search/providers/europepmc.d.ts +8 -0
  70. package/lib/search/providers/europepmc.js +105 -0
  71. package/lib/search/providers/index.d.ts +13 -0
  72. package/lib/search/providers/index.js +34 -0
  73. package/lib/search/providers/openalex.d.ts +8 -0
  74. package/lib/search/providers/openalex.js +109 -0
  75. package/lib/search/providers/pubmed.d.ts +10 -0
  76. package/lib/search/providers/pubmed.js +143 -0
  77. package/lib/search/providers/pubtator.d.ts +9 -0
  78. package/lib/search/providers/pubtator.js +79 -0
  79. package/lib/search/providers/semanticscholar.d.ts +9 -0
  80. package/lib/search/providers/semanticscholar.js +97 -0
  81. package/lib/search/pubtator-entities.d.ts +9 -0
  82. package/lib/search/pubtator-entities.js +19 -0
  83. package/lib/search/pubtator-parse.d.ts +3 -0
  84. package/lib/search/pubtator-parse.js +42 -0
  85. package/lib/search/query-adapter.d.ts +15 -0
  86. package/lib/search/query-adapter.js +69 -0
  87. package/lib/search/rate-limit.d.ts +31 -0
  88. package/lib/search/rate-limit.js +219 -0
  89. package/lib/search/types.d.ts +577 -0
  90. package/lib/search/types.js +15 -0
  91. package/lib/search/xml.d.ts +20 -0
  92. package/lib/search/xml.js +63 -0
  93. package/package.json +75 -4
@@ -0,0 +1,57 @@
1
+ /**
2
+ * PDF 获取与落地。
3
+ *
4
+ * 安全约束:只允许 http/https、拒绝回环/私网/链路本地地址、最多 5 次重定向、
5
+ * 20 秒超时、体积上限可配;只有以 `%PDF-` 开头的内容才会被当成 PDF。
6
+ */
7
+ import type { Paper, PdfStatus } from '../../search/types.js';
8
+ export type PdfFailure = 'paywalled' | 'blocked' | 'failed' | 'not_pdf' | 'too_large' | 'unsafe';
9
+ export declare class PdfFetchError extends Error {
10
+ readonly kind: PdfFailure;
11
+ readonly status?: number;
12
+ constructor(kind: PdfFailure, message: string, status?: number);
13
+ }
14
+ export interface PdfResolution {
15
+ ok: boolean;
16
+ status: PdfStatus;
17
+ buffer?: Buffer;
18
+ url?: string;
19
+ error?: string;
20
+ }
21
+ /** 设置里的根目录;空串回落到 ~/Downloads/dsh-litsearch。 */
22
+ export declare function resolvePdfRoot(): string;
23
+ export declare function pdfCacheDir(storageRoot: string): string;
24
+ /** 尝试所有候选并返回第一个合法 PDF;失败时给出可展示的状态与原因。 */
25
+ export declare function resolvePdf(paper: Paper, signal?: AbortSignal): Promise<PdfResolution>;
26
+ export interface FetchPdfOptions {
27
+ maxBytes?: number;
28
+ signal?: AbortSignal;
29
+ timeoutMs?: number;
30
+ }
31
+ /** 下载并校验单个 PDF;内部处理重定向,每一步都重新校验目标地址。 */
32
+ export declare function fetchPdf(url: string, options?: FetchPdfOptions): Promise<Buffer>;
33
+ /** 写入去重缓存与用户目录;返回两份路径。 */
34
+ export declare function persistPdf(buffer: Buffer, storageRoot: string, paper: Paper): Promise<{
35
+ cachePath: string;
36
+ filePath: string;
37
+ filename: string;
38
+ }>;
39
+ /** 已下载文件仍存在时直接读取,避免重复下载。 */
40
+ export declare function readSavedPdf(filePath: string): Promise<Buffer | null>;
41
+ /** `<第一作者><年份>_<短标题>_<PMID|DOI>.pdf`,去掉路径分隔符。 */
42
+ export declare function readableFilename(paper: Paper): string;
43
+ /** 预览时读取已保存的可读文件;没有则返回 null(由调用方决定是否联网)。 */
44
+ export declare function readLocalPdf(paper: Paper): Promise<Buffer | null>;
45
+ /** 清理 PDF 缓存目录。 */
46
+ export declare function cleanupPdfCache(storageRoot: string): Promise<{
47
+ removed: number;
48
+ freedBytes: number;
49
+ }>;
50
+ /** 测试下载目录是否可写(设置页)。 */
51
+ export declare function probePdfRoot(): Promise<{
52
+ root: string;
53
+ writable: boolean;
54
+ error?: string;
55
+ }>;
56
+ /** 供路由/服务判断是否有 EPMC HTML 全文。 */
57
+ export declare function epmcFullTextAvailable(paper: Paper): boolean;
@@ -0,0 +1,291 @@
1
+ /**
2
+ * PDF 获取与落地。
3
+ *
4
+ * 安全约束:只允许 http/https、拒绝回环/私网/链路本地地址、最多 5 次重定向、
5
+ * 20 秒超时、体积上限可配;只有以 `%PDF-` 开头的内容才会被当成 PDF。
6
+ */
7
+ import { createHash } from 'node:crypto';
8
+ import { lookup } from 'node:dns/promises';
9
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
10
+ import { homedir } from 'node:os';
11
+ import { isIP } from 'node:net';
12
+ import { join } from 'node:path';
13
+ import { getSettings } from '../../search/config.js';
14
+ import { epmcXmlUrl, pdfCandidates, unpaywallPdfCandidates } from '../../search/oa.js';
15
+ const MAX_REDIRECTS = 5;
16
+ const FETCH_TIMEOUT_MS = 20_000;
17
+ const MAX_PDF_BYTES_DEFAULT = 30 * 1024 * 1024;
18
+ export class PdfFetchError extends Error {
19
+ kind;
20
+ status;
21
+ constructor(kind, message, status) {
22
+ super(message);
23
+ this.name = 'PdfFetchError';
24
+ this.kind = kind;
25
+ if (status !== undefined)
26
+ this.status = status;
27
+ }
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;
39
+ }
40
+ export function pdfCacheDir(storageRoot) {
41
+ return join(storageRoot, 'pdfs');
42
+ }
43
+ /** 尝试所有候选并返回第一个合法 PDF;失败时给出可展示的状态与原因。 */
44
+ export async function resolvePdf(paper, signal) {
45
+ const settings = getSettings();
46
+ const maxBytes = settings.pdfMaxMb * 1024 * 1024;
47
+ const candidates = pdfCandidates(paper);
48
+ 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);
52
+ let last = null;
53
+ let sawBlocked = false;
54
+ let sawPaywalled = false;
55
+ for (const url of candidates) {
56
+ try {
57
+ const buffer = await fetchPdf(url, { maxBytes, signal });
58
+ return { ok: true, status: 'available', buffer, url };
59
+ }
60
+ catch (error) {
61
+ if (signal?.aborted === true)
62
+ throw error;
63
+ const failure = error instanceof PdfFetchError
64
+ ? error
65
+ : new PdfFetchError('failed', error instanceof Error ? error.message : String(error));
66
+ last = failure;
67
+ if (failure.kind === 'blocked')
68
+ sawBlocked = true;
69
+ if (failure.kind === 'paywalled')
70
+ sawPaywalled = true;
71
+ }
72
+ }
73
+ 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 };
78
+ }
79
+ /** 下载并校验单个 PDF;内部处理重定向,每一步都重新校验目标地址。 */
80
+ export async function fetchPdf(url, options = {}) {
81
+ const maxBytes = options.maxBytes ?? MAX_PDF_BYTES_DEFAULT;
82
+ const deadline = Date.now() + (options.timeoutMs ?? FETCH_TIMEOUT_MS);
83
+ let current = url;
84
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
85
+ await assertPublicUrl(current);
86
+ const controller = new AbortController();
87
+ const remaining = Math.max(1_000, deadline - Date.now());
88
+ const timer = setTimeout(() => controller.abort(), remaining);
89
+ const onAbort = () => controller.abort();
90
+ options.signal?.addEventListener('abort', onAbort);
91
+ try {
92
+ const response = await fetch(current, {
93
+ redirect: 'manual',
94
+ signal: controller.signal,
95
+ headers: { accept: 'application/pdf,application/octet-stream;q=0.9,*/*;q=0.5' },
96
+ });
97
+ if (response.status >= 300 && response.status < 400) {
98
+ const location = response.headers.get('location');
99
+ if (location === null)
100
+ throw new PdfFetchError('failed', `重定向缺少 Location:${current}`);
101
+ current = new URL(location, current).toString();
102
+ continue;
103
+ }
104
+ if (response.status === 401 || response.status === 402) {
105
+ throw new PdfFetchError('paywalled', `需要订阅或登录(HTTP ${response.status})`, response.status);
106
+ }
107
+ if (response.status === 403 || response.status === 429) {
108
+ throw new PdfFetchError('blocked', `出版商拒绝访问(HTTP ${response.status})`, response.status);
109
+ }
110
+ if (!response.ok) {
111
+ throw new PdfFetchError('failed', `下载失败(HTTP ${response.status})`, response.status);
112
+ }
113
+ const declared = Number(response.headers.get('content-length') ?? '0');
114
+ if (Number.isFinite(declared) && declared > maxBytes) {
115
+ throw new PdfFetchError('too_large', `文件超过 ${Math.round(maxBytes / 1024 / 1024)} MB`);
116
+ }
117
+ const buffer = await readCapped(response, maxBytes);
118
+ if (buffer.length < 5 || buffer.subarray(0, 5).toString('latin1') !== '%PDF-') {
119
+ throw new PdfFetchError('not_pdf', '返回内容不是 PDF(可能是 HTML 登录/摘要页)');
120
+ }
121
+ return buffer;
122
+ }
123
+ catch (error) {
124
+ if (options.signal?.aborted === true)
125
+ throw error;
126
+ if (error instanceof PdfFetchError)
127
+ throw error;
128
+ if (controller.signal.aborted) {
129
+ throw new PdfFetchError('failed', '下载超时');
130
+ }
131
+ throw new PdfFetchError('failed', error instanceof Error ? error.message : String(error));
132
+ }
133
+ finally {
134
+ clearTimeout(timer);
135
+ options.signal?.removeEventListener('abort', onAbort);
136
+ }
137
+ }
138
+ throw new PdfFetchError('failed', `重定向超过 ${MAX_REDIRECTS} 次`);
139
+ }
140
+ /** 写入去重缓存与用户目录;返回两份路径。 */
141
+ export async function persistPdf(buffer, storageRoot, paper) {
142
+ const hash = createHash('sha256').update(buffer).digest('hex');
143
+ const cacheDir = pdfCacheDir(storageRoot);
144
+ await mkdir(cacheDir, { recursive: true });
145
+ const cachePath = join(cacheDir, `${hash}.pdf`);
146
+ await writeFile(cachePath, buffer);
147
+ const root = resolvePdfRoot();
148
+ await mkdir(root, { recursive: true });
149
+ const filename = readableFilename(paper);
150
+ const filePath = join(root, filename);
151
+ await writeFile(filePath, buffer);
152
+ return { cachePath, filePath, filename };
153
+ }
154
+ /** 已下载文件仍存在时直接读取,避免重复下载。 */
155
+ export async function readSavedPdf(filePath) {
156
+ try {
157
+ return await readFile(filePath);
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ /** `<第一作者><年份>_<短标题>_<PMID|DOI>.pdf`,去掉路径分隔符。 */
164
+ export function readableFilename(paper) {
165
+ const first = paper.authors[0]?.trim() ?? '';
166
+ const author = first === '' ? 'Anon' : first.split(/\s+/u).at(-1) ?? 'Anon';
167
+ const year = paper.year === null ? 'nd' : String(paper.year);
168
+ const title = paper.title
169
+ .normalize('NFKC')
170
+ .replace(/[\\/:*?"<>|\r\n]+/gu, ' ')
171
+ .replace(/\s+/gu, ' ')
172
+ .trim()
173
+ .slice(0, 48)
174
+ .trim();
175
+ const id = paper.ids.pmid !== '' ? `PMID${paper.ids.pmid}` : paper.doi !== '' ? `DOI${paper.doi}` : paper.paperId;
176
+ const safeId = id.replace(/[^A-Za-z0-9._-]+/gu, '_').slice(0, 64);
177
+ const base = `${author}${year}_${title === '' ? 'untitled' : title}_${safeId}`;
178
+ return `${base.slice(0, 120)}.pdf`;
179
+ }
180
+ /** 预览时读取已保存的可读文件;没有则返回 null(由调用方决定是否联网)。 */
181
+ export async function readLocalPdf(paper) {
182
+ return readSavedPdf(join(resolvePdfRoot(), readableFilename(paper)));
183
+ }
184
+ /** 清理 PDF 缓存目录。 */
185
+ export async function cleanupPdfCache(storageRoot) {
186
+ const dir = pdfCacheDir(storageRoot);
187
+ let names;
188
+ try {
189
+ names = await readdir(dir);
190
+ }
191
+ catch {
192
+ return { removed: 0, freedBytes: 0 };
193
+ }
194
+ let removed = 0;
195
+ let freedBytes = 0;
196
+ for (const name of names) {
197
+ const path = join(dir, name);
198
+ try {
199
+ freedBytes += (await stat(path)).size;
200
+ await rm(path, { force: true });
201
+ removed += 1;
202
+ }
203
+ catch {
204
+ // 单个缓存文件删除失败不影响其余文件。
205
+ }
206
+ }
207
+ return { removed, freedBytes };
208
+ }
209
+ /** 测试下载目录是否可写(设置页)。 */
210
+ export async function probePdfRoot() {
211
+ const root = resolvePdfRoot();
212
+ const probe = join(root, `.write-test-${process.pid}-${Date.now()}`);
213
+ try {
214
+ await mkdir(root, { recursive: true });
215
+ await writeFile(probe, 'ok', 'utf8');
216
+ await rm(probe, { force: true });
217
+ return { root, writable: true };
218
+ }
219
+ catch (error) {
220
+ return {
221
+ root,
222
+ writable: false,
223
+ error: error instanceof Error ? error.message : String(error),
224
+ };
225
+ }
226
+ }
227
+ /** 供路由/服务判断是否有 EPMC HTML 全文。 */
228
+ export function epmcFullTextAvailable(paper) {
229
+ return epmcXmlUrl(paper) !== null;
230
+ }
231
+ async function readCapped(response, maxBytes) {
232
+ if (response.body === null)
233
+ return Buffer.alloc(0);
234
+ const reader = response.body.getReader();
235
+ const chunks = [];
236
+ let total = 0;
237
+ for (;;) {
238
+ const { done, value } = await reader.read();
239
+ if (done)
240
+ break;
241
+ total += value.byteLength;
242
+ if (total > maxBytes) {
243
+ await reader.cancel();
244
+ throw new PdfFetchError('too_large', `文件超过 ${Math.round(maxBytes / 1024 / 1024)} MB`);
245
+ }
246
+ chunks.push(value);
247
+ }
248
+ return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
249
+ }
250
+ async function assertPublicUrl(raw) {
251
+ let url;
252
+ try {
253
+ url = new URL(raw);
254
+ }
255
+ catch {
256
+ throw new PdfFetchError('unsafe', `非法地址:${raw}`);
257
+ }
258
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
259
+ throw new PdfFetchError('unsafe', `只允许 http/https:${url.protocol}`);
260
+ }
261
+ const host = url.hostname.replace(/^\[|\]$/gu, '').toLowerCase();
262
+ if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal')) {
263
+ throw new PdfFetchError('unsafe', `拒绝访问本机/内网地址:${host}`);
264
+ }
265
+ const addresses = isIP(host) !== 0 ? [{ address: host }] : await lookup(host, { all: true }).catch(() => []);
266
+ if (addresses.length === 0)
267
+ throw new PdfFetchError('failed', `无法解析域名:${host}`);
268
+ for (const entry of addresses) {
269
+ if (isPrivateAddress(entry.address)) {
270
+ throw new PdfFetchError('unsafe', `拒绝访问内网地址:${entry.address}`);
271
+ }
272
+ }
273
+ }
274
+ function isPrivateAddress(address) {
275
+ const value = address.toLowerCase();
276
+ if (value === '::1' || value === '0:0:0:0:0:0:0:1')
277
+ return true;
278
+ if (value.startsWith('fe80:') || value.startsWith('fc') || value.startsWith('fd'))
279
+ return true;
280
+ const parts = value.split('.').map((part) => Number(part));
281
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part)))
282
+ return false;
283
+ const [a, b] = parts;
284
+ return (a === 0 ||
285
+ a === 10 ||
286
+ a === 127 ||
287
+ (a === 100 && b >= 64 && b <= 127) ||
288
+ (a === 169 && b === 254) ||
289
+ (a === 172 && b >= 16 && b <= 31) ||
290
+ (a === 192 && b === 168));
291
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Host HTTP 路由:`/dsh-litsearch/api/*`。
3
+ *
4
+ * 面板与设置页都从这里取数;统一信封 `{ok:true,data}` / `{ok:false,error}`。
5
+ * 信任栅栏:Host 头必须是回环地址(防 DNS rebinding),并拒绝
6
+ * `sec-fetch-site: cross-site`。写端点额外要求自定义头 + JSON content-type,
7
+ * 两者一起让浏览器必须预检,堵掉跨站表单与简单请求。
8
+ */
9
+ import type { IncomingMessage, ServerResponse } from 'node:http';
10
+ import type { TaskService } from './tasks/service.js';
11
+ /** dsh `webServer` 的最小结构接口(类型不在已安装的 dsh 包里)。 */
12
+ export interface WebServerLike {
13
+ register(options: {
14
+ kind: 'exact' | 'prefix';
15
+ path: string;
16
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
17
+ }): () => void;
18
+ }
19
+ /** 构造挂在 `webServer` 上的前缀路由处理器。 */
20
+ export declare function createHandler(service: TaskService): (req: IncomingMessage, res: ServerResponse) => Promise<void>;