@swimmingliu/autovpn 1.7.0 → 1.8.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,12 @@
1
+ const ISO_ALPHA_2_CODES = new Set(`
2
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
3
+ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR
4
+ GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP
5
+ KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT
6
+ MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW
7
+ SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG
8
+ UM US UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW
9
+ `.trim().split(/\s+/));
10
+ export function isIsoAlpha2CountryCode(value) {
11
+ return typeof value === 'string' && ISO_ALPHA_2_CODES.has(value.trim().toUpperCase());
12
+ }
@@ -0,0 +1,232 @@
1
+ import { isIP } from 'node:net';
2
+ import { lookup as dnsLookup } from 'node:dns/promises';
3
+ import { isIsoAlpha2CountryCode } from './country-codes.js';
4
+ function countryCode(value) {
5
+ const normalized = typeof value === 'string' ? value.trim().toUpperCase() : '';
6
+ return isIsoAlpha2CountryCode(normalized) ? normalized : null;
7
+ }
8
+ function retryAfterMilliseconds(value, now, maximum) {
9
+ if (!value)
10
+ return 0;
11
+ const seconds = Number(value);
12
+ const delay = Number.isFinite(seconds)
13
+ ? seconds * 1000
14
+ : Date.parse(value) - now;
15
+ return Number.isFinite(delay) && delay > 0 ? Math.min(delay, maximum) : 0;
16
+ }
17
+ function ipv4Number(address) {
18
+ const octets = address.split('.').map(Number);
19
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255))
20
+ return null;
21
+ return (((octets[0] * 256 + octets[1]) * 256 + octets[2]) * 256 + octets[3]) >>> 0;
22
+ }
23
+ function ipv4InCidr(value, base, prefix) {
24
+ const baseValue = ipv4Number(base);
25
+ const size = 2 ** (32 - prefix);
26
+ return Math.floor(value / size) === Math.floor(baseValue / size);
27
+ }
28
+ function isRejectedIpv4(value) {
29
+ return [
30
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
31
+ ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24],
32
+ ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
33
+ ['224.0.0.0', 4], ['240.0.0.0', 4]
34
+ ].some(([base, prefix]) => ipv4InCidr(value, base, prefix));
35
+ }
36
+ function ipv6Number(address) {
37
+ const pieces = address.toLowerCase().split('::');
38
+ if (pieces.length > 2)
39
+ return null;
40
+ const parseSide = (side) => {
41
+ if (!side)
42
+ return [];
43
+ const groups = side.split(':');
44
+ const last = groups.at(-1) ?? '';
45
+ if (last.includes('.')) {
46
+ const mapped = ipv4Number(last);
47
+ if (mapped === null)
48
+ return null;
49
+ groups.splice(-1, 1, ((mapped >>> 16) & 0xffff).toString(16), (mapped & 0xffff).toString(16));
50
+ }
51
+ const parsed = groups.map((group) => /^[0-9a-f]{1,4}$/.test(group) ? Number.parseInt(group, 16) : -1);
52
+ return parsed.some((group) => group < 0) ? null : parsed;
53
+ };
54
+ const left = parseSide(pieces[0]);
55
+ const right = parseSide(pieces[1] ?? '');
56
+ if (!left || !right)
57
+ return null;
58
+ const missing = 8 - left.length - right.length;
59
+ if ((pieces.length === 1 && missing !== 0) || (pieces.length === 2 && missing < 1))
60
+ return null;
61
+ const groups = [...left, ...Array(missing).fill(0), ...right];
62
+ return groups.reduce((value, group) => (value << 16n) | BigInt(group), 0n);
63
+ }
64
+ function canonicalIpv6(value) {
65
+ const groups = Array.from({ length: 8 }, (_, index) => Number((value >> BigInt((7 - index) * 16)) & 0xffffn));
66
+ let bestStart = -1;
67
+ let bestLength = 0;
68
+ for (let index = 0; index < groups.length;) {
69
+ if (groups[index] !== 0) {
70
+ index += 1;
71
+ continue;
72
+ }
73
+ let end = index;
74
+ while (end < groups.length && groups[end] === 0)
75
+ end += 1;
76
+ if (end - index > bestLength) {
77
+ bestStart = index;
78
+ bestLength = end - index;
79
+ }
80
+ index = end;
81
+ }
82
+ const hex = groups.map((group) => group.toString(16));
83
+ if (bestLength < 2)
84
+ return hex.join(':');
85
+ const before = hex.slice(0, bestStart).join(':');
86
+ const after = hex.slice(bestStart + bestLength).join(':');
87
+ return `${before}::${after}`;
88
+ }
89
+ function normalizeGlobalAddress(address) {
90
+ if (isIP(address) === 4) {
91
+ const value = ipv4Number(address);
92
+ return value !== null && !isRejectedIpv4(value) ? address : null;
93
+ }
94
+ const value = ipv6Number(address);
95
+ if (value === null)
96
+ return null;
97
+ if ((value >> 32n) === 0xffffn) {
98
+ const mapped = Number(value & 0xffffffffn) >>> 0;
99
+ if (isRejectedIpv4(mapped))
100
+ return null;
101
+ return `${mapped >>> 24}.${(mapped >>> 16) & 255}.${(mapped >>> 8) & 255}.${mapped & 255}`;
102
+ }
103
+ const globalUnicast = value >= 0x20000000000000000000000000000000n && value <= 0x3fffffffffffffffffffffffffffffffn;
104
+ const ietfProtocolAssignments = value >= 0x20010000000000000000000000000000n && value <= 0x200101ffffffffffffffffffffffffffn;
105
+ const documentation = value >= 0x20010db8000000000000000000000000n && value <= 0x20010db8ffffffffffffffffffffffffn;
106
+ const extendedDocumentation = value >= 0x3fff0000000000000000000000000000n && value <= 0x3fff0fffffffffffffffffffffffffffn;
107
+ return globalUnicast && !ietfProtocolAssignments && !documentation && !extendedDocumentation ? canonicalIpv6(value) : null;
108
+ }
109
+ export function createGeoIpLookup(options = {}) {
110
+ const fetchFn = options.fetch ?? globalThis.fetch;
111
+ const resolve = options.resolve ?? ((hostname) => dnsLookup(hostname, { all: true, verbatim: true }));
112
+ const now = options.now ?? Date.now;
113
+ const sleep = options.sleep ?? ((milliseconds) => new Promise((done) => globalThis.setTimeout(done, milliseconds)));
114
+ const setTimer = options.setTimeout ?? ((callback, milliseconds) => globalThis.setTimeout(callback, milliseconds));
115
+ const clearTimer = options.clearTimeout ?? ((handle) => globalThis.clearTimeout(handle));
116
+ const timeoutMs = options.timeoutMs ?? 3000;
117
+ const successTtlMs = options.successTtlMs ?? 24 * 60 * 60 * 1000;
118
+ const negativeTtlMs = options.negativeTtlMs ?? 60 * 1000;
119
+ const maxRetryAfterMs = options.maxRetryAfterMs ?? 2000;
120
+ const providerConcurrency = Math.max(1, Math.floor(options.providerConcurrency ?? 4));
121
+ const primaryUrl = options.primaryUrl ?? ((ip) => `https://ipwho.is/${encodeURIComponent(ip)}`);
122
+ const fallbackUrl = options.fallbackUrl ?? ((ip) => `https://ipapi.co/${encodeURIComponent(ip)}/json/`);
123
+ const cache = new Map();
124
+ const pending = new Map();
125
+ const providerWaiters = [];
126
+ let activeProviderRequests = 0;
127
+ let providerCooldownUntil = 0;
128
+ async function acquireProviderSlot() {
129
+ if (activeProviderRequests >= providerConcurrency) {
130
+ await new Promise((resolve) => providerWaiters.push(resolve));
131
+ }
132
+ else {
133
+ activeProviderRequests += 1;
134
+ }
135
+ return () => {
136
+ const next = providerWaiters.shift();
137
+ if (next)
138
+ next();
139
+ else
140
+ activeProviderRequests -= 1;
141
+ };
142
+ }
143
+ async function request(url, provider) {
144
+ try {
145
+ const parsed = new URL(url);
146
+ const expectedHost = provider === 'primary' ? 'ipwho.is' : 'ipapi.co';
147
+ if (parsed.protocol !== 'https:' || parsed.hostname !== expectedHost || parsed.port || parsed.username || parsed.password) {
148
+ return { country: null, retryAfterMs: 0 };
149
+ }
150
+ }
151
+ catch {
152
+ return { country: null, retryAfterMs: 0 };
153
+ }
154
+ const release = await acquireProviderSlot();
155
+ const cooldownMs = providerCooldownUntil - now();
156
+ if (cooldownMs > 0)
157
+ await sleep(cooldownMs);
158
+ const controller = new AbortController();
159
+ const timer = setTimer(() => controller.abort(), timeoutMs);
160
+ try {
161
+ const response = await fetchFn(url, { signal: controller.signal, headers: { accept: 'application/json' } });
162
+ const retryAfterMs = response.status === 429
163
+ ? retryAfterMilliseconds(response.headers.get('retry-after'), now(), maxRetryAfterMs)
164
+ : 0;
165
+ if (retryAfterMs > 0)
166
+ providerCooldownUntil = Math.max(providerCooldownUntil, now() + retryAfterMs);
167
+ if (!response.ok)
168
+ return { country: null, retryAfterMs };
169
+ const payload = await response.json();
170
+ if (provider === 'primary' && payload.success !== true)
171
+ return { country: null, retryAfterMs: 0 };
172
+ if (provider === 'fallback' && payload.error === true)
173
+ return { country: null, retryAfterMs: 0 };
174
+ return { country: countryCode(payload.country_code), retryAfterMs: 0 };
175
+ }
176
+ catch {
177
+ return { country: null, retryAfterMs: 0 };
178
+ }
179
+ finally {
180
+ clearTimer(timer);
181
+ release();
182
+ }
183
+ }
184
+ async function lookupIp(ip) {
185
+ const cached = cache.get(ip);
186
+ if (cached && cached.expiresAt > now())
187
+ return cached;
188
+ const existing = pending.get(ip);
189
+ if (existing)
190
+ return existing;
191
+ const operation = (async () => {
192
+ const primary = await request(primaryUrl(ip), 'primary');
193
+ if (primary.country)
194
+ return { country: primary.country, detected: true };
195
+ const fallback = await request(fallbackUrl(ip), 'fallback');
196
+ return fallback.country
197
+ ? { country: fallback.country, detected: true }
198
+ : { country: 'US', detected: false };
199
+ })();
200
+ pending.set(ip, operation);
201
+ try {
202
+ const result = await operation;
203
+ cache.set(ip, { ...result, expiresAt: now() + (result.detected ? successTtlMs : negativeTtlMs) });
204
+ return result;
205
+ }
206
+ finally {
207
+ pending.delete(ip);
208
+ }
209
+ }
210
+ return async (rawAddress) => {
211
+ const address = String(rawAddress ?? '').trim();
212
+ if (!address)
213
+ return 'US';
214
+ try {
215
+ const results = isIP(address) ? [{ address, family: isIP(address) }] : await resolve(address);
216
+ const seen = new Set();
217
+ for (const result of results) {
218
+ const globalAddress = normalizeGlobalAddress(result.address);
219
+ if (!globalAddress || seen.has(globalAddress))
220
+ continue;
221
+ seen.add(globalAddress);
222
+ const resultCountry = await lookupIp(globalAddress);
223
+ if (resultCountry.detected)
224
+ return resultCountry.country;
225
+ }
226
+ return 'US';
227
+ }
228
+ catch {
229
+ return 'US';
230
+ }
231
+ };
232
+ }
@@ -7,6 +7,7 @@ import { resolveArtifactsRoot, resolveProfilePath } from '../runtime/paths.js';
7
7
  import { redactText } from '../runtime/redaction.js';
