@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.
- package/dist/pipeline/country-codes.js +12 -0
- package/dist/pipeline/geoip.js +232 -0
- package/dist/pipeline/orchestrator.js +60 -15
- package/dist/pipeline/postprocess.js +2 -2
- package/dist/pipeline/run-store.js +63 -11
- package/dist/web/renderer/app.js +230 -46
- package/dist/web/renderer/assets/fonts/AutoVPNVisualTest-NotoSansSC.woff2 +0 -0
- package/dist/web/renderer/assets/fonts/OFL.txt +92 -0
- package/dist/web/renderer/assets/fonts/README.md +5 -0
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/index.html +1 -1
- package/dist/web/renderer/state.js +8 -0
- package/dist/web/renderer/styles.css +410 -19
- package/dist/web/renderer/views.js +96 -51
- package/package.json +1 -1
|
@@ -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
|
|
155
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
202
|
-
destination.statement('INSERT INTO raw_observations(run_id, source, link) VALUES (?, ?, ?)').run(runId,
|
|
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.
|
|
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=
|
|
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) {
|