@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.
@@ -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
- function decodeVmessRegion(link) {
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 'OTHER';
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 match = String(payload.ps ?? '').toUpperCase().match(/\b([A-Z]{2})\b/);
42
- return match ? match[1] : 'OTHER';
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 'OTHER';
70
+ return null;
46
71
  }
47
72
  }
48
- function safeNodeCounts(artifactDir) {
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 { node_source: '', final_node_count: 0, regions: [] };
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 line of lines) {
56
- const region = decodeVmessRegion(line);
57
- counts.set(region, (counts.get(region) ?? 0) + 1);
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: lines.length,
62
- regions: [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([region_code, count]) => ({ region_code, count }))
97
+ final_node_count: rows.length,
98
+ regions: regionCounts(rows)
63
99
  };
64
100
  }
65
- export function previewArtifact(artifactDir) {
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(resolved)
129
+ safe_node_counts: safeNodeCounts(finalNodes.rows, finalNodes.nodeSource),
130
+ ...(options.includeNodeRows ? { nodeRows: finalNodes.rows } : {})
84
131
  };
85
132
  }
@@ -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 now one cursor step over the fixed area list (one region by
91
- // default), not a full sweep of every region. 200 / 50 preserves roughly
92
- // the work the old 4 full sweeps did and matches the early-stop rule:
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, so
155
- * the shipped budget is sized in rounds of one area each. 200 rounds × the
156
- * default 1 area is roughly the work the old 4 full sweeps performed.
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;
@@ -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
- const BLOCK_BODY_PATTERN = /封禁|封鎖|封锁|禁止访问|禁止訪問|访问受限|訪問受限|您的IP|你的IP|IP\s*被|异常访问|異常訪問|请求过于频繁|請求過於頻繁|操作频繁|操作頻繁|too\s*many\s*requests|rate\s*limit|access\s*denied|forbidden|blocked|cf-browser-verification|just\s*a\s*moment|attention\s*required|captcha/i;
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);
@@ -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
- nodeRows: []
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) {
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.2.1.0',
8
+ sidebarVersion: 'v.2.1.2',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',
@@ -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 is one
5
- // cursor step over that brand's fixed area list (one region by default), so the
6
- // count is per platform and per region: 4/2 would fetch only a handful of nodes.
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 轮);每轮是固定区域列表上的一个游标步,默认只取 1 个区域" data-drawer-source="${escapeHtml(name)}" data-drawer-key="max_iterations" value="${escapeHtml(source.max_iterations ?? SOURCE_ROUND_DEFAULTS.max_iterations)}" />
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmingliu/autovpn",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {