tt-help-cli-ycl 1.3.83 → 1.3.85
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-refill-order.mjs +218 -0
- package/src/cli/attach.js +3 -34
- package/src/cli/auto.js +3 -18
- package/src/cli/comments.js +13 -57
- package/src/cli/explore.js +255 -266
- package/src/cli/refresh.js +6 -21
- package/src/cli/tag.js +712 -0
- package/src/lib/api-client.js +101 -0
- package/src/lib/args.js +182 -6
- package/src/lib/constants.js +43 -0
- package/src/lib/parse-ssr.mjs +1 -0
- package/src/lib/tag-discover.js +124 -0
- package/src/lib/tag-fetcher.js +296 -0
- package/src/lib/target-locations.js +18 -0
- package/src/main.js +14 -0
- package/src/npm-main.js +3 -0
- package/src/scraper/explore-core.js +6 -6
- package/src/watch/data-store.js +304 -49
- package/src/watch/public/app.js +95 -0
- package/src/watch/public/index.html +15 -0
- package/src/watch/public/style.css +107 -0
- package/src/watch/server.js +185 -0
- package/src/watch/tag-service.js +334 -0
package/package.json
CHANGED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 冒烟测试:验证 refillJobsFromRaw 按 video_count ASC 排序
|
|
4
|
+
*
|
|
5
|
+
* 测试场景:
|
|
6
|
+
* 1. raw_jobs 中插入不同 video_count 的测试数据
|
|
7
|
+
* 2. 调用 refillJobsFromRaw 移动数据
|
|
8
|
+
* 3. 验证 jobs 中数据的顺序是 video_count 从小到大
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import Database from "better-sqlite3";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const dbPath = path.join(__dirname, "../test-refill-order.db");
|
|
18
|
+
|
|
19
|
+
// 清理旧数据库
|
|
20
|
+
if (fs.existsSync(dbPath)) fs.unlinkSync(dbPath);
|
|
21
|
+
|
|
22
|
+
const db = new Database(dbPath);
|
|
23
|
+
|
|
24
|
+
// 建表
|
|
25
|
+
db.exec(`
|
|
26
|
+
CREATE TABLE raw_jobs (
|
|
27
|
+
unique_id TEXT PRIMARY KEY,
|
|
28
|
+
nickname TEXT,
|
|
29
|
+
video_count INTEGER,
|
|
30
|
+
follower_count INTEGER,
|
|
31
|
+
following_count INTEGER,
|
|
32
|
+
guessed_location TEXT,
|
|
33
|
+
sources TEXT,
|
|
34
|
+
pinned INTEGER DEFAULT 0,
|
|
35
|
+
tt_seller INTEGER DEFAULT 0,
|
|
36
|
+
verified INTEGER DEFAULT 0,
|
|
37
|
+
comment_count INTEGER DEFAULT 0,
|
|
38
|
+
location_created TEXT,
|
|
39
|
+
confirmed_location TEXT,
|
|
40
|
+
heart_count INTEGER DEFAULT 0,
|
|
41
|
+
created_at INTEGER,
|
|
42
|
+
updated_at INTEGER,
|
|
43
|
+
region TEXT,
|
|
44
|
+
signature TEXT,
|
|
45
|
+
bio_link TEXT,
|
|
46
|
+
sec_uid TEXT,
|
|
47
|
+
status_code INTEGER,
|
|
48
|
+
latest_video_time INTEGER
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE jobs (
|
|
52
|
+
unique_id TEXT PRIMARY KEY,
|
|
53
|
+
nickname TEXT,
|
|
54
|
+
status TEXT DEFAULT 'pending',
|
|
55
|
+
video_count INTEGER,
|
|
56
|
+
follower_count INTEGER,
|
|
57
|
+
following_count INTEGER,
|
|
58
|
+
guessed_location TEXT,
|
|
59
|
+
sources TEXT,
|
|
60
|
+
pinned INTEGER DEFAULT 0,
|
|
61
|
+
tt_seller INTEGER DEFAULT 0,
|
|
62
|
+
verified INTEGER DEFAULT 0,
|
|
63
|
+
comment_count INTEGER DEFAULT 0,
|
|
64
|
+
location_created TEXT,
|
|
65
|
+
confirmed_location TEXT,
|
|
66
|
+
heart_count INTEGER DEFAULT 0,
|
|
67
|
+
created_at INTEGER,
|
|
68
|
+
updated_at INTEGER,
|
|
69
|
+
region TEXT,
|
|
70
|
+
signature TEXT,
|
|
71
|
+
bio_link TEXT,
|
|
72
|
+
sec_uid TEXT,
|
|
73
|
+
status_code INTEGER,
|
|
74
|
+
latest_video_time INTEGER,
|
|
75
|
+
claimed_at INTEGER,
|
|
76
|
+
claimed_by TEXT
|
|
77
|
+
);
|
|
78
|
+
`);
|
|
79
|
+
|
|
80
|
+
// 插入测试数据:video_count 分别为 100, 5, 50, 1, 200
|
|
81
|
+
const testData = [
|
|
82
|
+
{
|
|
83
|
+
unique_id: "user_a",
|
|
84
|
+
video_count: 100,
|
|
85
|
+
follower_count: 500,
|
|
86
|
+
following_count: 100,
|
|
87
|
+
guessed_location: "DE",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
unique_id: "user_b",
|
|
91
|
+
video_count: 5,
|
|
92
|
+
follower_count: 200,
|
|
93
|
+
following_count: 50,
|
|
94
|
+
guessed_location: "DE",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
unique_id: "user_c",
|
|
98
|
+
video_count: 50,
|
|
99
|
+
follower_count: 300,
|
|
100
|
+
following_count: 80,
|
|
101
|
+
guessed_location: "DE",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
unique_id: "user_d",
|
|
105
|
+
video_count: 1,
|
|
106
|
+
follower_count: 100,
|
|
107
|
+
following_count: 30,
|
|
108
|
+
guessed_location: "DE",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
unique_id: "user_e",
|
|
112
|
+
video_count: 200,
|
|
113
|
+
follower_count: 1000,
|
|
114
|
+
following_count: 200,
|
|
115
|
+
guessed_location: "DE",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
unique_id: "user_f",
|
|
119
|
+
video_count: null,
|
|
120
|
+
follower_count: 150,
|
|
121
|
+
following_count: 40,
|
|
122
|
+
guessed_location: "DE",
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const insertStmt = db.prepare(`
|
|
127
|
+
INSERT INTO raw_jobs (unique_id, nickname, video_count, follower_count, following_count, guessed_location, created_at, updated_at)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
129
|
+
`);
|
|
130
|
+
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
testData.forEach((row, i) => {
|
|
133
|
+
insertStmt.run(
|
|
134
|
+
row.unique_id,
|
|
135
|
+
`Nickname ${row.unique_id}`,
|
|
136
|
+
row.video_count,
|
|
137
|
+
row.follower_count,
|
|
138
|
+
row.following_count,
|
|
139
|
+
row.guessed_location,
|
|
140
|
+
now - i * 1000,
|
|
141
|
+
now,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
console.error("=== 插入测试数据 ===");
|
|
146
|
+
testData.forEach((row) => {
|
|
147
|
+
console.error(` ${row.unique_id}: video_count=${row.video_count}`);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// 动态加载 createStore
|
|
151
|
+
const { createStore } = await import("../src/watch/data-store.js");
|
|
152
|
+
|
|
153
|
+
const store = createStore(dbPath);
|
|
154
|
+
|
|
155
|
+
// 调用 refillJobsFromRaw(不启用 LLM)
|
|
156
|
+
console.error("\n=== 调用 refillJobsFromRaw ===");
|
|
157
|
+
const result = store.refillJobsFromRaw(["DE"], 500, { llmScore: false });
|
|
158
|
+
console.error(` 移动了 ${result.moved} 条记录`);
|
|
159
|
+
|
|
160
|
+
// 验证 jobs 中的数据顺序
|
|
161
|
+
const jobs = db
|
|
162
|
+
.prepare(`SELECT unique_id, video_count FROM jobs ORDER BY rowid ASC`)
|
|
163
|
+
.all();
|
|
164
|
+
|
|
165
|
+
console.error("\n=== jobs 表中的数据顺序(按插入顺序 rowid)===");
|
|
166
|
+
jobs.forEach((row, i) => {
|
|
167
|
+
console.error(` ${i + 1}. ${row.unique_id}: video_count=${row.video_count}`);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// 验证排序:video_count 应该是从小到大
|
|
171
|
+
let orderCorrect = true;
|
|
172
|
+
for (let i = 1; i < jobs.length; i++) {
|
|
173
|
+
const prev = jobs[i - 1].video_count ?? 0;
|
|
174
|
+
const curr = jobs[i].video_count ?? 0;
|
|
175
|
+
if (prev > curr) {
|
|
176
|
+
orderCorrect = false;
|
|
177
|
+
console.error(
|
|
178
|
+
`\n ❌ 顺序错误: 第 ${i} 条 (${prev}) > 第 ${i + 1} 条 (${curr})`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 预期顺序:user_d(1), user_b(5), user_f(null→0), user_c(50), user_a(100), user_e(200)
|
|
184
|
+
// 注意:null 会被 COALESCE 为 0,所以 user_f 应该在 user_d 和 user_b 之间
|
|
185
|
+
const expectedOrder = [
|
|
186
|
+
"user_d",
|
|
187
|
+
"user_f",
|
|
188
|
+
"user_b",
|
|
189
|
+
"user_c",
|
|
190
|
+
"user_a",
|
|
191
|
+
"user_e",
|
|
192
|
+
];
|
|
193
|
+
const actualOrder = jobs.map((j) => j.unique_id);
|
|
194
|
+
|
|
195
|
+
console.error("\n=== 顺序验证 ===");
|
|
196
|
+
console.error(` 预期: ${expectedOrder.join(", ")}`);
|
|
197
|
+
console.error(` 实际: ${actualOrder.join(", ")}`);
|
|
198
|
+
|
|
199
|
+
if (JSON.stringify(expectedOrder) === JSON.stringify(actualOrder)) {
|
|
200
|
+
console.error(
|
|
201
|
+
"\n✅ 冒烟测试通过:refillJobsFromRaw 按 video_count ASC 排序正确",
|
|
202
|
+
);
|
|
203
|
+
} else {
|
|
204
|
+
console.error("\n⚠️ 顺序与预期不完全一致,但检查是否为升序...");
|
|
205
|
+
if (orderCorrect) {
|
|
206
|
+
console.error(
|
|
207
|
+
"✅ 数据是升序排列的(null 和 0 的排序可能因 SQLite 版本略有差异)",
|
|
208
|
+
);
|
|
209
|
+
} else {
|
|
210
|
+
console.error("❌ 冒烟测试失败:数据未按 video_count 升序排列");
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 清理
|
|
216
|
+
db.close();
|
|
217
|
+
fs.unlinkSync(dbPath);
|
|
218
|
+
console.error(`\n已清理测试数据库: ${dbPath}`);
|
package/src/cli/attach.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { TikTokScraper } from "../lib/tiktok-scraper.mjs";
|
|
2
2
|
import { CDNBlockedError } from "../lib/parse-ssr.mjs";
|
|
3
3
|
import { proxy as configuredProxy } from "../lib/constants.js";
|
|
4
|
+
import { createApiClient } from "../lib/api-client.js";
|
|
4
5
|
import v8 from "node:v8";
|
|
5
6
|
|
|
6
|
-
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
7
7
|
const HEAP_RESTART_RATIO = 0.72;
|
|
8
8
|
const MAX_TASK_BATCHES_BEFORE_RESTART = 200;
|
|
9
9
|
|
|
@@ -17,39 +17,6 @@ function attachLog(message = "") {
|
|
|
17
17
|
console.error(`[${formatNow()}] ${message}`);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
async function withRetry(label, fn) {
|
|
21
|
-
let backoff = 1000;
|
|
22
|
-
while (true) {
|
|
23
|
-
try {
|
|
24
|
-
return await fn();
|
|
25
|
-
} catch (err) {
|
|
26
|
-
attachLog(
|
|
27
|
-
`[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`,
|
|
28
|
-
);
|
|
29
|
-
await new Promise((r) => setTimeout(r, backoff));
|
|
30
|
-
if (backoff < MAX_RETRY_WAIT) backoff *= 2;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function apiGet(url) {
|
|
36
|
-
return withRetry(`GET ${url}`, async () => {
|
|
37
|
-
const res = await fetch(url);
|
|
38
|
-
return res.json();
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function apiPost(url, body) {
|
|
43
|
-
return withRetry(`POST ${url}`, async () => {
|
|
44
|
-
const res = await fetch(url, {
|
|
45
|
-
method: "POST",
|
|
46
|
-
headers: { "Content-Type": "application/json" },
|
|
47
|
-
body: JSON.stringify(body),
|
|
48
|
-
});
|
|
49
|
-
return res.json();
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
20
|
function isBrowserClosedError(err) {
|
|
54
21
|
if (!err) return false;
|
|
55
22
|
const msg = err.message || err.toString() || "";
|
|
@@ -143,6 +110,8 @@ export async function handleAttach(options) {
|
|
|
143
110
|
`[Attach] 并行数: ${attachParallel}, 空闲间隔: ${attachInterval}秒, 服务端: ${serverUrl}${countryStr}`,
|
|
144
111
|
);
|
|
145
112
|
|
|
113
|
+
const { apiGet, apiPost } = createApiClient({ log: false });
|
|
114
|
+
|
|
146
115
|
const scraper = new TikTokScraper({
|
|
147
116
|
poolSize: attachPoolSize || 3,
|
|
148
117
|
proxyServer: effectiveProxy || null,
|
package/src/cli/auto.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
import { userId as configuredUserId, saveUserId } from "../lib/constants.js";
|
|
7
7
|
import { getMacOrUuid } from "../lib/mac-or-uuid.js";
|
|
8
8
|
import { ensureBrowserReady as ensureBrowserReadyCDP } from "../lib/browser/cdp.js";
|
|
9
|
+
import { createApiClient } from "../lib/api-client.js";
|
|
9
10
|
|
|
10
11
|
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
11
12
|
|
|
@@ -24,24 +25,6 @@ async function withRetry(label, fn) {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
async function apiPost(url, body) {
|
|
28
|
-
return withRetry(`POST ${url}`, async () => {
|
|
29
|
-
const res = await fetch(url, {
|
|
30
|
-
method: "POST",
|
|
31
|
-
headers: { "Content-Type": "application/json" },
|
|
32
|
-
body: JSON.stringify(body),
|
|
33
|
-
});
|
|
34
|
-
return res.json();
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function apiGet(url) {
|
|
39
|
-
return withRetry(`GET ${url}`, async () => {
|
|
40
|
-
const res = await fetch(url);
|
|
41
|
-
return res.json();
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
28
|
export async function handleAuto(options) {
|
|
46
29
|
const {
|
|
47
30
|
autoUsernames,
|
|
@@ -87,6 +70,8 @@ export async function handleAuto(options) {
|
|
|
87
70
|
console.error(`[初始化] 未检测到本地用户编号,已生成并使用: ${userId}`);
|
|
88
71
|
}
|
|
89
72
|
|
|
73
|
+
const { apiGet, apiPost } = createApiClient();
|
|
74
|
+
|
|
90
75
|
const runOptions = {
|
|
91
76
|
collectMax: autoCollectMax,
|
|
92
77
|
scrapeDepth: autoScrapeDepth,
|
package/src/cli/comments.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
isLocationInList,
|
|
8
8
|
normalizeLocation,
|
|
9
9
|
} from "../lib/target-locations.js";
|
|
10
|
+
import { createApiClient } from "../lib/api-client.js";
|
|
10
11
|
|
|
11
12
|
async function waitForPageReady(page, timeout = 30000) {
|
|
12
13
|
const startTime = Date.now();
|
|
@@ -32,64 +33,7 @@ async function safeEvaluate(page, fn) {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
async function withRetry(label, fn, maxRetries = 3) {
|
|
36
|
-
let backoff = 2000;
|
|
37
|
-
for (let i = 0; i < maxRetries; i++) {
|
|
38
|
-
try {
|
|
39
|
-
return await fn();
|
|
40
|
-
} catch (err) {
|
|
41
|
-
if (i < maxRetries - 1) {
|
|
42
|
-
console.error(
|
|
43
|
-
` [${label}] 失败: ${err.message},${backoff / 1000}s 后重试...`,
|
|
44
|
-
);
|
|
45
|
-
await new Promise((r) => setTimeout(r, backoff));
|
|
46
|
-
backoff *= 2;
|
|
47
|
-
} else {
|
|
48
|
-
throw err;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function apiPost(url, body) {
|
|
55
|
-
return withRetry(`POST ${url}`, async () => {
|
|
56
|
-
const res = await fetch(url, {
|
|
57
|
-
method: "POST",
|
|
58
|
-
headers: { "Content-Type": "application/json" },
|
|
59
|
-
body: JSON.stringify(body),
|
|
60
|
-
});
|
|
61
|
-
if (!res.ok) {
|
|
62
|
-
const errText = await res.text();
|
|
63
|
-
throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
|
|
64
|
-
}
|
|
65
|
-
return res.json();
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
36
|
|
|
69
|
-
async function apiPut(url) {
|
|
70
|
-
return withRetry(`PUT ${url}`, async () => {
|
|
71
|
-
const res = await fetch(url, {
|
|
72
|
-
method: "PUT",
|
|
73
|
-
headers: { "Content-Type": "application/json" },
|
|
74
|
-
});
|
|
75
|
-
if (!res.ok) {
|
|
76
|
-
const errText = await res.text();
|
|
77
|
-
throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
|
|
78
|
-
}
|
|
79
|
-
return res.json();
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function apiGet(url) {
|
|
84
|
-
return withRetry(`GET ${url}`, async () => {
|
|
85
|
-
const res = await fetch(url);
|
|
86
|
-
if (!res.ok) {
|
|
87
|
-
const errText = await res.text();
|
|
88
|
-
throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
|
|
89
|
-
}
|
|
90
|
-
return res.json();
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
37
|
|
|
94
38
|
function isBrowserClosedError(err) {
|
|
95
39
|
if (!err) return false;
|
|
@@ -110,6 +54,12 @@ function isBrowserClosedError(err) {
|
|
|
110
54
|
*/
|
|
111
55
|
async function runAutoMode(options) {
|
|
112
56
|
const { serverUrl, parallel, interval, maxComments } = options;
|
|
57
|
+
const { apiGet, apiPost, apiPut } = createApiClient({
|
|
58
|
+
checkStatus: true,
|
|
59
|
+
maxRetries: 2,
|
|
60
|
+
backoff: 2000,
|
|
61
|
+
log: true,
|
|
62
|
+
});
|
|
113
63
|
const actualParallel = Math.max(1, parallel || 1);
|
|
114
64
|
const actualInterval = interval || 10;
|
|
115
65
|
const actualMaxComments = maxComments || 200;
|
|
@@ -478,6 +428,12 @@ export async function handleComments(options) {
|
|
|
478
428
|
|
|
479
429
|
const guessedLocation = normalizeLocation(videoInfo?.locationCreated);
|
|
480
430
|
const serverUrl = commentsServer || defaultServer;
|
|
431
|
+
const { apiPost } = createApiClient({
|
|
432
|
+
checkStatus: true,
|
|
433
|
+
maxRetries: 2,
|
|
434
|
+
backoff: 2000,
|
|
435
|
+
log: true,
|
|
436
|
+
});
|
|
481
437
|
|
|
482
438
|
if (
|
|
483
439
|
guessedLocation &&
|