@swimmingliu/autovpn 2.0.3 → 2.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.
@@ -0,0 +1,372 @@
1
+ /**
2
+ * One egress authority for the crawl stages (credential + url extraction).
3
+ *
4
+ * Before this module the two stages disagreed: each had its own env-only,
5
+ * default-off proxy switch, the credential flow built its CommContext without
6
+ * the egress object so its curl/TLS fallback was dead code, and neither stage
7
+ * noticed that the vendor had started answering with a block page.
8
+ *
9
+ * Policy, in the order the operator asked for it:
10
+ * - `proxy` (the default) crawls through the upstream proxy. When the proxy
11
+ * is not listening the run drops to direct rather than failing every
12
+ * request, and a later block on direct switches back to the proxy.
13
+ * - `auto` starts direct and pins the proxy for the rest of the run the
14
+ * first time a response looks like an IP block.
15
+ * - `direct` never uses a proxy. It is the explicit override.
16
+ *
17
+ * Per-node measurement (speedtest/availability) deliberately does NOT use this
18
+ * module: those transports must reach the node itself, not the crawl proxy.
19
+ */
20
+ import net from 'node:net';
21
+ import { spawn as nodeSpawn } from 'node:child_process';
22
+ import { fetch as upstreamFetch, ProxyAgent } from 'undici';
23
+ export const DEFAULT_PROXY_URL = 'http://127.0.0.1:7897';
24
+ const DISABLED_VALUES = new Set(['', 'off', 'none', 'false', '0', 'no', 'direct']);
25
+ const ENABLED_VALUES = new Set(['1', 'true', 'yes', 'on']);
26
+ function normalizeMode(value) {
27
+ const text = String(value ?? '').trim().toLowerCase();
28
+ if (text === 'proxy' || text === 'upstream')
29
+ return 'proxy';
30
+ if (text === 'auto' || text === 'fallback')
31
+ return 'auto';
32
+ if (text === 'direct' || text === 'off' || text === 'none')
33
+ return 'direct';
34
+ return null;
35
+ }
36
+ /**
37
+ * Resolve the crawl egress policy.
38
+ *
39
+ * `VPN_AUTOMATION_PROXY_MODE` is the current knob. The older
40
+ * `VPN_AUTOMATION_USE_UPSTREAM_PROXY` boolean still works: an explicit false
41
+ * means direct, an explicit true means proxy. The standard proxy variables are
42
+ * a URL fallback only, so a shell that exports HTTPS_PROXY for unrelated tools
43
+ * does not silently change the crawl's transport mode.
44
+ */
45
+ export function resolveEgressPolicy(env = process.env) {
46
+ const explicit = normalizeMode(env.VPN_AUTOMATION_PROXY_MODE);
47
+ const legacyRaw = String(env.VPN_AUTOMATION_USE_UPSTREAM_PROXY ?? '').trim().toLowerCase();
48
+ const legacy = legacyRaw
49
+ ? (ENABLED_VALUES.has(legacyRaw) ? 'proxy' : (DISABLED_VALUES.has(legacyRaw) ? 'direct' : null))
50
+ : null;
51
+ const configured = String(env.VPN_AUTOMATION_UPSTREAM_PROXY ?? '').trim();
52
+ const standard = String(env.HTTPS_PROXY ?? env.https_proxy ?? env.HTTP_PROXY ?? env.http_proxy ?? env.ALL_PROXY ?? env.all_proxy ?? '').trim();
53
+ const candidate = configured || standard;
54
+ // An UNSET candidate falls back to the default proxy; only an explicit
55
+ // disable token ('off', 'none', '0', ...) turns the proxy off. Treating ''
56
+ // as disabled would make the default mode direct.
57
+ const proxyUrl = candidate && DISABLED_VALUES.has(candidate.toLowerCase())
58
+ ? ''
59
+ : (candidate || DEFAULT_PROXY_URL);
60
+ // An explicitly disabled proxy URL cannot serve any mode that needs one.
61
+ const mode = explicit ?? legacy ?? 'proxy';
62
+ if (!proxyUrl)
63
+ return { mode: 'direct', proxyUrl: '' };
64
+ return { mode, proxyUrl };
65
+ }
66
+ /** Status codes a vendor or its CDN uses when it stops serving this client. */
67
+ const BLOCK_STATUS = new Set([403, 407, 429, 451, 503]);
68
+ const BLOCK_BODY_PATTERN = /封禁|封鎖|封锁|禁止访问|禁止訪問|访问受限|訪問受限|您的IP|你的IP|IP\s*被|异常访问|異常訪問|请求过于频繁|請求過於頻繁|操作频繁|操作頻繁|too\s*many\s*requests|rate\s*limit|access\s*denied|forbidden|blocked|cf-browser-verification|just\s*a\s*moment|attention\s*required|captcha/i;
69
+ /** Classify a response or transport error for the switch decision. */
70
+ export function classifyEgressFailure(input) {
71
+ const status = Number(input.status ?? 0);
72
+ if (BLOCK_STATUS.has(status))
73
+ return 'blocked';
74
+ if (input.body && BLOCK_BODY_PATTERN.test(input.body.slice(0, 4096)))
75
+ return 'blocked';
76
+ if (input.error !== undefined) {
77
+ const text = errorText(input.error);
78
+ if (/econnreset|econnrefused|etimedout|enotfound|ehostunreach|enetunreach|socket\s*hang\s*up|fetch\s*failed|abort|timeout|und_err_/i.test(text)) {
79
+ return 'transient';
80
+ }
81
+ return 'fatal';
82
+ }
83
+ // A 2xx with no usable body is not a block on its own: the vendor answers an
84
+ // empty body for a blank `code` parameter too. The round-level empty sweep
85
+ // is what escalates that into a switch.
86
+ return status >= 400 ? 'transient' : 'fatal';
87
+ }
88
+ function errorText(error) {
89
+ if (error instanceof Error) {
90
+ const cause = error.cause;
91
+ return `${error.name}: ${error.message}${cause ? ` ${errorText(cause)}` : ''}`.toLowerCase();
92
+ }
93
+ return String(error ?? '').toLowerCase();
94
+ }
95
+ /**
96
+ * True when the failure is the proxy itself refusing the connection, not the
97
+ * target host. Only then may the run fall back to direct.
98
+ */
99
+ export function isProxyUnreachable(error) {
100
+ return /econnrefused|enotfound|ehostunreach|enetunreach|proxy\s*(connection|connect|tunnel)|und_err_proxy|err_proxy/i.test(errorText(error));
101
+ }
102
+ function isTlsFailure(error) {
103
+ return /ssl|tls|certificate|handshake/i.test(errorText(error));
104
+ }
105
+ const defaultSpawn = nodeSpawn;
106
+ /**
107
+ * curl handles a proxy or an intercepted TLS chain that Node's fetch rejects.
108
+ * The URL travels through stdin so it never lands in a process argument list.
109
+ */
110
+ export function defaultCurlFetch(url, proxyUrl, spawn, timeoutSeconds = 20) {
111
+ const run = spawn ?? defaultSpawn;
112
+ const args = [
113
+ '--fail', '--silent', '--show-error', '--location',
114
+ '--max-time', String(Math.max(1, Math.trunc(timeoutSeconds))),
115
+ '--connect-timeout', '10',
116
+ '--insecure', '--http1.1', '--config', '-'
117
+ ];
118
+ if (proxyUrl)
119
+ args.push('--proxy', proxyUrl);
120
+ else
121
+ args.push('--noproxy', '*');
122
+ const child = run('curl', args, { stdio: ['pipe', 'pipe', 'pipe'] });
123
+ let stdout = '';
124
+ let stderr = '';
125
+ child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
126
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
127
+ const completion = new Promise((resolve, reject) => {
128
+ child.on('error', reject);
129
+ child.on('close', (code) => {
130
+ if (code === 0)
131
+ resolve(stdout);
132
+ else
133
+ reject(new Error((stderr || stdout || 'curl TLS fallback failed').trim()));
134
+ });
135
+ });
136
+ child.stdin?.write(`url = ${JSON.stringify(url)}\n`);
137
+ child.stdin?.end();
138
+ return completion;
139
+ }
140
+ /** A TCP connect is enough to know whether a local proxy is listening. */
141
+ export function probeProxy(proxyUrl, timeoutMs = 1500) {
142
+ let target;
143
+ try {
144
+ target = new URL(proxyUrl);
145
+ }
146
+ catch {
147
+ return Promise.resolve(false);
148
+ }
149
+ const port = Number(target.port) || (target.protocol === 'https:' ? 443 : 80);
150
+ return new Promise((resolve) => {
151
+ let settled = false;
152
+ const socket = net.connect({ host: target.hostname, port });
153
+ const finish = (reachable) => {
154
+ if (settled)
155
+ return;
156
+ settled = true;
157
+ socket.destroy();
158
+ resolve(reachable);
159
+ };
160
+ socket.setTimeout(timeoutMs);
161
+ socket.once('connect', () => finish(true));
162
+ socket.once('timeout', () => finish(false));
163
+ socket.once('error', () => finish(false));
164
+ });
165
+ }
166
+ const FAILED_TEXT = (url, transport, status = 0, blocked = false) => ({ text: '', url, ok: false, status, transport, blocked });
167
+ /**
168
+ * A run-scoped transport choice. One session per source: the switch is sticky
169
+ * so a blocked run does not oscillate between transports on every request.
170
+ */
171
+ export class EgressSession {
172
+ policy;
173
+ switches = [];
174
+ /** HTTP attempts actually issued, including retries and fallbacks. */
175
+ attempts = 0;
176
+ current;
177
+ dispatcher = null;
178
+ fetchImpl;
179
+ curlFetch;
180
+ onSwitch;
181
+ proxyEverReachable;
182
+ /** A request has already been routed through the proxy this run. */
183
+ proxyAttempted = false;
184
+ closed = false;
185
+ constructor(policy, current, proxyReachable, options) {
186
+ this.policy = policy;
187
+ this.current = current;
188
+ this.proxyEverReachable = proxyReachable;
189
+ this.fetchImpl = options.fetchImpl;
190
+ this.curlFetch = options.curlFetch;
191
+ this.onSwitch = options.onSwitch;
192
+ }
193
+ static async open(env, options = {}) {
194
+ const policy = resolveEgressPolicy(env ?? {});
195
+ if (policy.mode === 'direct' || !policy.proxyUrl) {
196
+ return new EgressSession(policy, 'direct', false, options);
197
+ }
198
+ if (policy.mode === 'auto') {
199
+ // Direct first; the first blocked response pins the proxy.
200
+ return new EgressSession(policy, 'direct', false, options);
201
+ }
202
+ const probe = options.probe ?? ((url) => probeProxy(url));
203
+ // An injected fetch is a test seam: probing a real socket would make every
204
+ // unit test depend on whether a local proxy happens to be listening. The
205
+ // injected impl decides the bytes; `probe` can still be injected to drive
206
+ // the unreachable-proxy fallback.
207
+ let reachable = Boolean(options.fetchImpl) && !options.probe;
208
+ if (!reachable) {
209
+ try {
210
+ reachable = await probe(policy.proxyUrl);
211
+ }
212
+ catch {
213
+ reachable = false;
214
+ }
215
+ }
216
+ // When the proxy is not listening the run starts direct. `proxyReachable`
217
+ // stays false so the first blocked response may still try the proxy once
218
+ // (a local client often starts a moment later), and a failed proxy attempt
219
+ // is not retried after that.
220
+ return new EgressSession(policy, reachable ? 'proxy' : 'direct', reachable, options);
221
+ }
222
+ get transport() {
223
+ return this.current;
224
+ }
225
+ /**
226
+ * The transport-bound fetch, for the binary (APK) downloads that need
227
+ * `arrayBuffer` rather than text. It carries the same proxy dispatcher as
228
+ * `text()`, so a configured proxy serves the APK bytes too.
229
+ */
230
+ rawFetch() {
231
+ return this.impl();
232
+ }
233
+ get proxyAvailable() {
234
+ return Boolean(this.policy.proxyUrl) && this.policy.mode !== 'direct';
235
+ }
236
+ get proxyReachable() {
237
+ return this.proxyEverReachable;
238
+ }
239
+ switchTo(next, reason) {
240
+ if (this.current === next)
241
+ return false;
242
+ // A proxy the probe found unreachable gets exactly one more chance (it may
243
+ // have just started listening). After that a blocked response does not
244
+ // bounce the run back onto a transport that already refused it.
245
+ if (next === 'proxy' && !this.proxyEverReachable && this.proxyAttempted)
246
+ return false;
247
+ const record = { from: this.current, to: next, reason };
248
+ this.current = next;
249
+ this.switches.push(record);
250
+ this.onSwitch?.(record);
251
+ return true;
252
+ }
253
+ /**
254
+ * Escalate a silent failure sweep. The vendor can answer an empty body to a
255
+ * banned IP instead of a 403, so a whole round with no answer is the signal.
256
+ */
257
+ reportEmptySweep(reason = 'empty_responses') {
258
+ if (this.current !== 'direct' || !this.proxyAvailable)
259
+ return false;
260
+ return this.switchTo('proxy', reason);
261
+ }
262
+ impl() {
263
+ if (this.fetchImpl)
264
+ return this.fetchImpl;
265
+ if (this.current === 'proxy' && this.policy.proxyUrl) {
266
+ this.dispatcher ??= new ProxyAgent({ uri: this.policy.proxyUrl, proxyTunnel: false });
267
+ const dispatcher = this.dispatcher;
268
+ return (url, init) => upstreamFetch(url, { ...init, dispatcher });
269
+ }
270
+ return (url, init) => upstreamFetch(url, { ...init });
271
+ }
272
+ async attempt(url, timeoutMs, transport) {
273
+ if (transport === 'proxy')
274
+ this.proxyAttempted = true;
275
+ const controller = new AbortController();
276
+ const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
277
+ const fetchImpl = this.impl();
278
+ this.attempts += 1;
279
+ let response = null;
280
+ try {
281
+ response = await fetchImpl(url, { signal: controller.signal, redirect: 'follow' });
282
+ const status = Number(response.status ?? (response.ok === false ? 500 : 200));
283
+ const ok = response.ok !== false && status < 400;
284
+ if (!ok) {
285
+ // An abandoned body pins its keep-alive socket until GC; at thousands
286
+ // of route requests per run that exhausts the pool.
287
+ const body = await this.drain(response, 2048);
288
+ await this.cancel(response);
289
+ const blocked = classifyEgressFailure({ status, body }) === 'blocked';
290
+ if (blocked && transport === 'direct' && this.proxyAvailable) {
291
+ return { ...FAILED_TEXT(url, transport, status, true), retryWith: 'proxy', reason: `blocked_direct_${status}` };
292
+ }
293
+ return FAILED_TEXT(url, transport, status, blocked);
294
+ }
295
+ const text = await response.text();
296
+ const blocked = classifyEgressFailure({ status, body: text }) === 'blocked';
297
+ if (blocked && transport === 'direct' && this.proxyAvailable) {
298
+ return { ...FAILED_TEXT(url, transport, status, true), retryWith: 'proxy', reason: 'blocked_direct_body' };
299
+ }
300
+ return { text: blocked ? '' : text, url, ok: !blocked, status, transport, blocked };
301
+ }
302
+ catch (error) {
303
+ await this.cancel(response);
304
+ if (transport === 'proxy' && isProxyUnreachable(error)) {
305
+ this.proxyEverReachable = false;
306
+ return { ...FAILED_TEXT(url, transport), retryWith: 'direct', reason: 'proxy_unreachable' };
307
+ }
308
+ // Same fallback the url mode has always applied: curl handles a proxy or
309
+ // a self-signed/intercepted TLS chain that Node's fetch rejects.
310
+ if (this.curlFetch && (isTlsFailure(error) || classifyEgressFailure({ error }) === 'transient')) {
311
+ this.attempts += 1;
312
+ try {
313
+ const text = await this.curlFetch(url, transport === 'proxy' ? this.policy.proxyUrl : '');
314
+ if (text.trim()) {
315
+ return { text, url, ok: true, status: 200, transport, blocked: false };
316
+ }
317
+ }
318
+ catch {
319
+ // Fall through to the failure result below.
320
+ }
321
+ }
322
+ return FAILED_TEXT(url, transport);
323
+ }
324
+ finally {
325
+ clearTimeout(timer);
326
+ }
327
+ }
328
+ async drain(response, limit) {
329
+ if (!response)
330
+ return '';
331
+ try {
332
+ const text = await response.text();
333
+ return text.slice(0, limit);
334
+ }
335
+ catch {
336
+ return '';
337
+ }
338
+ }
339
+ async cancel(response) {
340
+ try {
341
+ await response?.body?.cancel?.();
342
+ }
343
+ catch {
344
+ // Already consumed or already closed.
345
+ }
346
+ }
347
+ /** Fetch a URL as text under the current transport, switching once if needed. */
348
+ async text(url, timeoutMs) {
349
+ if (this.closed)
350
+ throw new Error('egress session is closed');
351
+ const first = await this.attempt(url, timeoutMs, this.current);
352
+ if (!first.retryWith)
353
+ return first;
354
+ this.switchTo(first.retryWith, first.reason ?? 'retry');
355
+ const second = await this.attempt(url, timeoutMs, this.current);
356
+ const { retryWith: _ignored, reason: _reason, ...rest } = second;
357
+ return rest;
358
+ }
359
+ async close() {
360
+ this.closed = true;
361
+ const dispatcher = this.dispatcher;
362
+ this.dispatcher = null;
363
+ if (dispatcher) {
364
+ try {
365
+ await dispatcher.destroy();
366
+ }
367
+ catch {
368
+ // Destroy is best-effort; a dead proxy must not fail the run.
369
+ }
370
+ }
371
+ }
372
+ }
@@ -1,5 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { spawn as defaultSpawn } from 'node:child_process';
3
+ import { EgressSession, resolveEgressPolicy, defaultCurlFetch as egressCurlFetch } from './egress.js';
3
4
  function defaultRandomInt(start, end) {
4
5
  return Math.floor(Math.random() * (end - start + 1)) + start;
5
6
  }
