tt-help-cli-ycl 1.4.169 → 1.4.172
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/package.json +1 -1
- package/scripts/test-subscription-allocator.mjs +220 -0
- package/scripts/test-subscription-auto-alloc-ui.mjs +208 -0
- package/scripts/test-subscription-auto-alloc.mjs +259 -0
- package/scripts/test-subscription-center-e2e.mjs +134 -0
- package/scripts/test-subscription-center-smoke.mjs +154 -0
- package/scripts/test-subscription-center-ui.mjs +111 -0
- package/scripts/vnc-login.sh +68 -0
- package/src/cli/info.js +6 -1
- package/src/lib/browser/cdp.js +7 -1
- package/src/watch/public/app.js +713 -39
- package/src/watch/public/index.html +74 -3
- package/src/watch/subscription-allocator.js +255 -0
- package/src/watch/subscription-center.js +590 -0
- package/src/watch/watchdog-server.js +210 -0
- package/src/watchdog/agent.js +104 -10
- package/scripts/test-api-interceptor-429.mjs +0 -122
- package/scripts/test-api-interceptor-captcha.mjs +0 -176
- package/scripts/test-claim-fix.mjs +0 -216
- package/scripts/test-clash-discover.mjs +0 -126
- package/scripts/test-clash-hysteresis.mjs +0 -131
- package/scripts/test-clash-multichannel.mjs +0 -120
- package/scripts/test-client-error-monitor.mjs +0 -261
- package/scripts/test-country-fix.mjs +0 -341
- package/scripts/test-daemon-failover.mjs +0 -301
- package/scripts/test-daemon-switch-history.mjs +0 -141
- package/scripts/test-daemon-whitelist.mjs +0 -308
- package/scripts/test-explore-captcha-wait.mjs +0 -170
- package/scripts/test-photo-post.mjs +0 -156
- package/scripts/test-precheck-perf.mjs +0 -270
- package/scripts/test-precise-index.mjs +0 -114
- package/scripts/test-probe-live.mjs +0 -83
- package/scripts/test-probe.mjs +0 -315
- package/scripts/test-product-fill-cdp.mjs +0 -336
- package/scripts/test-product-fill-db.mjs +0 -216
- package/scripts/test-product-fill-http.mjs +0 -117
- package/scripts/test-proxy-switch-restart.mjs +0 -198
- package/scripts/test-refill-order.mjs +0 -218
- package/scripts/test-sample-all-fail.mjs +0 -142
- package/scripts/test-seller-any-country.mjs +0 -167
- package/scripts/test-stats-smoke.mjs +0 -43
- package/scripts/test-upgrade-spawn.mjs +0 -179
- package/scripts/test-webserver-error-classify.mjs +0 -409
- package/scripts/test-wmi-free-upgrade.mjs +0 -98
- package/src/cli/test-real-attach.js +0 -0
- package/src/results/user-videos-bar.lar.lar.moeta.json +0 -37
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
// 临时验证:被判重试期间弹出验证码 → fetchUserVideosAPI 立即抛 "验证码:",不再空转重试
|
|
2
|
-
// 运行:RM_BLOCKED_WAIT_MS=1 node scripts/test-api-interceptor-captcha.mjs
|
|
3
|
-
import { fetchUserVideosAPI } from "../src/lib/api-interceptor.js";
|
|
4
|
-
|
|
5
|
-
let pass = 0;
|
|
6
|
-
let fail = 0;
|
|
7
|
-
|
|
8
|
-
function check(name, ok, detail = "") {
|
|
9
|
-
if (ok) {
|
|
10
|
-
pass++;
|
|
11
|
-
console.log(`✓ ${name}${detail ? `: ${detail}` : ""}`);
|
|
12
|
-
} else {
|
|
13
|
-
fail++;
|
|
14
|
-
console.error(`✗ ${name}${detail ? `: ${detail}` : ""}`);
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// 429 + 页面有验证码容器
|
|
19
|
-
// captchaPhase 控制验证码何时出现:
|
|
20
|
-
// "instant" → 首次检测时已渲染(进分支 5s 检测命中)
|
|
21
|
-
// "delayed" → 首次检测时未渲染,进入重试等待后才出现(sleep 醒来后 3s 检测命中)
|
|
22
|
-
// "never" → 不出现(走正常被封重试)
|
|
23
|
-
function make429CaptchaPage(username, captchaPhase = "instant") {
|
|
24
|
-
let gotoCount = 0;
|
|
25
|
-
let captchaDelayElapsed = false;
|
|
26
|
-
const page = {
|
|
27
|
-
_handlers: {},
|
|
28
|
-
on(event, fn) {
|
|
29
|
-
(page._handlers[event] ||= []).push(fn);
|
|
30
|
-
return page;
|
|
31
|
-
},
|
|
32
|
-
off(event) {
|
|
33
|
-
delete page._handlers[event];
|
|
34
|
-
return page;
|
|
35
|
-
},
|
|
36
|
-
url: () => `https://www.tiktok.com/@${username}`,
|
|
37
|
-
goto: async () => {
|
|
38
|
-
gotoCount++;
|
|
39
|
-
},
|
|
40
|
-
// checkCdpHealth 用 () => document.readyState(fn.length===0)→ "complete"
|
|
41
|
-
// detectCaptcha/detectCaptchaWithWait 通过 captchaDelayElapsed 开关控制
|
|
42
|
-
_captchaVisible() {
|
|
43
|
-
if (captchaPhase === "never") return false;
|
|
44
|
-
if (captchaPhase === "instant") return true;
|
|
45
|
-
return captchaDelayElapsed; // delayed:等待后才可见
|
|
46
|
-
},
|
|
47
|
-
evaluate: async (fn) => {
|
|
48
|
-
if (
|
|
49
|
-
fn.toString().includes("captcha-verify-container") &&
|
|
50
|
-
page._captchaVisible()
|
|
51
|
-
) {
|
|
52
|
-
return {
|
|
53
|
-
exists: true,
|
|
54
|
-
visible: true,
|
|
55
|
-
rect: { x: 0, y: 0, w: 100, h: 100 },
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
// detectPageError(fn.length===1)→ null
|
|
59
|
-
if (fn.length === 1) return null;
|
|
60
|
-
return "complete";
|
|
61
|
-
},
|
|
62
|
-
waitForFunction: async (fn, opts) => {
|
|
63
|
-
// 带等待检测:轮询语义——可见即返回 handle,否则等满 timeout 返回 null
|
|
64
|
-
const deadline = Date.now() + (opts?.timeout || 5000);
|
|
65
|
-
while (Date.now() < deadline) {
|
|
66
|
-
if (page._captchaVisible()) {
|
|
67
|
-
return { jsonValue: async () => true };
|
|
68
|
-
}
|
|
69
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
70
|
-
}
|
|
71
|
-
return null;
|
|
72
|
-
},
|
|
73
|
-
reload: async () => {},
|
|
74
|
-
waitForResponse: async (pred) => {
|
|
75
|
-
const resp = {
|
|
76
|
-
url: () => "https://www.tiktok.com/api/post/item_list/?...",
|
|
77
|
-
status: () => 429,
|
|
78
|
-
headers: () => ({ "content-length": "19" }),
|
|
79
|
-
json: async () => {
|
|
80
|
-
throw new Error("Unexpected token 'r'...");
|
|
81
|
-
},
|
|
82
|
-
body: async () => Buffer.from("ratelimit triggered"),
|
|
83
|
-
};
|
|
84
|
-
if (!pred(resp)) throw new Error("response 不匹配 item_list");
|
|
85
|
-
return resp;
|
|
86
|
-
},
|
|
87
|
-
get gotoCount() {
|
|
88
|
-
return gotoCount;
|
|
89
|
-
},
|
|
90
|
-
// 测试钩子:标记 delayed 验证码已出现
|
|
91
|
-
_markCaptchaDelayed() {
|
|
92
|
-
captchaDelayElapsed = true;
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
return page;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 用例1:429 + 验证码立即可见 → 首次检测命中,只 goto 1 次(无重试)
|
|
99
|
-
{
|
|
100
|
-
const page = make429CaptchaPage("fumolbbf08o", "instant");
|
|
101
|
-
try {
|
|
102
|
-
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
103
|
-
check("429+验证码(即时) 立即转验证码", false, "未抛错");
|
|
104
|
-
} catch (e) {
|
|
105
|
-
check(
|
|
106
|
-
"429+验证码(即时) 立即转验证码",
|
|
107
|
-
e.message === "验证码: fumolbbf08o",
|
|
108
|
-
`抛错 "${e.message}"`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
check(
|
|
112
|
-
"429+验证码(即时) 不再重试(仅 1 次 goto)",
|
|
113
|
-
page.gotoCount === 1,
|
|
114
|
-
`goto 次数 = ${page.gotoCount}`,
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// 用例2(核心场景):429 首检时验证码未渲染 → 重试等待期间弹出
|
|
119
|
-
// → sleep 醒来后的检测必须抓住它(下次 goto 冲掉前),抛 "验证码:"
|
|
120
|
-
{
|
|
121
|
-
const page = make429CaptchaPage("fumolbbf08o", "delayed");
|
|
122
|
-
// 在 waitForFunction 轮询到 captchaDelayElapsed=false 一轮后标记出现,
|
|
123
|
-
// 模拟"等待期渲染":用 setTimeout 在重试 sleep 期间触发
|
|
124
|
-
setTimeout(() => page._markCaptchaDelayed(), 50);
|
|
125
|
-
try {
|
|
126
|
-
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
127
|
-
check("等待期间弹出的验证码 被醒来检测抓住", false, "未抛错");
|
|
128
|
-
} catch (e) {
|
|
129
|
-
check(
|
|
130
|
-
"等待期间弹出的验证码 被醒来检测抓住",
|
|
131
|
-
e.message === "验证码: fumolbbf08o",
|
|
132
|
-
`抛错 "${e.message}",goto ${page.gotoCount} 次`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
check(
|
|
136
|
-
"醒来检测抓住时未再发起下一轮 goto(goto ≤ 2)",
|
|
137
|
-
page.gotoCount <= 2,
|
|
138
|
-
`goto 次数 = ${page.gotoCount}`,
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// 用例3:429 + 验证码始终不出现 → 走正常被封重试,最终抛 "被封:"
|
|
143
|
-
{
|
|
144
|
-
const page = make429CaptchaPage("fumolbbf08o", "never");
|
|
145
|
-
try {
|
|
146
|
-
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
147
|
-
check("429无验证码 仍按被封重试", false, "未抛错");
|
|
148
|
-
} catch (e) {
|
|
149
|
-
check(
|
|
150
|
-
"429无验证码 仍按被封重试",
|
|
151
|
-
e.message === "被封: fumolbbf08o",
|
|
152
|
-
`抛错 "${e.message}",goto ${page.gotoCount} 次`,
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// 用例4:429 + waitForFunction/检测本身抛异常(上下文销毁)→ 忽略,按被封重试
|
|
158
|
-
{
|
|
159
|
-
const page = make429CaptchaPage("fumolbbf08o", "never");
|
|
160
|
-
page.waitForFunction = async () => {
|
|
161
|
-
throw new Error("Execution context was destroyed");
|
|
162
|
-
};
|
|
163
|
-
try {
|
|
164
|
-
await fetchUserVideosAPI(page, "fumolbbf08o", 5, () => {});
|
|
165
|
-
check("检测异常时忽略按被封重试", false, "未抛错");
|
|
166
|
-
} catch (e) {
|
|
167
|
-
check(
|
|
168
|
-
"检测异常时忽略按被封重试",
|
|
169
|
-
e.message === "被封: fumolbbf08o",
|
|
170
|
-
`抛错 "${e.message}"`,
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
console.error(`\n结果: ${pass} 通过, ${fail} 失败`);
|
|
176
|
-
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
// claimNextJob 修复验证:功能语义 + 性能(合成 32 万行模拟生产行数)
|
|
2
|
-
// 用完即删
|
|
3
|
-
import fs from "fs";
|
|
4
|
-
import { createStore } from "../src/watch/data-store.js";
|
|
5
|
-
import { getDb } from "../src/watch/db-schema.js";
|
|
6
|
-
|
|
7
|
-
const TMP = "/tmp/tiktok-claim-test";
|
|
8
|
-
fs.rmSync(TMP, { recursive: true, force: true });
|
|
9
|
-
fs.mkdirSync(TMP, { recursive: true });
|
|
10
|
-
|
|
11
|
-
function freshStore(name) {
|
|
12
|
-
const p = `${TMP}/${name}.db`;
|
|
13
|
-
const store = createStore(p, {});
|
|
14
|
-
return { store, db: getDb(), path: p };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function insertRows(db, rows) {
|
|
18
|
-
const stmt = db.prepare(`
|
|
19
|
-
INSERT OR REPLACE INTO task_queue
|
|
20
|
-
(unique_id, status, guessed_location, video_count, follower_count,
|
|
21
|
-
tt_seller, verified, status_code, pinned, sources, created_at, claimed_at, claimed_by)
|
|
22
|
-
VALUES
|
|
23
|
-
(@unique_id, @status, @guessed_location, @video_count, @follower_count,
|
|
24
|
-
@tt_seller, @verified, @status_code, @pinned, @sources, @created_at, @claimed_at, @claimed_by)
|
|
25
|
-
`);
|
|
26
|
-
const tx = db.transaction((list) => {
|
|
27
|
-
for (const r of list) stmt.run(r);
|
|
28
|
-
});
|
|
29
|
-
tx(rows);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const baseRow = {
|
|
33
|
-
status: "pending",
|
|
34
|
-
guessed_location: "",
|
|
35
|
-
video_count: 5,
|
|
36
|
-
follower_count: 1000,
|
|
37
|
-
tt_seller: 0,
|
|
38
|
-
verified: 0,
|
|
39
|
-
status_code: null,
|
|
40
|
-
pinned: 0,
|
|
41
|
-
sources: '["explore"]',
|
|
42
|
-
created_at: "2026-01-01T00:00:00Z",
|
|
43
|
-
claimed_at: null,
|
|
44
|
-
claimed_by: null,
|
|
45
|
-
};
|
|
46
|
-
const row = (over) => ({ ...baseRow, unique_id: `u_${Math.random().toString(36).slice(2, 9)}`, ...over });
|
|
47
|
-
|
|
48
|
-
let pass = 0, fail = 0;
|
|
49
|
-
function check(name, cond, detail = "") {
|
|
50
|
-
if (cond) { pass++; console.log(` ✅ ${name}${detail ? " " + detail : ""}`); }
|
|
51
|
-
else { fail++; console.log(` ❌ ${name}${detail ? " " + detail : ""}`); }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ───────── B. 功能语义(小数据集,各用独立库) ─────────
|
|
55
|
-
console.log("\n== B1. 首国等值严格优先于空位置(即使空位置粉丝更高)==");
|
|
56
|
-
{
|
|
57
|
-
const { store, db } = freshStore("b1");
|
|
58
|
-
insertRows(db, [
|
|
59
|
-
row({ unique_id: "empty-high", guessed_location: "", follower_count: 9999 }),
|
|
60
|
-
row({ unique_id: "mx-low", guessed_location: "mx", follower_count: 100 }),
|
|
61
|
-
]);
|
|
62
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
63
|
-
check("领到 mx-low", j?.uniqueId === "mx-low", `got=${j?.uniqueId}`);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
console.log("== B2. 首国无候选时空位置可领 ==");
|
|
67
|
-
{
|
|
68
|
-
const { store, db } = freshStore("b2");
|
|
69
|
-
insertRows(db, [row({ unique_id: "only-empty", guessed_location: "" })]);
|
|
70
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
71
|
-
check("领到 only-empty", j?.uniqueId === "only-empty", `got=${j?.uniqueId}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
console.log("== B3. hasVideo 层整体优先于 noVideoReq 层(含空位置)==");
|
|
75
|
-
{
|
|
76
|
-
const { store, db } = freshStore("b3");
|
|
77
|
-
insertRows(db, [
|
|
78
|
-
row({ unique_id: "mx-novideo", guessed_location: "mx", video_count: 0, follower_count: 5000 }),
|
|
79
|
-
row({ unique_id: "br-video", guessed_location: "br", video_count: 3, follower_count: 100 }),
|
|
80
|
-
]);
|
|
81
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
82
|
-
check("mx-noVideo 优先于 br-video(与原版层级一致)", j?.uniqueId === "mx-novideo", `got=${j?.uniqueId}`);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
console.log("== B4. 国家顺序优先:MX 行优先于 BR 行 ==");
|
|
86
|
-
{
|
|
87
|
-
const { store, db } = freshStore("b4");
|
|
88
|
-
insertRows(db, [
|
|
89
|
-
row({ unique_id: "br-high", guessed_location: "br", follower_count: 9999 }),
|
|
90
|
-
row({ unique_id: "mx-low", guessed_location: "mx", follower_count: 10 }),
|
|
91
|
-
]);
|
|
92
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
93
|
-
check("领到 mx-low", j?.uniqueId === "mx-low", `got=${j?.uniqueId}`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
console.log("== B5. pinned:空池记忆后 pin 立即可领(缓存失效生效)==");
|
|
97
|
-
{
|
|
98
|
-
const { store, db } = freshStore("b5");
|
|
99
|
-
insertRows(db, [
|
|
100
|
-
row({ unique_id: "plain", guessed_location: "mx", follower_count: 100 }),
|
|
101
|
-
row({ unique_id: "pinned-later", guessed_location: "br", follower_count: 50 }),
|
|
102
|
-
]);
|
|
103
|
-
// 第一次领取:pinned 分支执行且 miss → 写入空池记忆
|
|
104
|
-
const j1 = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
105
|
-
check("第一次领到 plain", j1?.uniqueId === "plain", `got=${j1?.uniqueId}`);
|
|
106
|
-
// pin 一个低优先级任务
|
|
107
|
-
const pinRet = store.togglePin("pinned-later");
|
|
108
|
-
check("togglePin 成功", pinRet?.saved === true);
|
|
109
|
-
// 立即换客户端领取:应直接领到 pinned 任务(不受空池记忆影响)
|
|
110
|
-
const j2 = await store.claimNextJob("c2", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
111
|
-
check("pin 后立即领到 pinned-later", j2?.uniqueId === "pinned-later", `got=${j2?.uniqueId}`);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
console.log("== B6. 未登录客户端不领商家 ==");
|
|
115
|
-
{
|
|
116
|
-
const { store, db } = freshStore("b6");
|
|
117
|
-
insertRows(db, [row({ unique_id: "seller-only", guessed_location: "mx", tt_seller: 1 })]);
|
|
118
|
-
const jOut = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], false);
|
|
119
|
-
check("未登录领不到", jOut === null, `got=${jOut?.uniqueId}`);
|
|
120
|
-
const jIn = await store.claimNextJob("c2", 5 * 60 * 1000, ["MX"], true);
|
|
121
|
-
check("登录可领", jIn?.uniqueId === "seller-only", `got=${jIn?.uniqueId}`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
console.log("== B7. status_code=209002 仅登录客户端可领 ==");
|
|
125
|
-
{
|
|
126
|
-
const { store, db } = freshStore("b7");
|
|
127
|
-
insertRows(db, [row({ unique_id: "code-209002", guessed_location: "mx", status_code: 209002 })]);
|
|
128
|
-
const jOut = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], false);
|
|
129
|
-
check("未登录领不到", jOut === null, `got=${jOut?.uniqueId}`);
|
|
130
|
-
const jIn = await store.claimNextJob("c2", 5 * 60 * 1000, ["MX"], true);
|
|
131
|
-
check("登录可领", jIn?.uniqueId === "code-209002", `got=${jIn?.uniqueId}`);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
console.log("== B8. 超时 processing 回收优先 ==");
|
|
135
|
-
{
|
|
136
|
-
const { store, db } = freshStore("b8");
|
|
137
|
-
const now = Date.now();
|
|
138
|
-
insertRows(db, [
|
|
139
|
-
row({ unique_id: "stale", guessed_location: "mx", status: "processing", claimed_at: now - 10 * 60 * 1000, claimed_by: "dead-client" }),
|
|
140
|
-
row({ unique_id: "fresh", guessed_location: "mx", follower_count: 99999 }),
|
|
141
|
-
]);
|
|
142
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], true);
|
|
143
|
-
check("回收的 stale 优先于高粉丝 fresh", j?.uniqueId === "stale", `got=${j?.uniqueId}`);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
console.log("== B9. 商家任意国家兜底(sellerAnyCountry)==");
|
|
147
|
-
{
|
|
148
|
-
const { store, db } = freshStore("b9");
|
|
149
|
-
insertRows(db, [
|
|
150
|
-
row({ unique_id: "zz-seller", guessed_location: "zz", tt_seller: 1, verified: 0, follower_count: 10 }),
|
|
151
|
-
row({ unique_id: "mx-normal", guessed_location: "mx", follower_count: 9999 }),
|
|
152
|
-
]);
|
|
153
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX"], true);
|
|
154
|
-
check("商家级高于 other 级:zz-seller 先领", j?.uniqueId === "zz-seller", `got=${j?.uniqueId}`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
console.log("== B10. seed 级优先于 follow/other ==");
|
|
158
|
-
{
|
|
159
|
-
const { store, db } = freshStore("b10");
|
|
160
|
-
insertRows(db, [
|
|
161
|
-
row({ unique_id: "mx-seed", guessed_location: "mx", sources: '["explore"]', follower_count: 10 }),
|
|
162
|
-
row({ unique_id: "br-seed", guessed_location: "br", sources: '["seed"]', follower_count: 20 }),
|
|
163
|
-
]);
|
|
164
|
-
const j = await store.claimNextJob("c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
165
|
-
check("seed 级 br-seed 胜过 other 级 mx(国家级让位于来源级)", j?.uniqueId === "br-seed", `got=${j?.uniqueId}`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ───────── A. 性能(32 万行)─────────
|
|
169
|
-
console.log("\n== A. 性能:320,600 行 pending,候选国家池全空,模拟生产全 miss 场景 ==");
|
|
170
|
-
{
|
|
171
|
-
const { store, db } = freshStore("perf");
|
|
172
|
-
// 32 万行:位置全 ZZ(配置外),含 3000 个高粉丝商家(seller any-country 分支秒命中用)
|
|
173
|
-
db.exec(`
|
|
174
|
-
WITH RECURSIVE seq(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM seq WHERE i < 320600)
|
|
175
|
-
INSERT INTO task_queue
|
|
176
|
-
(unique_id, status, guessed_location, video_count, follower_count, tt_seller, verified, status_code, pinned, sources, created_at)
|
|
177
|
-
SELECT
|
|
178
|
-
'bulk_' || i,
|
|
179
|
-
'pending',
|
|
180
|
-
'zz',
|
|
181
|
-
CASE WHEN i % 3 = 0 THEN 5 ELSE 0 END,
|
|
182
|
-
(i * 7919) % 1000000,
|
|
183
|
-
CASE WHEN i <= 3000 THEN 1 ELSE 0 END,
|
|
184
|
-
0,
|
|
185
|
-
NULL,
|
|
186
|
-
0,
|
|
187
|
-
'["explore"]',
|
|
188
|
-
'2026-01-01T00:00:00Z'
|
|
189
|
-
FROM seq
|
|
190
|
-
`);
|
|
191
|
-
// 保证 3 个最高粉丝都是商家 → seller any-country 命中不扫表
|
|
192
|
-
db.exec(`UPDATE task_queue SET follower_count = 100000000 + CAST(unique_id AS INTEGER) % 1000 WHERE tt_seller = 1 AND CAST(REPLACE(unique_id,'bulk_','') AS INTEGER) <= 3`);
|
|
193
|
-
|
|
194
|
-
const t1 = Date.now();
|
|
195
|
-
const j1 = await store.claimNextJob("perf-c1", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
196
|
-
const t1ms = Date.now() - t1;
|
|
197
|
-
check("首领成功(命中商家兜底)", j1?.uniqueId?.startsWith("bulk_"), `got=${j1?.uniqueId} ${t1ms}ms`);
|
|
198
|
-
|
|
199
|
-
const t2 = Date.now();
|
|
200
|
-
const j2 = await store.claimNextJob("perf-c2", 5 * 60 * 1000, ["MX", "BR"], true);
|
|
201
|
-
const t2ms = Date.now() - t2;
|
|
202
|
-
check("次领成功", j2?.uniqueId?.startsWith("bulk_"), `got=${j2?.uniqueId} ${t2ms}ms`);
|
|
203
|
-
check("次领显著快于首领(pinned/seed 空池缓存生效)", t2ms < t1ms, `t1=${t1ms}ms t2=${t2ms}ms`);
|
|
204
|
-
check("次领 < 150ms", t2ms < 150, `${t2ms}ms`);
|
|
205
|
-
|
|
206
|
-
// EXPLAIN:确认单国等值分支走 fast 索引
|
|
207
|
-
const plan = db
|
|
208
|
-
.prepare(`EXPLAIN QUERY PLAN SELECT unique_id FROM task_queue WHERE status='pending' AND pinned=0 AND video_count > 0 AND UPPER(COALESCE(guessed_location, '')) IN ('MX') AND (status_code IS NULL OR status_code = 0 OR status_code = 209002) ORDER BY follower_count DESC, created_at ASC, unique_id ASC LIMIT 1`)
|
|
209
|
-
.all();
|
|
210
|
-
const detail = plan.map((r) => r.detail).join(" | ");
|
|
211
|
-
check("单国分支走 idx_tq_claim_fast", detail.includes("idx_tq_claim_fast"), detail);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
console.log(`\n===== 结果: ${pass} passed, ${fail} failed =====`);
|
|
215
|
-
fs.rmSync(TMP, { recursive: true, force: true });
|
|
216
|
-
process.exit(fail > 0 ? 1 : 0);
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* clash 控制端自动发现 — 验证脚本
|
|
4
|
-
* 用法: node scripts/test-clash-discover.mjs
|
|
5
|
-
*/
|
|
6
|
-
import {
|
|
7
|
-
parseFlatYaml,
|
|
8
|
-
parseController,
|
|
9
|
-
verifyController,
|
|
10
|
-
discover,
|
|
11
|
-
invalidateDiscoverCache,
|
|
12
|
-
listListeningPorts,
|
|
13
|
-
getSystemProxyPorts,
|
|
14
|
-
} from "../src/clash/discover.js";
|
|
15
|
-
import { configure, getBaseConfig, resetConfig } from "../src/clash/api.js";
|
|
16
|
-
import { readFileSync, existsSync } from "fs";
|
|
17
|
-
import { homedir } from "os";
|
|
18
|
-
import { join } from "path";
|
|
19
|
-
|
|
20
|
-
let failed = 0;
|
|
21
|
-
function check(name, cond, extra = "") {
|
|
22
|
-
console.log(`${cond ? "✓" : "✗"} ${name}${extra ? ` — ${extra}` : ""}`);
|
|
23
|
-
if (!cond) failed++;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ── 单元 ──
|
|
27
|
-
const yaml = parseFlatYaml(
|
|
28
|
-
`external-controller: 127.0.0.1:19835\nsecret: "abc123"\nmode: rule\nproxies:\n - a\n`,
|
|
29
|
-
);
|
|
30
|
-
check(
|
|
31
|
-
"parseFlatYaml 顶层字段",
|
|
32
|
-
yaml["external-controller"] === "127.0.0.1:19835" && yaml.secret === "abc123",
|
|
33
|
-
);
|
|
34
|
-
check("parseFlatYaml 跳过嵌套/列表", !("proxies" in yaml));
|
|
35
|
-
check("parseController 标准格式", parseController("127.0.0.1:9097") === 9097);
|
|
36
|
-
check("parseController 空值", parseController("") === null);
|
|
37
|
-
check("listListeningPorts 枚举非空", listListeningPorts().length > 0);
|
|
38
|
-
|
|
39
|
-
// ── 集成: 按当前环境自适应验证 ──
|
|
40
|
-
const listening = listListeningPorts();
|
|
41
|
-
const clvUp = listening.includes(9097);
|
|
42
|
-
// 69cloud 控制端口会漂移(19835 → 19216 …),从配置文件动态读取
|
|
43
|
-
const n69CfgPath = join(
|
|
44
|
-
homedir(),
|
|
45
|
-
"Library/Application Support/69cloud",
|
|
46
|
-
"config",
|
|
47
|
-
"config.yaml",
|
|
48
|
-
);
|
|
49
|
-
let n69Port = null;
|
|
50
|
-
if (existsSync(n69CfgPath)) {
|
|
51
|
-
n69Port = parseController(
|
|
52
|
-
parseFlatYaml(readFileSync(n69CfgPath, "utf-8"))["external-controller"],
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
const n69Up = n69Port !== null && listening.includes(n69Port);
|
|
56
|
-
|
|
57
|
-
if (clvUp) {
|
|
58
|
-
check(
|
|
59
|
-
"verifyController 9097 + abcdefg",
|
|
60
|
-
await verifyController(9097, "abcdefg"),
|
|
61
|
-
);
|
|
62
|
-
} else {
|
|
63
|
-
console.log("○ 跳过 9097 验证(Clash Verge 未运行)");
|
|
64
|
-
}
|
|
65
|
-
if (n69Up) {
|
|
66
|
-
check(
|
|
67
|
-
"verifyController 69cloud + 错误secret 应失败",
|
|
68
|
-
!(await verifyController(n69Port, "wrong-secret")),
|
|
69
|
-
);
|
|
70
|
-
} else {
|
|
71
|
-
console.log("○ 跳过 69cloud 验证(69cloud 未运行)");
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const sysPorts = getSystemProxyPorts();
|
|
75
|
-
console.log(` 系统代理端口: [${sysPorts.join(", ") || "无"}]`);
|
|
76
|
-
|
|
77
|
-
// ── 集成: 自动发现 ──
|
|
78
|
-
invalidateDiscoverCache();
|
|
79
|
-
const d1 = await discover();
|
|
80
|
-
if (clvUp || n69Up) {
|
|
81
|
-
check(
|
|
82
|
-
"discover 自动模式(有客户端在线)",
|
|
83
|
-
!d1.fallback && listening.includes(d1.port),
|
|
84
|
-
`选定 ${d1.clientName} @${d1.port} 来源=${d1.source} proxy=${d1.proxyUrl}`,
|
|
85
|
-
);
|
|
86
|
-
console.log(` -> ${JSON.stringify(d1)}`);
|
|
87
|
-
|
|
88
|
-
// 显式覆盖(用自动发现选定的控制端做正向验证)
|
|
89
|
-
invalidateDiscoverCache();
|
|
90
|
-
const d2 = await discover({ apiPort: d1.port, secret: d1.secret });
|
|
91
|
-
check(
|
|
92
|
-
"discover 显式覆盖",
|
|
93
|
-
d2.port === d1.port && d2.source === "手动指定",
|
|
94
|
-
`@${d2.port} 来源=${d2.source}`,
|
|
95
|
-
);
|
|
96
|
-
} else {
|
|
97
|
-
check(
|
|
98
|
-
"discover 无客户端 → 回退默认值",
|
|
99
|
-
d1.fallback === true &&
|
|
100
|
-
d1.port === 9097 &&
|
|
101
|
-
d1.source === "未发现可用控制端",
|
|
102
|
-
`选定 ${d1.clientName} @${d1.port} 来源=${d1.source}`,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// 显式覆盖错误端口 → 必须抛错(任何环境都验证)
|
|
107
|
-
invalidateDiscoverCache();
|
|
108
|
-
let threw = false;
|
|
109
|
-
try {
|
|
110
|
-
await discover({ apiPort: 12345, secret: "x" });
|
|
111
|
-
} catch (e) {
|
|
112
|
-
threw = true;
|
|
113
|
-
console.log(` 预期报错: ${e.message}`);
|
|
114
|
-
}
|
|
115
|
-
check("discover 错误端口必须报错", threw);
|
|
116
|
-
|
|
117
|
-
// ── api.configure 注入 ──
|
|
118
|
-
resetConfig();
|
|
119
|
-
configure({ port: 19835, secret: "demo" });
|
|
120
|
-
const bc = getBaseConfig();
|
|
121
|
-
check("api.configure 注入", bc.port === 19835 && bc.secret === "demo");
|
|
122
|
-
resetConfig();
|
|
123
|
-
check("api.resetConfig 还原", getBaseConfig().port === 9097);
|
|
124
|
-
|
|
125
|
-
console.log(failed ? `\n✗ ${failed} 项失败` : "\n✓ 全部通过");
|
|
126
|
-
process.exit(failed ? 1 : 0);
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 候选集迟滞(mergeHealthyWithHysteresis)单测(离线,不碰真实健康文件)
|
|
3
|
-
*
|
|
4
|
-
* 背景:delay 初测为单点采样,瞬时抖动会把健康节点误踢出池(2026-09-28 生产实锤:
|
|
5
|
-
* 英国节点被白名单守卫切回)。迟滞规则:连续 2 轮初测失败才除名;失败第 1 轮保留
|
|
6
|
-
* 在池内作"降级观察",写盘顺序沉底(幸存者在前后降级在后,轮换优先实测存活节点)。
|
|
7
|
-
*
|
|
8
|
-
* 覆盖:
|
|
9
|
-
* - 首轮(无上一轮池)→ 仅幸存者,无降级
|
|
10
|
-
* - 本轮失败第 1 轮 → 保留 + 计数 1,排幸存者之后
|
|
11
|
-
* - 连续第 2 轮失败(prevDegraded=1)→ 除名
|
|
12
|
-
* - 降级后恢复 → 计数自动清零
|
|
13
|
-
* - maxRounds=1 → 关闭迟滞(失败即除名)
|
|
14
|
-
* - 容错:prev 非法值 / 空 prevDegraded
|
|
15
|
-
* - 文件往返:saveHealthyList(degraded) → loadHealthyList 读回
|
|
16
|
-
* - pruneHealthyListByCurrentProxies 剔除节点时携带留存节点的 degraded
|
|
17
|
-
*
|
|
18
|
-
* 运行: node scripts/test-clash-hysteresis.mjs
|
|
19
|
-
*/
|
|
20
|
-
import assert from "node:assert/strict";
|
|
21
|
-
import { unlinkSync, existsSync } from "fs";
|
|
22
|
-
import { tmpdir } from "os";
|
|
23
|
-
import { join } from "path";
|
|
24
|
-
import {
|
|
25
|
-
mergeHealthyWithHysteresis,
|
|
26
|
-
saveHealthyList,
|
|
27
|
-
loadHealthyList,
|
|
28
|
-
pruneHealthyListByCurrentProxies,
|
|
29
|
-
applyDiscovered,
|
|
30
|
-
} from "../src/clash/checker.js";
|
|
31
|
-
|
|
32
|
-
let passed = 0;
|
|
33
|
-
const failures = [];
|
|
34
|
-
async function check(name, fn) {
|
|
35
|
-
try {
|
|
36
|
-
await fn();
|
|
37
|
-
passed++;
|
|
38
|
-
console.log(`PASS ${name}`);
|
|
39
|
-
} catch (e) {
|
|
40
|
-
failures.push({ name, e });
|
|
41
|
-
console.log(`FAIL ${name}: ${e.message}`);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ── 纯函数:mergeHealthyWithHysteresis ──
|
|
46
|
-
|
|
47
|
-
await check("首轮(无上一轮池)→ 仅幸存者,无降级", () => {
|
|
48
|
-
const r = mergeHealthyWithHysteresis(["A", "B"], [], {}, 2);
|
|
49
|
-
assert.deepEqual(r.nodes, ["A", "B"]);
|
|
50
|
-
assert.deepEqual(r.degraded, {});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
await check("本轮失败第 1 轮 → 保留 + 计数 1,排幸存者之后", () => {
|
|
54
|
-
// 上一轮池 [A,B,C],本轮只有 A 可达:B/C 首次失败,保留观察
|
|
55
|
-
const r = mergeHealthyWithHysteresis(["A"], ["A", "B", "C"], {}, 2);
|
|
56
|
-
assert.deepEqual(r.nodes, ["A", "B", "C"]); // 幸存者在前、降级在后(B/C 保持原相对序)
|
|
57
|
-
assert.deepEqual(r.degraded, { B: 1, C: 1 });
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
await check("连续第 2 轮失败(prevDegraded=1)→ 除名", () => {
|
|
61
|
-
// B 上一轮已失败 1 次,本轮再失败 → 达到 maxRounds=2,除名;C 首次失败保留
|
|
62
|
-
const r = mergeHealthyWithHysteresis(["A"], ["A", "B", "C"], { B: 1 }, 2);
|
|
63
|
-
assert.deepEqual(r.nodes, ["A", "C"]);
|
|
64
|
-
assert.deepEqual(r.degraded, { C: 1 });
|
|
65
|
-
assert.ok(!("B" in r.degraded));
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
await check("降级后恢复 → 计数自动清零", () => {
|
|
69
|
-
// C 上轮降级(计数 1),本轮恢复 → 回到幸存者区,degraded 中无 C;
|
|
70
|
-
// B 本轮首次失败,照常保留观察
|
|
71
|
-
const r = mergeHealthyWithHysteresis(
|
|
72
|
-
["A", "C"],
|
|
73
|
-
["A", "B", "C"],
|
|
74
|
-
{ C: 1 },
|
|
75
|
-
2,
|
|
76
|
-
);
|
|
77
|
-
assert.deepEqual(r.nodes, ["A", "C", "B"]);
|
|
78
|
-
assert.deepEqual(r.degraded, { B: 1 });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
await check("maxRounds=1 → 关闭迟滞(失败即除名)", () => {
|
|
82
|
-
const r = mergeHealthyWithHysteresis(["A"], ["A", "B"], {}, 1);
|
|
83
|
-
assert.deepEqual(r.nodes, ["A"]);
|
|
84
|
-
assert.deepEqual(r.degraded, {});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
await check("容错:prev 非法 / prevDegraded 非法", () => {
|
|
88
|
-
const r = mergeHealthyWithHysteresis(["A"], null, "bad", 2);
|
|
89
|
-
assert.deepEqual(r.nodes, ["A"]);
|
|
90
|
-
assert.deepEqual(r.degraded, {});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// ── 文件往返(隔离端口通道,不碰真实 clash-healthy-nodes.json) ──
|
|
94
|
-
|
|
95
|
-
const TEST_PORT = 64000 + Math.floor(Math.random() * 1000);
|
|
96
|
-
const TEST_FILE = join(tmpdir(), `clash-healthy-nodes-${TEST_PORT}.json`);
|
|
97
|
-
applyDiscovered({ proxyPort: TEST_PORT });
|
|
98
|
-
|
|
99
|
-
try {
|
|
100
|
-
await check("saveHealthyList(degraded) → loadHealthyList 读回", () => {
|
|
101
|
-
saveHealthyList(
|
|
102
|
-
["🇺🇸美国HY1", "🇬🇧英国HY1"],
|
|
103
|
-
{ "🇺🇸美国HY1": 800 },
|
|
104
|
-
{ "🇬🇧英国HY1": 1 },
|
|
105
|
-
);
|
|
106
|
-
const d = loadHealthyList();
|
|
107
|
-
assert.deepEqual(d.nodes, ["🇺🇸美国HY1", "🇬🇧英国HY1"]);
|
|
108
|
-
assert.equal(d.delays["🇺🇸美国HY1"], 800);
|
|
109
|
-
assert.equal(d.degraded["🇬🇧英国HY1"], 1);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
await check("pruneHealthyListByCurrentProxies 携带留存节点的 degraded", () => {
|
|
113
|
-
// 内核中只剩美国节点:英国被剔除,美国的降级计数(若有)应保留
|
|
114
|
-
saveHealthyList(
|
|
115
|
-
["🇺🇸美国HY1", "🇺🇸美国HY2", "🇬🇧英国HY1"],
|
|
116
|
-
{ "🇺🇸美国HY1": 800 },
|
|
117
|
-
{ "🇺🇸美国HY2": 1, "🇬🇧英国HY1": 1 },
|
|
118
|
-
);
|
|
119
|
-
const r = pruneHealthyListByCurrentProxies(["🇺🇸美国HY1", "🇺🇸美国HY2"]);
|
|
120
|
-
assert.equal(r.removed, 1);
|
|
121
|
-
const d = loadHealthyList();
|
|
122
|
-
assert.deepEqual(d.nodes, ["🇺🇸美国HY1", "🇺🇸美国HY2"]);
|
|
123
|
-
assert.equal(d.degraded["🇺🇸美国HY2"], 1);
|
|
124
|
-
assert.ok(!("🇬🇧英国HY1" in d.degraded));
|
|
125
|
-
});
|
|
126
|
-
} finally {
|
|
127
|
-
if (existsSync(TEST_FILE)) unlinkSync(TEST_FILE);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
console.log(`\n结果: ${passed} 通过, ${failures.length} 失败`);
|
|
131
|
-
if (failures.length) process.exit(1);
|