8
8
  import { resolveWorkerTemplatePath } from '../runtime/templates.js';
9
9
  import { fetchSourceLinksWithBackend } from './extract.js';
10
+ import { parseVmessLink } from './dedupe.js';
10
11
  import { normalizeSpeedTestConfig, probeSpeedtestLinksInNode, speedtestLinksWithBackend, testSpeedtestLinkInNode } from './speedtest.js';
11
12
  import { checkLinkAvailabilityBatchWithBackend } from './availability.js';
12
13
  import { decorateLinkWithCountry, postprocessLinksWithBackend } from './postprocess.js';
@@ -16,6 +17,7 @@ import { deployPagesWithBackend, isVerifySuccess, verifyDeploymentWithBackend }
16
17
  import { safeDeployment } from '../runtime/redaction.js';
17
18
  import { RunStore, readLatestStageStatuses } from './run-store.js';
18
19
  import { AsyncPermitPool, BoundedWorkerPool } from './streaming-coordinator.js';
20
+ import { createGeoIpLookup } from './geoip.js';
19
21
  const STAGES = ['doctor', 'extract', 'dedupe', 'speedtest', 'availability', 'postprocess', 'render', 'obfuscate', 'deploy', 'verify'];
20
22
  const RETRYABLE_STAGES = ['speedtest', 'availability', 'postprocess', 'render', 'obfuscate', 'deploy', 'verify'];
