@swimmingliu/autovpn 1.8.0 → 2.0.2

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.
@@ -42,7 +42,19 @@ function generateVmessLink(payload) {
42
42
  const encoded = Buffer.from(json, 'utf8').toString('base64').replaceAll('+', '-').replaceAll('/', '_');
43
43
  return `vmess://${encoded}`;
44
44
  }
45
- function linkFingerprint(link) {
45
+ /**
46
+ * Exact-string identity for a link.
47
+ *
48
+ * `linkFingerprint` collapses links that describe the same node, which is what
49
+ * the dedupe card counts. Raw link totals have to use the same `seen` identity
50
+ * the pipeline itself uses, or two platforms that label one node differently
51
+ * would make the live raw count disagree with the final result. A digest keeps
52
+ * the link out of the event stream.
53
+ */
54
+ export function linkDigest(link) {
55
+ return crypto.createHash('sha256').update(String(link)).digest('hex');
56
+ }
57
+ export function linkFingerprint(link) {
46
58
  try {
47
59
  const encoded = String(link).replace(/^vmess:\/\//, '');
48
60
  const padded = encoded + '='.repeat((4 - (encoded.length % 4)) % 4);
@@ -350,5 +362,46 @@ async function fetchSourceLinksInNode(input, options) {
350
362
  };
351
363
  }
352
364
  export async function fetchSourceLinksWithBackend(input, options = {}) {
353
- return options.fetchSourceLinks ? options.fetchSourceLinks(input) : fetchSourceLinksInNode(input, options);
365
+ if (options.fetchSourceLinks) {
366
+ return options.fetchSourceLinks(input);
367
+ }
368
+ if (isCredentialSource(input.source)) {
369
+ const { fetchCredentialSourceLinks } = await import('./credential-source.js');
370
+ const result = await fetchCredentialSourceLinks(input, {
371
+ eventCallback: options.eventCallback,
372
+ linksCallback: options.linksCallback,
373
+ fingerprintStore: options.fingerprintStore,
374
+ memo: options.fingerprintMemo,
375
+ // The credential flow talks to the same hosts as the url flow, so it
376
+ // needs the same egress handling: an injected fetch for tests, the
377
+ // configured upstream proxy, and the curl TLS fallback.
378
+ fetchImpl: options.fetch,
379
+ env: options.env,
380
+ curlFetch: options.curlFetch
381
+ });
382
+ return {
383
+ source_name: result.source_name,
384
+ requested_iterations: result.requested_iterations,
385
+ successful_iterations: result.successful_iterations,
386
+ failed_iterations: result.failed_iterations,
387
+ links: result.links,
388
+ errors: result.diagnostics.errors
389
+ };
390
+ }
391
+ return fetchSourceLinksInNode(input, options);
392
+ }
393
+ /**
394
+ * A credential source is identified by its mode or by carrying an account
395
+ * instead of a request URL; this keeps older profiles working unchanged.
396
+ */
397
+ export function isCredentialSource(source) {
398
+ if (source.mode === 'credential') {
399
+ return true;
400
+ }
401
+ if (source.mode === 'url') {
402
+ return false;
403
+ }
404
+ const hasAccount = Boolean(String(source.account ?? '').trim());
405
+ const hasUrl = Boolean(String(source.url ?? '').trim());
406
+ return hasAccount && !hasUrl;
354
407
  }
@@ -2,8 +2,9 @@ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { parse } from '@iarna/toml';
5
+ import { normalizeOfficialSources } from '../config/profile.js';
5
6
  import { mergeProjectEnv } from '../runtime/env.js';
6
- import { resolveArtifactsRoot, resolveProfilePath } from '../runtime/paths.js';
7
+ import { resolveArtifactsRoot, resolveProfilePath, resolveUserRuntimeRoot } from '../runtime/paths.js';
7
8
  import { redactText } from '../runtime/redaction.js';
8
9
  import { resolveWorkerTemplatePath } from '../runtime/templates.js';
9
10
  import { fetchSourceLinksWithBackend } from './extract.js';
@@ -18,6 +19,8 @@ import { safeDeployment } from '../runtime/redaction.js';
18
19
  import { RunStore, readLatestStageStatuses } from './run-store.js';
19
20
  import { AsyncPermitPool, BoundedWorkerPool } from './streaming-coordinator.js';
20
21
  import { createGeoIpLookup } from './geoip.js';
22
+ import { FingerprintStore, resolveFingerprintCacheDir } from './brand-cache.js';
23
+ import { createFingerprintMemo, credentialBrandKey } from './credential-source.js';
21
24
  const STAGES = ['doctor', 'extract', 'dedupe', 'speedtest', 'availability', 'postprocess', 'render', 'obfuscate', 'deploy', 'verify'];
22
25
  const RETRYABLE_STAGES = ['speedtest', 'availability', 'postprocess', 'render', 'obfuscate', 'deploy', 'verify'];
23
26
  function formatTimestamp(date) {
@@ -45,12 +48,23 @@ function asProfile(payload) {
45
48
  async function readProfile(projectRoot, env) {
46
49
  const profilePath = resolveProfilePath(projectRoot, env);
47
50
  const text = await readFile(profilePath, 'utf8');
48
- return asProfile(parse(text));
51
+ const profile = asProfile(parse(text));
52
+ return { ...profile, sources: normalizeOfficialSources(profile.sources ?? {}) };
49
53
  }
50
54
  function enabledSources(profile) {
51
- return Object.entries(profile.sources ?? {}).filter(([, source]) => (source.enabled !== false
52
- && String(source.url ?? '').trim()
53
- && String(source.key ?? '').trim()));
55
+ return Object.entries(profile.sources ?? {}).filter(([, source]) => {
56
+ if (source.enabled === false) {
57
+ return false;
58
+ }
59
+ // Credential sources need only a location. The account is leased from the
60
+ // pool at extract time; an empty pool is a reported source failure, not a
61
+ // silently disabled source.
62
+ if (source.mode === 'credential') {
63
+ return [source.official_site, source.apk_url, source.apk_path]
64
+ .some((value) => String(value ?? '').trim().length > 0);
65
+ }
66
+ return Boolean(String(source.url ?? '').trim()) && Boolean(String(source.key ?? '').trim());
67
+ });
54
68
  }
55
69
  function linesText(lines) {
56
70
  return lines.length > 0 ? `${lines.join('\n')}\n` : '';
@@ -136,6 +150,9 @@ function renderHumanEvent(event) {
136
150
  if (event.type === 'run_failed') {
137
151
  return `[run_failed] ${String(event.error ?? 'unknown error')}`;
138
152
  }
153
+ if (event.type === 'extract_platform_started' || event.type === 'extract_platform_completed') {
154
+ return `[${event.type}] ${String(event.source_name ?? '')} platform=${String(event.platform ?? '')}`;
155
+ }
139
156
  return JSON.stringify(event);
140
157
  }
141
158
  function eventLogLine(event) {
@@ -443,6 +460,24 @@ export async function runNodePipeline(options, context = {}) {
443
460
  await setStage('doctor', 'success');
444
461
  const profile = await readProfile(projectRoot, env);
445
462
  const sourcesToRun = enabledSources(profile);
463
+ // Brand fingerprints are shared by every source of the same brand so one
464
+ // run downloads and inspects each APK at most once.
465
+ const fingerprintStore = new FingerprintStore({
466
+ cacheDir: resolveFingerprintCacheDir(resolveUserRuntimeRoot(env)),
467
+ now: context.now ?? (() => new Date())
468
+ });
469
+ // Keyed by brand, not by profile entry name: two sources of one brand must
470
+ // share both the in-run memo and the on-disk fingerprint.
471
+ const brandKeyFor = (sourceName, source) => (source.mode === 'credential' || String(source.account ?? '').trim() ? credentialBrandKey(sourceName, source) : sourceName);
472
+ const fingerprintMemos = new Map();
473
+ const fingerprintMemoFor = (brand) => {
474
+ let memo = fingerprintMemos.get(brand);
475
+ if (!memo) {
476
+ memo = createFingerprintMemo(fingerprintStore, brand);
477
+ fingerprintMemos.set(brand, memo);
478
+ }
479
+ return memo;
480
+ };
446
481
  const runtimePath = path.join(artifactDir, 'runtime');
447
482
  const speedConfig = normalizeSpeedTestConfig(profile.speed_test);
448
483
  // Preserve cross-stage streaming even for concurrency=1 while preventing
@@ -576,7 +611,9 @@ export async function runNodePipeline(options, context = {}) {
576
611
  cwd: projectRoot,
577
612
  env: runtimeStageEnv,
578
613
  eventCallback: (type, payload) => emit(type, payload),
579
- linksCallback: stream?.onLinks
614
+ linksCallback: stream?.onLinks,
615
+ fingerprintStore,
616
+ fingerprintMemo: fingerprintMemoFor(brandKeyFor(sourceName, source))
580
617
  });
581
618
  }
582
619
  catch (error) {
@@ -594,10 +631,20 @@ export async function runNodePipeline(options, context = {}) {
594
631
  successful_iterations: result.successful_iterations,
595
632
  failed_iterations: result.failed_iterations
596
633
  };
634
+ // A credential source reports its own failures (rejected login, APK not
635
+ // located) as zero links rather than an exception. Recording that as
636
+ // `success` hides the reason, so the source is marked failed and the
637
+ // text is emitted where an operator can read it.
638
+ const sourceErrors = (result.errors ?? []).filter((line) => String(line ?? '').trim());
639
+ const sourceFailed = result.links.length === 0 && sourceErrors.length > 0;
640
+ if (sourceFailed) {
641
+ emit('log', { message: `[extract] ${result.source_name} produced no links: ${sourceErrors.join('; ')}` });
642
+ }
597
643
  runStore.recordSourceProgress(sourceName, {
598
644
  processed: result.requested_iterations,
599
645
  total: result.requested_iterations,
600
- status: 'success'
646
+ status: sourceFailed ? 'failed' : 'success',
647
+ ...(sourceFailed ? { error: sourceErrors.join('; ') } : {})
601
648
  });
602
649
  return result;
603
650
  }));
@@ -1261,6 +1308,22 @@ export async function resumeNodePipeline(options, context = {}) {
1261
1308
  const report = await readJson(path.join(artifactDir, 'pipeline_report.json'), {});
1262
1309
  const summary = pipelineSummaryFromReport(artifactDir, report);
1263
1310
  const runtimePath = path.join(artifactDir, 'runtime');
1311
+ // A resumed run must reuse the fingerprint cache too: re-extracting a
1312
+ // credential source would otherwise re-download and re-parse the full APK.
1313
+ const resumeFingerprintStore = new FingerprintStore({
1314
+ cacheDir: resolveFingerprintCacheDir(resolveUserRuntimeRoot(env)),
1315
+ now: context.now ?? (() => new Date())
1316
+ });
1317
+ const resumeMemos = new Map();
1318
+ const resumeMemoFor = (brand) => {
1319
+ let memo = resumeMemos.get(brand);
1320
+ if (!memo) {
1321
+ memo = createFingerprintMemo(resumeFingerprintStore, brand);
1322
+ resumeMemos.set(brand, memo);
1323
+ }
1324
+ return memo;
1325
+ };
1326
+ const resumeBrandKeyFor = (sourceName, source) => (source.mode === 'credential' || String(source.account ?? '').trim() ? credentialBrandKey(sourceName, source) : sourceName);
1264
1327
  const emit = (type, payload = {}) => {
1265
1328
  const event = { type, ...payload };
1266
1329
  appendTextFile(eventLog, eventLogLine(event));
@@ -1375,9 +1438,25 @@ export async function resumeNodePipeline(options, context = {}) {
1375
1438
  try {
1376
1439
  const result = context.stages?.extract
1377
1440
  ? await context.stages.extract({ source_name: sourceName, source }, { onLinks })
1378
- : await fetchSourceLinksWithBackend({ source_name: sourceName, source }, { cwd: projectRoot, env: runtimeStageEnv, linksCallback: onLinks });
1441
+ : await fetchSourceLinksWithBackend({ source_name: sourceName, source }, {
1442
+ cwd: projectRoot,
1443
+ env: runtimeStageEnv,
1444
+ linksCallback: onLinks,
1445
+ fingerprintStore: resumeFingerprintStore,
1446
+ fingerprintMemo: resumeMemoFor(resumeBrandKeyFor(sourceName, source))
1447
+ });
1379
1448
  await onLinks(result.links.filter((link) => !streamed.has(link)));
1380
- runStore.recordSourceProgress(sourceName, { processed: result.requested_iterations, total: result.requested_iterations, status: 'success' });
1449
+ const resumeErrors = (result.errors ?? []).filter((line) => String(line ?? '').trim());
1450
+ const resumeFailed = result.links.length === 0 && resumeErrors.length > 0;
1451
+ if (resumeFailed) {
1452
+ emit('log', { message: `[extract] ${result.source_name} produced no links: ${resumeErrors.join('; ')}` });
1453
+ }
1454
+ runStore.recordSourceProgress(sourceName, {
1455
+ processed: result.requested_iterations,
1456
+ total: result.requested_iterations,
1457
+ status: resumeFailed ? 'failed' : 'success',
1458
+ ...(resumeFailed ? { error: resumeErrors.join('; ') } : {})
1459
+ });
1381
1460
  summary.source_counts[sourceName] = { raw_links: result.links.length, successful_iterations: result.successful_iterations, failed_iterations: result.failed_iterations };
1382
1461
  }
1383
1462
  catch (error) {