@swimmingliu/autovpn 2.2.1 → 2.2.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.
@@ -89,6 +89,22 @@ function siteHost(site) {
89
89
  return '';
90
90
  }
91
91
  }
92
+ /**
93
+ * Archive prefix for any spelling of a brand: its English key, its Chinese
94
+ * label, a known storefront host, or a name that normalizes to one of them.
95
+ */
96
+ function credentialPrefixFor(value) {
97
+ const raw = String(value ?? '').trim();
98
+ if (!raw) {
99
+ return null;
100
+ }
101
+ const direct = BRAND_APK_PREFIXES[raw] ?? BRAND_APK_PREFIXES[raw.toLowerCase()];
102
+ if (direct) {
103
+ return direct;
104
+ }
105
+ const brand = normalizeBrand(raw);
106
+ return brand ? (BRAND_APK_PREFIXES[brand] ?? null) : null;
107
+ }
92
108
  /**
93
109
  * Stable key for a brand's fingerprint.
94
110
  *
@@ -100,14 +116,82 @@ function siteHost(site) {
100
116
  * be established, which keeps custom entries working.
101
117
  */
102
118
  export function credentialBrandKey(sourceName, source) {
103
- const site = String(source.official_site ?? '').trim();
104
- const host = siteHost(site);
119
+ // Brand first, host second. A row's own name identifies the APK even when the
120
+ // operator points it at an alternate storefront (`www.leitingku.com`,
121
+ // `www.heidongm.com`, …), and hosting the file under that host split one
122
+ // brand across several cache entries — so a redirected row keyed by prefix
123
+ // never saw the entry the native row had just written.
124
+ const name = String(sourceName ?? '').trim();
125
+ const byName = credentialPrefixFor(name);
126
+ if (byName) {
127
+ return byName;
128
+ }
129
+ const host = siteHost(String(source.official_site ?? '').trim());
130
+ const byHost = credentialPrefixFor(host);
131
+ if (byHost) {
132
+ return byHost;
133
+ }
134
+ const lowered = name.toLowerCase();
135
+ return host || lowered || 'unknown';
136
+ }
137
+ /**
138
+ * Fingerprint key for the data plane a row actually reads.
139
+ *
140
+ * A redirected row carries the backend's canonical brand key (`leiting`), not
141
+ * the short archive prefix its cache file is named with (`lt`). Using the
142
+ * canonical key straight away produced `brand-leiting.json`, which never
143
+ * exists: every run missed the cache, fell back to downloading the APK from the
144
+ * storefront, and on a host that cannot reach that storefront failed with
145
+ * "APK could not be located" — while `brand-lt.json` sat on disk, unused.
146
+ */
147
+ export function fingerprintKeyFor(sourceName, source) {
148
+ if (!String(source.brand_override ?? '').trim()) {
149
+ return credentialBrandKey(sourceName, source);
150
+ }
151
+ return credentialPrefixFor(String(source.brand_override))
152
+ ?? String(source.brand_override).trim().toLowerCase();
153
+ }
154
+ /**
155
+ * What to do with a pooled account after its login was refused.
156
+ *
157
+ * Retirement and rotation are separate decisions. Only a *classified* rejection
158
+ * — the vendor said the password or the account is wrong, or that the
159
+ * membership lapsed — is evidence the row is dead, so only that retires it.
160
+ * Any rejection at all, though, means this account cannot be used right now and
161
+ * the pool holds more.
162
+ *
163
+ * Conflating the two ended the source at zero rounds: 雷霆 refuses most of this
164
+ * pool with a bare `code=128` and no message, which matches neither classifier,
165
+ * so the loop gave up on the first account while 159 active rows went unread.
166
+ */
167
+ export function accountFollowUp(state) {
168
+ if (state.membershipExpired) {
169
+ return { act: true, retire: 'expired', rotate: true };
170
+ }
171
+ if (state.credentialRejected) {
172
+ return { act: true, retire: 'invalid', rotate: true };
173
+ }
174
+ if (state.loginRejected) {
175
+ // Usable by nobody right now, but nothing proves it is dead: keep the row.
176
+ return { act: true, retire: null, rotate: true };
177
+ }
178
+ return { act: false, retire: null, rotate: false };
179
+ }
180
+ /**
181
+ * Cache files an earlier build may have written for this row.
182
+ *
183
+ * Before brand-first keying, the key was the official-site host, so a row
184
+ * pointed at an alternate storefront wrote `brand-www.leitingku.com.json`
185
+ * instead of `brand-lt.json`. Those files hold a valid fingerprint — often the
186
+ * only verified one on a host that cannot reach the vendor's site — so a read
187
+ * falls back to them and migrates the entry to the canonical key.
188
+ */
189
+ function legacyFingerprintKey(sourceName, source) {
190
+ const host = siteHost(String(source.official_site ?? '').trim());
105
191
  if (host) {
106
- return BRAND_APK_PREFIXES[host] ?? host;
192
+ return host;
107
193
  }
108
- const name = String(sourceName ?? '').trim().toLowerCase();
109
- const prefix = BRAND_APK_PREFIXES[sourceName] ?? BRAND_APK_PREFIXES[name];
110
- return prefix ?? (name || 'unknown');
194
+ return String(sourceName ?? '').trim().toLowerCase() || 'unknown';
111
195
  }
