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