@swimmingliu/autovpn 2.1.0 → 2.1.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.
- package/dist/artifacts/preview.js +61 -14
- package/dist/config/profile.js +3 -4
- package/dist/pipeline/credential-source.js +3 -3
- package/dist/pipeline/egress.js +7 -1
- package/dist/server/runtime.js +5 -2
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/views.js +5 -4
- package/package.json +1 -1
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { isIsoAlpha2CountryCode } from '../pipeline/country-codes.js';
|
|
3
4
|
import { redactText, safeDeployment } from '../runtime/redaction.js';
|
|
4
5
|
const FINAL_NODE_FILES = ['vpn_node_emoji.txt', 'vpn_node_availability.txt', 'vpn_node_speedtest.txt'];
|
|
6
|
+
// Postprocess decorates every node as `<flag> <CC> <vendor name>`. The country
|
|
7
|
+
// code is only trusted in that exact position; a bare two-letter token inside a
|
|
8
|
+
// vendor name is not a region.
|
|
9
|
+
const LEADING_FLAG_COUNTRY_PATTERN = /^[\u{1F1E6}-\u{1F1FF}]{2}\s+([A-Z]{2})(?:\s|$)/u;
|
|
5
10
|
function formatBytes(size) {
|
|
6
11
|
if (size >= 1024 * 1024) {
|
|
7
12
|
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
|
@@ -29,46 +34,87 @@ function fileInventory(artifactDir) {
|
|
|
29
34
|
.map((item) => ({ name: path.basename(item), size: formatBytes(fs.statSync(item).size) }))
|
|
30
35
|
.sort((left, right) => String(left.name).localeCompare(String(right.name)));
|
|
31
36
|
}
|
|
32
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Decode one final-output line into the row the UI renders.
|
|
39
|
+
*
|
|
40
|
+
* Country comes from the decorated name, never from the address: the pipeline
|
|
41
|
+
* writes `<flag> <CC> <vendor name>`, so the prefix is authoritative and a name
|
|
42
|
+
* without one is unknown rather than a guess.
|
|
43
|
+
*
|
|
44
|
+
* A line that is not a decodable vmess link returns null, matching the desktop
|
|
45
|
+
* preview (`electron/lib/artifact-preview.js`): counting it would report a
|
|
46
|
+
* "node" the results table cannot show.
|
|
47
|
+
*/
|
|
48
|
+
export function parseFinalNode(link) {
|
|
33
49
|
const value = link.trim();
|
|
34
50
|
if (!value.startsWith('vmess://')) {
|
|
35
|
-
return
|
|
51
|
+
return null;
|
|
36
52
|
}
|
|
37
53
|
let encoded = value.slice('vmess://'.length);
|
|
38
54
|
encoded += '='.repeat((4 - (encoded.length % 4)) % 4);
|
|
39
55
|
try {
|
|
40
56
|
const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
41
|
-
const
|
|
42
|
-
|
|
57
|
+
const name = String(payload.ps ?? '');
|
|
58
|
+
const match = name.trim().toUpperCase().match(LEADING_FLAG_COUNTRY_PATTERN);
|
|
59
|
+
const regionCode = match && isIsoAlpha2CountryCode(match[1]) ? match[1] : 'OTHER';
|
|
60
|
+
return {
|
|
61
|
+
name,
|
|
62
|
+
address: String(payload.add ?? ''),
|
|
63
|
+
protocol: 'vmess',
|
|
64
|
+
path: String(payload.path ?? ''),
|
|
65
|
+
link: value,
|
|
66
|
+
regionCode
|
|
67
|
+
};
|
|
43
68
|
}
|
|
44
69
|
catch {
|
|
45
|
-
return
|
|
70
|
+
return null;
|
|
46
71
|
}
|
|
47
72
|
}
|
|
48
|
-
function
|
|
73
|
+
function readFinalNodes(artifactDir) {
|
|
49
74
|
const nodeSource = FINAL_NODE_FILES.find((name) => fs.existsSync(path.join(artifactDir, name))) ?? '';
|
|
50
75
|
if (!nodeSource) {
|
|
51
|
-
return {
|
|
76
|
+
return { nodeSource, rows: [] };
|
|
52
77
|
}
|
|
53
78
|
const lines = fs.readFileSync(path.join(artifactDir, nodeSource), 'utf8').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
79
|
+
const rows = lines.map((line) => parseFinalNode(line)).filter((row) => row !== null);
|
|
80
|
+
return { nodeSource, rows };
|
|
81
|
+
}
|
|
82
|
+
function regionCounts(rows) {
|
|
54
83
|
const counts = new Map();
|
|
55
|
-
for (const
|
|
56
|
-
|
|
57
|
-
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
// The UI folds an undecorated name into US when it charts (buildRegionStats),
|
|
86
|
+
// so the summary must report the same number or the two disagree.
|
|
87
|
+
const regionCode = row.regionCode === 'OTHER' ? 'US' : row.regionCode;
|
|
88
|
+
counts.set(regionCode, (counts.get(regionCode) ?? 0) + 1);
|
|
58
89
|
}
|
|
90
|
+
return [...counts.entries()]
|
|
91
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
92
|
+
.map(([region_code, count]) => ({ region_code, count }));
|
|
93
|
+
}
|
|
94
|
+
function safeNodeCounts(rows, nodeSource) {
|
|
59
95
|
return {
|
|
60
96
|
node_source: nodeSource,
|
|
61
|
-
final_node_count:
|
|
62
|
-
regions:
|
|
97
|
+
final_node_count: rows.length,
|
|
98
|
+
regions: regionCounts(rows)
|
|
63
99
|
};
|
|
64
100
|
}
|
|
65
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Preview one artifact directory.
|
|
103
|
+
*
|
|
104
|
+
* `includeNodeRows` is what the serve runtime asks for: the results page and
|
|
105
|
+
* the 国家分布 donut read those rows, and answering `nodeRows: []` left a
|
|
106
|
+
* browser session at 0 nodes and an empty donut on a complete artifact. The
|
|
107
|
+
* CLI's `artifact preview` keeps them out of its JSON so a 400-node artifact
|
|
108
|
+
* does not print every link.
|
|
109
|
+
*/
|
|
110
|
+
export function previewArtifact(artifactDir, options = {}) {
|
|
66
111
|
const absolute = path.resolve(artifactDir);
|
|
67
112
|
const resolved = fs.existsSync(absolute) ? fs.realpathSync(absolute) : absolute;
|
|
68
113
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
69
114
|
return { ok: false, artifact_dir: resolved };
|
|
70
115
|
}
|
|
71
116
|
const report = loadJson(path.join(resolved, 'pipeline_report.json'));
|
|
117
|
+
const finalNodes = readFinalNodes(resolved);
|
|
72
118
|
return {
|
|
73
119
|
ok: true,
|
|
74
120
|
artifact_dir: resolved,
|
|
@@ -80,6 +126,7 @@ export function previewArtifact(artifactDir) {
|
|
|
80
126
|
retry_context: report.retry_context ?? {},
|
|
81
127
|
error: redactText(String(report.error ?? '')),
|
|
82
128
|
files: fileInventory(resolved),
|
|
83
|
-
safe_node_counts: safeNodeCounts(
|
|
129
|
+
safe_node_counts: safeNodeCounts(finalNodes.rows, finalNodes.nodeSource),
|
|
130
|
+
...(options.includeNodeRows ? { nodeRows: finalNodes.rows } : {})
|
|
84
131
|
};
|
|
85
132
|
}
|
package/dist/config/profile.js
CHANGED
|
@@ -87,10 +87,9 @@ export function normalizeOfficialSources(sources) {
|
|
|
87
87
|
mode: 'credential',
|
|
88
88
|
official_site: String(config.official_site ?? '').trim() || brandSite(brand),
|
|
89
89
|
enabled: config.enabled !== false,
|
|
90
|
-
// A round is
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// 50 consecutive rounds with no genuinely new node.
|
|
90
|
+
// A round is one cursor step over the fixed area list, not a full sweep
|
|
91
|
+
// of every region. 200 rounds × the default 8 areas / 50 with no new
|
|
92
|
+
// node matches the documented defaults.
|
|
94
93
|
max_iterations: wasCredential ? positiveInteger(config.max_iterations, 200) : 200,
|
|
95
94
|
plateau_limit: wasCredential ? positiveInteger(config.plateau_limit, 50) : 50,
|
|
96
95
|
areas_per_round: Math.min(64, positiveInteger(config.areas_per_round, 8)),
|
|
@@ -151,9 +151,9 @@ export function resolveRoundPolicy(source) {
|
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
153
|
/**
|
|
154
|
-
* A round is one cursor step over the fixed area list, not a full sweep
|
|
155
|
-
*
|
|
156
|
-
*
|
|
154
|
+
* A round is one cursor step over the fixed area list, not a full sweep.
|
|
155
|
+
* 200 rounds × the default 8 areas covers the whole list many times over, and
|
|
156
|
+
* the plateau stop ends a channel the moment the server has nothing new left.
|
|
157
157
|
*/
|
|
158
158
|
export const DEFAULT_MAX_ITERATIONS = 200;
|
|
159
159
|
export const DEFAULT_PLATEAU_LIMIT = 50;
|
package/dist/pipeline/egress.js
CHANGED
|
@@ -65,7 +65,13 @@ export function resolveEgressPolicy(env = process.env) {
|
|
|
65
65
|
}
|
|
66
66
|
/** Status codes a vendor or its CDN uses when it stops serving this client. */
|
|
67
67
|
const BLOCK_STATUS = new Set([403, 407, 429, 451, 503]);
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Body wording a block page uses. Every phrase needs a ban context: an
|
|
70
|
+
* accelerator's marketing copy legitimately says "隐藏您的IP地址" (hide your
|
|
71
|
+
* IP), and a bare `您的IP` read that as a ban, emptied the official-site
|
|
72
|
+
* response, and made 蜜蜂's APK undiscoverable for the whole run.
|
|
73
|
+
*/
|
|
74
|
+
const BLOCK_BODY_PATTERN = /封禁|封鎖|封锁|禁止访问|禁止訪問|(?:访问|訪問)受限|异常访问|異常訪問|请求过于频繁|請求過於頻繁|操作频繁|操作頻繁|(?:您的|你的|当前|當前|该|該)?\s*IP\s*(?:地址|位址)?\s*(?:已)?\s*(?:被|遭)\s*(?:封|禁|鎖|锁|限制|拉黑)|(?:您的|你的|当前|當前)?\s*IP\s*(?:已被|被)\s*(?:封禁|封鎖|封锁|限制|禁止)|too\s*many\s*requests|rate\s*limit|access\s*denied|forbidden|you\s+have\s+been\s+blocked|cf-browser-verification|just\s*a\s*moment|attention\s*required|captcha/i;
|
|
69
75
|
/** Classify a response or transport error for the switch decision. */
|
|
70
76
|
export function classifyEgressFailure(input) {
|
|
71
77
|
const status = Number(input.status ?? 0);
|
package/dist/server/runtime.js
CHANGED
|
@@ -66,7 +66,7 @@ function normalizeLatestArtifact(projectRoot, env) {
|
|
|
66
66
|
if (!latest.ok || !latest.artifact_dir) {
|
|
67
67
|
return undefined;
|
|
68
68
|
}
|
|
69
|
-
const preview = previewArtifact(String(latest.artifact_dir));
|
|
69
|
+
const preview = previewArtifact(String(latest.artifact_dir), { includeNodeRows: true });
|
|
70
70
|
const progress = readRunProgress(path.join(String(latest.artifact_dir), 'run.db'));
|
|
71
71
|
return {
|
|
72
72
|
...latest,
|
|
@@ -75,7 +75,10 @@ function normalizeLatestArtifact(projectRoot, env) {
|
|
|
75
75
|
// replace the authoritative totals that already exist in the run database.
|
|
76
76
|
...(progress ? { counts: { ...(preview.counts ?? {}), ...progress.counts }, source_counts: progress.source_counts, progress_snapshot: progress } : {}),
|
|
77
77
|
outputFiles: (preview.files ?? []),
|
|
78
|
-
|
|
78
|
+
// The results page and the country donut read these rows. Answering with an
|
|
79
|
+
// empty array left a browser session at 0 nodes and an empty 国家分布 while
|
|
80
|
+
// the artifact held the full final list.
|
|
81
|
+
nodeRows: (preview.nodeRows ?? [])
|
|
79
82
|
};
|
|
80
83
|
}
|
|
81
84
|
function parseJsonLine(line) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { buildStageModel, PAGE_INDEX, PAGE_ORDER, resolveRunControlState } from './state.js';
|
|
2
2
|
import { icon } from './icons.js';
|
|
3
3
|
|
|
4
|
-
// Defaults for a source row that has never stored a value. A round
|
|
5
|
-
// cursor
|
|
6
|
-
// count is per platform and per
|
|
4
|
+
// Defaults for a source row that has never stored a value. A round advances a
|
|
5
|
+
// cursor over that brand's fixed area list and pulls `areas_per_round` ids, so
|
|
6
|
+
// the count is per platform and per round: 4/2 would fetch only a handful of
|
|
7
|
+
// nodes.
|
|
7
8
|
export const SOURCE_ROUND_DEFAULTS = {
|
|
8
9
|
max_iterations: 200,
|
|
9
10
|
plateau_limit: 50,
|
|
@@ -929,7 +930,7 @@ function buildSettingsDrawerBody(section, draft, vm = null) {
|
|
|
929
930
|
<div class="source-credential-rounds">
|
|
930
931
|
<label class="source-field">
|
|
931
932
|
<span>轮数</span>
|
|
932
|
-
<input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:抓取轮数" title="Android 与 iOS 各自独立跑满该轮数(1000 = 两端各 1000
|
|
933
|
+
<input type="number" min="1" step="1" inputmode="numeric" aria-label="${label}:抓取轮数" title="Android 与 iOS 各自独立跑满该轮数(1000 = 两端各 1000 轮);每轮是固定区域列表上的一个游标步,按「每轮区域数」取区域" data-drawer-source="${escapeHtml(name)}" data-drawer-key="max_iterations" value="${escapeHtml(source.max_iterations ?? SOURCE_ROUND_DEFAULTS.max_iterations)}" />
|
|
933
934
|
</label>
|
|
934
935
|
<label class="source-field">
|
|
935
936
|
<span>早停</span>
|