21
23
  function formatTimestamp(date) {
@@ -151,8 +153,18 @@ function errorMessage(error) {
151
153
  function defaultRuntimeStageEnv(env) {
152
154
  return { ...env };
153
155
  }
154
- function defaultCountryFor(_link, _speedResult, _availabilityResult) {
155
- return 'US';
156
+ function createCountryLookup(context) {
157
+ if (context.stages?.countryLookup)
158
+ return context.stages.countryLookup;
159
+ const lookup = createGeoIpLookup(context.geoIp);
160
+ return async (link) => {
161
+ try {
162
+ return lookup(String(parseVmessLink(link).add ?? ''));
163
+ }
164
+ catch {
165
+ return 'US';
166
+ }
167
+ };
156
168
  }
157
169
  function normalizeRetryStage(stage) {
158
170
  if (RETRYABLE_STAGES.includes(stage)) {
@@ -222,6 +234,26 @@ function pipelineSummaryFromReport(artifactDir, report) {
222
234
  error: String(report.error ?? '')
223
235
  };
224
236
  }
237
+ export function refreshSourceCounts(summary, store) {
238
+ const rawCounts = store.sourceRawCounts();
239
+ const dedupedCounts = store.sourceDedupedCounts();
240
+ const completeOwnership = store.hasCompleteSourceOwnership();
241
+ const progressSources = store.sourceProgress().map((progress) => progress.source);
242
+ for (const source of new Set([...Object.keys(summary.source_counts), ...progressSources, ...Object.keys(rawCounts), ...Object.keys(dedupedCounts)])) {
243
+ const previous = { ...(summary.source_counts[source] ?? {}) };
244
+ if (!completeOwnership && !Object.hasOwn(dedupedCounts, source))
245
+ delete previous.deduped_links;
246
+ summary.source_counts[source] = {
247
+ ...previous,
248
+ raw_links: rawCounts[source] ?? 0,
249
+ ...(Object.hasOwn(dedupedCounts, source)
250
+ ? { deduped_links: dedupedCounts[source] }
251
+ : completeOwnership
252
+ ? { deduped_links: 0 }
253
+ : {})
254
+ };
255
+ }
256
+ }
225
257
  function passedSpeedResults(allResults, passedLinks) {
226
258
  const byLink = new Map(allResults.map((result) => [result.link, result]));
227
259
  return passedLinks
@@ -523,7 +555,7 @@ export async function runNodePipeline(options, context = {}) {
523
555
  emit('speedtest_runtime', { runtime_core: requestedRuntime === 'direct' ? 'direct' : 'mihomo', probe_url: speedConfig.probe_url, urls: [...speedConfig.urls] });
524
556
  emit('log', { message: `[speedtest] runtime_core=${requestedRuntime === 'direct' ? 'direct' : 'mihomo'} probe_url=${speedConfig.probe_url}` });
525
557
  }
526
- const extractResults = await Promise.all(sourcesToRun.map(async ([sourceName, source]) => {
558
+ const extractSettled = await Promise.allSettled(sourcesToRun.map(async ([sourceName, source]) => {
527
559
  runStore.recordSourceProgress(sourceName, { processed: 0, total: 0, status: 'running' });
528
560
  const streamedLinks = new Set();
529
561
  const stream = useStreamingStages
@@ -569,6 +601,10 @@ export async function runNodePipeline(options, context = {}) {
569
601
  });
570
602
  return result;
571
603
  }));
604
+ const failedExtraction = extractSettled.find((result) => result.status === 'rejected');
605
+ if (failedExtraction)
606
+ throw failedExtraction.reason;
607
+ const extractResults = extractSettled.map((result) => result.value);
572
608
  if (!useStreamingStages) {
573
609
  rawLinks = extractResults.flatMap((result) => result.links);
574
610
  for (const result of extractResults) {
@@ -595,6 +631,7 @@ export async function runNodePipeline(options, context = {}) {
595
631
  await setStage('dedupe', 'skipped', false);
596
632
  dedupedLinks = runStore.dedupedLinks();
597
633
  summary.counts.deduped_links = runStore.counts().deduped;
634
+ refreshSourceCounts(summary, runStore);
598
635
  await writeLines(artifactDir, 'vpn_node_deduped.txt', dedupedLinks);
599
636
  if (summary.stage_status.dedupe !== 'skipped')
600
637
  await setStage('dedupe', 'success', !useStreamingStages);
@@ -689,12 +726,12 @@ export async function runNodePipeline(options, context = {}) {
689
726
  if (summary.stage_status.availability !== 'skipped')
690
727
  await setStage('availability', 'success', !useStreamingStages);
691
728
  await setStage('postprocess', 'running');
692
- const countryLookup = context.stages?.countryLookup ?? defaultCountryFor;
729
+ const countryLookup = createCountryLookup(context);
693
730
  const availabilityByLink = new Map(availabilityResults.map((result) => [result.link, result]));
694
- const rankedLinks = availableLinks.map((link) => ({
731
+ const rankedLinks = await Promise.all(availableLinks.map(async (link) => ({
695
732
  link,
696
- country_code: countryLookup(link, speedResultByLink.get(link), availabilityByLink.get(link))
697
- }));
733
+ country_code: await countryLookup(link, speedResultByLink.get(link), availabilityByLink.get(link))
734
+ })));
698
735
  const postprocessed = context.stages?.countryLookup
699
736
  ? { links: rankedLinks.map((item) => decorateLinkWithCountry(item.link, item.country_code)) }
700
737
  : await postprocessLinksWithBackend({ ranked_links: rankedLinks, filters: profile.filters }, { cwd: projectRoot, env });
@@ -753,6 +790,10 @@ export async function runNodePipeline(options, context = {}) {
753
790
  activeSpeedPool?.abort(error);
754
791
  activeAvailabilityPool?.abort(error);
755
792
  await Promise.allSettled([activeSpeedPool?.drain(), activeAvailabilityPool?.drain()].filter((task) => Boolean(task)));
793
+ const failedCounts = runStore.counts();
794
+ summary.counts.raw_links = failedCounts.raw;
795
+ summary.counts.deduped_links = failedCounts.deduped;
796
+ refreshSourceCounts(summary, runStore);
756
797
  summary.run_status = 'failed';
757
798
  summary.error = errorMessage(error);
758
799
  runStore.setRunStatus('failed', summary.error);
@@ -799,6 +840,7 @@ export async function retryNodePipelineStage(options, context = {}) {
799
840
  };
800
841
  const summary = await seedRetryArtifact(sourceArtifactDir, retryArtifactDir, stage, retryContext, String(profile.worker_build?.bundle_subdir ?? 'pages_bundle'));
801
842
  const retryStore = RunStore.seedRetry(sourceArtifactDir, retryArtifactDir, stage);
843
+ refreshSourceCounts(summary, retryStore);
802
844
  try {
803
845
  const emit = (type, payload = {}) => {
804
846
  const event = { type, ...payload };
@@ -910,11 +952,11 @@ export async function retryNodePipelineStage(options, context = {}) {
910
952
  }
911
953
  await setStage('postprocess', 'running');
912
954
  const speedResultByLink = new Map(speedResults.map((result) => [result.link, result]));
913
- const countryLookup = context.stages?.countryLookup ?? defaultCountryFor;
914
- const rankedLinks = availabilityResults.map((availabilityResult) => ({
955
+ const countryLookup = createCountryLookup(context);
956
+ const rankedLinks = await Promise.all(availabilityResults.map(async (availabilityResult) => ({
915
957
  link: availabilityResult.link,
916
- country_code: countryLookup(availabilityResult.link, speedResultByLink.get(availabilityResult.link) ?? availabilityResult, availabilityResult)
917
- }));
958
+ country_code: await countryLookup(availabilityResult.link, speedResultByLink.get(availabilityResult.link) ?? availabilityResult, availabilityResult)
959
+ })));
918
960
  const postprocessed = context.stages?.countryLookup
919
961
  ? { links: rankedLinks.map((item) => decorateLinkWithCountry(item.link, item.country_code)) }
920
962
  : await postprocessLinksWithBackend({ ranked_links: rankedLinks, filters: profile.filters }, { cwd: projectRoot, env });
@@ -1044,6 +1086,7 @@ async function resumeNodeSpeedtest(options, context = {}) {
1044
1086
  try {
1045
1087
  runStore.reopenForResume();
1046
1088
  runStore.resetInterruptedRunning();
1089
+ refreshSourceCounts(summary, runStore);
1047
1090
  const emit = (type, payload = {}) => {
1048
1091
  const event = { type, ...payload };
1049
1092
  appendTextFile(eventLog, eventLogLine(event));
@@ -1244,6 +1287,7 @@ export async function resumeNodePipeline(options, context = {}) {
1244
1287
  const storeCounts = runStore.counts();
1245
1288
  summary.counts.raw_links = storeCounts.raw;
1246
1289
  summary.counts.deduped_links = storeCounts.deduped;
1290
+ refreshSourceCounts(summary, runStore);
1247
1291
  summary.counts.speedtest_links = speedResults.length;
1248
1292
  try {
1249
1293
  runStore.resetInterruptedRunning();
@@ -1357,6 +1401,7 @@ export async function resumeNodePipeline(options, context = {}) {
1357
1401
  }
1358
1402
  summary.counts.raw_links = runStore.counts().raw;
1359
1403
  summary.counts.deduped_links = runStore.counts().deduped;
1404
+ refreshSourceCounts(summary, runStore);
1360
1405
  await writeLines(artifactDir, 'vpn_node_raw.txt', runStore.rawLinks());
1361
1406
  await writeLines(artifactDir, 'vpn_node_deduped.txt', runStore.dedupedLinks());
1362
1407
  const storedPassedSpeedResults = runStore.speedResults().filter((result) => result.status === 'speed_passed');
@@ -1398,11 +1443,11 @@ export async function resumeNodePipeline(options, context = {}) {
1398
1443
  await setStage('postprocess', 'running');
1399
1444
  const speedResultByLink = new Map(speedResults.map((result) => [result.link, result]));
1400
1445
  const availabilityByLink = new Map(availabilityResults.map((result) => [result.link, result]));
1401
- const countryLookup = context.stages?.countryLookup ?? defaultCountryFor;
1402
- const rankedLinks = availableLinks.map((link) => ({
1446
+ const countryLookup = createCountryLookup(context);
1447
+ const rankedLinks = await Promise.all(availableLinks.map(async (link) => ({
1403
1448
  link,
1404
- country_code: countryLookup(link, speedResultByLink.get(link), availabilityByLink.get(link))
1405
- }));
1449
+ country_code: await countryLookup(link, speedResultByLink.get(link), availabilityByLink.get(link))
1450
+ })));
1406
1451
  const postprocessed = context.stages?.countryLookup
1407
1452
  ? { links: rankedLinks.map((item) => decorateLinkWithCountry(item.link, item.country_code)) }
1408
1453
  : await postprocessLinksWithBackend({ ranked_links: rankedLinks, filters: profile.filters }, { cwd: projectRoot, env });
@@ -1,3 +1,4 @@
1
+ import { isIsoAlpha2CountryCode } from './country-codes.js';
1
2
  const EMOJI_MAP = {
2
3
  AE: '🇦🇪',
3
4
  AR: '🇦🇷',
@@ -37,7 +38,6 @@ const EMOJI_MAP = {
37
38
  ZA: '🇿🇦'
38
39
  };
39
40
  const FALLBACK_COUNTRY_CODE = 'US';
40
- const UNKNOWN_COUNTRY_CODE = 'ZZ';
41
41
  const DEFAULT_FILTERS = {
42
42
  excluded_country_codes: ['CN'],
43
43
  per_country_limit: {}
@@ -55,7 +55,7 @@ export function generateVmessLink(payload) {
55
55
  }
56
56
  export function normalizeCountryCode(countryCode) {
57
57
  const normalized = String(countryCode || '').trim().toUpperCase();
58
- if (normalized.length !== 2 || !/^[A-Z]{2}$/.test(normalized) || normalized === UNKNOWN_COUNTRY_CODE) {
58
+ if (!isIsoAlpha2CountryCode(normalized)) {
59
59
  return FALLBACK_COUNTRY_CODE;
60
60
  }
61
61
  return normalized;
@@ -119,6 +119,7 @@ export class RunStore {
119
119
  canonical_key TEXT NOT NULL,
120
120
  link TEXT NOT NULL,
121
121
  sequence INTEGER NOT NULL,
122
+ first_source TEXT,
122
123
  UNIQUE (run_id, canonical_key),
123
124
  UNIQUE (run_id, sequence)
124
125
  );
@@ -198,12 +199,12 @@ export class RunStore {
198
199
  const inserted = destination.statement('INSERT INTO runs(status) VALUES (?)').run('running');
199
200
  destination.runId = Number(inserted.lastInsertRowid);
200
201
  const runId = destination.currentRunId();
201
- for (const link of source.rawLinks()) {
202
- destination.statement('INSERT INTO raw_observations(run_id, source, link) VALUES (?, ?, ?)').run(runId, 'retry-seed', link);
202
+ for (const observation of source.rawObservations()) {
203
+ destination.statement('INSERT INTO raw_observations(run_id, source, link) VALUES (?, ?, ?)').run(runId, observation.source, observation.link);
203
204
  }
204
- source.dedupedLinks().forEach((link, index) => {
205
- destination.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence) VALUES (?, ?, ?, ?)')
206
- .run(runId, canonicalVmessKey(parseVmessLink(link)), link, index + 1);
205
+ source.dedupedNodeOwnership().forEach(({ link, first_source }, index) => {
206
+ destination.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence, first_source) VALUES (?, ?, ?, ?, ?)')
207
+ .run(runId, canonicalVmessKey(parseVmessLink(link)), link, index + 1, first_source);
207
208
  });
208
209
  const preserveSpeed = boundary !== 'speedtest';
209
210
  const preserveAvailability = !['speedtest', 'availability'].includes(boundary);
@@ -368,8 +369,8 @@ export class RunStore {
368
369
  return { inserted: false, sequence: existing.sequence };
369
370
  const row = this.statement('SELECT COALESCE(MAX(sequence), 0) + 1 AS sequence FROM pipeline_nodes WHERE run_id = ?')
370
371
  .get(runId);
371
- this.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence) VALUES (?, ?, ?, ?)')
372
- .run(runId, key, link, row.sequence);
372
+ this.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence, first_source) VALUES (?, ?, ?, ?, ?)')
373
+ .run(runId, key, link, row.sequence, source);
373
374
  return { inserted: true, sequence: row.sequence };
374
375
  });
375
376
  }
@@ -383,6 +384,24 @@ export class RunStore {
383
384
  dedupedLinks() {
384
385
  return this.statement('SELECT link FROM pipeline_nodes WHERE run_id = ? ORDER BY sequence').all(this.currentRunId()).map((row) => row.link);
385
386
  }
387
+ sourceDedupedCounts() {
388
+ const rows = this.statement(`SELECT first_source AS source, COUNT(*) AS count FROM pipeline_nodes
389
+ WHERE run_id = ? AND first_source IS NOT NULL GROUP BY first_source ORDER BY MIN(sequence)`)
390
+ .all(this.currentRunId());
391
+ return Object.fromEntries(rows.map((row) => [row.source, Number(row.count)]));
392
+ }
393
+ hasCompleteSourceOwnership() {
394
+ const row = this.statement(`SELECT NOT EXISTS(
395
+ SELECT 1 FROM pipeline_nodes WHERE run_id = ? AND first_source IS NULL
396
+ ) AS complete`).get(this.currentRunId());
397
+ return Boolean(row.complete);
398
+ }
399
+ sourceRawCounts() {
400
+ const rows = this.statement(`SELECT source, COUNT(*) AS count FROM raw_observations
401
+ WHERE run_id = ? GROUP BY source ORDER BY MIN(observation_id)`)
402
+ .all(this.currentRunId());
403
+ return Object.fromEntries(rows.map((row) => [row.source, Number(row.count)]));
404
+ }
386
405
  markSpeedRunning(link) {
387
406
  const { runId, key } = this.nodeIdentity(link);
388
407
  this.statement(`INSERT INTO speed_results(run_id, canonical_key, status) VALUES (?, ?, 'running')
@@ -483,8 +502,9 @@ export class RunStore {
483
502
  const speedRows = readLegacyReport(path.join(artifactDir, 'vpn_node_speedtest_report.json'));
484
503
  const availabilityRows = readLegacyReport(path.join(artifactDir, 'vpn_node_availability_report.json'));
485
504
  this.transaction(() => {
505
+ const rawCanonicalKeys = new Set();
486
506
  for (const link of rawLinks) {
487
- parseVmessLink(link);
507
+ rawCanonicalKeys.add(canonicalVmessKey(parseVmessLink(link)));
488
508
  this.statement('INSERT INTO raw_observations(run_id, source, link) VALUES (?, ?, ?)').run(runId, 'legacy', link);
489
509
  }
490
510
  const nodeLinks = dedupedLinks.length > 0 ? dedupedLinks : rawLinks;
@@ -494,8 +514,8 @@ export class RunStore {
494
514
  if (seen.has(key))
495
515
  continue;
496
516
  seen.add(key);
497
- this.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence) VALUES (?, ?, ?, ?)')
498
- .run(runId, key, link, seen.size);
517
+ this.statement('INSERT INTO pipeline_nodes(run_id, canonical_key, link, sequence, first_source) VALUES (?, ?, ?, ?, ?)')
518
+ .run(runId, key, link, seen.size, rawCanonicalKeys.has(key) ? 'legacy' : null);
499
519
  }
500
520
  for (const row of speedRows) {
501
521
  const link = String(row.link ?? '');
@@ -528,6 +548,14 @@ export class RunStore {
528
548
  throw new Error('unknown pipeline node');
529
549
  return { runId, key };
530
550
  }
551
+ rawObservations() {
552
+ return this.statement('SELECT source, link FROM raw_observations WHERE run_id = ? ORDER BY observation_id')
553
+ .all(this.currentRunId());
554
+ }
555
+ dedupedNodeOwnership() {
556
+ return this.statement('SELECT link, first_source FROM pipeline_nodes WHERE run_id = ? ORDER BY sequence')
557
+ .all(this.currentRunId());
558
+ }
531
559
  migrateSchema() {
532
560
  const columns = (table) => new Set(this.db.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
533
561
  this.db.exec('PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;');
@@ -535,6 +563,30 @@ export class RunStore {
535
563
  try {
536
564
  if (!columns('runs').has('error'))
537
565
  this.db.exec("ALTER TABLE runs ADD COLUMN error TEXT NOT NULL DEFAULT ''");
566
+ if (!columns('pipeline_nodes').has('first_source')) {
567
+ this.db.exec('ALTER TABLE pipeline_nodes ADD COLUMN first_source TEXT');
568
+ const earliestSources = new Map();
569
+ const observations = this.db.prepare('SELECT run_id, source, link FROM raw_observations ORDER BY run_id, observation_id')
570
+ .all();
571
+ for (const observation of observations) {
572
+ try {
573
+ const identity = `${observation.run_id}\0${canonicalVmessKey(parseVmessLink(observation.link))}`;
574
+ if (!earliestSources.has(identity))
575
+ earliestSources.set(identity, observation.source);
576
+ }
577
+ catch {
578
+ // Historical malformed observations cannot establish canonical ownership.
579
+ }
580
+ }
581
+ const nodes = this.db.prepare('SELECT node_id, run_id, canonical_key FROM pipeline_nodes ORDER BY run_id, sequence')
582
+ .all();
583
+ const update = this.db.prepare('UPDATE pipeline_nodes SET first_source = ? WHERE node_id = ? AND first_source IS NULL');
584
+ for (const node of nodes) {
585
+ const source = earliestSources.get(`${node.run_id}\0${node.canonical_key}`);
586
+ if (source !== undefined)
587
+ update.run(source, node.node_id);
588
+ }
589
+ }
538
590
  const stageColumns = columns('stage_events');
539
591
  if (!stageColumns.has('run_id')) {
540
592
  this.db.exec('ALTER TABLE stage_events ADD COLUMN run_id INTEGER');
@@ -565,7 +617,7 @@ export class RunStore {
565
617
  DROP TABLE runs_v1;
566
618
  `);
567
619
  }
568
- this.db.exec('PRAGMA user_version=2; COMMIT');
620
+ this.db.exec('PRAGMA user_version=3; COMMIT');
569
621
  this.db.exec('PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;');
570
622
  }
571
623
  catch (error) {