@swimmingliu/autovpn 2.0.3 → 2.1.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.
@@ -1,10 +1,14 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { fetch as upstreamFetch, ProxyAgent } from 'undici';
3
2
  import { androidChunkUrls, apkDiscoveryFromBytes, inlineDownloadSpec, candidateApkUrls, cdnConfigUrls, parseCdnConfig, apkSlot } from './apk-discovery.js';
4
3
  import { buildUrl, commonParams, flattenRouteGroups, isPlaceholderNode, LOGIN_SALT, pathAndQuery, selectApiDomain, selectForeignRoutes } from './brand-protocol.js';
5
4
  import { decryptPayload, extractLinksFromPlaintext, linkDigest, linkFingerprint } from './extract.js';
6
- import { AccountStore } from '../pool/account-store.js';
7
- import { brandFromSite, normalizeBrand } from '../pool/brands.js';
5
+ import { mapWithConcurrency, resolveConcurrency } from './concurrency.js';
6
+ import { EgressSession, defaultCurlFetch, resolveEgressPolicy } from './egress.js';
7
+ import { AccountStore, maskAccount } from '../pool/account-store.js';
8
+ import { BRANDS, brandFromSite, brandSite, normalizeBrand } from '../pool/brands.js';
9
+ import { FingerprintStore, resolveFingerprintCacheDir } from './brand-cache.js';
10
+ import { resolveUserRuntimeRoot } from '../runtime/paths.js';
11
+ import { redactText } from '../runtime/redaction.js';
8
12
  /**
9
13
  * Each brand concatenates `getCommParams()` in its own order with its own
10
14
  * literal block. The layout can be overridden from the profile, and is picked
@@ -135,21 +139,83 @@ function numberOrDefault(value, fallback) {
135
139
  const parsed = Number(value);
136
140
  return Number.isFinite(parsed) ? parsed : fallback;
137
141
  }
138
- function today(now) {
139
- const pad = (value) => String(value).padStart(2, '0');
140
- return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
141
- }
142
142
  export function resolveRoundPolicy(source) {
143
- const maxIterations = Math.max(1, Math.trunc(numberOrDefault(source.max_iterations, 1)));
143
+ const maxIterations = Math.max(1, Math.trunc(numberOrDefault(source.max_iterations, DEFAULT_MAX_ITERATIONS)));
144
144
  const configuredMin = Math.max(0, Math.trunc(numberOrDefault(source.min_iterations, 0)));
145
145
  return {
146
146
  max_iterations: maxIterations,
147
147
  // A minimum above the maximum would otherwise make the cap unreachable.
148
148
  min_iterations: Math.min(configuredMin, maxIterations),
149
- plateau_limit: Math.max(1, Math.trunc(numberOrDefault(source.plateau_limit, 2))),
149
+ plateau_limit: Math.max(1, Math.trunc(numberOrDefault(source.plateau_limit, DEFAULT_PLATEAU_LIMIT))),
150
150
  max_runtime_seconds: Math.max(0, numberOrDefault(source.max_runtime_seconds, 0))
151
151
  };
152
152
  }
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.
157
+ */
158
+ export const DEFAULT_MAX_ITERATIONS = 200;
159
+ export const DEFAULT_PLATEAU_LIMIT = 50;
160
+ export const DEFAULT_AREAS_PER_ROUND = 8;
161
+ export const DEFAULT_AREA_CONCURRENCY = 4;
162
+ /**
163
+ * Consecutive rounds where every candidate host failed before the channel
164
+ * gives up. `getCommStr` already walks the whole domain list, so one empty
165
+ * round is a real outage; five in a row is not a transient blip.
166
+ */
167
+ export const MAX_EMPTY_ROUNDS = 5;
168
+ /**
169
+ * Consecutive rounds that answered with the placeholder template and nothing
170
+ * else, after which the channel is treated as downgraded and stopped.
171
+ *
172
+ * 黑洞's Android channel answers `1.1.1.1:443` for every area on every account
173
+ * (verified against the decompiled client's exact parameters, with and without
174
+ * `kbox`). Left alone it burns the whole round budget and half the run's
175
+ * concurrency producing nothing, so a channel that has never produced a real
176
+ * node is stopped explicitly and reported.
177
+ */
178
+ export const PLACEHOLDER_ONLY_ROUNDS = 8;
179
+ /** How many areas one round takes from the immutable list. */
180
+ export function resolveAreasPerRound(value, areaCount) {
181
+ const parsed = Math.trunc(Number(value));
182
+ const requested = Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_AREAS_PER_ROUND;
183
+ // Asking for more areas than exist would repeat the same id inside one round.
184
+ return Math.max(1, Math.min(requested, Math.max(1, areaCount)));
185
+ }
186
+ /**
187
+ * A cursor over a frozen area list.
188
+ *
189
+ * The list and its order are decided once per run; the cursor only moves. That
190
+ * is what keeps later-listed regions reachable when a run is cut short by the
191
+ * round cap, the runtime budget or an early stop — the old loop re-asked the
192
+ * same full list in vendor order every round, so the tail was starved.
193
+ */
194
+ export class AreaCursor {
195
+ items;
196
+ index = 0;
197
+ constructor(items) {
198
+ this.items = Object.freeze([...items]);
199
+ }
200
+ get length() {
201
+ return this.items.length;
202
+ }
203
+ get position() {
204
+ return this.index;
205
+ }
206
+ /** Take the next `count` entries, wrapping. Never mutates the list. */
207
+ take(count) {
208
+ if (this.items.length === 0)
209
+ return [];
210
+ const width = Math.max(1, Math.min(Math.trunc(count) || 1, this.items.length));
211
+ const out = [];
212
+ for (let step = 0; step < width; step += 1) {
213
+ out.push(this.items[this.index]);
214
+ this.index = (this.index + 1) % this.items.length;
215
+ }
216
+ return out;
217
+ }
218
+ }
153
219
  /** The floor always runs; past it, the runtime budget can stop the loop. */