112
196
  export function createFingerprintMemo(store, brand) {
113
197
  let value = store?.read(brand) ?? null;
@@ -195,6 +279,59 @@ export const MAX_EMPTY_ROUNDS = 5;
195
279
  * node is stopped explicitly and reported.
196
280
  */
197
281
  export const PLACEHOLDER_ONLY_ROUNDS = 8;
282
+ /**
283
+ * Lap-mode defaults. A lap is one full pass over the frozen area list, so
284
+ * these numbers mean what they say: 50 laps × N areas is the request ceiling,
285
+ * 13 empty laps × N areas is the give-up point. The legacy round defaults
286
+ * above stay untouched for rows that never set a lap key.
287
+ */
288
+ export const DEFAULT_MAX_LAPS = 50;
289
+ export const DEFAULT_DRY_LAPS = 13;
290
+ function finiteLapValue(value, min) {
291
+ const parsed = Number(value);
292
+ return Number.isFinite(parsed) && parsed >= min;
293
+ }
294
+ /**
295
+ * Resolve the lap policy, or null when the row never opted in.
296
+ *
297
+ * Null is the compatibility contract: the caller then runs the legacy round
298
+ * path with the same arguments as before, so an untouched profile produces
299
+ * the same request sequence after an upgrade.
300
+ */
301
+ export function resolveLapRoundPolicy(source, areaCount) {
302
+ const optedIn = finiteLapValue(source.max_laps, 1) || finiteLapValue(source.dry_laps, 1) || finiteLapValue(source.min_laps, 0);
303
+ if (!optedIn) {
304
+ return null;
305
+ }
306
+ const areas = Math.max(1, Math.trunc(numberOrDefault(areaCount, 1)));
307
+ const cap = Math.max(1, Math.trunc(numberOrDefault(source.max_laps, DEFAULT_MAX_LAPS)));
308
+ const plateau = Math.max(1, Math.trunc(numberOrDefault(source.dry_laps, DEFAULT_DRY_LAPS)));
309
+ const floor = Math.min(Math.max(0, Math.trunc(numberOrDefault(source.min_laps, 0))), cap);
310
+ return {
311
+ policy: {
312
+ max_iterations: cap,
313
+ min_iterations: floor,
314
+ plateau_limit: plateau,
315
+ max_runtime_seconds: Math.max(0, numberOrDefault(source.max_runtime_seconds, 0))
316
+ },
317
+ areas_per_round: areas,
318
+ // The guards were tuned in rounds of 8 areas; keep their request volume
319
+ // when the unit becomes a lap of N areas, never below two laps.
320
+ empty_round_limit: Math.max(2, Math.ceil((MAX_EMPTY_ROUNDS * DEFAULT_AREAS_PER_ROUND) / areas)),
321
+ placeholder_round_limit: Math.max(2, Math.ceil((PLACEHOLDER_ONLY_ROUNDS * DEFAULT_AREAS_PER_ROUND) / areas)),
322
+ round_unit: 'lap'
323
+ };
324
+ }
325
+ /**
326
+ * The budget to advertise before the area list is known. Pre-flight events
327
+ * fire before login, so they must report the unit the loop will run in.
328
+ */
329
+ export function resolveAdvertisedMaxIterations(source) {
330
+ if (finiteLapValue(source.max_laps, 1) || finiteLapValue(source.dry_laps, 1) || finiteLapValue(source.min_laps, 0)) {
331
+ return Math.max(1, Math.trunc(numberOrDefault(source.max_laps, DEFAULT_MAX_LAPS)));
332
+ }
333
+ return resolveRoundPolicy(source).max_iterations;
334
+ }
198
335
  /** How many areas one round takes from the immutable list. */
199
336
  export function resolveAreasPerRound(value, areaCount) {
200
337
  const parsed = Math.trunc(Number(value));
@@ -838,7 +975,10 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
838
975
  // and the caller's config object must not carry that into later stages.
839
976
  const source = { ...input.source };
840
977
  const platforms = resolveCredentialPlatforms(source);
841
- const policy = resolveRoundPolicy(source);
978
+ // Pre-flight events fire before the area list exists, so the budget is
979
+ // advertised in the unit the loop will run in: laps or legacy rounds.
980
+ const advertisedIterations = resolveAdvertisedMaxIterations(source);
981
+ const legacyPolicy = resolveRoundPolicy(source);
842
982
  const errors = [];
843
983
  const diagnostics = {
844
984
  brand: input.source_name,
@@ -858,7 +998,7 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
858
998
  };
859
999
  const empty = {
860
1000
  source_name: input.source_name,
861
- requested_iterations: policy.max_iterations,
1001
+ requested_iterations: advertisedIterations,
862
1002
  successful_iterations: 0,
863
1003
  failed_iterations: 0,
864
1004
  links: [],
@@ -866,8 +1006,8 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
866
1006
  };
867
1007
  event(options, 'extract_source_started', {
868
1008
  source_name: input.source_name,
869
- requested_iterations: policy.max_iterations,
870
- min_iterations: policy.min_iterations,
1009
+ requested_iterations: advertisedIterations,
1010
+ min_iterations: legacyPolicy.min_iterations,
871
1011
  resume_from_iteration: 1,
872
1012
  platforms
873
1013
  });
@@ -926,7 +1066,7 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
926
1066
  store?.close();
927
1067
  event(options, 'extract_source_completed', {
928
1068
  source_name: input.source_name,
929
- requested_iterations: policy.max_iterations,
1069
+ requested_iterations: advertisedIterations,
930
1070
  successful_iterations: 0,
931
1071
  failed_iterations: 1,
932
1072
  raw_links: 0
@@ -943,7 +1083,7 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
943
1083
  // backend on a cached run, and it hides the 雷霆 entry the next run should
944
1084
  // reuse. A redirect therefore always gets a memo of its own key; the shared
945
1085
  // one is used only when the two brands agree.
946
- const memoBrandKey = source.brand_override ?? credentialBrandKey(input.source_name, source);
1086
+ const memoBrandKey = fingerprintKeyFor(input.source_name, source);
947
1087
  const memo = source.brand_override
948
1088
  ? createFingerprintMemo(options.fingerprintStore ?? null, memoBrandKey)
949
1089
  : (options.memo ?? createFingerprintMemo(options.fingerprintStore ?? null, memoBrandKey));
@@ -981,8 +1121,8 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
981
1121
  event(options, 'extract_platform_started', {
982
1122
  ...payload,
983
1123
  platform,
984
- requested_iterations: policy.max_iterations,
985
- min_iterations: policy.min_iterations
1124
+ requested_iterations: advertisedIterations,
1125
+ min_iterations: legacyPolicy.min_iterations
986
1126
  });
987
1127
  return;
988
1128
  }
@@ -1008,7 +1148,7 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
1008
1148
  // counter.
1009
1149
  platform,
1010
1150
  iteration: Number(payload.iteration ?? 0),
1011
- requested_iterations: policy.max_iterations,
1151
+ requested_iterations: advertisedIterations,
1012
1152
  new_items: Number(payload.new_items ?? 0),
1013
1153
  new_meaningful_items: Number(payload.new_meaningful_items ?? 0),
1014
1154
  extracted_links: Number(payload.extracted_links ?? 0),
@@ -1107,16 +1247,19 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
1107
1247
  && results.every((result) => result.diagnostics.login_rejected)
1108
1248
  && results.some((result) => result.diagnostics.membership_expired);
1109
1249
  const credentialRejected = results.length > 0 && results.every((result) => result.diagnostics.credential_rejected);
1110
- if (store && leased && (membershipExpired || credentialRejected)) {
1111
- if (membershipExpired)
1250
+ const loginRejected = results.length > 0 && results.every((result) => result.diagnostics.login_rejected);
1251
+ const followUp = accountFollowUp({ membershipExpired, credentialRejected, loginRejected });
1252
+ if (store && leased && followUp.act) {
1253
+ if (followUp.retire === 'expired')
1112
1254
  store.markExpired(leased.id, 'membership expired');
1113
- else
1255
+ else if (followUp.retire === 'invalid')
1114
1256
  store.markInvalid(leased.id, 'login rejected');
1115
- if (attempt < attempts && brand) {
1116
- leased = store.lease(brand);
1117
- if (leased) {
1118
- account = leased.account;
1119
- password = leased.password;
1257
+ if (followUp.rotate && attempt < attempts && brand) {
1258
+ const next = store.lease(brand);
1259
+ if (next) {
1260
+ leased = next;
1261
+ account = next.account;
1262
+ password = next.password;
1120
1263
  continue;
1121
1264
  }
1122
1265
  }
@@ -1197,15 +1340,19 @@ async function fetchCredentialSourceLinksWithTransport(input, options) {
1197
1340
  }
1198
1341
  event(options, 'extract_source_completed', {
1199
1342
  source_name: input.source_name,
1200
- requested_iterations: policy.max_iterations,
1343
+ requested_iterations: advertisedIterations,
1201
1344
  successful_iterations: successful,
1202
1345
  failed_iterations: failed,
1203
1346
  raw_links: links.length,
1204
- platforms
1347
+ platforms,
1348
+ // route_count is the max across platforms; both channels freeze the same
1349
+ // vendor list, so this is the drawer's per-row area count.
1350
+ area_count: diagnostics.route_count,
1351
+ round_unit: resolveLapRoundPolicy(source, diagnostics.route_count) ? 'lap' : 'round'
1205
1352
  });
1206
1353
  return {
1207
1354
  source_name: input.source_name,
1208
- requested_iterations: policy.max_iterations,
1355
+ requested_iterations: advertisedIterations,
1209
1356
  successful_iterations: successful,
1210
1357
  failed_iterations: failed,
1211
1358
  links,
@@ -1278,11 +1425,13 @@ async function runCredentialPlatform(input, options) {
1278
1425
  errors.push('account and password are required');
1279
1426
  return empty;
1280
1427
  }
1428
+ // Advertised before the area list is known: lap rows report their lap cap.
1429
+ const startLap = resolveLapRoundPolicy(source, 0);
1281
1430
  const startPolicy = resolveRoundPolicy(source);
1282
1431
  event(options, 'extract_source_started', {
1283
1432
  source_name: input.source_name,
1284
- requested_iterations: startPolicy.max_iterations,
1285
- min_iterations: startPolicy.min_iterations,
1433
+ requested_iterations: resolveAdvertisedMaxIterations(source),
1434
+ min_iterations: startLap ? startLap.policy.min_iterations : startPolicy.min_iterations,
1286
1435
  resume_from_iteration: 1,
1287
1436
  platform
1288
1437
  });
@@ -1293,7 +1442,7 @@ async function runCredentialPlatform(input, options) {
1293
1442
  // answering. That keeps a normal run to a handful of HTTP calls.
1294
1443
  // Keyed by the data plane, not the storefront: a redirected row downloads and
1295
1444
  // inspects the backend's APK, so it must share that brand's cached entry.
1296
- const brandKey = source.brand_override ?? credentialBrandKey(input.source_name, source);
1445
+ const brandKey = fingerprintKeyFor(input.source_name, source);
1297
1446
  const store = options.fingerprintStore ?? null;
1298
1447
  // Same rule as the outer memo: a caller-supplied memo is keyed by the row
1299
1448
  // name, so a redirected row must not read or write through it.
@@ -1305,7 +1454,24 @@ async function runCredentialPlatform(input, options) {
1305
1454
  // vendor can rotate the signing certificate while the old seeds still serve
1306
1455
  // `/api/eversion`, and then every login fails on a stale certificate.
1307
1456
  const fresh = store?.readFresh(brandKey) ?? null;
1308
- const storedEntry = store?.read(brandKey) ?? null;
1457
+ let storedEntry = store?.read(brandKey) ?? null;
1458
+ if (!storedEntry && store && !source.brand_override) {
1459
+ // An older build keyed this row by its official-site host. Reuse that entry
1460
+ // instead of forcing an APK download the host may not be able to make, and
1461
+ // move it to the canonical key so the next run reads it directly.
1462
+ const legacyKey = legacyFingerprintKey(input.source_name, source);
1463
+ if (legacyKey !== brandKey) {
1464
+ const legacy = store.readFresh(legacyKey) ?? store.read(legacyKey);
1465
+ if (legacy) {
1466
+ log(`[extract] adopting fingerprint cached under ${legacyKey}`);
1467
+ // Copies the entry under the canonical key while keeping the original
1468
+ // `verified_at`: stamping a new one would promote an entry that is
1469
+ // genuinely past its window into a trusted one.
1470
+ store.adopt(brandKey, legacy);
1471
+ storedEntry = store.read(brandKey);
1472
+ }
1473
+ }
1474
+ }
1309
1475
  const staleButUsable = Boolean(storedEntry) && !fresh;
1310
1476
  const cached = staleButUsable ? storedEntry : memo.get();
1311
1477
  const session = options.egressSession;
@@ -1316,7 +1482,7 @@ async function runCredentialPlatform(input, options) {
1316
1482
  // Report real extraction work instead of making the UI infer APK/domain
1317
1483
  // activity from text logs. Cache reuse remains distinct from executed work.
1318
1484
  const substage = (name, status) => event(options, 'extract_substage', { source_name: input.source_name, platform, substage: name, status });
1319
- const inspectApk = async () => {
1485
+ const inspectApk = async (recordFailure = true) => {
1320
1486
  substage('apk_download', 'running');
1321
1487
  let apkBytes = null;
1322
1488
  try {
@@ -1331,11 +1497,15 @@ async function runCredentialPlatform(input, options) {
1331
1497
  source.brand_override ?? input.source_name, egress);
1332
1498
  }
1333
1499
  catch (error) {
1334
- errors.push(`apk: ${error instanceof Error ? error.message : String(error)}`);
1500
+ if (recordFailure) {
1501
+ errors.push(`apk: ${error instanceof Error ? error.message : String(error)}`);
1502
+ }
1335
1503
  }
1336
1504
  if (!apkBytes) {
1337
1505
  substage('apk_download', 'failed');
1338
- errors.push('APK could not be located');
1506
+ if (recordFailure) {
1507
+ errors.push('APK could not be located');
1508
+ }
1339
1509
  return null;
1340
1510
  }
1341
1511
  substage('apk_download', 'success');
@@ -1352,7 +1522,7 @@ async function runCredentialPlatform(input, options) {
1352
1522
  log(`[extract] ${discovered.package_name} ${discovered.version_name} seeds=${discovered.seed_domains.length}`);
1353
1523
  return discovered.seed_domains.length > 0 ? discovered : null;
1354
1524
  };
1355
- const inspectSharedApk = () => (memo.resolve ? memo.resolve(inspectApk) : inspectApk());
1525
+ const inspectSharedApk = (recordFailure = true) => (memo.resolve ? memo.resolve(() => inspectApk(recordFailure)) : inspectApk(recordFailure));
1356
1526
  // `apk_url` / `apk_path` are an explicit override: when either is set the
1357
1527
  // caller is asserting a specific build, so a cached fingerprint must not
1358
1528
  // shadow it. Otherwise the cache would make the override a no-op.
@@ -1373,7 +1543,11 @@ async function runCredentialPlatform(input, options) {
1373
1543
  // stale certificate fails every login with no other signal.
1374
1544
  if (staleButUsable) {
1375
1545
  log('[extract] cached fingerprint is past its trust window; re-inspecting the APK');
1376
- const refreshed = await inspectSharedApk();
1546
+ // The refresh is opportunistic: the cached entry already answered
1547
+ // `/api/eversion`, so an unreachable storefront must not also append
1548
+ // "APK could not be located" to a run that is about to work. That line
1549
+ // was the whole error text of a run whose nodes came through fine.
1550
+ const refreshed = await inspectSharedApk(false);
1377
1551
  if (refreshed) {
1378
1552
  const refreshedEversion = await tryEversion(refreshed, credentialKeyCandidates(source, refreshed));
1379
1553
  if (refreshedEversion) {
@@ -1491,10 +1665,15 @@ async function runCredentialPlatform(input, options) {
1491
1665
  diagnostics.membership_expired = isMembershipExpiredMessage(message);
1492
1666
  diagnostics.credential_rejected = !diagnostics.membership_expired && isCredentialLoginRejection(message);
1493
1667
  errors.push(`login rejected: code=${String(payload.code)}`);
1494
- // A cached fingerprint whose seeds answer eversion but whose signing
1495
- // cert the vendor rotated keeps failing here on every run. Drop the
1496
- // cache so the next run re-inspects the APK instead of repeating this.
1497
- if (cachedInUse) {
1668
+ // A cached fingerprint whose signing cert the vendor rotated fails here,
1669
+ // so the cache must not outlive the failure. Only a real credential
1670
+ // rejection is evidence of that: a throttled or free-tier account is
1671
+ // rejected too, and dropping the brand's fingerprint for it made the next
1672
+ // run re-download the APK from a storefront this host cannot reach —
1673
+ // turning one bad account into a source that never runs again. On a
1674
+ // redirected row that rejection is the norm (most accounts are unknown to
1675
+ // the backend), so there the cache is never dropped on a login result.
1676
+ if (cachedInUse && diagnostics.credential_rejected && !source.brand_override) {
1498
1677
  store?.clear(brandKey);
1499
1678
  }
1500
1679
  return { ...empty, failed_iterations: 1 };
@@ -1618,9 +1797,13 @@ async function runCredentialPlatform(input, options) {
1618
1797
  // Raw totals use the exact-string identity the pipeline dedupes with, so the
1619
1798
  // live number and `extract_source_completed.raw_links` agree.
1620
1799
  const linkIds = new Set();
1621
- const policy = resolveRoundPolicy(source);
1800
+ // Lap mode: one round is one full sweep of the frozen list, so the cap and
1801
+ // the plateau are laps with no conversion factor. A row with no lap key
1802
+ // keeps the legacy round policy with identical arguments.
1803
+ const lap = resolveLapRoundPolicy(source, activeRoutes.length);
1804
+ const policy = lap ? lap.policy : resolveRoundPolicy(source);
1622
1805
  const maxIterations = policy.max_iterations;
1623
- const areasPerRound = resolveAreasPerRound(source.areas_per_round, activeRoutes.length);
1806
+ const areasPerRound = lap ? lap.areas_per_round : resolveAreasPerRound(source.areas_per_round, activeRoutes.length);
1624
1807
  const areaConcurrency = resolveConcurrency(source.area_concurrency, DEFAULT_AREA_CONCURRENCY);
1625
1808
  const cursor = new AreaCursor(activeRoutes);
1626
1809
  const startedAt = Date.now();
@@ -1633,8 +1816,11 @@ async function runCredentialPlatform(input, options) {
1633
1816
  diagnostics.route_count = activeRoutes.length;
1634
1817
  diagnostics.areas_per_round = areasPerRound;
1635
1818
  diagnostics.area_concurrency = areaConcurrency;
1636
- log(`[extract] area list frozen: ${activeRoutes.length} areas,` +
1637
- ` ${areasPerRound}/round, concurrency ${areaConcurrency}, egress ${session.transport}`);
1819
+ log(lap
1820
+ ? `[extract] area list frozen: ${activeRoutes.length} areas, ${maxIterations} 遍 (1 遍 = 全部区域各一次),` +
1821
+ ` concurrency ${areaConcurrency}, egress ${session.transport}`
1822
+ : `[extract] area list frozen: ${activeRoutes.length} areas,` +
1823
+ ` ${areasPerRound}/round, concurrency ${areaConcurrency}, egress ${session.transport}`);
1638
1824
  for (let round = 1; round <= maxIterations; round += 1) {
1639
1825
  if (!shouldStartRound(round, policy, (Date.now() - startedAt) / 1000)) {
1640
1826
  break;
@@ -1720,8 +1906,8 @@ async function runCredentialPlatform(input, options) {
1720
1906
  if (session.reportEmptySweep()) {
1721
1907
  log('[extract] no route answered; switching to the upstream proxy');
1722
1908
  }
1723
- if (emptySweep >= MAX_EMPTY_ROUNDS) {
1724
- errors.push(`no route answered for ${emptySweep} consecutive rounds`);
1909
+ if (emptySweep >= (lap?.empty_round_limit ?? MAX_EMPTY_ROUNDS)) {
1910
+ errors.push(`no route answered for ${emptySweep} consecutive ${lap ? 'laps' : 'rounds'}`);
1725
1911
  break;
1726
1912
  }
1727
1913
  }
@@ -1744,17 +1930,19 @@ async function runCredentialPlatform(input, options) {
1744
1930
  new_item_fingerprints: newFingerprints,
1745
1931
  new_item_link_ids: newLinkIds
1746
1932
  });
1747
- log(`[extract] round ${round}/${maxIterations} area=${batch.map((route) => route.area).join(',')}` +
1933
+ log((lap
1934
+ ? `[extract] 遍 ${round}/${maxIterations} area=${batch.map((route) => route.area).join(',')}`
1935
+ : `[extract] round ${round}/${maxIterations} area=${batch.map((route) => route.area).join(',')}`) +
1748
1936
  ` +${appendedInRound} new (+${meaningfulInRound} meaningful, total ${links.length},` +
1749
- ` plateau ${plateau}/${policy.plateau_limit})`);
1937
+ ` plateau ${plateau}/${policy.plateau_limit}${lap ? ' 遍' : ''})`);
1750
1938
  if (shouldStopOnPlateau(round, plateau, policy)) {
1751
- log(`[extract] early stop: ${plateau} consecutive rounds without a meaningful new node`);
1939
+ log(`[extract] early stop: ${plateau} consecutive ${lap ? 'laps' : 'rounds'} without a meaningful new node`);
1752
1940
  break;
1753
1941
  }
1754
- if (placeholderStreak >= PLACEHOLDER_ONLY_ROUNDS) {
1755
- errors.push(`channel produced only placeholder nodes for ${placeholderStreak} rounds; stopping it` +
1942
+ if (placeholderStreak >= (lap?.placeholder_round_limit ?? PLACEHOLDER_ONLY_ROUNDS)) {
1943
+ errors.push(`channel produced only placeholder nodes for ${placeholderStreak} ${lap ? 'laps' : 'rounds'}; stopping it` +
1756
1944
  ' (this brand does not serve real nodes on this platform)');
1757
- log(`[extract] ${platform} channel stopped: ${placeholderStreak} rounds of placeholder nodes only.` +
1945
+ log(`[extract] ${platform} channel stopped: ${placeholderStreak} ${lap ? 'laps' : 'rounds'} of placeholder nodes only.` +
1758
1946
  ' The other channel still runs; this platform is not serving real nodes for this brand.');
1759
1947
  break;
1760
1948
  }
@@ -1770,7 +1958,9 @@ async function runCredentialPlatform(input, options) {
1770
1958
  requested_iterations: maxIterations,
1771
1959
  successful_iterations: successfulRounds,
1772
1960
  failed_iterations: failedRounds,
1773
- raw_links: links.length
1961
+ raw_links: links.length,
1962
+ area_count: activeRoutes.length,
1963
+ round_unit: lap ? 'lap' : 'round'
1774
1964
  });
1775
1965
  return {
1776
1966
  source_name: input.source_name,
@@ -1781,6 +1971,36 @@ async function runCredentialPlatform(input, options) {
1781
1971
  diagnostics
1782
1972
  };
1783
1973
  }
1974
+ /**
1975
+ * Decide what a membership sweep does with one pooled row.
1976
+ *
1977
+ * The sweep logs in with explicit credentials, so the run path's own retirement
1978
+ * (which only fires for a *leased* row) never runs for it — the same decision has
1979
+ * to be reachable from here, or a free-tier account with a date years in the
1980
+ * future stays in rotation forever. Kept pure so the policy can be asserted
1981
+ * without a vendor login.
1982
+ */
1983
+ export function classifyMembershipRefresh(facts, today) {
1984
+ const expires = String(facts.membership_expires_at ?? '');
1985
+ if (facts.login_ok) {
1986
+ if (expires && expires < today)
1987
+ return { status: 'expired', reason: 'membership expired' };
1988
+ const level = facts.membership_level;
1989
+ const produced = Number(facts.produced_links ?? 0) > 0 || Number(facts.route_count ?? 0) > 0;
1990
+ // A free-tier row (level 0/1) only ever yields placeholders. Retire it only
1991
+ // when it produced nothing, exactly as the run path does, so a paid row the
1992
+ // vendor misreports as level 1 keeps its place.
1993
+ if (level !== undefined && level < 2 && !produced)
1994
+ return { status: 'retired', reason: 'no paid membership' };
1995
+ return { status: 'updated', reason: '' };
1996
+ }
1997
+ if (facts.membership_expired)
1998
+ return { status: 'expired', reason: 'membership expired' };
1999
+ // The vendor rejected the credential itself: the row cannot log in at all.
2000
+ if (facts.credential_rejected)
2001
+ return { status: 'retired', reason: 'login rejected' };
2002
+ return { status: 'failed', reason: '' };
2003
+ }
1784
2004
  /**
1785
2005
  * Learn every pooled account's real membership expiry by logging in.
1786
2006
  *
@@ -1801,7 +2021,7 @@ export async function refreshAccountExpiry(input = {}) {
1801
2021
  cacheDir: resolveFingerprintCacheDir(resolveUserRuntimeRoot(env)),
1802
2022
  now
1803
2023
  });
1804
- const outcome = { checked: 0, updated: 0, expired: 0, failed: 0, results: [] };
2024
+ const outcome = { checked: 0, updated: 0, expired: 0, retired: 0, failed: 0, results: [] };
1805
2025
  const todayText = localDay(now());
1806
2026
  const requested = input.brand ? normalizeBrand(input.brand) : null;
1807
2027
  // Every brand the pool knows, from the registry rather than a second list.
@@ -1843,25 +2063,30 @@ export async function refreshAccountExpiry(input = {}) {
1843
2063
  }, { env, now, memo, fingerprintStore });
1844
2064
  const expires = result.diagnostics.membership_expires_at ?? '';
1845
2065
  entry.expires_at = expires;
1846
- if (result.diagnostics.login_ok && expires) {
1847
- store.setExpiry(brand, leased.account, expires);
1848
- if (expires < todayText) {
1849
- store.markExpired(leased.id, 'membership expired');
1850
- entry.status = 'expired';
1851
- outcome.expired += 1;
1852
- }
1853
- else {
1854
- entry.status = 'updated';
1855
- outcome.updated += 1;
1856
- }
2066
+ const verdict = classifyMembershipRefresh({
2067
+ ...result.diagnostics,
2068
+ produced_links: result.links.length
2069
+ }, todayText);
2070
+ entry.status = verdict.status;
2071
+ if (verdict.status === 'updated' || verdict.status === 'expired') {
2072
+ // A lapsed date is still the account's real date: keep it on the row
2073
+ // so the pool shows when it ran out, not an em dash.
2074
+ if (expires)
2075
+ store.setExpiry(brand, leased.account, expires);
1857
2076
  }
1858
- else if (result.diagnostics.membership_expired) {
1859
- store.markExpired(leased.id, 'membership expired');
1860
- entry.status = 'expired';
2077
+ if (verdict.status === 'expired') {
2078
+ store.markExpired(leased.id, verdict.reason);
1861
2079
  outcome.expired += 1;
1862
2080
  }
2081
+ else if (verdict.status === 'retired') {
2082
+ store.markInvalid(leased.id, verdict.reason);
2083
+ entry.error = verdict.reason;
2084
+ outcome.retired += 1;
2085
+ }
2086
+ else if (verdict.status === 'updated') {
2087
+ outcome.updated += 1;
2088
+ }
1863
2089
  else {
1864
- entry.status = 'failed';
1865
2090
  // The CLI prints this line, so nothing that can carry a credential
1866
2091
  // or a request URL may reach it.
1867
2092
  entry.error = redactText(result.diagnostics.errors.join('; ')) || 'login did not report an expiry';
@@ -424,7 +424,8 @@ export async function fetchSourceLinksWithBackend(input, options = {}) {
424
424
  // so an early-stopped 1000-round run still read as 1000/1000.
425
425
  rounds: result.diagnostics.rounds,
426
426
  rounds_total: result.diagnostics.rounds_total,
427
- platforms: result.diagnostics.platforms
427
+ platforms: result.diagnostics.platforms,
428
+ area_count: result.diagnostics.route_count
428
429
  };
429
430
  }
430
431
  return fetchSourceLinksInNode(input, options);
@@ -651,7 +651,10 @@ export async function runNodePipeline(options, context = {}) {
651
651
  summary.source_counts[result.source_name] = {
652
652
  raw_links: result.links.length,
653
653
  successful_iterations: result.successful_iterations,
654
- failed_iterations: result.failed_iterations
654
+ failed_iterations: result.failed_iterations,
655
+ // Only credential adapters report the frozen area list; URL rows and
656
+ // older adapters keep the pinned three-key object.
657
+ ...(result.area_count ? { area_count: result.area_count } : {})
655
658
  };
656
659
  // A credential source reports its own failures (rejected login, APK not
657
660
  // located) as zero links rather than an exception. Recording that as
@@ -1483,7 +1486,12 @@ export async function resumeNodePipeline(options, context = {}) {
1483
1486
  status: resumeFailed ? 'failed' : 'success',
1484
1487
  ...(resumeFailed ? { error: resumeErrors.join('; ') } : {})
1485
1488
  });
1486
- summary.source_counts[sourceName] = { raw_links: result.links.length, successful_iterations: result.successful_iterations, failed_iterations: result.failed_iterations };
1489
+ summary.source_counts[sourceName] = {
1490
+ raw_links: result.links.length,
1491
+ successful_iterations: result.successful_iterations,
1492
+ failed_iterations: result.failed_iterations,
1493
+ ...(result.area_count ? { area_count: result.area_count } : {})
1494
+ };
1487
1495
  }
1488
1496
  catch (error) {
1489
1497
  runStore.recordSourceProgress(sourceName, { processed: 0, total: 0, status: 'failed', error: errorMessage(error) });
@@ -217,6 +217,16 @@ export async function createAutoVpnServer(options) {
217
217
  writeJson(response, result.ok === false ? 400 : 200, result);
218
218
  return;
219
219
  }
220
+ if (request.method === 'POST' && url.pathname === '/api/accounts/refresh-expiry') {
221
+ const body = await readJsonBody(request);
222
+ const result = await options.runtime.refreshAccountExpiry?.({
223
+ brand: String(body.brand ?? ''),
224
+ limit: Number(body.limit ?? 0),
225
+ concurrency: Number(body.concurrency ?? 0)
226
+ }) ?? { ok: false, error: 'refresh_expiry_unavailable' };
227
+ writeJson(response, result.ok === false ? 400 : 200, result);
228
+ return;
229
+ }
220
230
  if (request.method === 'POST' && url.pathname === '/api/qr') {
221
231
  const body = await readJsonBody(request);
222
232
  const text = String(body.text ?? '');