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.
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/client/client.js +9036 -0
- package/cordis.patch.yml +10 -0
- package/lib/host/events.d.ts +19 -0
- package/lib/host/events.js +58 -0
- package/lib/host/export/bibtex.d.ts +3 -0
- package/lib/host/export/bibtex.js +42 -0
- package/lib/host/export/common.d.ts +18 -0
- package/lib/host/export/common.js +25 -0
- package/lib/host/export/csv.d.ts +8 -0
- package/lib/host/export/csv.js +68 -0
- package/lib/host/export/index.d.ts +5 -0
- package/lib/host/export/index.js +25 -0
- package/lib/host/export/markdown.d.ts +6 -0
- package/lib/host/export/markdown.js +120 -0
- package/lib/host/export/ris.d.ts +3 -0
- package/lib/host/export/ris.js +35 -0
- package/lib/host/paths.d.ts +43 -0
- package/lib/host/paths.js +128 -0
- package/lib/host/pdf/store.d.ts +57 -0
- package/lib/host/pdf/store.js +291 -0
- package/lib/host/router.d.ts +20 -0
- package/lib/host/router.js +852 -0
- package/lib/host/tasks/compare.d.ts +8 -0
- package/lib/host/tasks/compare.js +49 -0
- package/lib/host/tasks/filter.d.ts +42 -0
- package/lib/host/tasks/filter.js +100 -0
- package/lib/host/tasks/rounds.d.ts +28 -0
- package/lib/host/tasks/rounds.js +74 -0
- package/lib/host/tasks/runner.d.ts +29 -0
- package/lib/host/tasks/runner.js +208 -0
- package/lib/host/tasks/service.d.ts +171 -0
- package/lib/host/tasks/service.js +976 -0
- package/lib/host/tasks/states.d.ts +28 -0
- package/lib/host/tasks/states.js +223 -0
- package/lib/host/tasks/stats.d.ts +13 -0
- package/lib/host/tasks/stats.js +233 -0
- package/lib/host/tasks/store.d.ts +64 -0
- package/lib/host/tasks/store.js +280 -0
- package/lib/host/tasks/summary.d.ts +9 -0
- package/lib/host/tasks/summary.js +54 -0
- package/lib/host/tools.d.ts +47 -0
- package/lib/host/tools.js +1138 -0
- package/lib/host/zotero/connector.d.ts +46 -0
- package/lib/host/zotero/connector.js +263 -0
- package/lib/index.d.ts +107 -0
- package/lib/index.js +153 -0
- package/lib/search/aggregate.d.ts +17 -0
- package/lib/search/aggregate.js +60 -0
- package/lib/search/config.d.ts +96 -0
- package/lib/search/config.js +96 -0
- package/lib/search/errors.d.ts +22 -0
- package/lib/search/errors.js +24 -0
- package/lib/search/health.d.ts +13 -0
- package/lib/search/health.js +53 -0
- package/lib/search/http.d.ts +13 -0
- package/lib/search/http.js +81 -0
- package/lib/search/identity.d.ts +27 -0
- package/lib/search/identity.js +154 -0
- package/lib/search/normalize.d.ts +17 -0
- package/lib/search/normalize.js +47 -0
- package/lib/search/oa.d.ts +21 -0
- package/lib/search/oa.js +149 -0
- package/lib/search/providers/arxiv.d.ts +9 -0
- package/lib/search/providers/arxiv.js +99 -0
- package/lib/search/providers/crossref.d.ts +8 -0
- package/lib/search/providers/crossref.js +107 -0
- package/lib/search/providers/europepmc.d.ts +8 -0
- package/lib/search/providers/europepmc.js +105 -0
- package/lib/search/providers/index.d.ts +13 -0
- package/lib/search/providers/index.js +34 -0
- package/lib/search/providers/openalex.d.ts +8 -0
- package/lib/search/providers/openalex.js +109 -0
- package/lib/search/providers/pubmed.d.ts +10 -0
- package/lib/search/providers/pubmed.js +143 -0
- package/lib/search/providers/pubtator.d.ts +9 -0
- package/lib/search/providers/pubtator.js +79 -0
- package/lib/search/providers/semanticscholar.d.ts +9 -0
- package/lib/search/providers/semanticscholar.js +97 -0
- package/lib/search/pubtator-entities.d.ts +9 -0
- package/lib/search/pubtator-entities.js +19 -0
- package/lib/search/pubtator-parse.d.ts +3 -0
- package/lib/search/pubtator-parse.js +42 -0
- package/lib/search/query-adapter.d.ts +15 -0
- package/lib/search/query-adapter.js +69 -0
- package/lib/search/rate-limit.d.ts +31 -0
- package/lib/search/rate-limit.js +219 -0
- package/lib/search/types.d.ts +577 -0
- package/lib/search/types.js +15 -0
- package/lib/search/xml.d.ts +20 -0
- package/lib/search/xml.js +63 -0
- package/package.json +75 -4
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 每源串行队列 + 最小间隔 + 429 冷却。
|
|
3
|
+
*
|
|
4
|
+
* 同一数据源的所有请求共享一条队列:既保证最小间隔,也让一个源触发限流时
|
|
5
|
+
* 冷却状态对所有任务可见。遵守上游限制的原则是「放慢并等待」,而不是把冷却
|
|
6
|
+
* 当成失败:
|
|
7
|
+
*
|
|
8
|
+
* - 冷却期内排队等待(有上限、可取消);只有上游要求的冷却过长才优雅跳过。
|
|
9
|
+
* - 真正收到 429 时记录冷却、自适应放大该源间隔,并退避重试一次;重试仍
|
|
10
|
+
* 限流则抛 `SourceSkipped`,由聚合层标为 skipped,不计入失败。
|
|
11
|
+
* - 连续成功若干次后间隔逐步回落;超时 / 临时网络故障也重试一次。
|
|
12
|
+
* - 诊断探活(bypassCooldown)忽略冷却,429 仍按原始错误返回,便于定位。
|
|
13
|
+
*/
|
|
14
|
+
import { DEFAULT_ARXIV_INTERVAL_MS, DEFAULT_PUBMED_INTERVAL_MS, DEFAULT_S2_INTERVAL_MS, getSettings, } from './config.js';
|
|
15
|
+
import { SearchError } from './errors.js';
|
|
16
|
+
import { cooldownUntil, recordSourceCooldown, recordSourceError, recordSourceOk, } from './health.js';
|
|
17
|
+
import { fetchJson, fetchText } from './http.js';
|
|
18
|
+
const BASE_INTERVALS = {
|
|
19
|
+
pubmed: DEFAULT_PUBMED_INTERVAL_MS,
|
|
20
|
+
europepmc: 200,
|
|
21
|
+
openalex: 100,
|
|
22
|
+
crossref: 1000,
|
|
23
|
+
semanticscholar: DEFAULT_S2_INTERVAL_MS,
|
|
24
|
+
arxiv: DEFAULT_ARXIV_INTERVAL_MS,
|
|
25
|
+
pubtator: 350,
|
|
26
|
+
};
|
|
27
|
+
/** 429 未带 Retry-After 时的冷却时长(原为 30s,过久会拖垮多检索式任务)。 */
|
|
28
|
+
const COOLDOWN_FALLBACK_MS = 10_000;
|
|
29
|
+
/** 单次请求愿意为遵守冷却等待的上限;超过则优雅跳过。 */
|
|
30
|
+
const MAX_COOLDOWN_WAIT_MS = 20_000;
|
|
31
|
+
/** 429 / 临时故障的退避与重试次数。 */
|
|
32
|
+
const RATE_LIMIT_RETRY_MS = 2_000;
|
|
33
|
+
const TRANSIENT_RETRY_MS = 1_000;
|
|
34
|
+
const MAX_RETRIES = 1;
|
|
35
|
+
/** 等待期间冷却被反复刷新时的循环上限,避免活锁。 */
|
|
36
|
+
const MAX_WAIT_ROUNDS = 4;
|
|
37
|
+
/** 自适应间隔:放大步长、倍数上限、绝对上限、回落所需连续成功次数。 */
|
|
38
|
+
const BOOST_STEP = 1.5;
|
|
39
|
+
const MAX_BOOST = 4;
|
|
40
|
+
const MAX_INTERVAL_MS = 8_000;
|
|
41
|
+
const RECOVER_AFTER_OK = 5;
|
|
42
|
+
const MIN_INTERVAL_MS = 50;
|
|
43
|
+
const queues = new Map();
|
|
44
|
+
/**
|
|
45
|
+
* 因遵守上游限流而跳过本次请求。
|
|
46
|
+
*
|
|
47
|
+
* 这不是故障:聚合层据此标 `skipped`,任务不计失败,面板只给中性提示。
|
|
48
|
+
*/
|
|
49
|
+
export class SourceSkipped extends Error {
|
|
50
|
+
constructor(message) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'SourceSkipped';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** 当前生效的最小间隔(设置覆盖 + 下限保护)。 */
|
|
56
|
+
export function minIntervalMs(source) {
|
|
57
|
+
const settings = getSettings();
|
|
58
|
+
const configured = source === 'pubmed'
|
|
59
|
+
? settings.pubmedIntervalMs
|
|
60
|
+
: source === 'semanticscholar'
|
|
61
|
+
? settings.s2IntervalMs
|
|
62
|
+
: source === 'arxiv'
|
|
63
|
+
? settings.arxivIntervalMs
|
|
64
|
+
: BASE_INTERVALS[source];
|
|
65
|
+
return Number.isFinite(configured)
|
|
66
|
+
? Math.max(MIN_INTERVAL_MS, Math.round(configured))
|
|
67
|
+
: BASE_INTERVALS[source];
|
|
68
|
+
}
|
|
69
|
+
/** 叠加 429 自适应放大后的实际间隔。 */
|
|
70
|
+
function effectiveIntervalMs(source, state) {
|
|
71
|
+
return Math.min(minIntervalMs(source) * state.boost, MAX_INTERVAL_MS);
|
|
72
|
+
}
|
|
73
|
+
export function limitedJson(source, url, request) {
|
|
74
|
+
return runLimited(source, request, () => fetchJson(url, request));
|
|
75
|
+
}
|
|
76
|
+
export function limitedText(source, url, request) {
|
|
77
|
+
return runLimited(source, request, () => fetchText(url, request));
|
|
78
|
+
}
|
|
79
|
+
async function runLimited(source, request, operation) {
|
|
80
|
+
const state = queueState(source);
|
|
81
|
+
const previous = state.chain;
|
|
82
|
+
let release = () => undefined;
|
|
83
|
+
state.chain = new Promise((resolve) => {
|
|
84
|
+
release = resolve;
|
|
85
|
+
});
|
|
86
|
+
const bypass = request.bypassCooldown === true;
|
|
87
|
+
let retries = 0;
|
|
88
|
+
// 本请求 429 后自己退避重试时不再被刚写入的冷却拦下;其它排队请求仍遵守。
|
|
89
|
+
let ignoreCooldown = false;
|
|
90
|
+
let waitRounds = 0;
|
|
91
|
+
try {
|
|
92
|
+
await previous;
|
|
93
|
+
for (;;) {
|
|
94
|
+
throwIfAborted(request.signal);
|
|
95
|
+
const cooling = coolingWait(source, bypass, ignoreCooldown);
|
|
96
|
+
if (cooling > MAX_COOLDOWN_WAIT_MS)
|
|
97
|
+
throw skippedTooLong(source, cooling);
|
|
98
|
+
const spacing = Math.max(0, state.lastStart + effectiveIntervalMs(source, state) - Date.now());
|
|
99
|
+
const wait = Math.max(cooling, spacing);
|
|
100
|
+
if (wait > 0)
|
|
101
|
+
await sleep(wait, request.signal);
|
|
102
|
+
// 等待期间别的请求可能刚触发 429 并刷新冷却;补足等待,但有次数上限。
|
|
103
|
+
const refreshed = coolingWait(source, bypass, ignoreCooldown);
|
|
104
|
+
if (refreshed > 0) {
|
|
105
|
+
if (refreshed > MAX_COOLDOWN_WAIT_MS || waitRounds >= MAX_WAIT_ROUNDS) {
|
|
106
|
+
throw skippedTooLong(source, refreshed);
|
|
107
|
+
}
|
|
108
|
+
waitRounds += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
throwIfAborted(request.signal);
|
|
112
|
+
state.lastStart = Date.now();
|
|
113
|
+
const started = Date.now();
|
|
114
|
+
try {
|
|
115
|
+
const result = await operation();
|
|
116
|
+
recordSourceOk(source, Date.now() - started);
|
|
117
|
+
relaxInterval(state);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
// 取消必须立即透传,不能当成临时故障重试。
|
|
122
|
+
if (request.signal?.aborted === true)
|
|
123
|
+
throw error;
|
|
124
|
+
if (isRateLimited(error)) {
|
|
125
|
+
// 上游给了 Retry-After 就完全按它来;没给才用本地兜底。
|
|
126
|
+
const cooldown = error.retryAfterMs ?? COOLDOWN_FALLBACK_MS;
|
|
127
|
+
recordSourceCooldown(source, Date.now() + cooldown, errorMessage(error));
|
|
128
|
+
boostInterval(state);
|
|
129
|
+
if (bypass)
|
|
130
|
+
throw error;
|
|
131
|
+
if (retries >= MAX_RETRIES) {
|
|
132
|
+
throw new SourceSkipped(`${source} 触发上游限流,已放慢频率并重试,本次按限流跳过`);
|
|
133
|
+
}
|
|
134
|
+
retries += 1;
|
|
135
|
+
ignoreCooldown = true;
|
|
136
|
+
await sleep(Math.min(cooldown, RATE_LIMIT_RETRY_MS), request.signal);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (isTransient(error) && retries < MAX_RETRIES) {
|
|
140
|
+
retries += 1;
|
|
141
|
+
await sleep(TRANSIENT_RETRY_MS, request.signal);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
recordSourceError(source, errorMessage(error));
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
release();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function coolingWait(source, bypass, ignoreCooldown) {
|
|
154
|
+
if (bypass || ignoreCooldown)
|
|
155
|
+
return 0;
|
|
156
|
+
return Math.max(0, cooldownUntil(source) - Date.now());
|
|
157
|
+
}
|
|
158
|
+
function skippedTooLong(source, remaining) {
|
|
159
|
+
return new SourceSkipped(`${source} 处于上游限流冷却(约 ${Math.ceil(remaining / 1000)} 秒后恢复),本次已按限流跳过`);
|
|
160
|
+
}
|
|
161
|
+
function isRateLimited(error) {
|
|
162
|
+
return error instanceof SearchError && error.status === 429;
|
|
163
|
+
}
|
|
164
|
+
function isTransient(error) {
|
|
165
|
+
return (error instanceof SearchError &&
|
|
166
|
+
(error.code === 'timeout' || error.code === 'network_error'));
|
|
167
|
+
}
|
|
168
|
+
function errorMessage(error) {
|
|
169
|
+
return error instanceof Error ? error.message : String(error);
|
|
170
|
+
}
|
|
171
|
+
function boostInterval(state) {
|
|
172
|
+
state.boost = Math.min(state.boost * BOOST_STEP, MAX_BOOST);
|
|
173
|
+
state.consecutiveOk = 0;
|
|
174
|
+
}
|
|
175
|
+
function relaxInterval(state) {
|
|
176
|
+
if (state.boost <= 1)
|
|
177
|
+
return;
|
|
178
|
+
state.consecutiveOk += 1;
|
|
179
|
+
if (state.consecutiveOk < RECOVER_AFTER_OK)
|
|
180
|
+
return;
|
|
181
|
+
state.boost = Math.max(1, state.boost / BOOST_STEP);
|
|
182
|
+
state.consecutiveOk = 0;
|
|
183
|
+
}
|
|
184
|
+
function queueState(source) {
|
|
185
|
+
let state = queues.get(source);
|
|
186
|
+
if (state === undefined) {
|
|
187
|
+
state = { chain: Promise.resolve(), lastStart: 0, boost: 1, consecutiveOk: 0 };
|
|
188
|
+
queues.set(source, state);
|
|
189
|
+
}
|
|
190
|
+
return state;
|
|
191
|
+
}
|
|
192
|
+
function throwIfAborted(signal) {
|
|
193
|
+
if (signal?.aborted === true) {
|
|
194
|
+
throw new SearchError('network_error', '请求已取消');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function sleep(ms, signal) {
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
if (signal?.aborted === true) {
|
|
200
|
+
reject(new SearchError('network_error', '请求已取消'));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
let timer;
|
|
204
|
+
function cleanup() {
|
|
205
|
+
signal?.removeEventListener('abort', onAbort);
|
|
206
|
+
}
|
|
207
|
+
function onAbort() {
|
|
208
|
+
if (timer !== undefined)
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
cleanup();
|
|
211
|
+
reject(new SearchError('network_error', '请求已取消'));
|
|
212
|
+
}
|
|
213
|
+
timer = setTimeout(() => {
|
|
214
|
+
cleanup();
|
|
215
|
+
resolve();
|
|
216
|
+
}, ms);
|
|
217
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
218
|
+
});
|
|
219
|
+
}
|