154
220
  export function shouldStartRound(round, policy, elapsedSeconds) {
155
221
  if (round > policy.max_iterations) {
@@ -168,35 +234,32 @@ export function shouldStopOnPlateau(round, plateau, policy) {
168
234
  export function loginSignature(account, password) {
169
235
  return createHash('md5').update(`${account}${password}${LOGIN_SALT}`, 'utf8').digest('hex');
170
236
  }
171
- function isEnabled(value) {
172
- return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
173
- }
174
- function upstreamProxyUrl(env) {
175
- if (!env || !isEnabled(env.VPN_AUTOMATION_USE_UPSTREAM_PROXY)) {
176
- return '';
177
- }
178
- const value = String(env.VPN_AUTOMATION_UPSTREAM_PROXY ?? 'http://127.0.0.1:7897').trim();
179
- return ['', 'off', 'none', 'false', '0'].includes(value.toLowerCase()) ? '' : value;
180
- }
181
237
  async function fetchText(fetchImpl, url, timeoutMs, egress) {
238
+ // The session is the single egress authority: transport choice, block
239
+ // detection, body cancellation and the curl TLS fallback all live there.
240
+ if (egress?.session) {
241
+ const result = await egress.session.text(url, timeoutMs);
242
+ return { text: result.text, url: result.url, ok: result.ok };
243
+ }
182
244
  const controller = new AbortController();
183
245
  const timer = setTimeout(() => controller.abort(), timeoutMs);
246
+ let response = null;
184
247
  try {
185
- const response = await fetchImpl(url, { signal: controller.signal, redirect: 'follow' });
186
- if (!response.ok) {
248
+ response = await fetchImpl(url, { signal: controller.signal, redirect: 'follow' });
249
+ if (response.ok === false) {
250
+ // An unread body pins its keep-alive socket until GC.
251
+ await cancelBody(response);
187
252
  return { text: '', url, ok: false };
188
253
  }
189
254
  return { text: await response.text(), url, ok: true };
190
255
  }
191
256
  catch {
192
- // Same fallback the url mode applies: curl handles a proxy or a
193
- // self-signed/intercepted TLS chain that Node's fetch rejects.
194
- const proxyUrl = upstreamProxyUrl(egress?.env);
257
+ await cancelBody(response);
195
258
  if (!egress?.curlFetch) {
196
259
  return { text: '', url, ok: false };
197
260
  }
198
261
  try {
199
- const text = await egress.curlFetch(url, proxyUrl);
262
+ const text = await egress.curlFetch(url, '');
200
263
  return text.trim() ? { text, url, ok: true } : { text: '', url, ok: false };
201
264
  }
202
265
  catch {
@@ -207,6 +270,14 @@ async function fetchText(fetchImpl, url, timeoutMs, egress) {
207
270
  clearTimeout(timer);
208
271
  }
209
272
  }
273
+ async function cancelBody(response) {
274
+ try {
275
+ await response?.body?.cancel?.();
276
+ }
277
+ catch {
278
+ // Already consumed or already closed.
279
+ }
280
+ }
210
281
  /** Control-plane responses are AES-CBC with key == IV. */
211
282
  export function decodeControlPlane(cipherText, key) {
212
283
  if (!cipherText.trim() || !key) {
@@ -429,15 +500,22 @@ async function defaultLoadApk(source, fetchImpl, timeoutMs, now, log, brandName
429
500
  return null;
430
501
  }
431
502
  }
503
+ // The binary download must use the same transport as the text calls, or a
504
+ // configured proxy serves the homepage but the APK bytes go direct.
505
+ const binaryFetch = egress?.session
506
+ ? egress.session.rawFetch()
507
+ : fetchImpl;
432
508
  if (source.apk_url) {
433
509
  const controller = new AbortController();
434
510
  const timer = setTimeout(() => controller.abort(), Math.max(timeoutMs, 120_000));
435
511
  try {
436
- const response = await fetchImpl(source.apk_url, { signal: controller.signal });
512
+ const response = await binaryFetch(source.apk_url, { signal: controller.signal });
437
513
  if (!response.ok || typeof response.arrayBuffer !== 'function') {
514
+ await cancelBody(response);
438
515
  return null;
439
516
  }
440
517
  if (declaredSizeTooLarge(response)) {
518
+ await cancelBody(response);
441
519
  log(`[extract] apk at ${source.apk_url} exceeds ${MAX_APK_BYTES} bytes; refusing`);
442
520
  return null;
443
521
  }
@@ -563,11 +641,13 @@ async function defaultLoadApk(source, fetchImpl, timeoutMs, now, log, brandName
563
641
  const controller = new AbortController();
564
642
  const timer = setTimeout(() => controller.abort(), Math.max(timeoutMs, 120_000));
565
643
  try {
566
- const response = await fetchImpl(candidate, { signal: controller.signal });
644
+ const response = await binaryFetch(candidate, { signal: controller.signal });
567
645
  if (!response.ok || typeof response.arrayBuffer !== 'function') {
646
+ await cancelBody(response);
568
647
  continue;
569
648
  }
570
649
  if (declaredSizeTooLarge(response)) {
650
+ await cancelBody(response);
571
651
  continue;
572
652
  }
573
653
  const bytes = Buffer.from(await response.arrayBuffer());
@@ -621,11 +701,23 @@ export function isCredentialLoginRejection(message) {
621
701
  return false;
622
702
  return /密码错误|密碼錯誤|密码不正确|密碼不正確|账号不存在|帳號不存在|账户不存在|账户或密码|账号或密码|账号已被|帳號已被|密码已|被封禁|已被禁用|登录失败|登錄失敗|does\s*not\s*exist|not\s*(found|exist)|invalid\s*(password|account|credential|user)|incorrect\s*password|wrong\s*password|unauthorized/i.test(text);
623
703
  }
704
+ /**
705
+ * Local calendar day, matching the account store's `isoDay`.
706
+ *
707
+ * `toISOString().slice(0, 10)` is the UTC day: under Asia/Shanghai a vendor
708
+ * date of `2027-01-01 00:00:00` became `2026-12-31`, and the pool's local-day
709
+ * comparison then retired the account a day early. One formatter for both the
710
+ * "today" comparisons and the dates learned from the vendor.
711
+ */
712
+ export function localDay(date) {
713
+ const pad = (value) => String(value).padStart(2, '0');
714
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
715
+ }
624
716
  export function membershipDate(value) {
625
717
  if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
626
718
  const ms = value > 10_000_000_000 ? value : value * 1000;
627
719
  const date = new Date(ms);
628
- return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10);
720
+ return Number.isNaN(date.getTime()) ? '' : localDay(date);
629
721
  }
630
722
  const text = String(value ?? '').trim();
631
723
  if (!text)
@@ -636,7 +728,31 @@ export function membershipDate(value) {
636
728
  return /^\d{10,13}$/.test(text) ? membershipDate(Number(text)) : '';
637
729
  }
638
730
  const parsed = new Date(text);
639
- return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString().slice(0, 10);
731
+ return Number.isNaN(parsed.getTime()) ? '' : localDay(parsed);
732
+ }
733
+ /** Pick the first field that actually parses to a date; `0` is not a date. */
734
+ function firstMembershipDate(...values) {
735
+ for (const value of values) {
736
+ const parsed = membershipDate(value);
737
+ if (parsed)
738
+ return parsed;
739
+ }
740
+ return '';
741
+ }
742
+ /**
743
+ * A rejection that means the membership lapsed, not that the password is wrong.
744
+ *
745
+ * Checked before `isCredentialLoginRejection`: filing an expired membership as
746
+ * "invalid" hides the one fact the operator needs, and the row would otherwise
747
+ * stay active forever because neither classifier matched the wording.
748
+ */
749
+ export function isMembershipExpiredMessage(message) {
750
+ const text = String(message ?? '');
751
+ if (!text.trim())
752
+ return false;
753
+ if (TRANSIENT_LOGIN_PATTERN.test(text))
754
+ return false;
755
+ return /会员.*?(过期|到期|已到期)|會員.*?(過期|到期)|账号.*?(到期|过期)|帳號.*?(到期|過期)|账户.*?(到期|过期)|vip.*?(expired|expired)|membership.*?(expired|lapsed)|subscription.*?expired|已过期|已過期|expired/i.test(text);
640
756
  }
641
757
  function credentialRejectionMessage(payload) {
642
758
  return String(payload.msg ?? payload.message ?? '');
@@ -647,32 +763,45 @@ function credentialRejectionMessage(payload) {
647
763
  * the pipeline already consumes.
648
764
  */
649
765
  export async function fetchCredentialSourceLinks(input, options = {}) {
650
- const proxyUrl = upstreamProxyUrl(options.env);
651
- if (!proxyUrl) {
652
- return fetchCredentialSourceLinksWithTransport(input, options);
766
+ const policy = resolveEgressPolicy(options.env ?? {});
767
+ if (policy.proxyUrl) {
768
+ let proxy;
769
+ try {
770
+ proxy = new URL(policy.proxyUrl);
771
+ }
772
+ catch {
773
+ throw new Error('The configured upstream proxy URL is invalid');
774
+ }
775
+ if (proxy.protocol !== 'http:' && proxy.protocol !== 'https:') {
776
+ throw new Error('Credential sources require an HTTP or HTTPS upstream proxy');
777
+ }
653
778
  }
654
- let proxy;
655
- try {
656
- proxy = new URL(proxyUrl);
779
+ // One session per source: it owns the proxy dispatcher, decides the
780
+ // transport, and switches direct -> proxy when the vendor starts blocking.
781
+ // Both platforms share it, so the choice is made once and closed once.
782
+ const session = options.egressSession ?? await EgressSession.open(options.env, {
783
+ fetchImpl: options.fetchImpl,
784
+ curlFetch: options.curlFetch ?? ((url, proxyUrl) => defaultCurlFetch(url, proxyUrl)),
785
+ onSwitch: (switched) => event(options, 'log', {
786
+ message: `[extract][${input.source_name}] egress ${switched.from} -> ${switched.to} (${switched.reason})`
787
+ })
788
+ });
789
+ const ownsSession = !options.egressSession;
790
+ if (session.policy.mode === 'proxy' && session.transport === 'direct' && !session.proxyReachable) {
791
+ event(options, 'log', {
792
+ message: `[extract][${input.source_name}] upstream proxy ${session.policy.proxyUrl} is not reachable; crawling direct`
793
+ });
657
794
  }
658
- catch {
659
- throw new Error('The configured upstream proxy URL is invalid');
660
- }
661
- if (proxy.protocol !== 'http:' && proxy.protocol !== 'https:') {
662
- throw new Error('Credential sources require an HTTP or HTTPS upstream proxy');
663
- }
664
- // The proxy previously reached only a text fallback: native site and APK
665
- // requests still went direct. Keep one source-scoped dispatcher for both
666
- // response types, without changing the per-node measurement transports.
667
- const dispatcher = new ProxyAgent({ uri: proxy.href, proxyTunnel: false });
668
- const fetchImpl = (url, init) => options.fetchImpl
669
- ? options.fetchImpl(url, { ...init, dispatcher })
670
- : upstreamFetch(url, { ...init, dispatcher });
671
795
  try {
672
- return await fetchCredentialSourceLinksWithTransport(input, { ...options, fetchImpl });
796
+ return await fetchCredentialSourceLinksWithTransport(input, {
797
+ ...options,
798
+ egressSession: session,
799
+ curlFetch: options.curlFetch ?? ((url, proxyUrl) => defaultCurlFetch(url, proxyUrl))
800
+ });
673
801
  }
674
802
  finally {
675
- await dispatcher.destroy();
803
+ if (ownsSession)
804
+ await session.close();
676
805
  }
677
806
  }
678
807
  async function fetchCredentialSourceLinksWithTransport(input, options) {
@@ -771,13 +900,24 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
771
900
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
772
901
  liveLinkIds.clear();
773
902
  liveFingerprints.clear();
903
+ // Both channels judge "a genuinely new node" against this one union, so
904
+ // a node iOS already produced cannot reset Android's early-stop counter.
905
+ const sharedFingerprints = new Set();
774
906
  results = await Promise.all(platforms.map((platform) => {
775
907
  const scoped = {
776
908
  ...options,
777
909
  memo,
910
+ sharedFingerprints,
778
911
  eventCallback: (type, payload) => {
779
912
  if (type === 'extract_source_started') {
780
- event(options, 'extract_platform_started', { ...payload, platform });
913
+ // The per-platform start used to hardcode 0; the budget is the
914
+ // same full policy on each channel, never a split of it.
915
+ event(options, 'extract_platform_started', {
916
+ ...payload,
917
+ platform,
918
+ requested_iterations: policy.max_iterations,
919
+ min_iterations: policy.min_iterations
920
+ });
781
921
  return;
782
922
  }
783
923
  if (type === 'extract_source_completed') {
@@ -797,13 +937,21 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
797
937
  liveLinkIds.add(id);
798
938
  event(options, 'extract_iteration', {
799
939
  source_name: input.source_name,
940
+ // Two channels emit the same round number; without the tag the
941
+ // log shows two identical `#N` lines and reads like one shared
942
+ // counter.
943
+ platform,
800
944
  iteration: Number(payload.iteration ?? 0),
801
945
  requested_iterations: policy.max_iterations,
802
946
  new_items: Number(payload.new_items ?? 0),
947
+ new_meaningful_items: Number(payload.new_meaningful_items ?? 0),
803
948
  extracted_links: Number(payload.extracted_links ?? 0),
804
949
  total_links: liveLinkIds.size,
805
950
  deduped_links: liveFingerprints.size,
806
- new_item_fingerprints: fingerprints
951
+ new_item_fingerprints: fingerprints,
952
+ new_item_link_ids: Array.isArray(payload.new_item_link_ids)
953
+ ? payload.new_item_link_ids.map((value) => String(value)).filter(Boolean)
954
+ : []
807
955
  });
808
956
  return;
809
957
  }
@@ -849,19 +997,33 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
849
997
  const expires = results.map((result) => result.diagnostics.membership_expires_at ?? '').find(Boolean);
850
998
  if (expires)
851
999
  store.setExpiry(leased.brand, leased.account, expires);
1000
+ // A login can succeed while the membership it reports has already
1001
+ // lapsed. File that as expired, not as "no paid membership": the
1002
+ // reason is different and the operator acts on it differently.
1003
+ const lapsed = Boolean(expires) && (expires ?? '') < localDay((options.now ?? (() => new Date()))());
852
1004
  const level = results.map((result) => result.diagnostics.membership_level).find((value) => value !== undefined);
853
1005
  const linked = results.some((result) => result.links.length > 0);
854
- // A free-tier account (level 0/1) only ever produces placeholders.
855
- // Retire it, but only when it actually produced nothing: a paid
856
- // account misreported as level 1 must not lose its row.
857
- if (level !== undefined && level < 2 && !linked)
1006
+ if (lapsed) {
1007
+ store.markExpired(leased.id, 'membership expired');
1008
+ }
1009
+ else if (level !== undefined && level < 2 && !linked) {
1010
+ // A free-tier account (level 0/1) only ever produces placeholders.
1011
+ // Retire it, but only when it actually produced nothing: a paid
1012
+ // account misreported as level 1 must not lose its row.
858
1013
  store.markInvalid(leased.id, 'no paid membership');
1014
+ }
859
1015
  }
860
1016
  break;
861
1017
  }
1018
+ const membershipExpired = results.length > 0
1019
+ && results.every((result) => result.diagnostics.login_rejected)
1020
+ && results.some((result) => result.diagnostics.membership_expired);
862
1021
  const credentialRejected = results.length > 0 && results.every((result) => result.diagnostics.credential_rejected);
863
- if (store && leased && credentialRejected) {
864
- store.markInvalid(leased.id, 'login rejected');
1022
+ if (store && leased && (membershipExpired || credentialRejected)) {
1023
+ if (membershipExpired)
1024
+ store.markExpired(leased.id, 'membership expired');
1025
+ else
1026
+ store.markInvalid(leased.id, 'login rejected');
865
1027
  if (attempt < attempts && brand) {
866
1028
  leased = store.lease(brand);
867
1029
  if (leased) {
@@ -899,13 +1061,38 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
899
1061
  diagnostics.version_name = first.version_name;
900
1062
  diagnostics.seed_domains = first.seed_domains;
901
1063
  diagnostics.api_domain = first.api_domain;
1064
+ // The session facts are per-account, not per-platform: both channels log
1065
+ // the same pooled credential in. Without this copy the merged result looked
1066
+ // like a run that never logged in, and `accounts refresh-expiry` (which
1067
+ // reads the merged diagnostics) reported every account as failed.
1068
+ diagnostics.login_ok = results.some((result) => result.diagnostics.login_ok);
1069
+ diagnostics.login_rejected = results.length > 0 && results.every((result) => result.diagnostics.login_rejected);
1070
+ diagnostics.credential_rejected = results.length > 0 && results.every((result) => result.diagnostics.credential_rejected);
1071
+ diagnostics.membership_expired = results.some((result) => result.diagnostics.membership_expired);
1072
+ diagnostics.membership_expires_at = results
1073
+ .map((result) => result.diagnostics.membership_expires_at ?? '')
1074
+ .find(Boolean);
1075
+ diagnostics.membership_level = results
1076
+ .map((result) => result.diagnostics.membership_level)
1077
+ .find((value) => value !== undefined);
902
1078
  }
903
1079
  diagnostics.route_count = Math.max(0, ...results.map((result) => result.diagnostics.route_count));
904
1080
  diagnostics.route_tables = Math.max(0, ...results.map((result) => result.diagnostics.route_tables ?? 0));
905
1081
  diagnostics.node_count = links.length;
906
1082
  diagnostics.placeholder_count = results.reduce((total, result) => total + result.diagnostics.placeholder_count, 0);
1083
+ // Each platform runs the full budget on its own. `rounds` stays the
1084
+ // per-platform figure (the cap semantics), `rounds_total` is the work done
1085
+ // across channels, and platform_results carries the breakdown.
907
1086
  diagnostics.rounds = Math.max(0, ...results.map((result) => result.diagnostics.rounds));
1087
+ diagnostics.rounds_total = results.reduce((total, result) => total + result.diagnostics.rounds, 0);
908
1088
  diagnostics.requests = results.reduce((total, result) => total + result.diagnostics.requests, 0);
1089
+ diagnostics.areas_per_round = results[0]?.diagnostics.areas_per_round;
1090
+ diagnostics.area_concurrency = results[0]?.diagnostics.area_concurrency;
1091
+ if (options.egressSession) {
1092
+ diagnostics.request_attempts = options.egressSession.attempts;
1093
+ diagnostics.egress_transport = options.egressSession.transport;
1094
+ diagnostics.egress_switches = [...options.egressSession.switches];
1095
+ }
909
1096
  diagnostics.platform_results = results.map((result) => ({
910
1097
  platform: result.diagnostics.platform,
911
1098
  links: result.links.length,
@@ -913,8 +1100,8 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
913
1100
  requests: result.diagnostics.requests,
914
1101
  errors: result.diagnostics.errors
915
1102
  }));
916
- const successful = Math.max(0, ...results.map((result) => result.successful_iterations));
917
- const failed = results.length === 0 ? 1 : Math.max(0, ...results.map((result) => result.failed_iterations));
1103
+ const successful = results.reduce((total, result) => total + result.successful_iterations, 0);
1104
+ const failed = results.length === 0 ? 1 : results.reduce((total, result) => total + result.failed_iterations, 0);
918
1105
  if (callbackFailure) {
919
1106
  // Surfaced rather than logged: the orchestrator treats a thrown extract as
920
1107
  // a failed source, which is the truth — its downstream already died.
@@ -937,8 +1124,28 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
937
1124
  diagnostics
938
1125
  };
939
1126
  }
940
- /** One client channel. The public wrapper runs both and merges the results. */
1127
+ /**
1128
+ * One client channel. The public wrapper runs both and merges the results.
1129
+ *
1130
+ * The session is opened here so every early return path still closes it; the
1131
+ * inner worker assumes a live session and never owns one.
1132
+ */
941
1133
  export async function fetchCredentialPlatformLinks(input, options = {}) {
1134
+ if (options.egressSession) {
1135
+ return runCredentialPlatform(input, options);
1136
+ }
1137
+ const session = await EgressSession.open(options.env, {
1138
+ fetchImpl: (options.fetchImpl ?? globalThis.fetch?.bind(globalThis)),
1139
+ curlFetch: options.curlFetch ?? ((url, proxyUrl) => defaultCurlFetch(url, proxyUrl))
1140
+ });
1141
+ try {
1142
+ return await runCredentialPlatform(input, { ...options, egressSession: session });
1143
+ }
1144
+ finally {
1145
+ await session.close();
1146
+ }
1147
+ }
1148
+ async function runCredentialPlatform(input, options) {
942
1149
  const source = input.source;
943
1150
  const fetchImpl = options.fetchImpl ?? globalThis.fetch?.bind(globalThis);
944
1151
  const now = (options.now ?? (() => new Date()))();
@@ -946,7 +1153,7 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
946
1153
  const platform = source.platform === 'ios' ? 'ios' : 'android';
947
1154
  const device = {
948
1155
  device_id: source.device_id ?? DEFAULT_DEVICE_ID,
949
- install_date: source.install_date ?? today(now)
1156
+ install_date: source.install_date ?? localDay(now)
950
1157
  };
951
1158
  const errors = [];
952
1159
  const log = (message) => event(options, 'log', { message });
@@ -983,11 +1190,13 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
983
1190
  errors.push('account and password are required');
984
1191
  return empty;
985
1192
  }
1193
+ const startPolicy = resolveRoundPolicy(source);
986
1194
  event(options, 'extract_source_started', {
987
1195
  source_name: input.source_name,
988
- requested_iterations: 0,
989
- min_iterations: 0,
990
- resume_from_iteration: 1
1196
+ requested_iterations: startPolicy.max_iterations,
1197
+ min_iterations: startPolicy.min_iterations,
1198
+ resume_from_iteration: 1,
1199
+ platform
991
1200
  });
992
1201
  // 1) Brand fingerprint: package, version, certificate and seed domains.
993
1202
  //
@@ -1005,7 +1214,11 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1005
1214
  const storedEntry = store?.read(brandKey) ?? null;
1006
1215
  const staleButUsable = Boolean(storedEntry) && !fresh;
1007
1216
  const cached = staleButUsable ? storedEntry : memo.get();
1008
- const egress = { env: options.env, curlFetch: options.curlFetch };
1217
+ const session = options.egressSession;
1218
+ const egress = {
1219
+ session,
1220
+ curlFetch: options.curlFetch ?? ((url, proxyUrl) => defaultCurlFetch(url, proxyUrl))
1221
+ };
1009
1222
  // Report real extraction work instead of making the UI infer APK/domain
1010
1223
  // activity from text logs. Cache reuse remains distinct from executed work.
1011
1224
  const substage = (name, status) => event(options, 'extract_substage', { source_name: input.source_name, platform, substage: name, status });
@@ -1135,7 +1348,7 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1135
1348
  platform,
1136
1349
  timestampMs: ts,
1137
1350
  code: ''
1138
- }, resolveBrandLayout({ ...source, brand: input.source_name })), keys, timeoutMs);
1351
+ }, resolveBrandLayout({ ...source, brand: input.source_name })), keys, timeoutMs, egress);
1139
1352
  substage('domain_discovery', resolved ? 'success' : 'failed');
1140
1353
  return resolved;
1141
1354
  }
@@ -1154,7 +1367,10 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1154
1367
  lastActiveDomain: eversion.domain,
1155
1368
  key: eversion.key,
1156
1369
  fetchImpl,
1157
- timeoutMs
1370
+ timeoutMs,
1371
+ // Without this the curl/TLS fallback never reached login, apinodelist or
1372
+ // evmess: every control-plane call saw `ctx.egress === undefined`.
1373
+ egress
1158
1374
  };
1159
1375
  const apiDomain = selectApiDomain(discovery.seed_domains, device.device_id);
1160
1376
  diagnostics.api_domain = apiDomain;
@@ -1172,7 +1388,8 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1172
1388
  if (Number(payload.code) !== 0) {
1173
1389
  const message = credentialRejectionMessage(payload);
1174
1390
  diagnostics.login_rejected = true;
1175
- diagnostics.credential_rejected = isCredentialLoginRejection(message);
1391
+ diagnostics.membership_expired = isMembershipExpiredMessage(message);
1392
+ diagnostics.credential_rejected = !diagnostics.membership_expired && isCredentialLoginRejection(message);
1176
1393
  errors.push(`login rejected: code=${String(payload.code)}`);
1177
1394
  // A cached fingerprint whose seeds answer eversion but whose signing
1178
1395
  // cert the vendor rotated keeps failing here on every run. Drop the
@@ -1183,7 +1400,9 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1183
1400
  return { ...empty, failed_iterations: 1 };
1184
1401
  }
1185
1402
  token = String(payload.token ?? '');
1186
- diagnostics.membership_expires_at = membershipDate(payload.endtime ?? payload.expire_time);
1403
+ // `endtime ?? expire_time` does not fall through on 0, so a valid
1404
+ // expire_time was silently dropped and the row never got a date.
1405
+ diagnostics.membership_expires_at = firstMembershipDate(payload.endtime, payload.expire_time);
1187
1406
  diagnostics.membership_level = Number.isFinite(Number(payload.level)) ? Number(payload.level) : undefined;
1188
1407
  }
1189
1408
  catch {
@@ -1223,7 +1442,7 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1223
1442
  }
1224
1443
  if (unlimit)
1225
1444
  ctx.isUnlimited = unlimit === '1';
1226
- const expires = membershipDate(detail.endtime ?? detail.expire_time ?? info.endtime ?? info.expire_time);
1445
+ const expires = firstMembershipDate(detail.endtime, detail.expire_time, info.endtime, info.expire_time);
1227
1446
  if (expires)
1228
1447
  diagnostics.membership_expires_at = expires;
1229
1448
  }
@@ -1269,47 +1488,76 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1269
1488
  ` disabled=${selection.dropped_disabled} dup=${selection.dropped_duplicate})`);
1270
1489
  // 5) `/api/evmess?area=ID`, repeated.
1271
1490
  //
1272
- // The server picks a landing host per request, so asking the same `area` again
1273
- // usually returns a different machine: the route id chooses the region, not
1274
- // the host. A single pass therefore sees only one host per route, and the
1275
- // rounds below are what turn a handful of routes into a full node pool.
1491
+ // The server picks a landing host per request, so asking the same `area`
1492
+ // again usually returns a different machine: the route id chooses the
1493
+ // region, not the host.
1494
+ //
1495
+ // The area list is the one built above: every region the node list
1496
+ // advertised, China and free rows removed, duplicates collapsed, vendor
1497
+ // order preserved. It is frozen for the whole run. A cursor walks it and
1498
+ // takes `areas_per_round` entries each round, wrapping at the end — so a run
1499
+ // cut short by the round cap or an early stop still reaches the tail of the
1500
+ // list instead of re-asking the same head forever.
1276
1501
  //
1277
- // Round control mirrors the URL mode's iteration semantics:
1278
- // max_iterations upper bound on rounds (default 1)
1502
+ // Round control:
1503
+ // max_iterations rounds PER PLATFORM. Android and iOS each run the full
1504
+ // budget concurrently; it is never split or summed.
1279
1505
  // min_iterations rounds that always run before early stopping is allowed
1280
- // plateau_limit consecutive rounds with no new link that stop the run
1506
+ // plateau_limit consecutive rounds with no genuinely new node. Novelty
1507
+ // is the canonical fingerprint, deduped across platforms.
1281
1508
  const links = [];
1282
1509
  const seen = new Set();
1283
1510
  let placeholders = 0;
1284
1511
  let requests = 0;
1285
1512
  const activeRoutes = routes.filter((route) => route.enabled);
1286
- // The dashboard's dedupe card counts distinct fingerprints, not raw links: a
1287
- // link repeated across two platforms has to collapse there or the number
1288
- // only ever grows.
1513
+ // The dashboard's dedupe card counts distinct fingerprints, not raw links.
1289
1514
  const fingerprints = new Set();
1515
+ // Meaningful novelty is judged against the union both platforms share, so a
1516
+ // node the other channel already produced cannot reset this one's plateau.
1517
+ const novelty = options.sharedFingerprints ?? new Set();
1290
1518
  // Raw totals use the exact-string identity the pipeline dedupes with, so the
1291
1519
  // live number and `extract_source_completed.raw_links` agree.
1292
1520
  const linkIds = new Set();
1293
1521
  const policy = resolveRoundPolicy(source);
1294
1522
  const maxIterations = policy.max_iterations;
1295
- const minIterations = policy.min_iterations;
1296
- const plateauLimit = policy.plateau_limit;
1523
+ const areasPerRound = resolveAreasPerRound(source.areas_per_round, activeRoutes.length);
1524
+ const areaConcurrency = resolveConcurrency(source.area_concurrency, DEFAULT_AREA_CONCURRENCY);
1525
+ const cursor = new AreaCursor(activeRoutes);
1297
1526
  const startedAt = Date.now();
1298
1527
  let plateau = 0;
1299
1528
  let rounds = 0;
1300
1529
  let successfulRounds = 0;
1301
1530
  let failedRounds = 0;
1531
+ let emptySweep = 0;
1532
+ let placeholderStreak = 0;
1533
+ diagnostics.route_count = activeRoutes.length;
1534
+ diagnostics.areas_per_round = areasPerRound;
1535
+ diagnostics.area_concurrency = areaConcurrency;
1536
+ log(`[extract] area list frozen: ${activeRoutes.length} areas,` +
1537
+ ` ${areasPerRound}/round, concurrency ${areaConcurrency}, egress ${session.transport}`);
1302
1538
  for (let round = 1; round <= maxIterations; round += 1) {
1303
1539
  if (!shouldStartRound(round, policy, (Date.now() - startedAt) / 1000)) {
1304
1540
  break;
1305
1541
  }
1306
- let newInRound = 0;
1542
+ const batch = cursor.take(areasPerRound);
1543
+ requests += batch.length;
1544
+ // Fan the round out. `getCommStr` builds its own candidate list before the
1545
+ // first await, so workers share no iteration state; `lastActiveDomain` is
1546
+ // only a routing hint and last-writer-wins is safe.
1547
+ const answers = await mapWithConcurrency(batch, areaConcurrency, async (route) => ({
1548
+ route,
1549
+ plain: await getCommStr(ctx, eversionFields.apiLinks, apiDomain, '/api/evmess', `&area=${route.area}`, discovery.seed_domains)
1550
+ }));
1551
+ // Delivery and early stop are deliberately separate counters. Every new
1552
+ // raw link is still delivered downstream; only a new node identity resets
1553
+ // the plateau, so a relabelled duplicate cannot keep the run alive.
1554
+ let appendedInRound = 0;
1555
+ let meaningfulInRound = 0;
1307
1556
  let answeredInRound = 0;
1557
+ let placeholderInRound = 0;
1308
1558
  const newFingerprints = [];
1309
1559
  const newLinkIds = [];
1310
- for (const route of activeRoutes) {
1311
- const plain = await getCommStr(ctx, eversionFields.apiLinks, apiDomain, '/api/evmess', `&area=${route.area}`, discovery.seed_domains);
1312
- requests += 1;
1560
+ for (const { route, plain } of answers) {
1313
1561
  if (!plain) {
1314
1562
  continue;
1315
1563
  }
@@ -1327,15 +1575,20 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1327
1575
  for (const link of decoded) {
1328
1576
  if (isPlaceholderLink(link)) {
1329
1577
  placeholders += 1;
1578
+ placeholderInRound += 1;
1330
1579
  continue;
1331
1580
  }
1581
+ const fingerprint = linkFingerprint(link);
1582
+ if (!novelty.has(fingerprint)) {
1583
+ novelty.add(fingerprint);
1584
+ meaningfulInRound += 1;
1585
+ newFingerprints.push(fingerprint);
1586
+ }
1587
+ fingerprints.add(fingerprint);
1332
1588
  if (!seen.has(link)) {
1333
1589
  seen.add(link);
1334
1590
  links.push(link);
1335
- newInRound += 1;
1336
- const fingerprint = linkFingerprint(link);
1337
- fingerprints.add(fingerprint);
1338
- newFingerprints.push(fingerprint);
1591
+ appendedInRound += 1;
1339
1592
  const digest = linkDigest(link);
1340
1593
  linkIds.add(digest);
1341
1594
  newLinkIds.push(digest);
@@ -1345,31 +1598,64 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1345
1598
  rounds = round;
1346
1599
  if (answeredInRound > 0) {
1347
1600
  successfulRounds += 1;
1601
+ emptySweep = 0;
1602
+ plateau = meaningfulInRound === 0 ? plateau + 1 : 0;
1603
+ // A whole round of placeholders and not one real node is a downgraded
1604
+ // channel, not an exhausted pool: it would otherwise spend the entire
1605
+ // budget (and half the run's concurrency) producing nothing.
1606
+ if (placeholderInRound > 0 && appendedInRound === 0 && links.length === 0) {
1607
+ placeholderStreak += 1;
1608
+ }
1609
+ else {
1610
+ placeholderStreak = 0;
1611
+ }
1348
1612
  }
1349
1613
  else {
1350
1614
  failedRounds += 1;
1615
+ emptySweep += 1;
1616
+ // A round where every candidate failed is a network problem, not "the
1617
+ // pool is exhausted", so it must not advance the plateau. Escalate to
1618
+ // the proxy (a banned IP often gets an empty body rather than a 403)
1619
+ // and stop after a short run of them instead of burning the budget.
1620
+ if (session.reportEmptySweep()) {
1621
+ log('[extract] no route answered; switching to the upstream proxy');
1622
+ }
1623
+ if (emptySweep >= MAX_EMPTY_ROUNDS) {
1624
+ errors.push(`no route answered for ${emptySweep} consecutive rounds`);
1625
+ break;
1626
+ }
1351
1627
  }
1352
- if (newInRound > 0) {
1353
- await options.linksCallback?.(links.slice(-newInRound));
1628
+ if (appendedInRound > 0) {
1629
+ await options.linksCallback?.(links.slice(-appendedInRound));
1354
1630
  }
1355
1631
  // The dashboard's raw/dedupe cards read these numbers while the run is in
1356
1632
  // flight. Without a per-round event a credential source stays at 0 until
1357
- // it finishes, which reads as a stalled run: the URL mode already emits
1358
- // `extract_iteration` for exactly this reason.
1633
+ // it finishes, which reads as a stalled run.
1359
1634
  event(options, 'extract_iteration', {
1360
1635
  source_name: input.source_name,
1636
+ platform,
1361
1637
  iteration: round,
1362
1638
  requested_iterations: maxIterations,
1363
- new_items: newInRound,
1364
- extracted_links: newInRound,
1639
+ new_items: appendedInRound,
1640
+ new_meaningful_items: meaningfulInRound,
1641
+ extracted_links: appendedInRound,
1365
1642
  total_links: links.length,
1366
1643
  deduped_links: fingerprints.size,
1367
1644
  new_item_fingerprints: newFingerprints,
1368
1645
  new_item_link_ids: newLinkIds
1369
1646
  });
1370
- log(`[extract] round ${round}/${maxIterations} +${newInRound} new (total ${links.length})`);
1371
- plateau = newInRound === 0 ? plateau + 1 : 0;
1647
+ log(`[extract] round ${round}/${maxIterations} area=${batch.map((route) => route.area).join(',')}` +
1648
+ ` +${appendedInRound} new (+${meaningfulInRound} meaningful, total ${links.length},` +
1649
+ ` plateau ${plateau}/${policy.plateau_limit})`);
1372
1650
  if (shouldStopOnPlateau(round, plateau, policy)) {
1651
+ log(`[extract] early stop: ${plateau} consecutive rounds without a meaningful new node`);
1652
+ break;
1653
+ }
1654
+ if (placeholderStreak >= PLACEHOLDER_ONLY_ROUNDS) {
1655
+ errors.push(`channel produced only placeholder nodes for ${placeholderStreak} rounds; stopping it` +
1656
+ ' (this brand does not serve real nodes on this platform)');
1657
+ log(`[extract] ${platform} channel stopped: ${placeholderStreak} rounds of placeholder nodes only.` +
1658
+ ' The other channel still runs; this platform is not serving real nodes for this brand.');
1373
1659
  break;
1374
1660
  }
1375
1661
  }
@@ -1377,6 +1663,8 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1377
1663
  diagnostics.node_count = links.length;
1378
1664
  diagnostics.rounds = rounds;
1379
1665
  diagnostics.requests = requests;
1666
+ diagnostics.egress_transport = session.transport;
1667
+ diagnostics.egress_switches = [...session.switches];
1380
1668
  event(options, 'extract_source_completed', {
1381
1669
  source_name: input.source_name,
1382
1670
  requested_iterations: maxIterations,
@@ -1393,6 +1681,109 @@ export async function fetchCredentialPlatformLinks(input, options = {}) {
1393
1681
  diagnostics
1394
1682
  };
1395
1683
  }
1684
+ /**
1685
+ * Learn every pooled account's real membership expiry by logging in.
1686
+ *
1687
+ * The date is only written by a successful pooled login, and LRU leasing means
1688
+ * one account per brand per run — so a freshly imported pool stays blank
1689
+ * indefinitely and the UI shows an em dash for almost every row. This walks the
1690
+ * active rows deliberately, reusing the normal credential flow (cached
1691
+ * fingerprint, one node request) so the login path is the same one a run uses.
1692
+ */
1693
+ export async function refreshAccountExpiry(input = {}) {
1694
+ const env = input.env ?? process.env;
1695
+ const now = input.now ?? (() => new Date());
1696
+ const store = input.store ?? new AccountStore({ env, now });
1697
+ const ownsStore = !input.store;
1698
+ const concurrency = resolveConcurrency(input.concurrency, 3, 8);
1699
+ const limit = Math.max(0, Math.trunc(Number(input.limit ?? 0)) || 0);
1700
+ const fingerprintStore = new FingerprintStore({
1701
+ cacheDir: resolveFingerprintCacheDir(resolveUserRuntimeRoot(env)),
1702
+ now
1703
+ });
1704
+ const outcome = { checked: 0, updated: 0, expired: 0, failed: 0, results: [] };
1705
+ const todayText = localDay(now());
1706
+ const requested = input.brand ? normalizeBrand(input.brand) : null;
1707
+ // Every brand the pool knows, from the registry rather than a second list.
1708
+ const brands = requested ? [requested] : BRANDS.map((brand) => brand.key);
1709
+ try {
1710
+ for (const brand of brands) {
1711
+ let accounts = store.leaseAll(brand);
1712
+ if (limit > 0)
1713
+ accounts = accounts.slice(0, limit);
1714
+ if (accounts.length === 0)
1715
+ continue;
1716
+ // One memo per brand: the APK is inspected at most once for the whole
1717
+ // sweep, not once per account. The cache key is the short brand key the
1718
+ // normal run writes (`mf`, not `mifeng`); using the long name missed the
1719
+ // cache on every sweep and re-downloaded the APK per account.
1720
+ const brandKey = credentialBrandKey(brand, { official_site: brandSite(brand) });
1721
+ const memo = createFingerprintMemo(fingerprintStore, brandKey);
1722
+ await mapWithConcurrency(accounts, concurrency, async (leased) => {
1723
+ const entry = {
1724
+ brand,
1725
+ account_masked: maskAccount(leased.account),
1726
+ expires_at: '',
1727
+ status: 'failed'
1728
+ };
1729
+ outcome.checked += 1;
1730
+ try {
1731
+ const result = await fetchCredentialSourceLinks({
1732
+ source_name: brand,
1733
+ source: {
1734
+ mode: 'credential',
1735
+ official_site: brandSite(brand),
1736
+ account: leased.account,
1737
+ password: leased.password,
1738
+ platforms: ['android'],
1739
+ max_iterations: 1,
1740
+ areas_per_round: 1,
1741
+ plateau_limit: 1
1742
+ }
1743
+ }, { env, now, memo, fingerprintStore });
1744
+ const expires = result.diagnostics.membership_expires_at ?? '';
1745
+ entry.expires_at = expires;
1746
+ if (result.diagnostics.login_ok && expires) {
1747
+ store.setExpiry(brand, leased.account, expires);
1748
+ if (expires < todayText) {
1749
+ store.markExpired(leased.id, 'membership expired');
1750
+ entry.status = 'expired';
1751
+ outcome.expired += 1;
1752
+ }
1753
+ else {
1754
+ entry.status = 'updated';
1755
+ outcome.updated += 1;
1756
+ }
1757
+ }
1758
+ else if (result.diagnostics.membership_expired) {
1759
+ store.markExpired(leased.id, 'membership expired');
1760
+ entry.status = 'expired';
1761
+ outcome.expired += 1;
1762
+ }
1763
+ else {
1764
+ entry.status = 'failed';
1765
+ // The CLI prints this line, so nothing that can carry a credential
1766
+ // or a request URL may reach it.
1767
+ entry.error = redactText(result.diagnostics.errors.join('; ')) || 'login did not report an expiry';
1768
+ outcome.failed += 1;
1769
+ }
1770
+ }
1771
+ catch (error) {
1772
+ entry.status = 'failed';
1773
+ entry.error = redactText(error instanceof Error ? error.message : String(error));
1774
+ outcome.failed += 1;
1775
+ }
1776
+ outcome.results.push(entry);
1777
+ input.onProgress?.(entry);
1778
+ });
1779
+ }
1780
+ }
1781
+ finally {
1782
+ if (ownsStore)
1783
+ store.close();
1784
+ }
1785
+ return outcome;
1786
+ }
1396
1787
  function isPlaceholderLink(link) {
1397
1788
  try {
1398
1789
  const encoded = link.replace(/^vmess:\/\//, '');