dsh-scholar-desk 0.1.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/dist/domain.js ADDED
@@ -0,0 +1,163 @@
1
+ import { CARD_CATEGORIES, CARD_STATUSES } from './shared/types.js';
2
+ import { newCardId, paperIdFor } from './store.js';
3
+ import { parseArxivId } from './metadata.js';
4
+ /**
5
+ * 归一化 arXiv id 变体(裸 id 带 v2 / arXiv: 前缀 / abs、pdf 链接 → 裸 id),
6
+ * 消掉同一论文因写法不同分裂成两条的去重漏洞;非 arXiv 形态原样返回。
7
+ */
8
+ export function normalizeArxivId(input) {
9
+ const s = input?.trim();
10
+ if (!s)
11
+ return input;
12
+ return parseArxivId(s) ?? input;
13
+ }
14
+ /** Build a new Paper (id from arXiv id or title slug, timestamps filled). */
15
+ export function createPaper(input, now = Date.now()) {
16
+ const title = (input.title ?? '').trim();
17
+ if (!title)
18
+ throw new Error('论文标题不能为空');
19
+ const tags = [...new Set((input.tags ?? []).map((t) => t.trim()).filter(Boolean))];
20
+ return {
21
+ id: paperIdFor(title, normalizeArxivId(input.arxivId)),
22
+ title,
23
+ authors: [...(input.authors ?? [])],
24
+ year: input.year,
25
+ venue: input.venue?.trim() || undefined,
26
+ arxivId: normalizeArxivId(input.arxivId)?.trim() || undefined,
27
+ doi: input.doi?.trim() || undefined,
28
+ url: input.url?.trim() || undefined,
29
+ abstract: input.abstract,
30
+ summary: input.summary,
31
+ tags,
32
+ importance: input.importance,
33
+ source: input.source,
34
+ notes: input.notes,
35
+ collectionIds: input.collectionIds?.length ? [...new Set(input.collectionIds)] : undefined,
36
+ createdAt: now,
37
+ updatedAt: now,
38
+ };
39
+ }
40
+ /** Merge only the provided fields onto an existing paper. */
41
+ export function applyPaperPatch(existing, patch, now = Date.now()) {
42
+ const next = { ...existing, updatedAt: now };
43
+ if (patch.title !== undefined)
44
+ next.title = patch.title.trim();
45
+ if (patch.authors !== undefined)
46
+ next.authors = [...patch.authors];
47
+ if (patch.year !== undefined)
48
+ next.year = patch.year;
49
+ if (patch.venue !== undefined)
50
+ next.venue = patch.venue.trim() || undefined;
51
+ if (patch.arxivId !== undefined)
52
+ next.arxivId = normalizeArxivId(patch.arxivId)?.trim() || undefined;
53
+ if (patch.doi !== undefined)
54
+ next.doi = patch.doi.trim() || undefined;
55
+ if (patch.url !== undefined)
56
+ next.url = patch.url.trim() || undefined;
57
+ if (patch.abstract !== undefined)
58
+ next.abstract = patch.abstract;
59
+ if (patch.summary !== undefined)
60
+ next.summary = patch.summary;
61
+ if (patch.tags !== undefined)
62
+ next.tags = [...new Set(patch.tags.map((t) => t.trim()).filter(Boolean))];
63
+ if (patch.importance !== undefined)
64
+ next.importance = patch.importance;
65
+ if (patch.notes !== undefined)
66
+ next.notes = patch.notes;
67
+ if (patch.collectionIds !== undefined) {
68
+ const ids = [...new Set(patch.collectionIds.filter((x) => typeof x === 'string' && !!x))];
69
+ if (ids.length === 0)
70
+ delete next.collectionIds;
71
+ else
72
+ next.collectionIds = ids;
73
+ }
74
+ if (!next.title)
75
+ throw new Error('论文标题不能为空');
76
+ return next;
77
+ }
78
+ /** First duplicate hit by arXiv id (normalized), DOI, or normalized title. */
79
+ export function findDuplicate(papers, patch) {
80
+ const titleKey = patch.title?.trim().toLowerCase();
81
+ const arxiv = normalizeArxivId(patch.arxivId)?.trim().toLowerCase();
82
+ const doi = patch.doi?.trim().toLowerCase();
83
+ for (const p of papers) {
84
+ if (arxiv && p.arxivId && p.arxivId.toLowerCase() === arxiv)
85
+ return p;
86
+ if (doi && p.doi && p.doi.toLowerCase() === doi)
87
+ return p;
88
+ if (titleKey && p.title.trim().toLowerCase() === titleKey)
89
+ return p;
90
+ }
91
+ return undefined;
92
+ }
93
+ function normalizeSteps(steps) {
94
+ if (!Array.isArray(steps))
95
+ return [];
96
+ return steps.map((x) => String(x).trim()).filter(Boolean);
97
+ }
98
+ export function createCard(input, now = Date.now()) {
99
+ const title = (input.title ?? '').trim();
100
+ const insight = (input.insight ?? '').trim();
101
+ if (!title)
102
+ throw new Error('卡片标题不能为空');
103
+ if (!insight)
104
+ throw new Error('卡片内容(创新点)不能为空');
105
+ const category = input.category && CARD_CATEGORIES.includes(input.category) ? input.category : 'other';
106
+ const status = input.status && CARD_STATUSES.includes(input.status) ? input.status : 'pending';
107
+ return {
108
+ id: newCardId(),
109
+ title,
110
+ insight,
111
+ paperId: input.paperId || undefined,
112
+ category,
113
+ tags: [...new Set((input.tags ?? []).map((t) => t.trim()).filter(Boolean))],
114
+ importance: input.importance ?? 3,
115
+ status,
116
+ notes: input.notes,
117
+ relatedCardIds: input.relatedCardIds?.length ? [...new Set(input.relatedCardIds)] : undefined,
118
+ plain: input.plain?.trim() || undefined,
119
+ steps: normalizeSteps(input.steps),
120
+ createdAt: now,
121
+ updatedAt: now,
122
+ };
123
+ }
124
+ export function applyCardPatch(existing, patch, now = Date.now()) {
125
+ const next = { ...existing, updatedAt: now };
126
+ if (patch.title !== undefined)
127
+ next.title = patch.title.trim();
128
+ if (patch.insight !== undefined)
129
+ next.insight = patch.insight.trim();
130
+ if (patch.paperId !== undefined)
131
+ next.paperId = patch.paperId || undefined;
132
+ if (patch.category !== undefined && CARD_CATEGORIES.includes(patch.category))
133
+ next.category = patch.category;
134
+ if (patch.tags !== undefined)
135
+ next.tags = [...new Set(patch.tags.map((t) => t.trim()).filter(Boolean))];
136
+ if (patch.importance !== undefined)
137
+ next.importance = patch.importance;
138
+ if (patch.status !== undefined && CARD_STATUSES.includes(patch.status))
139
+ next.status = patch.status;
140
+ if (patch.notes !== undefined)
141
+ next.notes = patch.notes;
142
+ if (patch.relatedCardIds !== undefined) {
143
+ const ids = [...new Set(patch.relatedCardIds.filter((x) => typeof x === 'string' && !!x))];
144
+ if (ids.length === 0)
145
+ delete next.relatedCardIds;
146
+ else
147
+ next.relatedCardIds = ids;
148
+ }
149
+ if (patch.plain !== undefined)
150
+ next.plain = patch.plain.trim() || undefined;
151
+ if (patch.steps !== undefined) {
152
+ const st = normalizeSteps(patch.steps);
153
+ if (st.length === 0)
154
+ delete next.steps;
155
+ else
156
+ next.steps = st;
157
+ }
158
+ if (!next.title)
159
+ throw new Error('卡片标题不能为空');
160
+ if (!next.insight)
161
+ throw new Error('卡片内容(创新点)不能为空');
162
+ return next;
163
+ }
package/dist/index.js ADDED
@@ -0,0 +1,75 @@
1
+ import z from 'schemastery';
2
+ import { PaperStore } from './store.js';
3
+ import { registerScholarRoutes } from './routes.js';
4
+ import { registerScholarTools } from './tools.js';
5
+ export const name = 'dsh-scholar';
6
+ /** Host services this plugin waits for (settings registry, http carrier, tool registry, prompt assembly). */
7
+ export const inject = ['settings', 'webServer', 'tools', 'systemPrompt'];
8
+ // 0.1.5 起 dsh-settings 移除了 settingsNamespace 包装:命名空间直接以字面量传入
9
+ const NS = 'scholar';
10
+ function defaultPaperDir() {
11
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? process.cwd();
12
+ return `${home}/Documents/ResearchPapers`;
13
+ }
14
+ const ScholarConfigSchema = z.object({
15
+ paperDir: z.string().required(),
16
+ defaultTags: z.array(z.string()).default([]),
17
+ fetchProxy: z.string().default(''),
18
+ });
19
+ export function apply(ctx) {
20
+ // 科研精读动线:把 保存 → 精读 → 沉淀 串成 agent 的默认工作方式
21
+ ctx.effect(() => ctx.systemPrompt.section({
22
+ name: 'dsh-scholar-workflow',
23
+ order: 151,
24
+ text: [
25
+ '科研论文动线(用户要求保存、精读、沉淀论文时严格遵守):',
26
+ '1) 保存:用 paper_save 入库;参数带 arxivId 时会自动从 arXiv 下载 PDF 归档,无需手动处理;',
27
+ ' 非 arXiv 来源会返回 pdfWarning,转告用户在论文详情页手动上传。',
28
+ '2) 精读:先 paper_search / paper_get 找到该论文的 pdfPath,然后用 deepread 工具读该 PDF 路径;',
29
+ ' 科研论文默认 map 模式(每个重要主张给出证据、出处与置信度),完成后导出 html,',
30
+ ' 并调用 paper_save_report 把报告 html 内容存回论文库。',
31
+ '3) 沉淀:从精读结论提取 1-3 条创新点,用 idea_card_create 建卡并标注来源论文;',
32
+ ' 再用 paper_update 将该论文的 summary 更新为精读核心结论。',
33
+ '用户提到的论文尚未入库时,先 paper_save 再走上述流程。',
34
+ ].join('\n'),
35
+ }), 'dsh-scholar: workflow prompt');
36
+ const scope = ctx.settings.register(NS, ScholarConfigSchema, {});
37
+ const getConfig = () => {
38
+ const cfg = scope.get();
39
+ return {
40
+ paperDir: cfg?.paperDir?.trim() || defaultPaperDir(),
41
+ defaultTags: cfg?.defaultTags ?? [],
42
+ fetchProxy: cfg?.fetchProxy?.trim() || '',
43
+ };
44
+ };
45
+ // Lazy singleton store, re-created when the configured directory changes.
46
+ let storePromise = null;
47
+ let storeDir = '';
48
+ const getStore = () => {
49
+ const dir = getConfig().paperDir;
50
+ if (!storePromise || storeDir !== dir) {
51
+ // 目录热切换:旧 store 打 disposed 标记,在途请求的后续写操作会显式失败
52
+ // (而不是"写旧目录成功但新库里没有")
53
+ storePromise?.then((old) => old.dispose()).catch(() => { });
54
+ storeDir = dir;
55
+ const store = new PaperStore(dir);
56
+ storePromise = store.init()
57
+ .then(() => {
58
+ const s = store.stats();
59
+ console.log(`[dsh-scholar] 论文库就绪: ${dir}(${s.papers} 篇论文 / ${s.cards} 张卡片 / 图谱 ${s.nodes} 节点 ${s.edges} 边)`);
60
+ return store;
61
+ })
62
+ .catch((err) => {
63
+ storePromise = null;
64
+ throw err;
65
+ });
66
+ }
67
+ return storePromise;
68
+ };
69
+ const updateConfig = async (patch) => {
70
+ await scope.update({ ...getConfig(), ...patch });
71
+ };
72
+ registerScholarRoutes(ctx, getStore, getConfig, updateConfig);
73
+ const toolCount = registerScholarTools(ctx, getStore, getConfig);
74
+ console.log(`[dsh-scholar] host half ready: settings ns + /scholar/* routes + ${toolCount} agent tools`);
75
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * dsh-scholar — outbound paper-metadata lookup (host half).
3
+ *
4
+ * `fetchMetadata(input)` accepts an arXiv id, arXiv abs/pdf URL, DOI or
5
+ * doi.org URL and returns normalized paper fields. Sources: the official
6
+ * arXiv Atom API and api.crossref.org. Zero dependencies; when HTTPS_PROXY /
7
+ * HTTP_PROXY is set, requests tunnel through it via a CONNECT agent.
8
+ */
9
+ import http from 'node:http';
10
+ import https from 'node:https';
11
+ import tls from 'node:tls';
12
+ /** 与直连路径一致的 UA(代理路径也带上,避免被数据源按默认 UA 拦截)。 */
13
+ const USER_AGENT = 'dsh-scholar/0.1 (paper library)';
14
+ const MAX_TEXT_BYTES = 5 * 1024 * 1024;
15
+ const MAX_PDF_BYTES = 50 * 1024 * 1024;
16
+ const MAX_REDIRECTS = 5;
17
+ /* ---------- input classification ---------- */
18
+ const ARXIV_NEW = /^(\d{4}\.\d{4,5})(v\d+)?$/i;
19
+ /** Try to read an arXiv id out of arbitrary user input; null if not arXiv-ish. */
20
+ export function parseArxivId(input) {
21
+ const s = input.trim();
22
+ if (!s)
23
+ return null;
24
+ // bare new-style id (with optional version)
25
+ let m = ARXIV_NEW.exec(s);
26
+ if (m)
27
+ return m[1];
28
+ try {
29
+ const u = new URL(s.startsWith('http') ? s : `https://${s}`);
30
+ if (!/arxiv\.org$/i.test(u.hostname))
31
+ return null;
32
+ m = /\/abs\/([^/?#]+)/i.exec(u.pathname) ?? /\/pdf\/([^/?#]+?)(?:\.pdf)?$/i.exec(u.pathname);
33
+ if (!m)
34
+ return null;
35
+ const raw = decodeURIComponent(m[1]).replace(/^arxiv:/i, '');
36
+ const n = ARXIV_NEW.exec(raw);
37
+ return n ? n[1] : null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /** Read a bare or doi.org-wrapped DOI; null if not DOI-ish. */
44
+ export function parseDoi(input) {
45
+ let s = input.trim();
46
+ if (!s)
47
+ return null;
48
+ s = s.replace(/^https?:\/\/(dx\.)?doi\.org\//i, '');
49
+ if (/^10\.\d{4,9}\/\S+$/.test(s))
50
+ return s;
51
+ return null;
52
+ }
53
+ /* ---------- proxy-aware https helpers ---------- */
54
+ const proxyUrl = () => process.env.HTTPS_PROXY ?? process.env.https_proxy ?? process.env.HTTP_PROXY ?? process.env.http_proxy;
55
+ /** 代理 URL 带用户名/密码时生成 Proxy-Authorization 头(Basic)。 */
56
+ function proxyAuthHeader(pu) {
57
+ if (!pu.username && !pu.password)
58
+ return {};
59
+ const cred = `${decodeURIComponent(pu.username)}:${decodeURIComponent(pu.password)}`;
60
+ return { 'proxy-authorization': `Basic ${Buffer.from(cred).toString('base64')}` };
61
+ }
62
+ /** 经代理建立 CONNECT 隧道(带超时,无响应即销毁而不是永久挂起)。 */
63
+ function connectViaProxy(pu, targetHost, timeoutMs) {
64
+ return new Promise((resolve, reject) => {
65
+ const setup = http.request({
66
+ host: pu.hostname,
67
+ port: Number(pu.port || 443),
68
+ method: 'CONNECT',
69
+ path: `${targetHost}:443`,
70
+ headers: {
71
+ host: `${targetHost}:443`,
72
+ ...proxyAuthHeader(pu),
73
+ },
74
+ });
75
+ setup.setTimeout(timeoutMs, () => setup.destroy(new Error('代理 CONNECT 超时')));
76
+ setup.once('connect', (res, socket) => {
77
+ if (res.statusCode !== 200) {
78
+ socket.destroy();
79
+ reject(new Error(`代理 CONNECT 失败 (${res.statusCode})`));
80
+ return;
81
+ }
82
+ resolve(socket);
83
+ });
84
+ setup.once('error', reject);
85
+ setup.end();
86
+ });
87
+ }
88
+ /** 在已建立的隧道上发一次 GET(不跟重定向),响应体流式累计、超限即断开。 */
89
+ function tunneledGetOnce(u, socket, timeoutMs, accept, maxBytes) {
90
+ return new Promise((resolve, reject) => {
91
+ const req = https.request({
92
+ hostname: u.hostname,
93
+ path: `${u.pathname}${u.search}`,
94
+ method: 'GET',
95
+ headers: { host: u.host, accept, 'user-agent': USER_AGENT },
96
+ signal: AbortSignal.timeout(timeoutMs),
97
+ createConnection: () => tls.connect({ socket, servername: u.hostname }),
98
+ }, (r) => {
99
+ const chunks = [];
100
+ let total = 0;
101
+ r.on('data', (c) => {
102
+ total += c.length;
103
+ if (total > maxBytes) {
104
+ req.destroy(); // 超限立刻断流,而不是全量入内存后再检查
105
+ reject(new Error(`响应体超过大小上限(${Math.floor(maxBytes / 1024 / 1024)}MB)`));
106
+ return;
107
+ }
108
+ chunks.push(c);
109
+ });
110
+ r.on('end', () => resolve({
111
+ status: r.statusCode ?? 500,
112
+ location: typeof r.headers.location === 'string' ? r.headers.location : undefined,
113
+ body: Buffer.concat(chunks),
114
+ }));
115
+ });
116
+ req.once('error', reject);
117
+ req.end();
118
+ });
119
+ }
120
+ /**
121
+ * 代理路径 GET(CONNECT 隧道 + 手动跟随 3xx,≤5 跳)。我们只发 GET,
122
+ * 301/302/303 的"改写为 GET"语义天然满足;每跳重建隧道以支持跨 host 重定向。
123
+ */
124
+ async function proxyGet(url, timeoutMs, proxy, accept, maxBytes) {
125
+ let current = new URL(url);
126
+ if (current.protocol !== 'https:')
127
+ throw new Error('仅支持 https 数据源');
128
+ const pu = new URL(proxy);
129
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
130
+ const socket = await connectViaProxy(pu, current.host, timeoutMs);
131
+ let res;
132
+ try {
133
+ res = await tunneledGetOnce(current, socket, timeoutMs, accept, maxBytes);
134
+ }
135
+ catch (err) {
136
+ socket.destroy();
137
+ throw err;
138
+ }
139
+ socket.destroy();
140
+ if (res.status >= 300 && res.status < 400 && res.location) {
141
+ if (hop === MAX_REDIRECTS)
142
+ throw new Error(`重定向超过 ${MAX_REDIRECTS} 跳`);
143
+ const next = new URL(res.location, current);
144
+ if (next.protocol !== 'https:')
145
+ throw new Error('重定向目标不是 https,已停止跟随');
146
+ current = next;
147
+ continue;
148
+ }
149
+ if (res.status >= 400)
150
+ throw new Error(`HTTP ${res.status}`);
151
+ return res.body;
152
+ }
153
+ throw new Error(`重定向超过 ${MAX_REDIRECTS} 跳`);
154
+ }
155
+ /** 直连 fetch 的流式读取 + 大小上限(与代理路径同语义,超限即取消下载)。 */
156
+ async function fetchBodyWithLimit(res, maxBytes) {
157
+ const reader = res.body?.getReader();
158
+ if (!reader)
159
+ return Buffer.from(await res.arrayBuffer());
160
+ const chunks = [];
161
+ let total = 0;
162
+ for (;;) {
163
+ const { done, value } = await reader.read();
164
+ if (done)
165
+ break;
166
+ total += value.byteLength;
167
+ if (total > maxBytes) {
168
+ await reader.cancel().catch(() => { });
169
+ throw new Error(`响应体超过大小上限(${Math.floor(maxBytes / 1024 / 1024)}MB)`);
170
+ }
171
+ chunks.push(Buffer.from(value));
172
+ }
173
+ return Buffer.concat(chunks);
174
+ }
175
+ /**
176
+ * GET an https resource as text. Proxy resolution order: explicit argument
177
+ * (plugin setting) → HTTPS_PROXY/HTTP_PROXY env → direct fetch.
178
+ */
179
+ async function getText(url, timeoutMs = 15000, proxyOverride) {
180
+ const signal = AbortSignal.timeout(timeoutMs);
181
+ const proxy = proxyOverride || proxyUrl();
182
+ if (!proxy) {
183
+ const res = await fetch(url, { signal, headers: { 'user-agent': USER_AGENT } });
184
+ if (!res.ok)
185
+ throw new Error(`HTTP ${res.status}`);
186
+ const body = await fetchBodyWithLimit(res, MAX_TEXT_BYTES);
187
+ return body.toString('utf8');
188
+ }
189
+ const body = await proxyGet(url, timeoutMs, proxy, '*/*', MAX_TEXT_BYTES);
190
+ return body.toString('utf8');
191
+ }
192
+ const decodeEntities = (s) => s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
193
+ .replace(/&quot;/g, '"').replace(/&#39;|&apos;|&#x27;/g, "'")
194
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(Number.parseInt(n, 16)))
195
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
196
+ .replace(/\s+/g, ' ').trim();
197
+ /**
198
+ * GET an https resource as raw bytes (PDF download). Same proxy resolution
199
+ * as getText: explicit argument (plugin setting) -> env proxy -> direct.
200
+ */
201
+ export async function fetchPdf(url, timeoutMs = 60000, proxyOverride) {
202
+ const signal = AbortSignal.timeout(timeoutMs);
203
+ const proxy = proxyOverride || proxyUrl();
204
+ if (!proxy) {
205
+ const res = await fetch(url, { signal, headers: { 'user-agent': USER_AGENT } });
206
+ if (!res.ok)
207
+ throw new Error(`HTTP ${res.status}`);
208
+ return fetchBodyWithLimit(res, MAX_PDF_BYTES);
209
+ }
210
+ return proxyGet(url, timeoutMs, proxy, 'application/pdf,*/*', MAX_PDF_BYTES);
211
+ }
212
+ /** Minimal Atom entry reader for the arXiv API response. */
213
+ export function parseArxivFeed(xml) {
214
+ const entry = /<entry>([\s\S]*?)<\/entry>/.exec(xml)?.[1];
215
+ if (!entry)
216
+ throw new Error('arXiv 返回中没有条目(id 可能无效)');
217
+ const tag = (name) => {
218
+ const m = new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`).exec(entry);
219
+ return m ? decodeEntities(m[1]) : '';
220
+ };
221
+ const title = tag('title');
222
+ if (!title)
223
+ throw new Error('arXiv 条目缺少标题');
224
+ const authors = [...entry.matchAll(/<author>[\s\S]*?<name>([\s\S]*?)<\/name>[\s\S]*?<\/author>/g)]
225
+ .map((m) => decodeEntities(m[1]));
226
+ const published = tag('published');
227
+ const year = Number(published.slice(0, 4)) || undefined;
228
+ const journalRef = tag('arxiv:journal_ref');
229
+ const idAttr = /<id>http:\/\/arxiv\.org\/abs\/([^<]+)<\/id>/.exec(entry)?.[1] ?? '';
230
+ const abstract = tag('summary');
231
+ return {
232
+ title,
233
+ authors,
234
+ year,
235
+ venue: journalRef || 'arXiv',
236
+ arxivId: ARXIV_NEW.exec(idAttr)?.[1] ?? idAttr.replace(/v\d+$/i, ''),
237
+ abstract,
238
+ };
239
+ }
240
+ /** 仅 429/网络类瞬态错误(限流、超时、断连、代理失败)值得重试;解析失败等确定性错误直接抛。 */
241
+ function isTransientError(err) {
242
+ const msg = err instanceof Error ? err.message : String(err);
243
+ return /429|timeout|timed out|abort|ECONN|socket|network|fetch failed|代理/i.test(msg);
244
+ }
245
+ async function fetchArxiv(arxivId, proxy) {
246
+ const url = `https://export.arxiv.org/api/query?id_list=${encodeURIComponent(arxivId)}&max_results=1`;
247
+ // arXiv asks for ~3s between API hits and answers 429 when a burst arrives;
248
+ // retry once after a polite pause before giving up (仅限流/网络类瞬态错误).
249
+ try {
250
+ return parseArxivFeed(await getText(url, 20000, proxy));
251
+ }
252
+ catch (err) {
253
+ if (!isTransientError(err))
254
+ throw err;
255
+ await new Promise((r) => setTimeout(r, 3500));
256
+ return parseArxivFeed(await getText(url, 20000, proxy));
257
+ }
258
+ }
259
+ /** CrossRef JSON → common shape; exported for tests (pure). */
260
+ export function crossrefToMeta(body) {
261
+ const msg = body.message ?? {};
262
+ const title = msg.title?.[0]?.trim();
263
+ if (!title)
264
+ throw new Error('CrossRef 条目缺少标题');
265
+ const year = msg.issued?.['date-parts']?.[0]?.[0] ??
266
+ msg['published-print']?.['date-parts']?.[0]?.[0];
267
+ const authors = (msg.author ?? []).map((a) => ([a.given, a.family].filter(Boolean).join(' ') || a.name || '')).filter(Boolean);
268
+ const jats = msg.abstract ?? '';
269
+ const abstract = decodeEntities(jats.replace(/<[^>]+>/g, '')).replace(/^Abstract\s*[:.]?\s*/i, '') || undefined;
270
+ return {
271
+ title,
272
+ authors,
273
+ year: typeof year === 'number' ? year : undefined,
274
+ venue: msg['container-title']?.[0] || undefined,
275
+ doi: msg.DOI,
276
+ url: msg.URL || (msg.DOI ? `https://doi.org/${msg.DOI}` : undefined),
277
+ abstract,
278
+ };
279
+ }
280
+ async function fetchDoi(doi, proxy) {
281
+ const text = await getText(`https://api.crossref.org/works/${encodeURIComponent(doi)}`, 15000, proxy);
282
+ return crossrefToMeta(JSON.parse(text));
283
+ }
284
+ /** Main entry: classify input then query the matching source. */
285
+ export async function fetchMetadata(input, proxy) {
286
+ const s = input.trim();
287
+ if (!s)
288
+ throw new Error('请输入 arXiv 链接/编号或 DOI');
289
+ const arxiv = parseArxivId(s);
290
+ if (arxiv)
291
+ return fetchArxiv(arxiv, proxy);
292
+ const doi = parseDoi(s);
293
+ if (doi)
294
+ return fetchDoi(doi, proxy);
295
+ throw new Error('无法识别的输入:请提供 arXiv 链接/编号(如 2106.09685)或 DOI');
296
+ }