@@ -135,11 +136,9 @@ function isEnabled(value) {
135
136
  return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
136
137
  }
137
138
  function resolveUpstreamProxyUrl(env) {
138
- if (!isEnabled(env.VPN_AUTOMATION_USE_UPSTREAM_PROXY)) {
139
- return '';
140
- }
141
- const value = String(env.VPN_AUTOMATION_UPSTREAM_PROXY ?? 'http://127.0.0.1:7897').trim();
142
- return ['', 'off', 'none', 'false', '0'].includes(value.toLowerCase()) ? '' : value;
139
+ // One resolver for both extract modes, so a run cannot half-use a proxy.
140
+ const policy = resolveEgressPolicy(env);
141
+ return policy.mode === 'direct' ? '' : policy.proxyUrl;
143
142
  }
144
143
  function isTlsFailure(error) {
145
144
  const text = error instanceof Error
@@ -201,10 +200,29 @@ function defaultCurlFetch(url, proxyUrl, spawn = defaultSpawn) {
201
200
  function emitExtractEvent(options, type, payload) {
202
201
  options.eventCallback?.(type, payload);
203
202
  }
204
- async function fetchSourceText(input, url, attempt, fetchImpl, options, upstreamProxy) {
203
+ async function fetchSourceText(input, url, attempt, fetchImpl, options, upstreamProxy, session) {
204
+ // The shared session is the egress authority for both modes: it owns the
205
+ // proxy dispatcher, cancels abandoned bodies and switches direct -> proxy
206
+ // when the vendor starts answering with a block page.
207
+ if (session) {
208
+ const result = await session.text(url, 20_000);
209
+ const via = result.transport === 'proxy' ? 'upstream_proxy' : 'direct';
210
+ emitExtractEvent(options, 'extract_request_result', {
211
+ source_name: input.source_name,
212
+ iteration: attempt,
213
+ success: result.ok,
214
+ via,
215
+ ...(result.ok ? {} : { error: result.blocked ? 'blocked' : `HTTP ${result.status}` })
216
+ });
217
+ if (!result.ok) {
218
+ throw new Error(result.blocked ? 'egress blocked' : `HTTP ${result.status}`);
219
+ }
220
+ return { text: result.text, via };
221
+ }
205
222
  try {
206
223
  const response = await fetchWithTimeout(fetchImpl, url, 20_000);
207
224
  if (response.ok === false || Number(response.status ?? 200) >= 400) {
225
+ await response.body?.cancel?.();
208
226
  throw new Error(`HTTP ${Number(response.status ?? 0)}`);
209
227
  }
210
228
  const text = await response.text();
@@ -270,88 +288,104 @@ async function fetchSourceLinksInNode(input, options) {
270
288
  let failures = 0;
271
289
  const startedAt = Date.now();
272
290
  const upstreamProxy = resolveUpstreamProxyUrl(options.env ?? process.env);
291
+ const session = await EgressSession.open(options.env ?? process.env, {
292
+ fetchImpl: fetchImpl,
293
+ curlFetch: options.curlFetch ?? ((url, proxyUrl) => egressCurlFetch(url, proxyUrl, options.spawn)),
294
+ // The switch has to be visible in the run log for both extract modes; a
295
+ // credential source logs it from its own wrapper, a url source from here.
296
+ onSwitch: (switched) => emitExtractEvent(options, 'log', {
297
+ message: `[extract][${input.source_name}] egress ${switched.from} -> ${switched.to} (${switched.reason})`
298
+ })
299
+ });
273
300
  emitExtractEvent(options, 'extract_source_started', {
274
301
  source_name: input.source_name,
275
302
  requested_iterations: maxIterations,
276
303
  min_iterations: minIterations,
277
304
  resume_from_iteration: startIteration
278
305
  });
279
- for (let iteration = startIteration - 1; iteration < maxIterations; iteration += 1) {
280
- const attempt = iteration + 1;
281
- if (maxRuntimeSeconds > 0 && attempt > minIterations && (Date.now() - startedAt) / 1000 >= maxRuntimeSeconds) {
282
- break;
283
- }
284
- try {
285
- const url = buildRuntimeSourceUrl(source, iteration);
286
- const response = await fetchSourceText(input, url, attempt, fetchImpl, options, upstreamProxy);
287
- let plaintext = '';
288
- try {
289
- plaintext = decryptPayload(response.text.trim(), source.key);
290
- emitExtractEvent(options, 'extract_decrypt_result', {
291
- source_name: input.source_name,
292
- iteration: attempt,
293
- success: true
294
- });
306
+ try {
307
+ for (let iteration = startIteration - 1; iteration < maxIterations; iteration += 1) {
308
+ const attempt = iteration + 1;
309
+ if (maxRuntimeSeconds > 0 && attempt > minIterations && (Date.now() - startedAt) / 1000 >= maxRuntimeSeconds) {
310
+ break;
295
311
  }
296
- catch (decryptError) {
297
- emitExtractEvent(options, 'extract_decrypt_result', {
312
+ try {
313
+ const url = buildRuntimeSourceUrl(source, iteration);
314
+ const response = await fetchSourceText(input, url, attempt, fetchImpl, options, upstreamProxy, session);
315
+ let plaintext = '';
316
+ try {
317
+ plaintext = decryptPayload(response.text.trim(), source.key);
318
+ emitExtractEvent(options, 'extract_decrypt_result', {
319
+ source_name: input.source_name,
320
+ iteration: attempt,
321
+ success: true
322
+ });
323
+ }
324
+ catch (decryptError) {
325
+ emitExtractEvent(options, 'extract_decrypt_result', {
326
+ source_name: input.source_name,
327
+ iteration: attempt,
328
+ success: false,
329
+ error: decryptError instanceof Error ? `${decryptError.constructor.name}: ${decryptError.message}` : String(decryptError)
330
+ });
331
+ throw decryptError;
332
+ }
333
+ const extracted = extractLinksFromPlaintext(input.source_name, plaintext);
334
+ successes += 1;
335
+ failures = 0;
336
+ let newItems = 0;
337
+ const newLinks = [];
338
+ const newItemFingerprints = [];
339
+ for (const link of extracted) {
340
+ if (seen.has(link)) {
341
+ continue;
342
+ }
343
+ seen.add(link);
344
+ links.push(link);
345
+ newLinks.push(link);
346
+ newItems += 1;
347
+ newItemFingerprints.push(linkFingerprint(link));
348
+ }
349
+ emitExtractEvent(options, 'extract_iteration', {
298
350
  source_name: input.source_name,
299
351
  iteration: attempt,
300
- success: false,
301
- error: decryptError instanceof Error ? `${decryptError.constructor.name}: ${decryptError.message}` : String(decryptError)
352
+ requested_iterations: maxIterations,
353
+ new_items: newItems,
354
+ extracted_links: extracted.length,
355
+ total_links: links.length,
356
+ deduped_links: links.length,
357
+ new_item_fingerprints: newItemFingerprints
302
358
  });
303
- throw decryptError;
304
- }
305
- const extracted = extractLinksFromPlaintext(input.source_name, plaintext);
306
- successes += 1;
307
- failures = 0;
308
- let newItems = 0;
309
- const newLinks = [];
310
- const newItemFingerprints = [];
311
- for (const link of extracted) {
312
- if (seen.has(link)) {
313
- continue;
359
+ if (newItems > 0) {
360
+ await options.linksCallback?.(newLinks);
361
+ }
362
+ plateau = newItems === 0 ? plateau + 1 : 0;
363
+ if (plateau >= plateauLimit && attempt >= minIterations) {
364
+ break;
314
365
  }
315
- seen.add(link);
316
- links.push(link);
317
- newLinks.push(link);
318
- newItems += 1;
319
- newItemFingerprints.push(linkFingerprint(link));
320
- }
321
- emitExtractEvent(options, 'extract_iteration', {
322
- source_name: input.source_name,
323
- iteration: attempt,
324
- requested_iterations: maxIterations,
325
- new_items: newItems,
326
- extracted_links: extracted.length,
327
- total_links: links.length,
328
- deduped_links: links.length,
329
- new_item_fingerprints: newItemFingerprints
330
- });
331
- if (newItems > 0) {
332
- await options.linksCallback?.(newLinks);
333
- }
334
- plateau = newItems === 0 ? plateau + 1 : 0;
335
- if (plateau >= plateauLimit && attempt >= minIterations) {
336
- break;
337
- }
338
- }
339
- catch (error) {
340
- failures += 1;
341
- if (failures >= failureLimit) {
342
- break;
343
366
  }
344
- if (attempt >= maxIterations) {
345
- throw new Error(`Node extract request failed: ${error instanceof Error ? error.message : String(error)}`);
367
+ catch (error) {
368
+ failures += 1;
369
+ if (failures >= failureLimit) {
370
+ break;
371
+ }
372
+ if (attempt >= maxIterations) {
373
+ throw new Error(`Node extract request failed: ${error instanceof Error ? error.message : String(error)}`);
374
+ }
346
375
  }
347
376
  }
348
377
  }
378
+ finally {
379
+ await session.close();
380
+ }
349
381
  emitExtractEvent(options, 'extract_source_completed', {
350
382
  source_name: input.source_name,
351
383
  requested_iterations: maxIterations,
352
384
  successful_iterations: successes,
353
385
  failed_iterations: failures,
354
- raw_links: links.length
386
+ raw_links: links.length,
387
+ egress_transport: session.transport,
388
+ egress_switches: session.switches.length
355
389
  });
356
390
  return {
357
391
  source_name: input.source_name,
@@ -385,7 +419,12 @@ export async function fetchSourceLinksWithBackend(input, options = {}) {
385
419
  successful_iterations: result.successful_iterations,
386
420
  failed_iterations: result.failed_iterations,
387
421
  links: result.links,
388
- errors: result.diagnostics.errors
422
+ errors: result.diagnostics.errors,
423
+ // Without these the orchestrator could only report the requested cap,
424
+ // so an early-stopped 1000-round run still read as 1000/1000.
425
+ rounds: result.diagnostics.rounds,
426
+ rounds_total: result.diagnostics.rounds_total,
427
+ platforms: result.diagnostics.platforms
389
428
  };
390
429
  }
391
430
  return fetchSourceLinksInNode(input, options);