@swimmingliu/autovpn 2.1.1 → 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.
|
@@ -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/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>
|