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,96 @@
1
+ /**
2
+ * 检索层常量与运行时设置。
3
+ *
4
+ * 设置由插件入口在 `apply` 时注入,读取时逐次走 volatile 引用,因此设置中心
5
+ * 保存配置后无需重启即生效。
6
+ */
7
+ import type { SortMode, SourceId, TaskLanguage } from './types.js';
8
+ /** 插件版本(与 package.json 同步;仅用于 /health 诊断展示)。 */
9
+ export declare const PLUGIN_VERSION = "0.1.0";
10
+ /** 默认每次加载条数。 */
11
+ export declare const DEFAULT_PAGE_SIZE = 20;
12
+ /**
13
+ * 单次上游请求超时(毫秒)。
14
+ *
15
+ * Europe PMC 的 `resultType=core` 明显慢于其它源,15s 容易误判为失败;
16
+ * 这里给全局一个更宽裕的默认值,检索层仍对该源单独设下限。
17
+ */
18
+ export declare const REQUEST_TIMEOUT_MS = 20000;
19
+ /** 返回条数上限,防止一次请求拉爆上游与宿主。 */
20
+ export declare const MAX_LIMIT = 50;
21
+ /** 默认排序。 */
22
+ export declare const DEFAULT_SORT: SortMode;
23
+ /** 默认语言筛选。 */
24
+ export declare const DEFAULT_LANGUAGE: TaskLanguage;
25
+ /** 模型发起检索时默认自动打开任务 tab。 */
26
+ export declare const DEFAULT_AUTO_OPEN = true;
27
+ /** 同时保留的任务 tab 上限。 */
28
+ export declare const DEFAULT_MAX_TASK_TABS = 5;
29
+ /** 任务保留天数与条数。 */
30
+ export declare const DEFAULT_RETENTION_DAYS = 90;
31
+ export declare const DEFAULT_RETENTION_COUNT = 120;
32
+ /** 单任务结果上限。 */
33
+ export declare const DEFAULT_TASK_MAX_PAPERS = 1500;
34
+ /** 单任务保留的检索轮数上限;只记元数据,超出后淘汰最旧的轮次。 */
35
+ export declare const DEFAULT_TASK_MAX_ROUNDS = 30;
36
+ /** 各源默认最小请求间隔(毫秒);PubMed/S2/arXiv 可由设置覆盖。 */
37
+ export declare const DEFAULT_PUBMED_INTERVAL_MS = 350;
38
+ export declare const DEFAULT_S2_INTERVAL_MS = 3000;
39
+ export declare const DEFAULT_ARXIV_INTERVAL_MS = 3000;
40
+ /** 全文下载默认设置。 */
41
+ export declare const DEFAULT_PDF_MAX_MB = 30;
42
+ export declare const DEFAULT_AUTO_DOWNLOAD_PDF = false;
43
+ export declare const DEFAULT_ENABLE_EPMC_FULLTEXT = true;
44
+ /** Zotero connector 默认设置。 */
45
+ export declare const DEFAULT_ZOTERO_ENABLED = true;
46
+ export declare const DEFAULT_ZOTERO_BASE_URL = "http://127.0.0.1:23119";
47
+ export declare const DEFAULT_ZOTERO_ATTACH_PDF = true;
48
+ export declare const DEFAULT_ZOTERO_TAG = "dsh-litsearch";
49
+ /** 持久化时单条摘要保留的字符数。 */
50
+ export declare const MAX_ABSTRACT_CHARS = 800;
51
+ /** 写端点的自定义请求头(与 JSON content-type 一起构成非简单请求)。 */
52
+ export declare const WRITE_HEADER = "x-dsh-litsearch-write";
53
+ /** Host 路由前缀(Client fetch 路径与 handler 的剥离基准,带尾斜杠)。 */
54
+ export declare const ROUTE_PREFIX = "/dsh-litsearch/api/";
55
+ /**
56
+ * 注册到 `webServer` 的 prefix 路径。
57
+ *
58
+ * 必须**不带尾斜杠**:宿主按 `pathname === prefix || pathname.startsWith(prefix + '/')`
59
+ * 匹配,带尾斜杠会变成 `.../api//tasks`,永远匹配不上。
60
+ */
61
+ export declare const ROUTE_MATCH = "/dsh-litsearch/api";
62
+ export interface LitsearchSettings {
63
+ /** 联系邮箱,作为 OpenAlex / Crossref polite pool 与 NCBI email 标识;留空也可用。 */
64
+ contactEmail: string;
65
+ defaultPageSize: number;
66
+ requestTimeoutMs: number;
67
+ defaultSources: SourceId[];
68
+ defaultSort: SortMode;
69
+ defaultLanguage: TaskLanguage;
70
+ autoOpenTaskTab: boolean;
71
+ maxTaskTabs: number;
72
+ taskRetentionDays: number;
73
+ taskRetentionCount: number;
74
+ taskMaxPapers: number;
75
+ /** 可选 NCBI API Key;有 key 时 E-utilities 额度更高。 */
76
+ ncbiApiKey: string;
77
+ /** 可选 Semantic Scholar API Key;提高配额。 */
78
+ s2ApiKey: string;
79
+ /** 各源最小请求间隔覆盖值。 */
80
+ pubmedIntervalMs: number;
81
+ s2IntervalMs: number;
82
+ arxivIntervalMs: number;
83
+ /** PDF 下载根目录;空串由 Host 回落到 ~/Downloads/dsh-litsearch。 */
84
+ pdfRoot: string;
85
+ pdfMaxMb: number;
86
+ autoDownloadPdf: boolean;
87
+ enableEpmcFulltext: boolean;
88
+ zoteroEnabled: boolean;
89
+ zoteroBaseUrl: string;
90
+ zoteroAttachPdf: boolean;
91
+ zoteroTag: string;
92
+ }
93
+ /** 由插件入口在 `apply` 时注入。 */
94
+ export declare function setSettingsReader(reader: () => LitsearchSettings): void;
95
+ /** 读取当前生效的设置。 */
96
+ export declare function getSettings(): LitsearchSettings;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 检索层常量与运行时设置。
3
+ *
4
+ * 设置由插件入口在 `apply` 时注入,读取时逐次走 volatile 引用,因此设置中心
5
+ * 保存配置后无需重启即生效。
6
+ */
7
+ import { SOURCE_IDS } from './types.js';
8
+ /** 插件版本(与 package.json 同步;仅用于 /health 诊断展示)。 */
9
+ export const PLUGIN_VERSION = '0.1.0';
10
+ /** 默认每次加载条数。 */
11
+ export const DEFAULT_PAGE_SIZE = 20;
12
+ /**
13
+ * 单次上游请求超时(毫秒)。
14
+ *
15
+ * Europe PMC 的 `resultType=core` 明显慢于其它源,15s 容易误判为失败;
16
+ * 这里给全局一个更宽裕的默认值,检索层仍对该源单独设下限。
17
+ */
18
+ export const REQUEST_TIMEOUT_MS = 20_000;
19
+ /** 返回条数上限,防止一次请求拉爆上游与宿主。 */
20
+ export const MAX_LIMIT = 50;
21
+ /** 默认排序。 */
22
+ export const DEFAULT_SORT = 'relevance';
23
+ /** 默认语言筛选。 */
24
+ export const DEFAULT_LANGUAGE = 'any';
25
+ /** 模型发起检索时默认自动打开任务 tab。 */
26
+ export const DEFAULT_AUTO_OPEN = true;
27
+ /** 同时保留的任务 tab 上限。 */
28
+ export const DEFAULT_MAX_TASK_TABS = 5;
29
+ /** 任务保留天数与条数。 */
30
+ export const DEFAULT_RETENTION_DAYS = 90;
31
+ export const DEFAULT_RETENTION_COUNT = 120;
32
+ /** 单任务结果上限。 */
33
+ export const DEFAULT_TASK_MAX_PAPERS = 1500;
34
+ /** 单任务保留的检索轮数上限;只记元数据,超出后淘汰最旧的轮次。 */
35
+ export const DEFAULT_TASK_MAX_ROUNDS = 30;
36
+ /** 各源默认最小请求间隔(毫秒);PubMed/S2/arXiv 可由设置覆盖。 */
37
+ export const DEFAULT_PUBMED_INTERVAL_MS = 350;
38
+ export const DEFAULT_S2_INTERVAL_MS = 3000;
39
+ export const DEFAULT_ARXIV_INTERVAL_MS = 3000;
40
+ /** 全文下载默认设置。 */
41
+ export const DEFAULT_PDF_MAX_MB = 30;
42
+ export const DEFAULT_AUTO_DOWNLOAD_PDF = false;
43
+ export const DEFAULT_ENABLE_EPMC_FULLTEXT = true;
44
+ /** Zotero connector 默认设置。 */
45
+ export const DEFAULT_ZOTERO_ENABLED = true;
46
+ export const DEFAULT_ZOTERO_BASE_URL = 'http://127.0.0.1:23119';
47
+ export const DEFAULT_ZOTERO_ATTACH_PDF = true;
48
+ export const DEFAULT_ZOTERO_TAG = 'dsh-litsearch';
49
+ /** 持久化时单条摘要保留的字符数。 */
50
+ export const MAX_ABSTRACT_CHARS = 800;
51
+ /** 写端点的自定义请求头(与 JSON content-type 一起构成非简单请求)。 */
52
+ export const WRITE_HEADER = 'x-dsh-litsearch-write';
53
+ /** Host 路由前缀(Client fetch 路径与 handler 的剥离基准,带尾斜杠)。 */
54
+ export const ROUTE_PREFIX = '/dsh-litsearch/api/';
55
+ /**
56
+ * 注册到 `webServer` 的 prefix 路径。
57
+ *
58
+ * 必须**不带尾斜杠**:宿主按 `pathname === prefix || pathname.startsWith(prefix + '/')`
59
+ * 匹配,带尾斜杠会变成 `.../api//tasks`,永远匹配不上。
60
+ */
61
+ export const ROUTE_MATCH = '/dsh-litsearch/api';
62
+ /** 默认读取器:入口未注入时回落到常量。 */
63
+ let readSettings = () => ({
64
+ contactEmail: '',
65
+ defaultPageSize: DEFAULT_PAGE_SIZE,
66
+ requestTimeoutMs: REQUEST_TIMEOUT_MS,
67
+ defaultSources: [...SOURCE_IDS],
68
+ defaultSort: DEFAULT_SORT,
69
+ defaultLanguage: DEFAULT_LANGUAGE,
70
+ autoOpenTaskTab: DEFAULT_AUTO_OPEN,
71
+ maxTaskTabs: DEFAULT_MAX_TASK_TABS,
72
+ taskRetentionDays: DEFAULT_RETENTION_DAYS,
73
+ taskRetentionCount: DEFAULT_RETENTION_COUNT,
74
+ taskMaxPapers: DEFAULT_TASK_MAX_PAPERS,
75
+ ncbiApiKey: '',
76
+ s2ApiKey: '',
77
+ pubmedIntervalMs: DEFAULT_PUBMED_INTERVAL_MS,
78
+ s2IntervalMs: DEFAULT_S2_INTERVAL_MS,
79
+ arxivIntervalMs: DEFAULT_ARXIV_INTERVAL_MS,
80
+ pdfRoot: '',
81
+ pdfMaxMb: DEFAULT_PDF_MAX_MB,
82
+ autoDownloadPdf: DEFAULT_AUTO_DOWNLOAD_PDF,
83
+ enableEpmcFulltext: DEFAULT_ENABLE_EPMC_FULLTEXT,
84
+ zoteroEnabled: DEFAULT_ZOTERO_ENABLED,
85
+ zoteroBaseUrl: DEFAULT_ZOTERO_BASE_URL,
86
+ zoteroAttachPdf: DEFAULT_ZOTERO_ATTACH_PDF,
87
+ zoteroTag: DEFAULT_ZOTERO_TAG,
88
+ });
89
+ /** 由插件入口在 `apply` 时注入。 */
90
+ export function setSettingsReader(reader) {
91
+ readSettings = reader;
92
+ }
93
+ /** 读取当前生效的设置。 */
94
+ export function getSettings() {
95
+ return readSettings();
96
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 检索错误:只区分调用方可处理的几类。
3
+ *
4
+ * - `bad_request`:参数不合法(关键词为空、年份区间倒置等)。
5
+ * - `not_found` / `conflict`:任务不存在、或与运行中/版本号冲突。
6
+ * - `upstream_error`:上游返回非 2xx 或响应不可解析。
7
+ * - `timeout` / `network_error`:请求超时或网络不可达。
8
+ *
9
+ * `status` / `retryAfterMs` 只用于限流判断,不进入对外错误信封。
10
+ */
11
+ import type { ApiErrorCode } from './types.js';
12
+ export type SearchErrorCode = Exclude<ApiErrorCode, 'internal_error'>;
13
+ export interface SearchErrorExtra {
14
+ status?: number;
15
+ retryAfterMs?: number;
16
+ }
17
+ export declare class SearchError extends Error {
18
+ readonly code: SearchErrorCode;
19
+ readonly status?: number;
20
+ readonly retryAfterMs?: number;
21
+ constructor(code: SearchErrorCode, message: string, extra?: SearchErrorExtra);
22
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 检索错误:只区分调用方可处理的几类。
3
+ *
4
+ * - `bad_request`:参数不合法(关键词为空、年份区间倒置等)。
5
+ * - `not_found` / `conflict`:任务不存在、或与运行中/版本号冲突。
6
+ * - `upstream_error`:上游返回非 2xx 或响应不可解析。
7
+ * - `timeout` / `network_error`:请求超时或网络不可达。
8
+ *
9
+ * `status` / `retryAfterMs` 只用于限流判断,不进入对外错误信封。
10
+ */
11
+ export class SearchError extends Error {
12
+ code;
13
+ status;
14
+ retryAfterMs;
15
+ constructor(code, message, extra = {}) {
16
+ super(message);
17
+ this.name = 'SearchError';
18
+ this.code = code;
19
+ if (extra.status !== undefined)
20
+ this.status = extra.status;
21
+ if (extra.retryAfterMs !== undefined)
22
+ this.retryAfterMs = extra.retryAfterMs;
23
+ }
24
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 数据源运行时健康:限流冷却、最近一次成功/失败与延迟。
3
+ *
4
+ * 只存在于 Host 进程内,不落盘;重启后视为全新状态。`/health` 与设置页
5
+ * 诊断读取这里的快照,密钥不经过本模块。
6
+ */
7
+ import type { SourceHealth, SourceId } from './types.js';
8
+ export declare function cooldownUntil(id: SourceId): number;
9
+ export declare function recordSourceOk(id: SourceId, latencyMs: number): void;
10
+ export declare function recordSourceError(id: SourceId, message: string): void;
11
+ export declare function recordSourceCooldown(id: SourceId, until: number, message: string): void;
12
+ export declare function sourceHealthList(): SourceHealth[];
13
+ export declare function resetSourceHealth(): void;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 数据源运行时健康:限流冷却、最近一次成功/失败与延迟。
3
+ *
4
+ * 只存在于 Host 进程内,不落盘;重启后视为全新状态。`/health` 与设置页
5
+ * 诊断读取这里的快照,密钥不经过本模块。
6
+ */
7
+ import { SOURCE_IDS } from './types.js';
8
+ const runtimes = new Map();
9
+ function runtime(id) {
10
+ let current = runtimes.get(id);
11
+ if (current === undefined) {
12
+ current = {};
13
+ runtimes.set(id, current);
14
+ }
15
+ return current;
16
+ }
17
+ export function cooldownUntil(id) {
18
+ return runtime(id).cooldownUntil ?? 0;
19
+ }
20
+ export function recordSourceOk(id, latencyMs) {
21
+ const current = runtime(id);
22
+ current.lastOkAt = Date.now();
23
+ current.lastLatencyMs = Math.max(0, Math.round(latencyMs));
24
+ current.lastError = undefined;
25
+ }
26
+ export function recordSourceError(id, message) {
27
+ runtime(id).lastError = message;
28
+ }
29
+ export function recordSourceCooldown(id, until, message) {
30
+ const current = runtime(id);
31
+ current.cooldownUntil = Math.max(until, Date.now());
32
+ current.lastError = message;
33
+ }
34
+ export function sourceHealthList() {
35
+ const now = Date.now();
36
+ return SOURCE_IDS.map((id) => {
37
+ const current = runtime(id);
38
+ const health = { id };
39
+ if (current.lastOkAt !== undefined)
40
+ health.lastOkAt = new Date(current.lastOkAt).toISOString();
41
+ if (current.lastError !== undefined)
42
+ health.lastError = current.lastError;
43
+ if (current.lastLatencyMs !== undefined)
44
+ health.lastLatencyMs = current.lastLatencyMs;
45
+ if (current.cooldownUntil !== undefined && current.cooldownUntil > now) {
46
+ health.cooldownUntil = new Date(current.cooldownUntil).toISOString();
47
+ }
48
+ return health;
49
+ });
50
+ }
51
+ export function resetSourceHealth() {
52
+ runtimes.clear();
53
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 带超时与取消的上游请求。
3
+ *
4
+ * 所有 provider 共用这一条路径,保证错误码一致:超时 → `timeout`,
5
+ * 非 2xx → `upstream_error`(429 额外带 `retryAfterMs`),网络异常 → `network_error`。
6
+ */
7
+ export interface HttpRequest {
8
+ timeoutMs: number;
9
+ signal?: AbortSignal | undefined;
10
+ headers?: Record<string, string>;
11
+ }
12
+ export declare function fetchJson(url: string, request: HttpRequest): Promise<unknown>;
13
+ export declare function fetchText(url: string, request: HttpRequest): Promise<string>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * 带超时与取消的上游请求。
3
+ *
4
+ * 所有 provider 共用这一条路径,保证错误码一致:超时 → `timeout`,
5
+ * 非 2xx → `upstream_error`(429 额外带 `retryAfterMs`),网络异常 → `network_error`。
6
+ */
7
+ import { SearchError } from './errors.js';
8
+ export async function fetchJson(url, request) {
9
+ const text = await fetchText(url, {
10
+ ...request,
11
+ headers: { accept: 'application/json', ...request.headers },
12
+ });
13
+ try {
14
+ return JSON.parse(text);
15
+ }
16
+ catch {
17
+ throw new SearchError('upstream_error', `${safeHost(url)} 返回了无法解析的 JSON`);
18
+ }
19
+ }
20
+ export async function fetchText(url, request) {
21
+ const controller = new AbortController();
22
+ let timedOut = false;
23
+ const onAbort = () => controller.abort();
24
+ if (request.signal?.aborted === true)
25
+ controller.abort();
26
+ else
27
+ request.signal?.addEventListener('abort', onAbort, { once: true });
28
+ const timer = setTimeout(() => {
29
+ timedOut = true;
30
+ controller.abort();
31
+ }, request.timeoutMs);
32
+ const host = safeHost(url);
33
+ try {
34
+ const response = await fetch(url, { headers: request.headers, signal: controller.signal });
35
+ if (!response.ok) {
36
+ const retryAfterMs = parseRetryAfter(response.headers.get('retry-after'));
37
+ const detail = response.status === 429
38
+ ? `${host} 触发限流(HTTP 429)`
39
+ : `${host} 返回 HTTP ${response.status}`;
40
+ throw new SearchError('upstream_error', detail, {
41
+ status: response.status,
42
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
43
+ });
44
+ }
45
+ return await response.text();
46
+ }
47
+ catch (error) {
48
+ if (error instanceof SearchError)
49
+ throw error;
50
+ if (timedOut) {
51
+ throw new SearchError('timeout', `${host} 请求超时(${request.timeoutMs} ms)`);
52
+ }
53
+ if (controller.signal.aborted) {
54
+ throw new SearchError('network_error', `${host} 请求已取消`);
55
+ }
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ throw new SearchError('network_error', `无法访问 ${host}:${message}`);
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ request.signal?.removeEventListener('abort', onAbort);
62
+ }
63
+ }
64
+ /** `Retry-After` 支持秒数与 HTTP 日期两种写法。 */
65
+ function parseRetryAfter(value) {
66
+ if (value === null || value.trim() === '')
67
+ return undefined;
68
+ const seconds = Number(value);
69
+ if (Number.isFinite(seconds))
70
+ return Math.max(0, seconds * 1000);
71
+ const date = Date.parse(value);
72
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
73
+ }
74
+ function safeHost(url) {
75
+ try {
76
+ return new URL(url).host;
77
+ }
78
+ catch {
79
+ return url;
80
+ }
81
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 跨源论文身份与合并。
3
+ *
4
+ * 身份优先级:DOI > PMID > PMCID > arXiv id > 标题键。标题键在 NFKC 归一化后
5
+ * 只保留字母数字,长度不足 12 个字符不参与合并(短标题误合并的代价太高)。
6
+ *
7
+ * 合并后按 RRF(reciprocal rank fusion)排序:Σ w_s / (60 + rank_s),
8
+ * 命中源数、最佳排位、被引数与年份依次作为并列决胜。
9
+ */
10
+ import type { Paper, PaperRecord, SourceId } from './types.js';
11
+ export interface RankedHit {
12
+ record: PaperRecord;
13
+ source: SourceId;
14
+ /** 1-based 排位。 */
15
+ rank: number;
16
+ queryId: string;
17
+ }
18
+ export declare function normalizeTitle(title: string): string;
19
+ /** 单条记录的身份键;无法可靠识别时回落到「源 + 内部 id」,保证不误合并。 */
20
+ export declare function identityKey(record: PaperRecord): string;
21
+ /** 由身份键生成稳定、URL 安全的 paperId(双 FNV-1a,16 位十六进制)。 */
22
+ export declare function makePaperId(key: string): string;
23
+ /** 把一次检索的命中合并成去重后的论文列表,并按 RRF 排序。 */
24
+ export declare function mergeHits(hits: readonly RankedHit[]): Paper[];
25
+ /** RRF 分数;供排序与调试使用。 */
26
+ export declare function rrfScore(paper: Paper): number;
27
+ export declare function comparePapers(a: Paper, b: Paper): number;
@@ -0,0 +1,154 @@
1
+ /**
2
+ * 跨源论文身份与合并。
3
+ *
4
+ * 身份优先级:DOI > PMID > PMCID > arXiv id > 标题键。标题键在 NFKC 归一化后
5
+ * 只保留字母数字,长度不足 12 个字符不参与合并(短标题误合并的代价太高)。
6
+ *
7
+ * 合并后按 RRF(reciprocal rank fusion)排序:Σ w_s / (60 + rank_s),
8
+ * 命中源数、最佳排位、被引数与年份依次作为并列决胜。
9
+ */
10
+ import { SOURCE_IDS } from './types.js';
11
+ /** 各源权重(与方案一致)。 */
12
+ const RRF_WEIGHTS = {
13
+ pubmed: 1.1,
14
+ europepmc: 1.0,
15
+ openalex: 1.0,
16
+ crossref: 0.95,
17
+ semanticscholar: 0.9,
18
+ arxiv: 0.85,
19
+ pubtator: 0.7,
20
+ };
21
+ /** RRF 平滑常数。 */
22
+ const RRF_K = 60;
23
+ /** 标题键参与合并的最小长度(归一化后的字符数)。 */
24
+ const MIN_TITLE_KEY = 12;
25
+ export function normalizeTitle(title) {
26
+ return title
27
+ .normalize('NFKC')
28
+ .toLowerCase()
29
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
30
+ .trim();
31
+ }
32
+ /** 单条记录的身份键;无法可靠识别时回落到「源 + 内部 id」,保证不误合并。 */
33
+ export function identityKey(record) {
34
+ if (record.ids.doi !== '')
35
+ return `doi:${record.ids.doi}`;
36
+ if (record.ids.pmid !== '')
37
+ return `pmid:${record.ids.pmid}`;
38
+ if (record.ids.pmcid !== '')
39
+ return `pmcid:${record.ids.pmcid}`;
40
+ if (record.ids.arxivId !== '')
41
+ return `arxiv:${record.ids.arxivId}`;
42
+ const title = normalizeTitle(record.title);
43
+ if (title.length >= MIN_TITLE_KEY)
44
+ return `title:${title}:${record.year ?? ''}`;
45
+ return `src:${record.source}:${record.sourceId}`;
46
+ }
47
+ /** 由身份键生成稳定、URL 安全的 paperId(双 FNV-1a,16 位十六进制)。 */
48
+ export function makePaperId(key) {
49
+ const a = fnv1a(key, 0x811c9dc5);
50
+ const b = fnv1a(key, 0x01000193);
51
+ return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0');
52
+ }
53
+ function fnv1a(text, seed) {
54
+ let hash = seed >>> 0;
55
+ for (let index = 0; index < text.length; index += 1) {
56
+ hash ^= text.charCodeAt(index);
57
+ hash = Math.imul(hash, 0x01000193) >>> 0;
58
+ }
59
+ return hash >>> 0;
60
+ }
61
+ /** 把一次检索的命中合并成去重后的论文列表,并按 RRF 排序。 */
62
+ export function mergeHits(hits) {
63
+ const groups = new Map();
64
+ for (const hit of hits) {
65
+ const key = identityKey(hit.record);
66
+ const group = groups.get(key);
67
+ if (group === undefined) {
68
+ groups.set(key, { base: { ...hit.record }, hits: [hit] });
69
+ }
70
+ else {
71
+ mergeRecord(group.base, hit.record);
72
+ group.hits.push(hit);
73
+ }
74
+ }
75
+ const papers = [...groups.entries()].map(([key, group]) => {
76
+ const hitSources = [];
77
+ const matchedQueries = new Set();
78
+ for (const hit of group.hits) {
79
+ matchedQueries.add(hit.queryId);
80
+ const existing = hitSources.find((entry) => entry.source === hit.source);
81
+ if (existing === undefined)
82
+ hitSources.push({ source: hit.source, rank: hit.rank });
83
+ else
84
+ existing.rank = Math.min(existing.rank, hit.rank);
85
+ }
86
+ hitSources.sort((a, b) => SOURCE_IDS.indexOf(a.source) - SOURCE_IDS.indexOf(b.source));
87
+ return {
88
+ ...group.base,
89
+ paperId: makePaperId(key),
90
+ hitSources,
91
+ matchedQueries: [...matchedQueries],
92
+ };
93
+ });
94
+ return papers.sort(comparePapers);
95
+ }
96
+ /** RRF 分数;供排序与调试使用。 */
97
+ export function rrfScore(paper) {
98
+ let score = 0;
99
+ for (const hit of paper.hitSources) {
100
+ score += (RRF_WEIGHTS[hit.source] ?? 1) / (RRF_K + hit.rank);
101
+ }
102
+ return score;
103
+ }
104
+ export function comparePapers(a, b) {
105
+ const score = rrfScore(b) - rrfScore(a);
106
+ if (score !== 0)
107
+ return score;
108
+ const sources = b.hitSources.length - a.hitSources.length;
109
+ if (sources !== 0)
110
+ return sources;
111
+ const best = bestRank(a) - bestRank(b);
112
+ if (best !== 0)
113
+ return best;
114
+ const citations = (b.citedBy ?? -1) - (a.citedBy ?? -1);
115
+ if (citations !== 0)
116
+ return citations;
117
+ return (b.year ?? 0) - (a.year ?? 0);
118
+ }
119
+ function bestRank(paper) {
120
+ return paper.hitSources.reduce((min, hit) => Math.min(min, hit.rank), Number.POSITIVE_INFINITY);
121
+ }
122
+ function mergeRecord(base, extra) {
123
+ base.ids = mergeIds(base.ids, extra.ids);
124
+ if (base.ids.doi !== '')
125
+ base.doi = base.ids.doi;
126
+ if (extra.abstract.length > base.abstract.length)
127
+ base.abstract = extra.abstract;
128
+ if (base.pdfUrl === '' && extra.pdfUrl !== '')
129
+ base.pdfUrl = extra.pdfUrl;
130
+ if (extra.venue.length > base.venue.length)
131
+ base.venue = extra.venue;
132
+ if (base.year === null)
133
+ base.year = extra.year;
134
+ if (base.citedBy === null || (extra.citedBy !== null && extra.citedBy > base.citedBy)) {
135
+ base.citedBy = extra.citedBy;
136
+ }
137
+ if (extra.authors.length > base.authors.length)
138
+ base.authors = [...extra.authors];
139
+ if (base.language === undefined && extra.language !== undefined)
140
+ base.language = extra.language;
141
+ if (base.type === '' && extra.type !== '')
142
+ base.type = extra.type;
143
+ if (base.url === '' || base.url === base.sourceId)
144
+ base.url = extra.url;
145
+ base.openAccess = base.openAccess || extra.openAccess;
146
+ }
147
+ function mergeIds(base, extra) {
148
+ return {
149
+ doi: base.doi !== '' ? base.doi : extra.doi,
150
+ pmid: base.pmid !== '' ? base.pmid : extra.pmid,
151
+ pmcid: base.pmcid !== '' ? base.pmcid : extra.pmcid,
152
+ arxivId: base.arxivId !== '' ? base.arxivId : extra.arxivId,
153
+ };
154
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 上游 JSON 的窄化与归一化助手。
3
+ *
4
+ * 免费 API 的字段普遍可空、类型漂移,因此统一在 provider 边界做一次
5
+ * 「未知 → 安全值」的转换,聚合层之后只处理规整的 `Paper`。
6
+ */
7
+ export declare function asRecord(value: unknown): Record<string, unknown>;
8
+ export declare function asArray(value: unknown): unknown[];
9
+ export declare function asString(value: unknown): string;
10
+ export declare function asNumber(value: unknown): number | null;
11
+ /** 去掉 `https://doi.org/` / `doi:` 前缀并转小写。 */
12
+ export declare function normalizeDoi(value: unknown): string;
13
+ export declare function doiUrl(doi: string): string;
14
+ /** 取字符串数组的第一项(Crossref 的 title / container-title)。 */
15
+ export declare function firstString(value: unknown): string;
16
+ /** 去掉 JATS/HTML 标签与常见实体,压成单行文本。 */
17
+ export declare function stripTags(value: unknown): string;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 上游 JSON 的窄化与归一化助手。
3
+ *
4
+ * 免费 API 的字段普遍可空、类型漂移,因此统一在 provider 边界做一次
5
+ * 「未知 → 安全值」的转换,聚合层之后只处理规整的 `Paper`。
6
+ */
7
+ export function asRecord(value) {
8
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
9
+ ? value
10
+ : {};
11
+ }
12
+ export function asArray(value) {
13
+ return Array.isArray(value) ? value : [];
14
+ }
15
+ export function asString(value) {
16
+ return typeof value === 'string' ? value : '';
17
+ }
18
+ export function asNumber(value) {
19
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
20
+ }
21
+ /** 去掉 `https://doi.org/` / `doi:` 前缀并转小写。 */
22
+ export function normalizeDoi(value) {
23
+ return asString(value)
24
+ .trim()
25
+ .replace(/^https?:\/\/(?:dx\.)?doi\.org\//iu, '')
26
+ .replace(/^doi:\s*/iu, '')
27
+ .toLowerCase();
28
+ }
29
+ export function doiUrl(doi) {
30
+ return doi === '' ? '' : `https://doi.org/${doi}`;
31
+ }
32
+ /** 取字符串数组的第一项(Crossref 的 title / container-title)。 */
33
+ export function firstString(value) {
34
+ return asString(asArray(value)[0]);
35
+ }
36
+ /** 去掉 JATS/HTML 标签与常见实体,压成单行文本。 */
37
+ export function stripTags(value) {
38
+ return asString(value)
39
+ .replace(/<[^>]*>/gu, ' ')
40
+ .replace(/&lt;/gu, '<')
41
+ .replace(/&gt;/gu, '>')
42
+ .replace(/&quot;/gu, '"')
43
+ .replace(/&#39;/gu, "'")
44
+ .replace(/&amp;/gu, '&')
45
+ .replace(/\s+/gu, ' ')
46
+ .trim();
47
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 开放获取全文定位:候选 PDF 与 Europe PMC HTML 全文。
3
+ *
4
+ * 候选只负责“可能在哪”,真正的下载与安全校验在 Host 侧(`host/pdf/store.ts`)。
5
+ */
6
+ import type { Paper } from './types.js';
7
+ /** 去重后的 PDF 候选地址(顺序即尝试优先级)。 */
8
+ export declare function pdfCandidates(paper: Paper): string[];
9
+ /** Europe PMC 的全文 XML 地址;没有 PMCID 时返回 null。 */
10
+ export declare function epmcXmlUrl(paper: Paper): string | null;
11
+ /** Unpaywall 查询(需要联系邮箱,遵守其 polite pool)。 */
12
+ export declare function unpaywallPdfCandidates(doi: string, email: string, timeoutMs: number, signal?: AbortSignal): Promise<string[]>;
13
+ /**
14
+ * JATS XML → 可读 HTML 片段。
15
+ *
16
+ * 只保留标题、段落、列表等结构,其余标签一律剥离;所有文本先转义,
17
+ * 因此上游 XML 里的脚本/属性不会进入阅读页。
18
+ */
19
+ export declare function jatsToHtml(xml: string): string;
20
+ /** 包一层最小阅读页样式。 */
21
+ export declare function fullTextHtml(paper: Paper, fragment: string, source: string): string;