@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.
- package/dist/cli/accounts-commands.js +123 -7
- package/dist/cli/output.js +1 -1
- package/dist/config/profile.js +40 -9
- package/dist/pipeline/availability.js +201 -150
- package/dist/pipeline/concurrency.js +68 -0
- package/dist/pipeline/credential-source.js +490 -99
- package/dist/pipeline/egress.js +372 -0
- package/dist/pipeline/extract.js +108 -69
- package/dist/pipeline/http-transport.js +232 -0
- package/dist/pipeline/orchestrator.js +34 -8
- package/dist/pipeline/proxy-runtime.js +35 -2
- package/dist/pipeline/speedtest.js +187 -144
- package/dist/pool/account-store.js +46 -4
- package/dist/server/http.js +11 -0
- package/dist/server/runtime.js +27 -1
- package/dist/server/web-adapter.js +12 -0
- package/dist/web/renderer/app.js +115 -10
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/styles.css +37 -2
- package/dist/web/renderer/tables.js +2 -2
- package/dist/web/renderer/views.js +156 -20
- package/dist/web/renderer/workspace.css +5 -0
- package/package.json +1 -1
|
@@ -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 {
|
|
7
|
-
import {
|
|
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,
|
|
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,
|
|
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
|
-
|
|
186
|
-
if (
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
651
|
-
if (
|
|
652
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
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 =
|
|
917
|
-
const failed = results.length === 0 ? 1 :
|
|
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
|
-
/**
|
|
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 ??
|
|
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:
|
|
989
|
-
min_iterations:
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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`
|
|
1273
|
-
// usually returns a different machine: the route id chooses the
|
|
1274
|
-
// the host.
|
|
1275
|
-
//
|
|
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
|
|
1278
|
-
// max_iterations
|
|
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
|
|
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
|
|
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
|
|
1296
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1353
|
-
await options.linksCallback?.(links.slice(-
|
|
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
|
|
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:
|
|
1364
|
-
|
|
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}
|
|
1371
|
-
|
|
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:\/\//, '');
|