tt-help-cli-ycl 1.3.34 → 1.3.35
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/README.md +17 -17
- package/cli.js +9 -9
- package/package.json +47 -47
- package/scripts/run-explore copy.bat +101 -101
- package/scripts/run-explore.bat +132 -132
- package/scripts/run-explore.ps1 +157 -157
- package/scripts/run-explore.sh +119 -119
- package/scripts/test-captcha-lib.mjs +68 -0
- package/scripts/test-captcha.mjs +81 -0
- package/scripts/test-incognito-lib.mjs +36 -0
- package/scripts/test-login-state.mjs +128 -0
- package/scripts/test-safe-click.mjs +45 -0
- package/src/cli/attach.js +180 -180
- package/src/cli/auto.js +240 -240
- package/src/cli/config.js +152 -152
- package/src/cli/explore.js +488 -488
- package/src/cli/info.js +88 -88
- package/src/cli/open.js +111 -111
- package/src/cli/progress.js +111 -111
- package/src/cli/refresh.js +216 -216
- package/src/cli/scrape.js +47 -47
- package/src/cli/utils.js +18 -18
- package/src/cli/videos.js +41 -41
- package/src/cli/watch.js +31 -31
- package/src/lib/args.js +722 -722
- package/src/lib/browser/anti-detect.js +23 -23
- package/src/lib/browser/cdp.js +261 -261
- package/src/lib/browser/health-checker.js +114 -114
- package/src/lib/browser/launch.js +43 -43
- package/src/lib/browser/page.js +183 -183
- package/src/lib/constants.js +216 -216
- package/src/lib/delay.js +54 -54
- package/src/lib/explore-fetch.js +118 -118
- package/src/lib/fetcher.js +45 -45
- package/src/lib/filter.js +66 -66
- package/src/lib/io.js +54 -54
- package/src/lib/output.js +80 -80
- package/src/lib/page-error-detector.js +105 -105
- package/src/lib/parse-ssr.mjs +69 -69
- package/src/lib/parser.js +47 -47
- package/src/lib/retry.js +45 -45
- package/src/lib/scrape.js +89 -89
- package/src/lib/tiktok-scraper.mjs +194 -194
- package/src/lib/url.js +52 -52
- package/src/main.js +48 -48
- package/src/results/user-videos-bar.lar.lar.moeta.json +37 -0
- package/src/scraper/auto-core.js +203 -203
- package/src/scraper/core.js +211 -211
- package/src/scraper/explore-core.js +177 -167
- package/src/scraper/modules/captcha-handler.js +114 -114
- package/src/scraper/modules/follow-extractor.js +194 -194
- package/src/scraper/modules/guess-extractor.js +51 -51
- package/src/scraper/modules/page-helpers.js +48 -48
- package/src/scraper/refresh-core.js +179 -179
- package/src/videos/core.js +125 -125
- package/src/watch/data-store.js +1040 -1030
- package/src/watch/public/index.html +1458 -753
- package/src/watch/server.js +939 -933
package/src/watch/data-store.js
CHANGED
|
@@ -1,1030 +1,1040 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
|
-
import { promises as fsPromises } from "fs";
|
|
3
|
-
import path from "path";
|
|
4
|
-
|
|
5
|
-
function inferStatus(u) {
|
|
6
|
-
if (u.restricted) return "restricted";
|
|
7
|
-
if (u.error) return "error";
|
|
8
|
-
if (u.processed) return "done";
|
|
9
|
-
return "pending";
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function createStore(filePath) {
|
|
13
|
-
let data = [];
|
|
14
|
-
// uniqueId → index 内存索引,O(1) 查找
|
|
15
|
-
let uidIndex = new Map();
|
|
16
|
-
let clientErrors = new Map();
|
|
17
|
-
|
|
18
|
-
// done 用户归档(独立文件,主文件只保留活跃用户)
|
|
19
|
-
let doneArchive = [];
|
|
20
|
-
let doneArchivePath = null;
|
|
21
|
-
let doneUidIndex = new Map();
|
|
22
|
-
if (filePath) {
|
|
23
|
-
doneArchivePath = path.resolve(filePath).replace(/\.json$/, "-done.json");
|
|
24
|
-
if (fs.existsSync(doneArchivePath)) {
|
|
25
|
-
try {
|
|
26
|
-
const content = fs.readFileSync(doneArchivePath, "utf-8");
|
|
27
|
-
const parsed = JSON.parse(content);
|
|
28
|
-
if (Array.isArray(parsed)) doneArchive = parsed;
|
|
29
|
-
} catch (e) {
|
|
30
|
-
console.error(`[data-store] 读取 done 归档失败: ${e.message}`);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// stats 缓存
|
|
36
|
-
let statsCache = null;
|
|
37
|
-
let statsDirty = true;
|
|
38
|
-
|
|
39
|
-
function markStatsDirty() {
|
|
40
|
-
statsDirty = true;
|
|
41
|
-
groupsDirty = true;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function computeStatsInternal() {
|
|
45
|
-
const total = data.length;
|
|
46
|
-
const statusCounts = {
|
|
47
|
-
pending: 0,
|
|
48
|
-
processing: 0,
|
|
49
|
-
done: 0,
|
|
50
|
-
error: 0,
|
|
51
|
-
restricted: 0,
|
|
52
|
-
};
|
|
53
|
-
for (const u of data) {
|
|
54
|
-
statusCounts[u.status] = (statusCounts[u.status] || 0) + 1;
|
|
55
|
-
}
|
|
56
|
-
statsCache = { total, statusCounts };
|
|
57
|
-
statsDirty = false;
|
|
58
|
-
return statsCache;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function getStats() {
|
|
62
|
-
if (statsDirty) {
|
|
63
|
-
return computeStatsInternal();
|
|
64
|
-
}
|
|
65
|
-
return statsCache;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// 按 status 的分组索引,避免每次请求全量遍历
|
|
69
|
-
let statusGroups = null;
|
|
70
|
-
let groupsDirty = true;
|
|
71
|
-
|
|
72
|
-
const tier1LocSet = new Set(["PL", "NL", "BE"]);
|
|
73
|
-
const tier2LocSet = new Set(["DE", "FR", "IT", "IE", "ES"]);
|
|
74
|
-
function locationTier(u) {
|
|
75
|
-
const loc = (u.guessedLocation || "").toUpperCase();
|
|
76
|
-
if (tier1LocSet.has(loc)) return 0;
|
|
77
|
-
if (tier2LocSet.has(loc)) return 1;
|
|
78
|
-
return 2;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function sortGroup(key, arr) {
|
|
82
|
-
if (key === "done")
|
|
83
|
-
arr.sort((a, b) => (b.processedAt || 0) - (a.processedAt || 0));
|
|
84
|
-
else if (key === "pending")
|
|
85
|
-
arr.sort((a, b) => {
|
|
86
|
-
const aSeller = a.ttSeller === true && a.verified === false ? 0 : 1;
|
|
87
|
-
const bSeller = b.ttSeller === true && b.verified === false ? 0 : 1;
|
|
88
|
-
if (aSeller !== bSeller) return aSeller - bSeller;
|
|
89
|
-
const la = locationTier(a),
|
|
90
|
-
lb = locationTier(b);
|
|
91
|
-
if (la !== lb) return la - lb;
|
|
92
|
-
return (b.followerCount || 0) - (a.followerCount || 0);
|
|
93
|
-
});
|
|
94
|
-
else arr.sort((a, b) => (b.followerCount || 0) - (a.followerCount || 0));
|
|
95
|
-
// 置顶冒泡到组首
|
|
96
|
-
const pinned = arr.filter((u) => u.pinned);
|
|
97
|
-
const unpinned = arr.filter((u) => !u.pinned);
|
|
98
|
-
return pinned.concat(unpinned);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function rebuildStatusGroups() {
|
|
102
|
-
statusGroups = {
|
|
103
|
-
pending: [],
|
|
104
|
-
processing: [],
|
|
105
|
-
done: [],
|
|
106
|
-
error: [],
|
|
107
|
-
restricted: [],
|
|
108
|
-
};
|
|
109
|
-
for (const u of data) {
|
|
110
|
-
const key = u.status || "pending";
|
|
111
|
-
if (statusGroups[key]) statusGroups[key].push(u);
|
|
112
|
-
else statusGroups[key] = [u];
|
|
113
|
-
}
|
|
114
|
-
// done 归档单独处理
|
|
115
|
-
if (doneArchive.length > 0) {
|
|
116
|
-
statusGroups.done = statusGroups.done.concat(doneArchive);
|
|
117
|
-
}
|
|
118
|
-
// 各组内排序
|
|
119
|
-
for (const key of Object.keys(statusGroups)) {
|
|
120
|
-
statusGroups[key] = sortGroup(key, statusGroups[key]);
|
|
121
|
-
}
|
|
122
|
-
groupsDirty = false;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function getStatusGroups() {
|
|
126
|
-
if (groupsDirty) rebuildStatusGroups();
|
|
127
|
-
return statusGroups;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function markGroupsDirty() {
|
|
131
|
-
groupsDirty = true;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// 视频存储(独立 JSON 文件)
|
|
135
|
-
let videos = [];
|
|
136
|
-
let videoFilePath = null;
|
|
137
|
-
if (filePath) {
|
|
138
|
-
const resolved = path.resolve(filePath);
|
|
139
|
-
videoFilePath = resolved.replace(/\.json$/, "-videos.json");
|
|
140
|
-
if (fs.existsSync(videoFilePath)) {
|
|
141
|
-
try {
|
|
142
|
-
const content = fs.readFileSync(videoFilePath, "utf-8");
|
|
143
|
-
const parsed = JSON.parse(content);
|
|
144
|
-
if (Array.isArray(parsed)) {
|
|
145
|
-
videos = parsed;
|
|
146
|
-
}
|
|
147
|
-
} catch (e) {
|
|
148
|
-
console.error(`[data-store] 读取视频文件失败: ${e.message}`);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
let backupTimer = null;
|
|
154
|
-
|
|
155
|
-
if (filePath) {
|
|
156
|
-
const resolved = path.resolve(filePath);
|
|
157
|
-
const backupDir = path.join(path.dirname(resolved), ".backup");
|
|
158
|
-
const maxBackups = 3;
|
|
159
|
-
|
|
160
|
-
if (fs.existsSync(resolved)) {
|
|
161
|
-
try {
|
|
162
|
-
const content = fs.readFileSync(resolved, "utf-8");
|
|
163
|
-
data = JSON.parse(content);
|
|
164
|
-
if (!Array.isArray(data)) {
|
|
165
|
-
data = [];
|
|
166
|
-
}
|
|
167
|
-
} catch (e) {
|
|
168
|
-
console.error(`[data-store] 读取文件失败: ${e.message}`);
|
|
169
|
-
data = [];
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function runBackup() {
|
|
174
|
-
if (!fs.existsSync(resolved)) return;
|
|
175
|
-
if (!fs.existsSync(backupDir))
|
|
176
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
177
|
-
const now = new Date();
|
|
178
|
-
const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 13);
|
|
179
|
-
const backupFile = path.join(backupDir, `data-${timestamp}.json`);
|
|
180
|
-
try {
|
|
181
|
-
fs.copyFileSync(resolved, backupFile);
|
|
182
|
-
const files = fs
|
|
183
|
-
.readdirSync(backupDir)
|
|
184
|
-
.filter((f) => f.startsWith("data-") && f.endsWith(".json"))
|
|
185
|
-
.sort()
|
|
186
|
-
.map((f) => path.join(backupDir, f));
|
|
187
|
-
while (files.length > maxBackups) {
|
|
188
|
-
fs.unlinkSync(files.shift());
|
|
189
|
-
}
|
|
190
|
-
} catch (e) {
|
|
191
|
-
console.error(`[data-store] 备份失败: ${e.message}`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
backupTimer = setInterval(runBackup, 60 * 60 * 1000);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// 构建索引 + 推断 status
|
|
199
|
-
for (let i = 0; i < data.length; i++) {
|
|
200
|
-
const u = data[i];
|
|
201
|
-
if (!u.status) u.status = inferStatus(u);
|
|
202
|
-
uidIndex.set(u.uniqueId, i);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// 构建 done 归档索引
|
|
206
|
-
for (let i = 0; i < doneArchive.length; i++) {
|
|
207
|
-
doneUidIndex.set(doneArchive[i].uniqueId, i);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function rebuildIndex() {
|
|
211
|
-
uidIndex = new Map();
|
|
212
|
-
for (let i = 0; i < data.length; i++) {
|
|
213
|
-
uidIndex.set(data[i].uniqueId, i);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
let saveTimer = null;
|
|
218
|
-
let savePending = false;
|
|
219
|
-
|
|
220
|
-
function scheduleSave() {
|
|
221
|
-
if (saveTimer) return;
|
|
222
|
-
savePending = true;
|
|
223
|
-
saveTimer = setTimeout(() => {
|
|
224
|
-
saveTimer = null;
|
|
225
|
-
savePending = false;
|
|
226
|
-
doSave();
|
|
227
|
-
}, 2000);
|
|
228
|
-
saveTimer.unref();
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function doSave() {
|
|
232
|
-
if (!filePath) return;
|
|
233
|
-
const resolved = path.resolve(filePath);
|
|
234
|
-
|
|
235
|
-
// 将 done 用户归档到独立文件
|
|
236
|
-
if (doneArchivePath && data.length > 1000) {
|
|
237
|
-
const active = [];
|
|
238
|
-
const toArchive = [];
|
|
239
|
-
for (const u of data) {
|
|
240
|
-
if (u.status === "done") {
|
|
241
|
-
toArchive.push(u);
|
|
242
|
-
} else {
|
|
243
|
-
active.push(u);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
if (toArchive.length > 0) {
|
|
247
|
-
doneArchive = doneArchive.concat(toArchive);
|
|
248
|
-
data = active;
|
|
249
|
-
rebuildIndex();
|
|
250
|
-
// 重建 done 归档索引
|
|
251
|
-
doneUidIndex = new Map();
|
|
252
|
-
for (let i = 0; i < doneArchive.length; i++) {
|
|
253
|
-
doneUidIndex.set(doneArchive[i].uniqueId, i);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const json = JSON.stringify(data);
|
|
259
|
-
const tmpPath = resolved + ".tmp";
|
|
260
|
-
fsPromises
|
|
261
|
-
.writeFile(tmpPath, json, "utf-8")
|
|
262
|
-
.then(() => fsPromises.rename(tmpPath, resolved))
|
|
263
|
-
.then(() => {
|
|
264
|
-
if (doneArchivePath && doneArchive.length > 0) {
|
|
265
|
-
const doneJson = JSON.stringify(doneArchive);
|
|
266
|
-
const doneTmp = doneArchivePath + ".tmp";
|
|
267
|
-
return fsPromises
|
|
268
|
-
.writeFile(doneTmp, doneJson, "utf-8")
|
|
269
|
-
.then(() => fsPromises.rename(doneTmp, doneArchivePath));
|
|
270
|
-
}
|
|
271
|
-
})
|
|
272
|
-
.catch((err) => {
|
|
273
|
-
console.error(`[data-store] save 写入失败: ${err.message}`);
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function save() {
|
|
278
|
-
scheduleSave();
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function flushSave() {
|
|
282
|
-
return new Promise((resolve) => {
|
|
283
|
-
if (saveTimer) {
|
|
284
|
-
clearTimeout(saveTimer);
|
|
285
|
-
saveTimer = null;
|
|
286
|
-
savePending = false;
|
|
287
|
-
}
|
|
288
|
-
if (!filePath) {
|
|
289
|
-
resolve();
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
const resolved = path.resolve(filePath);
|
|
293
|
-
|
|
294
|
-
// 将 done 用户归档到独立文件
|
|
295
|
-
if (doneArchivePath && data.length > 1000) {
|
|
296
|
-
const active = [];
|
|
297
|
-
const toArchive = [];
|
|
298
|
-
for (const u of data) {
|
|
299
|
-
if (u.status === "done") {
|
|
300
|
-
toArchive.push(u);
|
|
301
|
-
} else {
|
|
302
|
-
active.push(u);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
if (toArchive.length > 0) {
|
|
306
|
-
doneArchive = doneArchive.concat(toArchive);
|
|
307
|
-
data = active;
|
|
308
|
-
rebuildIndex();
|
|
309
|
-
doneUidIndex = new Map();
|
|
310
|
-
for (let i = 0; i < doneArchive.length; i++) {
|
|
311
|
-
doneUidIndex.set(doneArchive[i].uniqueId, i);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const json = JSON.stringify(data);
|
|
317
|
-
const tmpPath = resolved + ".tmp";
|
|
318
|
-
fsPromises
|
|
319
|
-
.writeFile(tmpPath, json, "utf-8")
|
|
320
|
-
.then(() => fsPromises.rename(tmpPath, resolved))
|
|
321
|
-
.then(() => {
|
|
322
|
-
if (doneArchivePath && doneArchive.length > 0) {
|
|
323
|
-
const doneJson = JSON.stringify(doneArchive);
|
|
324
|
-
const doneTmp = doneArchivePath + ".tmp";
|
|
325
|
-
return fsPromises
|
|
326
|
-
.writeFile(doneTmp, doneJson, "utf-8")
|
|
327
|
-
.then(() => fsPromises.rename(doneTmp, doneArchivePath));
|
|
328
|
-
}
|
|
329
|
-
resolve();
|
|
330
|
-
})
|
|
331
|
-
.catch((err) => {
|
|
332
|
-
console.error(`[data-store] flushSave 写入失败: ${err.message}`);
|
|
333
|
-
resolve();
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
let videosSaveTimer = null;
|
|
339
|
-
|
|
340
|
-
function saveVideos() {
|
|
341
|
-
if (!videoFilePath) return;
|
|
342
|
-
if (videosSaveTimer) return;
|
|
343
|
-
videosSaveTimer = setTimeout(() => {
|
|
344
|
-
videosSaveTimer = null;
|
|
345
|
-
const json = JSON.stringify(videos, null, 2);
|
|
346
|
-
fsPromises.writeFile(videoFilePath, json, "utf-8").catch((err) => {
|
|
347
|
-
console.error(`[data-store] saveVideos 写入失败: ${err.message}`);
|
|
348
|
-
});
|
|
349
|
-
}, 2000);
|
|
350
|
-
videosSaveTimer.unref();
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function stopBackup() {
|
|
354
|
-
if (backupTimer) {
|
|
355
|
-
clearInterval(backupTimer);
|
|
356
|
-
backupTimer = null;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function getUser(uid) {
|
|
361
|
-
const idx = uidIndex.get(uid);
|
|
362
|
-
if (idx !== undefined) return data[idx];
|
|
363
|
-
const doneIdx = doneUidIndex.get(uid);
|
|
364
|
-
if (doneIdx !== undefined) return doneArchive[doneIdx];
|
|
365
|
-
return undefined;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function hasUser(uid) {
|
|
369
|
-
return uidIndex.has(uid) || doneUidIndex.has(uid);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function userExists(uid) {
|
|
373
|
-
return uidIndex.has(uid) || doneUidIndex.has(uid);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function addUser(user, append) {
|
|
377
|
-
const existing = getUser(user.uniqueId);
|
|
378
|
-
if (existing) {
|
|
379
|
-
for (const key of Object.keys(user)) {
|
|
380
|
-
if (key === "uniqueId" || key === "sources") continue;
|
|
381
|
-
if (user[key] !== undefined && user[key] !== null && user[key] !== "") {
|
|
382
|
-
existing[key] = user[key];
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
} else {
|
|
386
|
-
if (!user.status) user.status = inferStatus(user);
|
|
387
|
-
if (user.processed) user.processedAt = user.processedAt || Date.now();
|
|
388
|
-
if (append) {
|
|
389
|
-
const idx = data.length;
|
|
390
|
-
data.push(user);
|
|
391
|
-
uidIndex.set(user.uniqueId, idx);
|
|
392
|
-
} else {
|
|
393
|
-
data.unshift(user);
|
|
394
|
-
uidIndex.set(user.uniqueId, 0);
|
|
395
|
-
}
|
|
396
|
-
markStatsDirty();
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
function getPendingUsers() {
|
|
401
|
-
return data.filter((u) => u.status === "pending");
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function getProcessedUsers() {
|
|
405
|
-
return doneArchive.length > 0
|
|
406
|
-
? data.filter((u) => u.status === "done").concat(doneArchive)
|
|
407
|
-
: data.filter((u) => u.status === "done");
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function getAllUsers() {
|
|
411
|
-
if (doneArchive.length > 0) {
|
|
412
|
-
return data.concat(doneArchive);
|
|
413
|
-
}
|
|
414
|
-
return data;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function claimNextJob(
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
return {
|
|
581
|
-
uniqueId:
|
|
582
|
-
nickname:
|
|
583
|
-
sources: ["
|
|
584
|
-
guessedLocation:
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
user.
|
|
649
|
-
user.
|
|
650
|
-
user.
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
user.
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
return { saved:
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
);
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
return
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
existing.
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
errorType
|
|
841
|
-
errorMessage
|
|
842
|
-
errorStack
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
return {
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
// userUpdateCount
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
const
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { promises as fsPromises } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
function inferStatus(u) {
|
|
6
|
+
if (u.restricted) return "restricted";
|
|
7
|
+
if (u.error) return "error";
|
|
8
|
+
if (u.processed) return "done";
|
|
9
|
+
return "pending";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createStore(filePath) {
|
|
13
|
+
let data = [];
|
|
14
|
+
// uniqueId → index 内存索引,O(1) 查找
|
|
15
|
+
let uidIndex = new Map();
|
|
16
|
+
let clientErrors = new Map();
|
|
17
|
+
|
|
18
|
+
// done 用户归档(独立文件,主文件只保留活跃用户)
|
|
19
|
+
let doneArchive = [];
|
|
20
|
+
let doneArchivePath = null;
|
|
21
|
+
let doneUidIndex = new Map();
|
|
22
|
+
if (filePath) {
|
|
23
|
+
doneArchivePath = path.resolve(filePath).replace(/\.json$/, "-done.json");
|
|
24
|
+
if (fs.existsSync(doneArchivePath)) {
|
|
25
|
+
try {
|
|
26
|
+
const content = fs.readFileSync(doneArchivePath, "utf-8");
|
|
27
|
+
const parsed = JSON.parse(content);
|
|
28
|
+
if (Array.isArray(parsed)) doneArchive = parsed;
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error(`[data-store] 读取 done 归档失败: ${e.message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// stats 缓存
|
|
36
|
+
let statsCache = null;
|
|
37
|
+
let statsDirty = true;
|
|
38
|
+
|
|
39
|
+
function markStatsDirty() {
|
|
40
|
+
statsDirty = true;
|
|
41
|
+
groupsDirty = true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function computeStatsInternal() {
|
|
45
|
+
const total = data.length;
|
|
46
|
+
const statusCounts = {
|
|
47
|
+
pending: 0,
|
|
48
|
+
processing: 0,
|
|
49
|
+
done: 0,
|
|
50
|
+
error: 0,
|
|
51
|
+
restricted: 0,
|
|
52
|
+
};
|
|
53
|
+
for (const u of data) {
|
|
54
|
+
statusCounts[u.status] = (statusCounts[u.status] || 0) + 1;
|
|
55
|
+
}
|
|
56
|
+
statsCache = { total, statusCounts };
|
|
57
|
+
statsDirty = false;
|
|
58
|
+
return statsCache;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getStats() {
|
|
62
|
+
if (statsDirty) {
|
|
63
|
+
return computeStatsInternal();
|
|
64
|
+
}
|
|
65
|
+
return statsCache;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 按 status 的分组索引,避免每次请求全量遍历
|
|
69
|
+
let statusGroups = null;
|
|
70
|
+
let groupsDirty = true;
|
|
71
|
+
|
|
72
|
+
const tier1LocSet = new Set(["PL", "NL", "BE"]);
|
|
73
|
+
const tier2LocSet = new Set(["DE", "FR", "IT", "IE", "ES"]);
|
|
74
|
+
function locationTier(u) {
|
|
75
|
+
const loc = (u.guessedLocation || "").toUpperCase();
|
|
76
|
+
if (tier1LocSet.has(loc)) return 0;
|
|
77
|
+
if (tier2LocSet.has(loc)) return 1;
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sortGroup(key, arr) {
|
|
82
|
+
if (key === "done")
|
|
83
|
+
arr.sort((a, b) => (b.processedAt || 0) - (a.processedAt || 0));
|
|
84
|
+
else if (key === "pending")
|
|
85
|
+
arr.sort((a, b) => {
|
|
86
|
+
const aSeller = a.ttSeller === true && a.verified === false ? 0 : 1;
|
|
87
|
+
const bSeller = b.ttSeller === true && b.verified === false ? 0 : 1;
|
|
88
|
+
if (aSeller !== bSeller) return aSeller - bSeller;
|
|
89
|
+
const la = locationTier(a),
|
|
90
|
+
lb = locationTier(b);
|
|
91
|
+
if (la !== lb) return la - lb;
|
|
92
|
+
return (b.followerCount || 0) - (a.followerCount || 0);
|
|
93
|
+
});
|
|
94
|
+
else arr.sort((a, b) => (b.followerCount || 0) - (a.followerCount || 0));
|
|
95
|
+
// 置顶冒泡到组首
|
|
96
|
+
const pinned = arr.filter((u) => u.pinned);
|
|
97
|
+
const unpinned = arr.filter((u) => !u.pinned);
|
|
98
|
+
return pinned.concat(unpinned);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function rebuildStatusGroups() {
|
|
102
|
+
statusGroups = {
|
|
103
|
+
pending: [],
|
|
104
|
+
processing: [],
|
|
105
|
+
done: [],
|
|
106
|
+
error: [],
|
|
107
|
+
restricted: [],
|
|
108
|
+
};
|
|
109
|
+
for (const u of data) {
|
|
110
|
+
const key = u.status || "pending";
|
|
111
|
+
if (statusGroups[key]) statusGroups[key].push(u);
|
|
112
|
+
else statusGroups[key] = [u];
|
|
113
|
+
}
|
|
114
|
+
// done 归档单独处理
|
|
115
|
+
if (doneArchive.length > 0) {
|
|
116
|
+
statusGroups.done = statusGroups.done.concat(doneArchive);
|
|
117
|
+
}
|
|
118
|
+
// 各组内排序
|
|
119
|
+
for (const key of Object.keys(statusGroups)) {
|
|
120
|
+
statusGroups[key] = sortGroup(key, statusGroups[key]);
|
|
121
|
+
}
|
|
122
|
+
groupsDirty = false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getStatusGroups() {
|
|
126
|
+
if (groupsDirty) rebuildStatusGroups();
|
|
127
|
+
return statusGroups;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function markGroupsDirty() {
|
|
131
|
+
groupsDirty = true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 视频存储(独立 JSON 文件)
|
|
135
|
+
let videos = [];
|
|
136
|
+
let videoFilePath = null;
|
|
137
|
+
if (filePath) {
|
|
138
|
+
const resolved = path.resolve(filePath);
|
|
139
|
+
videoFilePath = resolved.replace(/\.json$/, "-videos.json");
|
|
140
|
+
if (fs.existsSync(videoFilePath)) {
|
|
141
|
+
try {
|
|
142
|
+
const content = fs.readFileSync(videoFilePath, "utf-8");
|
|
143
|
+
const parsed = JSON.parse(content);
|
|
144
|
+
if (Array.isArray(parsed)) {
|
|
145
|
+
videos = parsed;
|
|
146
|
+
}
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.error(`[data-store] 读取视频文件失败: ${e.message}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let backupTimer = null;
|
|
154
|
+
|
|
155
|
+
if (filePath) {
|
|
156
|
+
const resolved = path.resolve(filePath);
|
|
157
|
+
const backupDir = path.join(path.dirname(resolved), ".backup");
|
|
158
|
+
const maxBackups = 3;
|
|
159
|
+
|
|
160
|
+
if (fs.existsSync(resolved)) {
|
|
161
|
+
try {
|
|
162
|
+
const content = fs.readFileSync(resolved, "utf-8");
|
|
163
|
+
data = JSON.parse(content);
|
|
164
|
+
if (!Array.isArray(data)) {
|
|
165
|
+
data = [];
|
|
166
|
+
}
|
|
167
|
+
} catch (e) {
|
|
168
|
+
console.error(`[data-store] 读取文件失败: ${e.message}`);
|
|
169
|
+
data = [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function runBackup() {
|
|
174
|
+
if (!fs.existsSync(resolved)) return;
|
|
175
|
+
if (!fs.existsSync(backupDir))
|
|
176
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
177
|
+
const now = new Date();
|
|
178
|
+
const timestamp = now.toISOString().replace(/[:.]/g, "-").slice(0, 13);
|
|
179
|
+
const backupFile = path.join(backupDir, `data-${timestamp}.json`);
|
|
180
|
+
try {
|
|
181
|
+
fs.copyFileSync(resolved, backupFile);
|
|
182
|
+
const files = fs
|
|
183
|
+
.readdirSync(backupDir)
|
|
184
|
+
.filter((f) => f.startsWith("data-") && f.endsWith(".json"))
|
|
185
|
+
.sort()
|
|
186
|
+
.map((f) => path.join(backupDir, f));
|
|
187
|
+
while (files.length > maxBackups) {
|
|
188
|
+
fs.unlinkSync(files.shift());
|
|
189
|
+
}
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.error(`[data-store] 备份失败: ${e.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
backupTimer = setInterval(runBackup, 60 * 60 * 1000);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 构建索引 + 推断 status
|
|
199
|
+
for (let i = 0; i < data.length; i++) {
|
|
200
|
+
const u = data[i];
|
|
201
|
+
if (!u.status) u.status = inferStatus(u);
|
|
202
|
+
uidIndex.set(u.uniqueId, i);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 构建 done 归档索引
|
|
206
|
+
for (let i = 0; i < doneArchive.length; i++) {
|
|
207
|
+
doneUidIndex.set(doneArchive[i].uniqueId, i);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function rebuildIndex() {
|
|
211
|
+
uidIndex = new Map();
|
|
212
|
+
for (let i = 0; i < data.length; i++) {
|
|
213
|
+
uidIndex.set(data[i].uniqueId, i);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let saveTimer = null;
|
|
218
|
+
let savePending = false;
|
|
219
|
+
|
|
220
|
+
function scheduleSave() {
|
|
221
|
+
if (saveTimer) return;
|
|
222
|
+
savePending = true;
|
|
223
|
+
saveTimer = setTimeout(() => {
|
|
224
|
+
saveTimer = null;
|
|
225
|
+
savePending = false;
|
|
226
|
+
doSave();
|
|
227
|
+
}, 2000);
|
|
228
|
+
saveTimer.unref();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function doSave() {
|
|
232
|
+
if (!filePath) return;
|
|
233
|
+
const resolved = path.resolve(filePath);
|
|
234
|
+
|
|
235
|
+
// 将 done 用户归档到独立文件
|
|
236
|
+
if (doneArchivePath && data.length > 1000) {
|
|
237
|
+
const active = [];
|
|
238
|
+
const toArchive = [];
|
|
239
|
+
for (const u of data) {
|
|
240
|
+
if (u.status === "done") {
|
|
241
|
+
toArchive.push(u);
|
|
242
|
+
} else {
|
|
243
|
+
active.push(u);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (toArchive.length > 0) {
|
|
247
|
+
doneArchive = doneArchive.concat(toArchive);
|
|
248
|
+
data = active;
|
|
249
|
+
rebuildIndex();
|
|
250
|
+
// 重建 done 归档索引
|
|
251
|
+
doneUidIndex = new Map();
|
|
252
|
+
for (let i = 0; i < doneArchive.length; i++) {
|
|
253
|
+
doneUidIndex.set(doneArchive[i].uniqueId, i);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const json = JSON.stringify(data);
|
|
259
|
+
const tmpPath = resolved + ".tmp";
|
|
260
|
+
fsPromises
|
|
261
|
+
.writeFile(tmpPath, json, "utf-8")
|
|
262
|
+
.then(() => fsPromises.rename(tmpPath, resolved))
|
|
263
|
+
.then(() => {
|
|
264
|
+
if (doneArchivePath && doneArchive.length > 0) {
|
|
265
|
+
const doneJson = JSON.stringify(doneArchive);
|
|
266
|
+
const doneTmp = doneArchivePath + ".tmp";
|
|
267
|
+
return fsPromises
|
|
268
|
+
.writeFile(doneTmp, doneJson, "utf-8")
|
|
269
|
+
.then(() => fsPromises.rename(doneTmp, doneArchivePath));
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
.catch((err) => {
|
|
273
|
+
console.error(`[data-store] save 写入失败: ${err.message}`);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function save() {
|
|
278
|
+
scheduleSave();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function flushSave() {
|
|
282
|
+
return new Promise((resolve) => {
|
|
283
|
+
if (saveTimer) {
|
|
284
|
+
clearTimeout(saveTimer);
|
|
285
|
+
saveTimer = null;
|
|
286
|
+
savePending = false;
|
|
287
|
+
}
|
|
288
|
+
if (!filePath) {
|
|
289
|
+
resolve();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const resolved = path.resolve(filePath);
|
|
293
|
+
|
|
294
|
+
// 将 done 用户归档到独立文件
|
|
295
|
+
if (doneArchivePath && data.length > 1000) {
|
|
296
|
+
const active = [];
|
|
297
|
+
const toArchive = [];
|
|
298
|
+
for (const u of data) {
|
|
299
|
+
if (u.status === "done") {
|
|
300
|
+
toArchive.push(u);
|
|
301
|
+
} else {
|
|
302
|
+
active.push(u);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (toArchive.length > 0) {
|
|
306
|
+
doneArchive = doneArchive.concat(toArchive);
|
|
307
|
+
data = active;
|
|
308
|
+
rebuildIndex();
|
|
309
|
+
doneUidIndex = new Map();
|
|
310
|
+
for (let i = 0; i < doneArchive.length; i++) {
|
|
311
|
+
doneUidIndex.set(doneArchive[i].uniqueId, i);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const json = JSON.stringify(data);
|
|
317
|
+
const tmpPath = resolved + ".tmp";
|
|
318
|
+
fsPromises
|
|
319
|
+
.writeFile(tmpPath, json, "utf-8")
|
|
320
|
+
.then(() => fsPromises.rename(tmpPath, resolved))
|
|
321
|
+
.then(() => {
|
|
322
|
+
if (doneArchivePath && doneArchive.length > 0) {
|
|
323
|
+
const doneJson = JSON.stringify(doneArchive);
|
|
324
|
+
const doneTmp = doneArchivePath + ".tmp";
|
|
325
|
+
return fsPromises
|
|
326
|
+
.writeFile(doneTmp, doneJson, "utf-8")
|
|
327
|
+
.then(() => fsPromises.rename(doneTmp, doneArchivePath));
|
|
328
|
+
}
|
|
329
|
+
resolve();
|
|
330
|
+
})
|
|
331
|
+
.catch((err) => {
|
|
332
|
+
console.error(`[data-store] flushSave 写入失败: ${err.message}`);
|
|
333
|
+
resolve();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let videosSaveTimer = null;
|
|
339
|
+
|
|
340
|
+
function saveVideos() {
|
|
341
|
+
if (!videoFilePath) return;
|
|
342
|
+
if (videosSaveTimer) return;
|
|
343
|
+
videosSaveTimer = setTimeout(() => {
|
|
344
|
+
videosSaveTimer = null;
|
|
345
|
+
const json = JSON.stringify(videos, null, 2);
|
|
346
|
+
fsPromises.writeFile(videoFilePath, json, "utf-8").catch((err) => {
|
|
347
|
+
console.error(`[data-store] saveVideos 写入失败: ${err.message}`);
|
|
348
|
+
});
|
|
349
|
+
}, 2000);
|
|
350
|
+
videosSaveTimer.unref();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function stopBackup() {
|
|
354
|
+
if (backupTimer) {
|
|
355
|
+
clearInterval(backupTimer);
|
|
356
|
+
backupTimer = null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function getUser(uid) {
|
|
361
|
+
const idx = uidIndex.get(uid);
|
|
362
|
+
if (idx !== undefined) return data[idx];
|
|
363
|
+
const doneIdx = doneUidIndex.get(uid);
|
|
364
|
+
if (doneIdx !== undefined) return doneArchive[doneIdx];
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function hasUser(uid) {
|
|
369
|
+
return uidIndex.has(uid) || doneUidIndex.has(uid);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function userExists(uid) {
|
|
373
|
+
return uidIndex.has(uid) || doneUidIndex.has(uid);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function addUser(user, append) {
|
|
377
|
+
const existing = getUser(user.uniqueId);
|
|
378
|
+
if (existing) {
|
|
379
|
+
for (const key of Object.keys(user)) {
|
|
380
|
+
if (key === "uniqueId" || key === "sources") continue;
|
|
381
|
+
if (user[key] !== undefined && user[key] !== null && user[key] !== "") {
|
|
382
|
+
existing[key] = user[key];
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
if (!user.status) user.status = inferStatus(user);
|
|
387
|
+
if (user.processed) user.processedAt = user.processedAt || Date.now();
|
|
388
|
+
if (append) {
|
|
389
|
+
const idx = data.length;
|
|
390
|
+
data.push(user);
|
|
391
|
+
uidIndex.set(user.uniqueId, idx);
|
|
392
|
+
} else {
|
|
393
|
+
data.unshift(user);
|
|
394
|
+
uidIndex.set(user.uniqueId, 0);
|
|
395
|
+
}
|
|
396
|
+
markStatsDirty();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function getPendingUsers() {
|
|
401
|
+
return data.filter((u) => u.status === "pending");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function getProcessedUsers() {
|
|
405
|
+
return doneArchive.length > 0
|
|
406
|
+
? data.filter((u) => u.status === "done").concat(doneArchive)
|
|
407
|
+
: data.filter((u) => u.status === "done");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function getAllUsers() {
|
|
411
|
+
if (doneArchive.length > 0) {
|
|
412
|
+
return data.concat(doneArchive);
|
|
413
|
+
}
|
|
414
|
+
return data;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function claimNextJob(
|
|
418
|
+
userId,
|
|
419
|
+
expireMs = 5 * 60 * 1000,
|
|
420
|
+
locations = null,
|
|
421
|
+
loggedIn = true,
|
|
422
|
+
) {
|
|
423
|
+
const now = Date.now();
|
|
424
|
+
|
|
425
|
+
// 0. 该客户端有未过期的任务,续期返回
|
|
426
|
+
const ongoing = data.find(
|
|
427
|
+
(u) =>
|
|
428
|
+
u.status === "processing" &&
|
|
429
|
+
u.claimedBy === userId &&
|
|
430
|
+
u.claimedAt &&
|
|
431
|
+
now - u.claimedAt < expireMs,
|
|
432
|
+
);
|
|
433
|
+
if (ongoing) {
|
|
434
|
+
ongoing.claimedAt = now;
|
|
435
|
+
return {
|
|
436
|
+
uniqueId: ongoing.uniqueId,
|
|
437
|
+
nickname: ongoing.nickname,
|
|
438
|
+
claimedAt: ongoing.claimedAt,
|
|
439
|
+
claimedBy: userId,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 按猜测国家梯队排序
|
|
444
|
+
const tier1 = new Set(["PL", "NL", "BE"]);
|
|
445
|
+
const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
|
|
446
|
+
function locationTier(u) {
|
|
447
|
+
const loc = (u.guessedLocation || "").toUpperCase();
|
|
448
|
+
if (tier1.has(loc)) return 0;
|
|
449
|
+
if (tier2.has(loc)) return 1;
|
|
450
|
+
return 2;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 国家过滤:如果指定了 locations,只保留 guessedLocation 在列表中的用户
|
|
454
|
+
function locationFilter(u) {
|
|
455
|
+
if (!locations || locations.length === 0) return true;
|
|
456
|
+
const loc = (u.guessedLocation || "").toUpperCase();
|
|
457
|
+
return locations.includes(loc);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 从候选列表中按优先级取第一个:pinned > 超时回收 > seed > ttSeller(仅登录) > follow > other
|
|
461
|
+
function pickCandidate(candidates) {
|
|
462
|
+
let next = candidates.find((u) => u.pinned);
|
|
463
|
+
|
|
464
|
+
if (!next) {
|
|
465
|
+
const expired = data.find(
|
|
466
|
+
(u) =>
|
|
467
|
+
u.status === "processing" &&
|
|
468
|
+
u.claimedAt &&
|
|
469
|
+
now - u.claimedAt > expireMs,
|
|
470
|
+
);
|
|
471
|
+
if (expired) {
|
|
472
|
+
expired.status = "pending";
|
|
473
|
+
markStatsDirty();
|
|
474
|
+
delete expired.claimedAt;
|
|
475
|
+
next = expired;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (!next) {
|
|
480
|
+
const seed = candidates.filter(
|
|
481
|
+
(u) => u.sources && u.sources.includes("seed"),
|
|
482
|
+
);
|
|
483
|
+
seed.sort((a, b) => locationTier(a) - locationTier(b));
|
|
484
|
+
next = seed[0] || null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// 未登录时跳过 ttSeller 优先级
|
|
488
|
+
if (!next && loggedIn) {
|
|
489
|
+
const ttSeller = candidates.filter(
|
|
490
|
+
(u) => u.ttSeller === true && u.verified === false,
|
|
491
|
+
);
|
|
492
|
+
ttSeller.sort((a, b) => locationTier(a) - locationTier(b));
|
|
493
|
+
next = ttSeller[0] || null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (!next) {
|
|
497
|
+
const follow = candidates.filter(
|
|
498
|
+
(u) =>
|
|
499
|
+
u.sources &&
|
|
500
|
+
(u.sources.includes("following") || u.sources.includes("follower")),
|
|
501
|
+
);
|
|
502
|
+
follow.sort((a, b) => locationTier(a) - locationTier(b));
|
|
503
|
+
next = follow[0] || null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!next) {
|
|
507
|
+
candidates.sort((a, b) => locationTier(a) - locationTier(b));
|
|
508
|
+
next = candidates[0] || null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return next;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 先在有视频的 pending 用户中找;找不到再用全部 pending 用户兜底
|
|
515
|
+
let pending = data.filter((u) => u.status === "pending");
|
|
516
|
+
// 应用国家过滤
|
|
517
|
+
if (locations && locations.length > 0) {
|
|
518
|
+
pending = pending.filter(locationFilter);
|
|
519
|
+
}
|
|
520
|
+
// 未登录客户端不能领取 ttSeller 用户
|
|
521
|
+
if (!loggedIn) {
|
|
522
|
+
pending = pending.filter((u) => u.ttSeller !== true);
|
|
523
|
+
}
|
|
524
|
+
let hasVideo = pending.filter((u) => u.videoCount > 0);
|
|
525
|
+
const next = pickCandidate(hasVideo) || pickCandidate(pending);
|
|
526
|
+
|
|
527
|
+
if (next) {
|
|
528
|
+
next.status = "processing";
|
|
529
|
+
markStatsDirty();
|
|
530
|
+
next.claimedAt = now;
|
|
531
|
+
next.claimedBy = userId;
|
|
532
|
+
return {
|
|
533
|
+
uniqueId: next.uniqueId,
|
|
534
|
+
nickname: next.nickname,
|
|
535
|
+
claimedAt: next.claimedAt,
|
|
536
|
+
claimedBy: userId,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function processDiscoveredUsers(result) {
|
|
543
|
+
const guessedLocation = result.guessedLocation || null;
|
|
544
|
+
const discovered = [
|
|
545
|
+
...(result.discoveredVideoAuthors || []).map((v) => ({
|
|
546
|
+
uniqueId:
|
|
547
|
+
typeof v === "string"
|
|
548
|
+
? v.replace(/^@/, "")
|
|
549
|
+
: v.uniqueId?.replace(/^@/, "") || "",
|
|
550
|
+
nickname: typeof v === "string" ? null : v.nickname || null,
|
|
551
|
+
locationCreated:
|
|
552
|
+
typeof v === "string" ? null : v.locationCreated || null,
|
|
553
|
+
guessedLocation:
|
|
554
|
+
typeof v === "string"
|
|
555
|
+
? guessedLocation
|
|
556
|
+
: v.guessedLocation || guessedLocation,
|
|
557
|
+
sources: ["video"],
|
|
558
|
+
})),
|
|
559
|
+
...(result.discoveredCommentAuthors || []).map((c) => {
|
|
560
|
+
if (typeof c === "string")
|
|
561
|
+
return {
|
|
562
|
+
uniqueId: c.replace(/^@/, ""),
|
|
563
|
+
sources: ["comment"],
|
|
564
|
+
guessedLocation,
|
|
565
|
+
};
|
|
566
|
+
return {
|
|
567
|
+
uniqueId: (c.author || c.uniqueId || "").replace(/^@/, ""),
|
|
568
|
+
nickname: c.nickname || null,
|
|
569
|
+
sources: ["comment"],
|
|
570
|
+
guessedLocation: c.guessedLocation || guessedLocation,
|
|
571
|
+
};
|
|
572
|
+
}),
|
|
573
|
+
...(result.discoveredGuessAuthors || []).map((g) => {
|
|
574
|
+
if (typeof g === "string")
|
|
575
|
+
return {
|
|
576
|
+
uniqueId: g.replace(/^@/, ""),
|
|
577
|
+
sources: ["guess"],
|
|
578
|
+
guessedLocation,
|
|
579
|
+
};
|
|
580
|
+
return {
|
|
581
|
+
uniqueId: (g.author || g.uniqueId || "").replace(/^@/, ""),
|
|
582
|
+
nickname: g.nickname || null,
|
|
583
|
+
sources: ["guess"],
|
|
584
|
+
guessedLocation: g.guessedLocation || guessedLocation,
|
|
585
|
+
};
|
|
586
|
+
}),
|
|
587
|
+
...(result.discoveredFollowing || []).map((f) => {
|
|
588
|
+
const handle = Array.isArray(f) ? f[0] : f.handle || "";
|
|
589
|
+
const name = Array.isArray(f) ? f[1] : f.displayName || null;
|
|
590
|
+
return {
|
|
591
|
+
uniqueId: handle.replace(/^@/, ""),
|
|
592
|
+
nickname: name,
|
|
593
|
+
sources: ["following"],
|
|
594
|
+
guessedLocation:
|
|
595
|
+
(typeof f === "object" && f.guessedLocation) || guessedLocation,
|
|
596
|
+
};
|
|
597
|
+
}),
|
|
598
|
+
...(result.discoveredFollowers || []).map((f) => {
|
|
599
|
+
const handle = Array.isArray(f) ? f[0] : f.handle || "";
|
|
600
|
+
const name = Array.isArray(f) ? f[1] : f.displayName || null;
|
|
601
|
+
return {
|
|
602
|
+
uniqueId: handle.replace(/^@/, ""),
|
|
603
|
+
nickname: name,
|
|
604
|
+
sources: ["follower"],
|
|
605
|
+
guessedLocation:
|
|
606
|
+
(typeof f === "object" && f.guessedLocation) || guessedLocation,
|
|
607
|
+
};
|
|
608
|
+
}),
|
|
609
|
+
].filter((u) => u.uniqueId);
|
|
610
|
+
|
|
611
|
+
// 先对 discovered 内部去重,再用 uidIndex 批量判断
|
|
612
|
+
const seen = new Set();
|
|
613
|
+
const unique = [];
|
|
614
|
+
for (const d of discovered) {
|
|
615
|
+
if (!seen.has(d.uniqueId)) {
|
|
616
|
+
seen.add(d.uniqueId);
|
|
617
|
+
unique.push(d);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const newUsers = [];
|
|
622
|
+
for (const d of unique) {
|
|
623
|
+
if (!hasUser(d.uniqueId)) {
|
|
624
|
+
addUser(d, true);
|
|
625
|
+
newUsers.push(d.uniqueId);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return newUsers;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function updateUserFromResult(user, result) {
|
|
632
|
+
const oldStatus = user.status;
|
|
633
|
+
if (result.restricted) {
|
|
634
|
+
user.status = "restricted";
|
|
635
|
+
if (result.userInfo) {
|
|
636
|
+
const info = result.userInfo;
|
|
637
|
+
for (const key of Object.keys(info)) {
|
|
638
|
+
if (key === "uniqueId" || key === "sources") continue;
|
|
639
|
+
if (
|
|
640
|
+
info[key] !== undefined &&
|
|
641
|
+
info[key] !== null &&
|
|
642
|
+
info[key] !== ""
|
|
643
|
+
) {
|
|
644
|
+
user[key] = info[key];
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
user.processed = true;
|
|
649
|
+
user.processedAt = Date.now();
|
|
650
|
+
user.sources = [...new Set([...(user.sources || []), "restricted"])];
|
|
651
|
+
} else if (result.error) {
|
|
652
|
+
user.status = "error";
|
|
653
|
+
user.error = result.error;
|
|
654
|
+
user.sources = [...new Set([...(user.sources || []), "error"])];
|
|
655
|
+
} else {
|
|
656
|
+
user.status = "done";
|
|
657
|
+
user.processed = true;
|
|
658
|
+
user.processedAt = Date.now();
|
|
659
|
+
user.noVideo = result.noVideo || false;
|
|
660
|
+
user.keepFollow = result.keepFollow || false;
|
|
661
|
+
user.hasFollowData = result.hasFollowData || false;
|
|
662
|
+
|
|
663
|
+
if (result.userInfo) {
|
|
664
|
+
const info = result.userInfo;
|
|
665
|
+
for (const key of Object.keys(info)) {
|
|
666
|
+
if (key === "uniqueId" || key === "sources") continue;
|
|
667
|
+
if (
|
|
668
|
+
info[key] !== undefined &&
|
|
669
|
+
info[key] !== null &&
|
|
670
|
+
info[key] !== ""
|
|
671
|
+
) {
|
|
672
|
+
user[key] = info[key];
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
user.followerCount = result.userInfo?.followerCount ?? user.followerCount;
|
|
678
|
+
user.videoCount = result.userInfo?.videoCount ?? user.videoCount;
|
|
679
|
+
user.nickname = result.userInfo?.nickname || user.nickname;
|
|
680
|
+
user.locationCreated =
|
|
681
|
+
result.userInfo?.locationCreated || user.locationCreated;
|
|
682
|
+
user.ttSeller = result.userInfo?.ttSeller ?? user.ttSeller;
|
|
683
|
+
user.verified = result.userInfo?.verified ?? user.verified;
|
|
684
|
+
user.region = result.userInfo?.region || user.region;
|
|
685
|
+
user.signature = result.userInfo?.signature ?? user.signature;
|
|
686
|
+
user.followingCount =
|
|
687
|
+
result.userInfo?.followingCount ?? user.followingCount;
|
|
688
|
+
user.heartCount = result.userInfo?.heartCount ?? user.heartCount;
|
|
689
|
+
if (result.userInfo?.secUid) user.secUid = result.userInfo.secUid;
|
|
690
|
+
const extraFields = [
|
|
691
|
+
"restricted",
|
|
692
|
+
"error",
|
|
693
|
+
"userInfo",
|
|
694
|
+
"discoveredVideoAuthors",
|
|
695
|
+
"discoveredCommentAuthors",
|
|
696
|
+
"discoveredGuessAuthors",
|
|
697
|
+
"discoveredFollowing",
|
|
698
|
+
"discoveredFollowers",
|
|
699
|
+
"uniqueId",
|
|
700
|
+
"sources",
|
|
701
|
+
];
|
|
702
|
+
for (const key of Object.keys(result)) {
|
|
703
|
+
if (extraFields.includes(key)) continue;
|
|
704
|
+
if (
|
|
705
|
+
result[key] !== undefined &&
|
|
706
|
+
result[key] !== null &&
|
|
707
|
+
result[key] !== ""
|
|
708
|
+
) {
|
|
709
|
+
user[key] = result[key];
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
user.sources = [...new Set([...(user.sources || []), "processed"])];
|
|
713
|
+
}
|
|
714
|
+
if (user.status !== oldStatus) markStatsDirty();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function commitJob(uniqueId, result) {
|
|
718
|
+
const user = getUser(uniqueId);
|
|
719
|
+
if (!user) return { saved: false, error: "user not found" };
|
|
720
|
+
|
|
721
|
+
updateUserFromResult(user, result);
|
|
722
|
+
delete user.claimedAt;
|
|
723
|
+
const newUsers = processDiscoveredUsers(result);
|
|
724
|
+
|
|
725
|
+
save();
|
|
726
|
+
return { saved: true, status: user.status, newUsers };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function commitNewExplore(uniqueId, result) {
|
|
730
|
+
const existing = getUser(uniqueId);
|
|
731
|
+
if (existing) {
|
|
732
|
+
updateUserFromResult(existing, result);
|
|
733
|
+
const newUsers = processDiscoveredUsers(result);
|
|
734
|
+
save();
|
|
735
|
+
return { saved: true, created: false, status: existing.status, newUsers };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const userObj = {
|
|
739
|
+
uniqueId,
|
|
740
|
+
...(result.userInfo || {}),
|
|
741
|
+
sources: ["refresh-explore"],
|
|
742
|
+
};
|
|
743
|
+
updateUserFromResult(userObj, result);
|
|
744
|
+
addUser(userObj, true);
|
|
745
|
+
const newUsers = processDiscoveredUsers(result);
|
|
746
|
+
|
|
747
|
+
save();
|
|
748
|
+
return { saved: true, created: true, status: userObj.status, newUsers };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function resetJob(uniqueId) {
|
|
752
|
+
const user = getUser(uniqueId);
|
|
753
|
+
if (!user) return { saved: false, error: "user not found" };
|
|
754
|
+
user.status = "pending";
|
|
755
|
+
markStatsDirty();
|
|
756
|
+
delete user.claimedAt;
|
|
757
|
+
delete user.processedAt;
|
|
758
|
+
delete user.processed;
|
|
759
|
+
delete user.error;
|
|
760
|
+
delete user.restricted;
|
|
761
|
+
delete user.noVideo;
|
|
762
|
+
save();
|
|
763
|
+
return { saved: true };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function togglePin(uniqueId) {
|
|
767
|
+
const user = getUser(uniqueId);
|
|
768
|
+
if (!user) return { saved: false, error: "user not found" };
|
|
769
|
+
user.pinned = !user.pinned;
|
|
770
|
+
save();
|
|
771
|
+
return { saved: true, pinned: user.pinned };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function getNextRedoJob(userId) {
|
|
775
|
+
const now = Date.now();
|
|
776
|
+
const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
|
|
777
|
+
|
|
778
|
+
// 筛选目标国家用户,按 refreshTime 升序取最远的(没有则默认 2016-01-01)
|
|
779
|
+
const targetLocations = ["ES", "PL", "NL", "BE", "DE", "FR", "IT", "IE"];
|
|
780
|
+
const targetUsers = data.filter(
|
|
781
|
+
(u) =>
|
|
782
|
+
u.ttSeller &&
|
|
783
|
+
u.verified === false &&
|
|
784
|
+
targetLocations.includes(u.locationCreated),
|
|
785
|
+
);
|
|
786
|
+
if (targetUsers.length === 0) return null;
|
|
787
|
+
|
|
788
|
+
targetUsers.sort((a, b) => {
|
|
789
|
+
const ta = a.refreshTime || defaultTime;
|
|
790
|
+
const tb = b.refreshTime || defaultTime;
|
|
791
|
+
return ta - tb;
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const next = targetUsers[0];
|
|
795
|
+
next.refreshTime = now;
|
|
796
|
+
return {
|
|
797
|
+
uniqueId: next.uniqueId,
|
|
798
|
+
nickname: next.nickname,
|
|
799
|
+
refreshTime: next.refreshTime,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function commitRedoJob(uniqueId, result) {
|
|
804
|
+
const user = getUser(uniqueId);
|
|
805
|
+
if (!user) return { saved: false, error: "user not found" };
|
|
806
|
+
|
|
807
|
+
user.refreshTime = Date.now();
|
|
808
|
+
|
|
809
|
+
if (result.userInfo) {
|
|
810
|
+
const info = result.userInfo;
|
|
811
|
+
for (const key of Object.keys(info)) {
|
|
812
|
+
if (key === "uniqueId" || key === "sources") continue;
|
|
813
|
+
if (info[key] !== undefined && info[key] !== null && info[key] !== "") {
|
|
814
|
+
user[key] = info[key];
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return { saved: true };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function reportClientError(
|
|
823
|
+
userId,
|
|
824
|
+
errorType,
|
|
825
|
+
errorMessage,
|
|
826
|
+
username,
|
|
827
|
+
stage,
|
|
828
|
+
errorStack,
|
|
829
|
+
) {
|
|
830
|
+
const existing = clientErrors.get(userId);
|
|
831
|
+
if (existing) {
|
|
832
|
+
existing.timestamp = Date.now();
|
|
833
|
+
if (errorType === "captcha") {
|
|
834
|
+
existing.captchaCount = (existing.captchaCount || 0) + 1;
|
|
835
|
+
if (!existing.captchaStage) existing.captchaStage = stage || "";
|
|
836
|
+
if (!existing.captchaMessage)
|
|
837
|
+
existing.captchaMessage = errorMessage || "";
|
|
838
|
+
if (!existing.captchaStack) existing.captchaStack = errorStack || "";
|
|
839
|
+
} else {
|
|
840
|
+
existing.errorType = errorType;
|
|
841
|
+
existing.errorMessage = errorMessage || "";
|
|
842
|
+
existing.errorStack = errorStack || "";
|
|
843
|
+
existing.stage = stage || "";
|
|
844
|
+
existing.reportCount = (existing.reportCount || 1) + 1;
|
|
845
|
+
}
|
|
846
|
+
if (username) existing.username = username;
|
|
847
|
+
} else {
|
|
848
|
+
clientErrors.set(userId, {
|
|
849
|
+
userId,
|
|
850
|
+
errorType,
|
|
851
|
+
errorMessage: errorMessage || "",
|
|
852
|
+
errorStack: errorStack || "",
|
|
853
|
+
username,
|
|
854
|
+
stage: stage || "",
|
|
855
|
+
timestamp: Date.now(),
|
|
856
|
+
reportCount: 1,
|
|
857
|
+
captchaCount: errorType === "captcha" ? 1 : 0,
|
|
858
|
+
captchaStage: errorType === "captcha" ? stage || "" : "",
|
|
859
|
+
captchaMessage: errorType === "captcha" ? errorMessage || "" : "",
|
|
860
|
+
captchaStack: errorType === "captcha" ? errorStack || "" : "",
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function deleteClientError(userId) {
|
|
866
|
+
clientErrors.delete(userId);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function getClientErrors() {
|
|
870
|
+
return Array.from(clientErrors.values());
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function getPendingUserUpdateTasks(limit) {
|
|
874
|
+
const l = Math.max(1, parseInt(limit) || 5);
|
|
875
|
+
const pending = data
|
|
876
|
+
.filter((u) => {
|
|
877
|
+
const updateCount = u.userUpdateCount;
|
|
878
|
+
const ttSellerEmpty =
|
|
879
|
+
u.ttSeller === null || u.ttSeller === undefined || u.ttSeller === "";
|
|
880
|
+
if (!ttSellerEmpty) return false;
|
|
881
|
+
return (
|
|
882
|
+
updateCount === null || updateCount === undefined || updateCount <= 0
|
|
883
|
+
);
|
|
884
|
+
})
|
|
885
|
+
.slice(0, l);
|
|
886
|
+
// 接受任务时 userUpdateCount + 1
|
|
887
|
+
pending.forEach((u) => {
|
|
888
|
+
u.userUpdateCount = (u.userUpdateCount || 0) + 1;
|
|
889
|
+
u.updatedAt = Date.now();
|
|
890
|
+
});
|
|
891
|
+
save();
|
|
892
|
+
return pending;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function updateUserInfo(uniqueId, info) {
|
|
896
|
+
const user = getUser(uniqueId);
|
|
897
|
+
if (!user) return { error: "user not found" };
|
|
898
|
+
for (const key of Object.keys(info)) {
|
|
899
|
+
if (key === "uniqueId" || key === "sources") continue;
|
|
900
|
+
if (info[key] !== undefined && info[key] !== null && info[key] !== "") {
|
|
901
|
+
user[key] = info[key];
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
user.userUpdateCount = (user.userUpdateCount || 0) + 1;
|
|
905
|
+
user.updatedAt = Date.now();
|
|
906
|
+
save();
|
|
907
|
+
return { ok: true, userUpdateCount: user.userUpdateCount };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function batchUpdateUserInfo(updates) {
|
|
911
|
+
const results = [];
|
|
912
|
+
for (const item of updates) {
|
|
913
|
+
const user = getUser(item.uniqueId);
|
|
914
|
+
if (!user) {
|
|
915
|
+
results.push({ uniqueId: item.uniqueId, error: "user not found" });
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
for (const key of Object.keys(item.info)) {
|
|
919
|
+
if (key === "uniqueId" || key === "sources") continue;
|
|
920
|
+
if (
|
|
921
|
+
item.info[key] !== undefined &&
|
|
922
|
+
item.info[key] !== null &&
|
|
923
|
+
item.info[key] !== ""
|
|
924
|
+
) {
|
|
925
|
+
user[key] = item.info[key];
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
user.userUpdateCount = (user.userUpdateCount || 0) + 1;
|
|
929
|
+
user.updatedAt = Date.now();
|
|
930
|
+
results.push({
|
|
931
|
+
uniqueId: item.uniqueId,
|
|
932
|
+
ok: true,
|
|
933
|
+
userUpdateCount: user.userUpdateCount,
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
save();
|
|
937
|
+
return results;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// 视频登记
|
|
941
|
+
function registerVideos(sourceUser, videoList, locationCreated, ttSeller) {
|
|
942
|
+
if (!videoList || !Array.isArray(videoList) || videoList.length === 0) {
|
|
943
|
+
return { registered: 0, skipped: 0 };
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const existingIds = new Set(videos.map((v) => v.id));
|
|
947
|
+
let registered = 0;
|
|
948
|
+
let skipped = 0;
|
|
949
|
+
|
|
950
|
+
for (const item of videoList) {
|
|
951
|
+
if (existingIds.has(item.id)) {
|
|
952
|
+
skipped++;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
videos.push({
|
|
956
|
+
id: item.id,
|
|
957
|
+
href: item.href,
|
|
958
|
+
authorUniqueId: sourceUser,
|
|
959
|
+
locationCreated: locationCreated || null,
|
|
960
|
+
ttSeller: ttSeller || false,
|
|
961
|
+
registeredAt: Date.now(),
|
|
962
|
+
});
|
|
963
|
+
existingIds.add(item.id);
|
|
964
|
+
registered++;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
saveVideos();
|
|
968
|
+
return { registered, skipped };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function getVideos() {
|
|
972
|
+
return videos;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function getVideoCount() {
|
|
976
|
+
return videos.length;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function getPendingCommentTasks(limit) {
|
|
980
|
+
// 筛选待处理视频(userUpdateCount <= 0 或 null/undefined)
|
|
981
|
+
const pending = videos.filter((v) => (v.userUpdateCount || 0) <= 0);
|
|
982
|
+
// ttSeller=true 优先
|
|
983
|
+
pending.sort((a, b) => {
|
|
984
|
+
if (a.ttSeller && !b.ttSeller) return -1;
|
|
985
|
+
if (!a.ttSeller && b.ttSeller) return 1;
|
|
986
|
+
return (a.registeredAt || 0) - (b.registeredAt || 0);
|
|
987
|
+
});
|
|
988
|
+
// 取前 limit 个
|
|
989
|
+
const tasks = pending.slice(0, limit);
|
|
990
|
+
// userUpdateCount +1
|
|
991
|
+
for (const task of tasks) {
|
|
992
|
+
task.userUpdateCount = (task.userUpdateCount || 0) + 1;
|
|
993
|
+
}
|
|
994
|
+
saveVideos();
|
|
995
|
+
return tasks;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function commitCommentTask(videoId) {
|
|
999
|
+
const video = videos.find((v) => v.id === videoId);
|
|
1000
|
+
if (!video) return { ok: false, error: "video not found" };
|
|
1001
|
+
video.userUpdateCount = (video.userUpdateCount || 0) + 1;
|
|
1002
|
+
saveVideos();
|
|
1003
|
+
return { ok: true, userUpdateCount: video.userUpdateCount };
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return {
|
|
1007
|
+
save,
|
|
1008
|
+
flushSave,
|
|
1009
|
+
getUser,
|
|
1010
|
+
hasUser,
|
|
1011
|
+
userExists,
|
|
1012
|
+
addUser,
|
|
1013
|
+
getPendingUsers,
|
|
1014
|
+
getProcessedUsers,
|
|
1015
|
+
getAllUsers,
|
|
1016
|
+
getStats,
|
|
1017
|
+
getStatusGroups,
|
|
1018
|
+
markGroupsDirty,
|
|
1019
|
+
claimNextJob,
|
|
1020
|
+
commitJob,
|
|
1021
|
+
commitNewExplore,
|
|
1022
|
+
resetJob,
|
|
1023
|
+
togglePin,
|
|
1024
|
+
getNextRedoJob,
|
|
1025
|
+
commitRedoJob,
|
|
1026
|
+
getPendingUserUpdateTasks,
|
|
1027
|
+
updateUserInfo,
|
|
1028
|
+
batchUpdateUserInfo,
|
|
1029
|
+
reportClientError,
|
|
1030
|
+
deleteClientError,
|
|
1031
|
+
getClientErrors,
|
|
1032
|
+
registerVideos,
|
|
1033
|
+
getVideos,
|
|
1034
|
+
getVideoCount,
|
|
1035
|
+
getPendingCommentTasks,
|
|
1036
|
+
commitCommentTask,
|
|
1037
|
+
stopBackup,
|
|
1038
|
+
data,
|
|
1039
|
+
};
|
|
1040
|
+
}
|