tt-help-cli-ycl 1.3.35 → 1.3.37
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 -1
- package/cli.js +3 -3
- package/package.json +7 -2
- package/scripts/test-watch-db-smoke.mjs +246 -0
- package/src/cli/attach.js +149 -89
- package/src/cli/auto.js +180 -155
- package/src/cli/comments.js +301 -210
- package/src/cli/config.js +62 -44
- package/src/cli/db-import.js +51 -0
- package/src/cli/explore.js +373 -342
- package/src/cli/refresh.js +223 -151
- package/src/cli/videostats.js +140 -25
- package/src/cli/watch.js +15 -16
- package/src/lib/args.js +50 -6
- package/src/lib/constants.js +159 -92
- package/src/lib/tiktok-scraper.mjs +59 -21
- package/src/main.js +42 -20
- package/src/npm-main.js +69 -0
- package/src/watch/data-store.js +1560 -236
- package/src/watch/public/index.html +51 -15
- package/src/watch/server.js +63 -374
package/src/watch/data-store.js
CHANGED
|
@@ -1,6 +1,878 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
-
import { promises as fsPromises } from "fs";
|
|
3
2
|
import path from "path";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
|
|
5
|
+
// SQLite 用户表(用于判重)
|
|
6
|
+
let db = null;
|
|
7
|
+
let dbPath = null;
|
|
8
|
+
|
|
9
|
+
function normalizeDbFilePath(filePath) {
|
|
10
|
+
if (!filePath) {
|
|
11
|
+
throw new Error("db path is required");
|
|
12
|
+
}
|
|
13
|
+
const resolved = path.resolve(filePath);
|
|
14
|
+
if (path.extname(resolved).toLowerCase() !== ".db") {
|
|
15
|
+
throw new Error(`仅支持 .db 路径,当前为: ${filePath}`);
|
|
16
|
+
}
|
|
17
|
+
return resolved;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resetDbConnection() {
|
|
21
|
+
if (db) {
|
|
22
|
+
db.close();
|
|
23
|
+
db = null;
|
|
24
|
+
}
|
|
25
|
+
dbPath = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadLegacyUsersFromFiles(userFilePath, doneFilePath) {
|
|
29
|
+
const merged = new Map();
|
|
30
|
+
|
|
31
|
+
const tryLoad = (targetPath, label) => {
|
|
32
|
+
if (!targetPath) return;
|
|
33
|
+
if (!fs.existsSync(targetPath)) return;
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(fs.readFileSync(targetPath, "utf-8"));
|
|
36
|
+
if (!Array.isArray(parsed)) return;
|
|
37
|
+
for (const item of parsed) {
|
|
38
|
+
const uniqueId = item?.uniqueId || item?.unique_id;
|
|
39
|
+
if (!uniqueId) continue;
|
|
40
|
+
merged.set(uniqueId, {
|
|
41
|
+
...merged.get(uniqueId),
|
|
42
|
+
...item,
|
|
43
|
+
uniqueId,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.error(`[data-store] SQLite 导入 ${label} 失败: ${e.message}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
tryLoad(userFilePath, "result.json");
|
|
52
|
+
tryLoad(doneFilePath, "result-done.json");
|
|
53
|
+
|
|
54
|
+
return [...merged.values()];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadLegacyVideosFromFile(videoPath) {
|
|
58
|
+
if (!videoPath) return [];
|
|
59
|
+
if (!fs.existsSync(videoPath)) return [];
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(fs.readFileSync(videoPath, "utf-8"));
|
|
63
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error(
|
|
66
|
+
`[data-store] SQLite 导入 result-videos.json 失败: ${e.message}`,
|
|
67
|
+
);
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function initUserDb(filePath) {
|
|
73
|
+
dbPath = normalizeDbFilePath(filePath);
|
|
74
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
75
|
+
db = new Database(dbPath);
|
|
76
|
+
db.pragma("journal_mode = WAL");
|
|
77
|
+
db.exec(`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
79
|
+
unique_id TEXT PRIMARY KEY,
|
|
80
|
+
tt_seller TEXT,
|
|
81
|
+
verified INTEGER,
|
|
82
|
+
location_created TEXT,
|
|
83
|
+
created_at TEXT,
|
|
84
|
+
updated_at TEXT
|
|
85
|
+
)
|
|
86
|
+
`);
|
|
87
|
+
db.exec(`
|
|
88
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
89
|
+
unique_id TEXT PRIMARY KEY,
|
|
90
|
+
nickname TEXT,
|
|
91
|
+
status TEXT DEFAULT 'pending',
|
|
92
|
+
sources TEXT,
|
|
93
|
+
claimed_by TEXT,
|
|
94
|
+
claimed_at INTEGER,
|
|
95
|
+
error TEXT,
|
|
96
|
+
pinned INTEGER DEFAULT 0,
|
|
97
|
+
no_video INTEGER DEFAULT 0,
|
|
98
|
+
restricted INTEGER DEFAULT 0,
|
|
99
|
+
user_update_count INTEGER DEFAULT 0,
|
|
100
|
+
tt_seller INTEGER,
|
|
101
|
+
verified INTEGER,
|
|
102
|
+
video_count INTEGER DEFAULT 0,
|
|
103
|
+
comment_count INTEGER DEFAULT 0,
|
|
104
|
+
guessed_location TEXT,
|
|
105
|
+
location_created TEXT,
|
|
106
|
+
follower_count INTEGER DEFAULT 0,
|
|
107
|
+
following_count INTEGER DEFAULT 0,
|
|
108
|
+
heart_count INTEGER DEFAULT 0,
|
|
109
|
+
refresh_time INTEGER,
|
|
110
|
+
processed INTEGER DEFAULT 0,
|
|
111
|
+
processed_at INTEGER,
|
|
112
|
+
created_at INTEGER,
|
|
113
|
+
updated_at INTEGER,
|
|
114
|
+
region TEXT,
|
|
115
|
+
signature TEXT,
|
|
116
|
+
sec_uid TEXT
|
|
117
|
+
)
|
|
118
|
+
`);
|
|
119
|
+
db.exec(`
|
|
120
|
+
CREATE TABLE IF NOT EXISTS videos (
|
|
121
|
+
id TEXT PRIMARY KEY,
|
|
122
|
+
href TEXT,
|
|
123
|
+
author_unique_id TEXT,
|
|
124
|
+
location_created TEXT,
|
|
125
|
+
tt_seller INTEGER DEFAULT 0,
|
|
126
|
+
registered_at INTEGER,
|
|
127
|
+
user_update_count INTEGER DEFAULT 0,
|
|
128
|
+
play_count INTEGER,
|
|
129
|
+
digg_count INTEGER,
|
|
130
|
+
comment_count INTEGER,
|
|
131
|
+
share_count INTEGER,
|
|
132
|
+
collect_count INTEGER,
|
|
133
|
+
stats_updated_at INTEGER
|
|
134
|
+
)
|
|
135
|
+
`);
|
|
136
|
+
db.exec(`
|
|
137
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_status_video
|
|
138
|
+
ON jobs(status, video_count DESC)
|
|
139
|
+
`);
|
|
140
|
+
db.exec(`
|
|
141
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_claimed_by_status
|
|
142
|
+
ON jobs(claimed_by, status, claimed_at)
|
|
143
|
+
`);
|
|
144
|
+
db.exec(`
|
|
145
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_status_claimed_at
|
|
146
|
+
ON jobs(status, claimed_at)
|
|
147
|
+
`);
|
|
148
|
+
db.exec(`
|
|
149
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_redo_target
|
|
150
|
+
ON jobs(tt_seller, verified, location_created, refresh_time)
|
|
151
|
+
`);
|
|
152
|
+
db.exec(`
|
|
153
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_pending_priority
|
|
154
|
+
ON jobs(status, pinned DESC, guessed_location, follower_count DESC)
|
|
155
|
+
`);
|
|
156
|
+
db.exec(`
|
|
157
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_pinned
|
|
158
|
+
ON jobs(created_at ASC, unique_id ASC)
|
|
159
|
+
WHERE status = 'pending' AND COALESCE(pinned, 0) = 1
|
|
160
|
+
`);
|
|
161
|
+
db.exec(`
|
|
162
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_seller
|
|
163
|
+
ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC)
|
|
164
|
+
WHERE status = 'pending'
|
|
165
|
+
AND COALESCE(pinned, 0) = 0
|
|
166
|
+
AND tt_seller = 1
|
|
167
|
+
AND verified = 0
|
|
168
|
+
`);
|
|
169
|
+
db.exec(`
|
|
170
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_follow
|
|
171
|
+
ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC)
|
|
172
|
+
WHERE status = 'pending'
|
|
173
|
+
AND COALESCE(pinned, 0) = 0
|
|
174
|
+
AND (
|
|
175
|
+
instr(COALESCE(sources, ''), '"following"') > 0
|
|
176
|
+
OR instr(COALESCE(sources, ''), '"follower"') > 0
|
|
177
|
+
)
|
|
178
|
+
`);
|
|
179
|
+
db.exec(`
|
|
180
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_other
|
|
181
|
+
ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC)
|
|
182
|
+
WHERE status = 'pending' AND COALESCE(pinned, 0) = 0
|
|
183
|
+
`);
|
|
184
|
+
db.exec(`
|
|
185
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_user_update_queue
|
|
186
|
+
ON jobs(created_at ASC, unique_id ASC)
|
|
187
|
+
WHERE (tt_seller IS NULL OR tt_seller = '')
|
|
188
|
+
AND (user_update_count IS NULL OR user_update_count <= 0)
|
|
189
|
+
`);
|
|
190
|
+
db.exec(`
|
|
191
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_user_update_queue_expr
|
|
192
|
+
ON jobs(created_at ASC, unique_id ASC)
|
|
193
|
+
WHERE COALESCE(tt_seller, '') = ''
|
|
194
|
+
AND COALESCE(user_update_count, 0) <= 0
|
|
195
|
+
`);
|
|
196
|
+
db.exec(`
|
|
197
|
+
CREATE INDEX IF NOT EXISTS idx_videos_comment_queue
|
|
198
|
+
ON videos(user_update_count, tt_seller DESC, registered_at ASC)
|
|
199
|
+
`);
|
|
200
|
+
db.exec(`
|
|
201
|
+
CREATE INDEX IF NOT EXISTS idx_videos_comment_queue_pending
|
|
202
|
+
ON videos(tt_seller DESC, registered_at ASC, id)
|
|
203
|
+
WHERE user_update_count IS NULL OR user_update_count <= 0
|
|
204
|
+
`);
|
|
205
|
+
|
|
206
|
+
const existingVideoColumns = new Set(
|
|
207
|
+
db
|
|
208
|
+
.prepare("PRAGMA table_info(videos)")
|
|
209
|
+
.all()
|
|
210
|
+
.map((column) => column.name),
|
|
211
|
+
);
|
|
212
|
+
const requiredVideoColumns = {
|
|
213
|
+
play_count: "INTEGER",
|
|
214
|
+
digg_count: "INTEGER",
|
|
215
|
+
comment_count: "INTEGER",
|
|
216
|
+
share_count: "INTEGER",
|
|
217
|
+
collect_count: "INTEGER",
|
|
218
|
+
stats_updated_at: "INTEGER",
|
|
219
|
+
};
|
|
220
|
+
for (const [column, type] of Object.entries(requiredVideoColumns)) {
|
|
221
|
+
if (!existingVideoColumns.has(column)) {
|
|
222
|
+
db.exec(`ALTER TABLE videos ADD COLUMN ${column} ${type}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM users").get().c;
|
|
227
|
+
console.log(`[data-store] SQLite users 表初始化完成: ${count} 条`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function importLegacyJsonToDb({
|
|
231
|
+
dbFilePath,
|
|
232
|
+
usersFilePath,
|
|
233
|
+
doneFilePath,
|
|
234
|
+
videosFilePath,
|
|
235
|
+
}) {
|
|
236
|
+
resetDbConnection();
|
|
237
|
+
initUserDb(dbFilePath);
|
|
238
|
+
|
|
239
|
+
const legacyUsers = loadLegacyUsersFromFiles(usersFilePath, doneFilePath);
|
|
240
|
+
const legacyVideos = loadLegacyVideosFromFile(videosFilePath);
|
|
241
|
+
|
|
242
|
+
const beforeUsers = db.prepare("SELECT COUNT(*) as c FROM users").get().c;
|
|
243
|
+
const beforeJobs = db.prepare("SELECT COUNT(*) as c FROM jobs").get().c;
|
|
244
|
+
const beforeVideos = db.prepare("SELECT COUNT(*) as c FROM videos").get().c;
|
|
245
|
+
|
|
246
|
+
const insertUserStmt = db.prepare(`
|
|
247
|
+
INSERT OR IGNORE INTO users (unique_id) VALUES (?)
|
|
248
|
+
`);
|
|
249
|
+
const insertVideoStmt = db.prepare(`
|
|
250
|
+
INSERT OR IGNORE INTO videos (
|
|
251
|
+
id,
|
|
252
|
+
href,
|
|
253
|
+
author_unique_id,
|
|
254
|
+
location_created,
|
|
255
|
+
tt_seller,
|
|
256
|
+
registered_at,
|
|
257
|
+
user_update_count
|
|
258
|
+
)
|
|
259
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
260
|
+
`);
|
|
261
|
+
|
|
262
|
+
const importUsersTxn = db.transaction((items) => {
|
|
263
|
+
for (const item of items) {
|
|
264
|
+
const uniqueId = item.uniqueId || item.unique_id;
|
|
265
|
+
if (!uniqueId) continue;
|
|
266
|
+
insertUserStmt.run(uniqueId);
|
|
267
|
+
addJobToDb({ ...item, uniqueId });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const importVideosTxn = db.transaction((items) => {
|
|
272
|
+
for (const item of items) {
|
|
273
|
+
if (!item?.id) continue;
|
|
274
|
+
insertVideoStmt.run(
|
|
275
|
+
item.id,
|
|
276
|
+
item.href || null,
|
|
277
|
+
item.authorUniqueId || item.author_unique_id || null,
|
|
278
|
+
item.locationCreated || item.location_created || null,
|
|
279
|
+
item.ttSeller ? 1 : 0,
|
|
280
|
+
item.registeredAt || item.registered_at || Date.now(),
|
|
281
|
+
item.userUpdateCount || item.user_update_count || 0,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
importUsersTxn(legacyUsers);
|
|
287
|
+
importVideosTxn(legacyVideos);
|
|
288
|
+
|
|
289
|
+
const afterUsers = db.prepare("SELECT COUNT(*) as c FROM users").get().c;
|
|
290
|
+
const afterJobs = db.prepare("SELECT COUNT(*) as c FROM jobs").get().c;
|
|
291
|
+
const afterVideos = db.prepare("SELECT COUNT(*) as c FROM videos").get().c;
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
dbPath,
|
|
295
|
+
usersImported: afterUsers - beforeUsers,
|
|
296
|
+
jobsImported: afterJobs - beforeJobs,
|
|
297
|
+
videosImported: afterVideos - beforeVideos,
|
|
298
|
+
totalUsers: afterUsers,
|
|
299
|
+
totalJobs: afterJobs,
|
|
300
|
+
totalVideos: afterVideos,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function closeStoreDb() {
|
|
305
|
+
resetDbConnection();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function hasUserInDb(uid) {
|
|
309
|
+
if (!db) return false;
|
|
310
|
+
const row = db.prepare("SELECT 1 FROM users WHERE unique_id = ?").get(uid);
|
|
311
|
+
return !!row;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function addUserToDb(user) {
|
|
315
|
+
if (!db) return;
|
|
316
|
+
db.prepare(
|
|
317
|
+
`
|
|
318
|
+
INSERT OR IGNORE INTO users (unique_id, tt_seller, verified, location_created, created_at, updated_at)
|
|
319
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
320
|
+
`,
|
|
321
|
+
).run(
|
|
322
|
+
user.uniqueId,
|
|
323
|
+
user.ttSeller === undefined ||
|
|
324
|
+
user.ttSeller === null ||
|
|
325
|
+
user.ttSeller === ""
|
|
326
|
+
? null
|
|
327
|
+
: user.ttSeller
|
|
328
|
+
? 1
|
|
329
|
+
: 0,
|
|
330
|
+
user.verified === undefined ||
|
|
331
|
+
user.verified === null ||
|
|
332
|
+
user.verified === ""
|
|
333
|
+
? null
|
|
334
|
+
: user.verified
|
|
335
|
+
? 1
|
|
336
|
+
: 0,
|
|
337
|
+
user.locationCreated || null,
|
|
338
|
+
new Date().toISOString(),
|
|
339
|
+
new Date().toISOString(),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function addJobToDb(user) {
|
|
344
|
+
if (!db) return;
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
db.prepare(
|
|
347
|
+
`
|
|
348
|
+
INSERT OR IGNORE INTO jobs (
|
|
349
|
+
unique_id,
|
|
350
|
+
nickname,
|
|
351
|
+
status,
|
|
352
|
+
sources,
|
|
353
|
+
claimed_by,
|
|
354
|
+
claimed_at,
|
|
355
|
+
error,
|
|
356
|
+
pinned,
|
|
357
|
+
no_video,
|
|
358
|
+
restricted,
|
|
359
|
+
user_update_count,
|
|
360
|
+
tt_seller,
|
|
361
|
+
verified,
|
|
362
|
+
video_count,
|
|
363
|
+
comment_count,
|
|
364
|
+
guessed_location,
|
|
365
|
+
location_created,
|
|
366
|
+
follower_count,
|
|
367
|
+
following_count,
|
|
368
|
+
heart_count,
|
|
369
|
+
refresh_time,
|
|
370
|
+
processed,
|
|
371
|
+
processed_at,
|
|
372
|
+
created_at,
|
|
373
|
+
updated_at,
|
|
374
|
+
region,
|
|
375
|
+
signature,
|
|
376
|
+
sec_uid
|
|
377
|
+
)
|
|
378
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
379
|
+
`,
|
|
380
|
+
).run(
|
|
381
|
+
user.uniqueId,
|
|
382
|
+
user.nickname || null,
|
|
383
|
+
user.status || inferStatus(user),
|
|
384
|
+
JSON.stringify(
|
|
385
|
+
Array.isArray(user.sources) ? [...new Set(user.sources)] : [],
|
|
386
|
+
),
|
|
387
|
+
user.claimedBy || null,
|
|
388
|
+
user.claimedAt || null,
|
|
389
|
+
user.error || null,
|
|
390
|
+
user.pinned ? 1 : 0,
|
|
391
|
+
user.noVideo ? 1 : 0,
|
|
392
|
+
user.restricted ? 1 : 0,
|
|
393
|
+
user.userUpdateCount || 0,
|
|
394
|
+
user.ttSeller === undefined ||
|
|
395
|
+
user.ttSeller === null ||
|
|
396
|
+
user.ttSeller === ""
|
|
397
|
+
? null
|
|
398
|
+
: user.ttSeller
|
|
399
|
+
? 1
|
|
400
|
+
: 0,
|
|
401
|
+
user.verified === undefined ||
|
|
402
|
+
user.verified === null ||
|
|
403
|
+
user.verified === ""
|
|
404
|
+
? null
|
|
405
|
+
: user.verified
|
|
406
|
+
? 1
|
|
407
|
+
: 0,
|
|
408
|
+
user.videoCount || 0,
|
|
409
|
+
user.commentCount || 0,
|
|
410
|
+
user.guessedLocation || null,
|
|
411
|
+
user.locationCreated || null,
|
|
412
|
+
user.followerCount || 0,
|
|
413
|
+
user.followingCount || 0,
|
|
414
|
+
user.heartCount || 0,
|
|
415
|
+
user.refreshTime || null,
|
|
416
|
+
user.processed ? 1 : 0,
|
|
417
|
+
user.processedAt || null,
|
|
418
|
+
user.createdAt || now,
|
|
419
|
+
user.updatedAt || now,
|
|
420
|
+
user.region || null,
|
|
421
|
+
user.signature || null,
|
|
422
|
+
user.secUid || null,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function getUserDbCount() {
|
|
427
|
+
if (!db) return 0;
|
|
428
|
+
return db.prepare("SELECT COUNT(*) as c FROM users").get().c;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function getJobsCount() {
|
|
432
|
+
if (!db) return 0;
|
|
433
|
+
return db.prepare("SELECT COUNT(*) as c FROM jobs").get().c;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function getPendingJobsCount() {
|
|
437
|
+
if (!db) return 0;
|
|
438
|
+
return db
|
|
439
|
+
.prepare("SELECT COUNT(*) as c FROM jobs WHERE status = 'pending'")
|
|
440
|
+
.get().c;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function getPendingJobsUserUpdateCount() {
|
|
444
|
+
if (!db) return 0;
|
|
445
|
+
return db
|
|
446
|
+
.prepare(
|
|
447
|
+
`
|
|
448
|
+
SELECT COUNT(*) as c
|
|
449
|
+
FROM jobs
|
|
450
|
+
WHERE COALESCE(tt_seller, '') = ''
|
|
451
|
+
AND COALESCE(user_update_count, 0) <= 0
|
|
452
|
+
`,
|
|
453
|
+
)
|
|
454
|
+
.get().c;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function getDashboardStatsFromDb(targetLocations = []) {
|
|
458
|
+
if (!db) return null;
|
|
459
|
+
|
|
460
|
+
const total = getJobsCount();
|
|
461
|
+
const statusCounts = {
|
|
462
|
+
pending: 0,
|
|
463
|
+
processing: 0,
|
|
464
|
+
done: 0,
|
|
465
|
+
error: 0,
|
|
466
|
+
restricted: 0,
|
|
467
|
+
};
|
|
468
|
+
const rows = db
|
|
469
|
+
.prepare(
|
|
470
|
+
`
|
|
471
|
+
SELECT status, COUNT(*) as count
|
|
472
|
+
FROM jobs
|
|
473
|
+
GROUP BY status
|
|
474
|
+
`,
|
|
475
|
+
)
|
|
476
|
+
.all();
|
|
477
|
+
for (const row of rows) {
|
|
478
|
+
if (row.status && statusCounts[row.status] !== undefined) {
|
|
479
|
+
statusCounts[row.status] = row.count;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const targetPlaceholders = targetLocations.map(() => "?").join(", ");
|
|
484
|
+
const targetUsers = targetLocations.length
|
|
485
|
+
? db
|
|
486
|
+
.prepare(
|
|
487
|
+
`
|
|
488
|
+
SELECT COUNT(*) as c
|
|
489
|
+
FROM jobs
|
|
490
|
+
WHERE tt_seller = 1
|
|
491
|
+
AND verified = 0
|
|
492
|
+
AND location_created IN (${targetPlaceholders})
|
|
493
|
+
`,
|
|
494
|
+
)
|
|
495
|
+
.get(...targetLocations).c
|
|
496
|
+
: 0;
|
|
497
|
+
|
|
498
|
+
const countryStats = db
|
|
499
|
+
.prepare(
|
|
500
|
+
`
|
|
501
|
+
SELECT
|
|
502
|
+
COALESCE(location_created, '未知') as country,
|
|
503
|
+
COUNT(*) as count,
|
|
504
|
+
SUM(CASE
|
|
505
|
+
WHEN tt_seller = 1 AND verified = 0 ${
|
|
506
|
+
targetLocations.length
|
|
507
|
+
? `AND location_created IN (${targetPlaceholders})`
|
|
508
|
+
: "AND 1 = 0"
|
|
509
|
+
}
|
|
510
|
+
THEN 1 ELSE 0 END) as targetCount
|
|
511
|
+
FROM jobs
|
|
512
|
+
WHERE status = 'done'
|
|
513
|
+
GROUP BY COALESCE(location_created, '未知')
|
|
514
|
+
ORDER BY count DESC
|
|
515
|
+
`,
|
|
516
|
+
)
|
|
517
|
+
.all(...targetLocations);
|
|
518
|
+
|
|
519
|
+
const targetCountryStats = targetLocations.length
|
|
520
|
+
? db
|
|
521
|
+
.prepare(
|
|
522
|
+
`
|
|
523
|
+
SELECT location_created as country, COUNT(*) as count
|
|
524
|
+
FROM jobs
|
|
525
|
+
WHERE tt_seller = 1
|
|
526
|
+
AND verified = 0
|
|
527
|
+
AND location_created IN (${targetPlaceholders})
|
|
528
|
+
GROUP BY location_created
|
|
529
|
+
ORDER BY count DESC
|
|
530
|
+
`,
|
|
531
|
+
)
|
|
532
|
+
.all(...targetLocations)
|
|
533
|
+
: [];
|
|
534
|
+
|
|
535
|
+
const sourceRow = db
|
|
536
|
+
.prepare(
|
|
537
|
+
`
|
|
538
|
+
SELECT
|
|
539
|
+
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as processed,
|
|
540
|
+
SUM(CASE WHEN status = 'restricted' THEN 1 ELSE 0 END) as restricted,
|
|
541
|
+
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
|
|
542
|
+
SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo,
|
|
543
|
+
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"video"') > 0 THEN 1 ELSE 0 END) as video,
|
|
544
|
+
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"comment"') > 0 THEN 1 ELSE 0 END) as comment,
|
|
545
|
+
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"guess"') > 0 THEN 1 ELSE 0 END) as guess,
|
|
546
|
+
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"following"') > 0 THEN 1 ELSE 0 END) as following,
|
|
547
|
+
SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"follower"') > 0 THEN 1 ELSE 0 END) as follower,
|
|
548
|
+
SUM(CASE
|
|
549
|
+
WHEN status != 'done'
|
|
550
|
+
AND instr(COALESCE(sources, ''), '"video"') = 0
|
|
551
|
+
AND instr(COALESCE(sources, ''), '"comment"') = 0
|
|
552
|
+
AND instr(COALESCE(sources, ''), '"guess"') = 0
|
|
553
|
+
AND instr(COALESCE(sources, ''), '"following"') = 0
|
|
554
|
+
AND instr(COALESCE(sources, ''), '"follower"') = 0
|
|
555
|
+
THEN 1 ELSE 0 END) as seed
|
|
556
|
+
FROM jobs
|
|
557
|
+
`,
|
|
558
|
+
)
|
|
559
|
+
.get();
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
totalUsers: total,
|
|
563
|
+
dbTotalUsers: getUserDbCount(),
|
|
564
|
+
jobsTotal: total,
|
|
565
|
+
jobsPending: getPendingJobsCount(),
|
|
566
|
+
processedUsers: statusCounts.done,
|
|
567
|
+
pendingUsers: statusCounts.pending,
|
|
568
|
+
processingUsers: statusCounts.processing,
|
|
569
|
+
restrictedUsers: statusCounts.restricted,
|
|
570
|
+
errorUsers: statusCounts.error,
|
|
571
|
+
targetUsers,
|
|
572
|
+
userUpdateTasks: getPendingJobsUserUpdateCount(),
|
|
573
|
+
targetCountryStats,
|
|
574
|
+
countryStats,
|
|
575
|
+
sourceStats: {
|
|
576
|
+
seed: sourceRow.seed || 0,
|
|
577
|
+
video: sourceRow.video || 0,
|
|
578
|
+
comment: sourceRow.comment || 0,
|
|
579
|
+
guess: sourceRow.guess || 0,
|
|
580
|
+
following: sourceRow.following || 0,
|
|
581
|
+
follower: sourceRow.follower || 0,
|
|
582
|
+
processed: sourceRow.processed || 0,
|
|
583
|
+
restricted: sourceRow.restricted || 0,
|
|
584
|
+
error: sourceRow.error || 0,
|
|
585
|
+
noVideo: sourceRow.noVideo || 0,
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function getUsersPageFromDb({
|
|
591
|
+
status,
|
|
592
|
+
search,
|
|
593
|
+
location,
|
|
594
|
+
target,
|
|
595
|
+
targetLocation,
|
|
596
|
+
limit,
|
|
597
|
+
offset,
|
|
598
|
+
targetLocations = [],
|
|
599
|
+
}) {
|
|
600
|
+
if (!db) return null;
|
|
601
|
+
|
|
602
|
+
const safeLimit = Math.max(1, Math.min(200, parseInt(limit) || 50));
|
|
603
|
+
const safeOffset = Math.max(0, parseInt(offset) || 0);
|
|
604
|
+
const where = [];
|
|
605
|
+
const args = [];
|
|
606
|
+
|
|
607
|
+
if (status && status !== "all") {
|
|
608
|
+
where.push("status = ?");
|
|
609
|
+
args.push(status);
|
|
610
|
+
}
|
|
611
|
+
if (target === "1") {
|
|
612
|
+
if (targetLocation) {
|
|
613
|
+
where.push("tt_seller = 1 AND verified = 0 AND location_created = ?");
|
|
614
|
+
args.push(targetLocation);
|
|
615
|
+
} else if (targetLocations.length > 0) {
|
|
616
|
+
where.push(
|
|
617
|
+
`tt_seller = 1 AND verified = 0 AND location_created IN (${targetLocations
|
|
618
|
+
.map(() => "?")
|
|
619
|
+
.join(", ")})`,
|
|
620
|
+
);
|
|
621
|
+
args.push(...targetLocations);
|
|
622
|
+
} else {
|
|
623
|
+
where.push("1 = 0");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (search) {
|
|
627
|
+
where.push(
|
|
628
|
+
"(LOWER(unique_id) LIKE ? OR LOWER(COALESCE(nickname, '')) LIKE ?)",
|
|
629
|
+
);
|
|
630
|
+
const pattern = `%${String(search).toLowerCase()}%`;
|
|
631
|
+
args.push(pattern, pattern);
|
|
632
|
+
}
|
|
633
|
+
if (location) {
|
|
634
|
+
where.push("location_created = ?");
|
|
635
|
+
args.push(location);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
639
|
+
const total = db
|
|
640
|
+
.prepare(`SELECT COUNT(*) as c FROM jobs ${whereSql}`)
|
|
641
|
+
.get(...args).c;
|
|
642
|
+
|
|
643
|
+
const rows = db
|
|
644
|
+
.prepare(
|
|
645
|
+
`
|
|
646
|
+
SELECT *
|
|
647
|
+
FROM jobs
|
|
648
|
+
${whereSql}
|
|
649
|
+
ORDER BY
|
|
650
|
+
pinned DESC,
|
|
651
|
+
CASE status
|
|
652
|
+
WHEN 'processing' THEN 0
|
|
653
|
+
WHEN 'pending' THEN 1
|
|
654
|
+
WHEN 'done' THEN 2
|
|
655
|
+
WHEN 'error' THEN 3
|
|
656
|
+
WHEN 'restricted' THEN 4
|
|
657
|
+
ELSE 9
|
|
658
|
+
END ASC,
|
|
659
|
+
CASE
|
|
660
|
+
WHEN status = 'done' THEN -COALESCE(processed_at, 0)
|
|
661
|
+
ELSE 0
|
|
662
|
+
END ASC,
|
|
663
|
+
CASE
|
|
664
|
+
WHEN status = 'pending' AND tt_seller = 1 AND verified = 0 THEN 0
|
|
665
|
+
ELSE 1
|
|
666
|
+
END ASC,
|
|
667
|
+
CASE
|
|
668
|
+
WHEN status = 'pending' AND UPPER(COALESCE(guessed_location, '')) IN ('PL', 'NL', 'BE', 'AT') THEN 0
|
|
669
|
+
WHEN status = 'pending' AND UPPER(COALESCE(guessed_location, '')) IN ('DE', 'FR', 'IT', 'IE', 'ES') THEN 1
|
|
670
|
+
ELSE 2
|
|
671
|
+
END ASC,
|
|
672
|
+
COALESCE(follower_count, 0) DESC,
|
|
673
|
+
COALESCE(processed_at, 0) DESC,
|
|
674
|
+
unique_id ASC
|
|
675
|
+
LIMIT ? OFFSET ?
|
|
676
|
+
`,
|
|
677
|
+
)
|
|
678
|
+
.all(...args, safeLimit, safeOffset)
|
|
679
|
+
.map(mapJobRow);
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
total,
|
|
683
|
+
users: rows,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function getTargetUsersFromDb(targetLocations = []) {
|
|
688
|
+
if (!db) return null;
|
|
689
|
+
if (!targetLocations.length) {
|
|
690
|
+
return { total: 0, users: [] };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const placeholders = targetLocations.map(() => "?").join(", ");
|
|
694
|
+
const rows = db
|
|
695
|
+
.prepare(
|
|
696
|
+
`
|
|
697
|
+
SELECT
|
|
698
|
+
unique_id,
|
|
699
|
+
nickname,
|
|
700
|
+
follower_count,
|
|
701
|
+
tt_seller,
|
|
702
|
+
verified,
|
|
703
|
+
location_created,
|
|
704
|
+
status,
|
|
705
|
+
sources
|
|
706
|
+
FROM jobs
|
|
707
|
+
WHERE tt_seller = 1
|
|
708
|
+
AND verified = 0
|
|
709
|
+
AND location_created IN (${placeholders})
|
|
710
|
+
ORDER BY COALESCE(follower_count, 0) DESC, unique_id ASC
|
|
711
|
+
`,
|
|
712
|
+
)
|
|
713
|
+
.all(...targetLocations)
|
|
714
|
+
.map(mapJobRow);
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
total: rows.length,
|
|
718
|
+
users: rows,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function snakeToCamel(key) {
|
|
723
|
+
return key.replace(/_([a-z])/g, (_, ch) => ch.toUpperCase());
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function camelToSnake(key) {
|
|
727
|
+
return key.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const jobBooleanColumns = new Set([
|
|
731
|
+
"pinned",
|
|
732
|
+
"no_video",
|
|
733
|
+
"restricted",
|
|
734
|
+
"processed",
|
|
735
|
+
"tt_seller",
|
|
736
|
+
"verified",
|
|
737
|
+
]);
|
|
738
|
+
|
|
739
|
+
const videoBooleanColumns = new Set(["tt_seller"]);
|
|
740
|
+
|
|
741
|
+
const writableJobColumns = new Set([
|
|
742
|
+
"nickname",
|
|
743
|
+
"status",
|
|
744
|
+
"sources",
|
|
745
|
+
"claimed_by",
|
|
746
|
+
"claimed_at",
|
|
747
|
+
"error",
|
|
748
|
+
"pinned",
|
|
749
|
+
"no_video",
|
|
750
|
+
"restricted",
|
|
751
|
+
"user_update_count",
|
|
752
|
+
"tt_seller",
|
|
753
|
+
"verified",
|
|
754
|
+
"video_count",
|
|
755
|
+
"comment_count",
|
|
756
|
+
"guessed_location",
|
|
757
|
+
"location_created",
|
|
758
|
+
"follower_count",
|
|
759
|
+
"following_count",
|
|
760
|
+
"heart_count",
|
|
761
|
+
"refresh_time",
|
|
762
|
+
"processed",
|
|
763
|
+
"processed_at",
|
|
764
|
+
"updated_at",
|
|
765
|
+
"region",
|
|
766
|
+
"signature",
|
|
767
|
+
"sec_uid",
|
|
768
|
+
]);
|
|
769
|
+
|
|
770
|
+
function normalizeJobValue(column, value) {
|
|
771
|
+
if (column === "sources") {
|
|
772
|
+
if (!Array.isArray(value)) return JSON.stringify([]);
|
|
773
|
+
return JSON.stringify([...new Set(value)]);
|
|
774
|
+
}
|
|
775
|
+
if (jobBooleanColumns.has(column)) {
|
|
776
|
+
if (value === undefined || value === null || value === "") return null;
|
|
777
|
+
return value ? 1 : 0;
|
|
778
|
+
}
|
|
779
|
+
return value;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function mapJobRow(row) {
|
|
783
|
+
if (!row) return undefined;
|
|
784
|
+
const mapped = {};
|
|
785
|
+
for (const [key, value] of Object.entries(row)) {
|
|
786
|
+
const camelKey = snakeToCamel(key);
|
|
787
|
+
if (key === "sources") {
|
|
788
|
+
try {
|
|
789
|
+
mapped[camelKey] = value ? JSON.parse(value) : [];
|
|
790
|
+
} catch {
|
|
791
|
+
mapped[camelKey] = [];
|
|
792
|
+
}
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (jobBooleanColumns.has(key)) {
|
|
796
|
+
mapped[camelKey] = value === null || value === undefined ? null : !!value;
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
mapped[camelKey] = value;
|
|
800
|
+
}
|
|
801
|
+
return mapped;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function getJobRow(uniqueId) {
|
|
805
|
+
if (!db) return null;
|
|
806
|
+
return db.prepare("SELECT * FROM jobs WHERE unique_id = ?").get(uniqueId);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function getJob(uniqueId) {
|
|
810
|
+
return mapJobRow(getJobRow(uniqueId));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function getAllJobs() {
|
|
814
|
+
if (!db) return [];
|
|
815
|
+
return db.prepare("SELECT * FROM jobs").all().map(mapJobRow);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function mapVideoRow(row) {
|
|
819
|
+
if (!row) return undefined;
|
|
820
|
+
const mapped = {};
|
|
821
|
+
for (const [key, value] of Object.entries(row)) {
|
|
822
|
+
const camelKey = snakeToCamel(key);
|
|
823
|
+
if (videoBooleanColumns.has(key)) {
|
|
824
|
+
mapped[camelKey] = value === null || value === undefined ? null : !!value;
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
mapped[camelKey] = value;
|
|
828
|
+
}
|
|
829
|
+
return mapped;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function getVideoRow(videoId) {
|
|
833
|
+
if (!db) return null;
|
|
834
|
+
return db.prepare("SELECT * FROM videos WHERE id = ?").get(videoId);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function getAllVideoRows() {
|
|
838
|
+
if (!db) return [];
|
|
839
|
+
return db.prepare("SELECT * FROM videos").all();
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function updateJobInfo(uniqueId, info, incrementCount = true) {
|
|
843
|
+
if (!db) return { error: "db not initialized" };
|
|
844
|
+
const existing = getJobRow(uniqueId);
|
|
845
|
+
if (!existing) return { error: "user not found" };
|
|
846
|
+
|
|
847
|
+
const nextValues = {};
|
|
848
|
+
for (const [key, value] of Object.entries(info || {})) {
|
|
849
|
+
if (key === "uniqueId" || key === "unique_id") continue;
|
|
850
|
+
if (value === undefined || value === "") continue;
|
|
851
|
+
const column = camelToSnake(key);
|
|
852
|
+
if (!writableJobColumns.has(column)) continue;
|
|
853
|
+
nextValues[column] = normalizeJobValue(column, value);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
nextValues.updated_at = Date.now();
|
|
857
|
+
if (incrementCount) {
|
|
858
|
+
nextValues.user_update_count = (existing.user_update_count || 0) + 1;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const columns = Object.keys(nextValues);
|
|
862
|
+
if (columns.length > 0) {
|
|
863
|
+
const sql = `UPDATE jobs SET ${columns.map((column) => `${column} = ?`).join(", ")} WHERE unique_id = ?`;
|
|
864
|
+
db.prepare(sql).run(
|
|
865
|
+
...columns.map((column) => nextValues[column]),
|
|
866
|
+
uniqueId,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
ok: true,
|
|
872
|
+
userUpdateCount:
|
|
873
|
+
nextValues.user_update_count ?? existing.user_update_count ?? 0,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
4
876
|
|
|
5
877
|
function inferStatus(u) {
|
|
6
878
|
if (u.restricted) return "restricted";
|
|
@@ -9,27 +881,32 @@ function inferStatus(u) {
|
|
|
9
881
|
return "pending";
|
|
10
882
|
}
|
|
11
883
|
|
|
884
|
+
function addJob(user) {
|
|
885
|
+
if (!db) {
|
|
886
|
+
addUserToDb(user);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
if (!user.status) user.status = inferStatus(user);
|
|
890
|
+
if (!user.createdAt) user.createdAt = Date.now();
|
|
891
|
+
if (!user.updatedAt) user.updatedAt = user.createdAt;
|
|
892
|
+
const writeTxn = db.transaction((job) => {
|
|
893
|
+
addUserToDb(job);
|
|
894
|
+
addJobToDb(job);
|
|
895
|
+
});
|
|
896
|
+
writeTxn(user);
|
|
897
|
+
}
|
|
898
|
+
|
|
12
899
|
export function createStore(filePath) {
|
|
900
|
+
if (!filePath) {
|
|
901
|
+
throw new Error("createStore requires an explicit .db path");
|
|
902
|
+
}
|
|
13
903
|
let data = [];
|
|
14
904
|
// uniqueId → index 内存索引,O(1) 查找
|
|
15
905
|
let uidIndex = new Map();
|
|
16
906
|
let clientErrors = new Map();
|
|
17
|
-
|
|
18
|
-
// done 用户归档(独立文件,主文件只保留活跃用户)
|
|
19
|
-
let doneArchive = [];
|
|
20
|
-
let doneArchivePath = null;
|
|
21
|
-
let doneUidIndex = new Map();
|
|
22
907
|
if (filePath) {
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
}
|
|
908
|
+
// 初始化 SQLite 用户表(用于判重)
|
|
909
|
+
initUserDb(filePath);
|
|
33
910
|
}
|
|
34
911
|
|
|
35
912
|
// stats 缓存
|
|
@@ -42,6 +919,34 @@ export function createStore(filePath) {
|
|
|
42
919
|
}
|
|
43
920
|
|
|
44
921
|
function computeStatsInternal() {
|
|
922
|
+
if (db) {
|
|
923
|
+
const total = getJobsCount();
|
|
924
|
+
const statusCounts = {
|
|
925
|
+
pending: 0,
|
|
926
|
+
processing: 0,
|
|
927
|
+
done: 0,
|
|
928
|
+
error: 0,
|
|
929
|
+
restricted: 0,
|
|
930
|
+
};
|
|
931
|
+
const rows = db
|
|
932
|
+
.prepare(
|
|
933
|
+
`
|
|
934
|
+
SELECT status, COUNT(*) as count
|
|
935
|
+
FROM jobs
|
|
936
|
+
GROUP BY status
|
|
937
|
+
`,
|
|
938
|
+
)
|
|
939
|
+
.all();
|
|
940
|
+
for (const row of rows) {
|
|
941
|
+
if (row.status && statusCounts[row.status] !== undefined) {
|
|
942
|
+
statusCounts[row.status] = row.count;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
statsCache = { total, statusCounts };
|
|
946
|
+
statsDirty = false;
|
|
947
|
+
return statsCache;
|
|
948
|
+
}
|
|
949
|
+
|
|
45
950
|
const total = data.length;
|
|
46
951
|
const statusCounts = {
|
|
47
952
|
pending: 0,
|
|
@@ -99,6 +1004,26 @@ export function createStore(filePath) {
|
|
|
99
1004
|
}
|
|
100
1005
|
|
|
101
1006
|
function rebuildStatusGroups() {
|
|
1007
|
+
if (db) {
|
|
1008
|
+
statusGroups = {
|
|
1009
|
+
pending: [],
|
|
1010
|
+
processing: [],
|
|
1011
|
+
done: [],
|
|
1012
|
+
error: [],
|
|
1013
|
+
restricted: [],
|
|
1014
|
+
};
|
|
1015
|
+
for (const u of getAllJobs()) {
|
|
1016
|
+
const key = u.status || "pending";
|
|
1017
|
+
if (statusGroups[key]) statusGroups[key].push(u);
|
|
1018
|
+
else statusGroups[key] = [u];
|
|
1019
|
+
}
|
|
1020
|
+
for (const key of Object.keys(statusGroups)) {
|
|
1021
|
+
statusGroups[key] = sortGroup(key, statusGroups[key]);
|
|
1022
|
+
}
|
|
1023
|
+
groupsDirty = false;
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
102
1027
|
statusGroups = {
|
|
103
1028
|
pending: [],
|
|
104
1029
|
processing: [],
|
|
@@ -111,10 +1036,6 @@ export function createStore(filePath) {
|
|
|
111
1036
|
if (statusGroups[key]) statusGroups[key].push(u);
|
|
112
1037
|
else statusGroups[key] = [u];
|
|
113
1038
|
}
|
|
114
|
-
// done 归档单独处理
|
|
115
|
-
if (doneArchive.length > 0) {
|
|
116
|
-
statusGroups.done = statusGroups.done.concat(doneArchive);
|
|
117
|
-
}
|
|
118
1039
|
// 各组内排序
|
|
119
1040
|
for (const key of Object.keys(statusGroups)) {
|
|
120
1041
|
statusGroups[key] = sortGroup(key, statusGroups[key]);
|
|
@@ -131,69 +1052,8 @@ export function createStore(filePath) {
|
|
|
131
1052
|
groupsDirty = true;
|
|
132
1053
|
}
|
|
133
1054
|
|
|
134
|
-
//
|
|
1055
|
+
// 视频存储(SQLite 真相源)
|
|
135
1056
|
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
1057
|
|
|
198
1058
|
// 构建索引 + 推断 status
|
|
199
1059
|
for (let i = 0; i < data.length; i++) {
|
|
@@ -202,189 +1062,69 @@ export function createStore(filePath) {
|
|
|
202
1062
|
uidIndex.set(u.uniqueId, i);
|
|
203
1063
|
}
|
|
204
1064
|
|
|
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
1065
|
function save() {
|
|
278
|
-
|
|
1066
|
+
return;
|
|
279
1067
|
}
|
|
280
1068
|
|
|
281
1069
|
function flushSave() {
|
|
282
|
-
return
|
|
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
|
-
});
|
|
1070
|
+
return Promise.resolve();
|
|
336
1071
|
}
|
|
337
1072
|
|
|
338
|
-
let videosSaveTimer = null;
|
|
339
|
-
|
|
340
1073
|
function saveVideos() {
|
|
341
|
-
|
|
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();
|
|
1074
|
+
return;
|
|
351
1075
|
}
|
|
352
1076
|
|
|
353
1077
|
function stopBackup() {
|
|
354
|
-
|
|
355
|
-
clearInterval(backupTimer);
|
|
356
|
-
backupTimer = null;
|
|
357
|
-
}
|
|
1078
|
+
return;
|
|
358
1079
|
}
|
|
359
1080
|
|
|
360
1081
|
function getUser(uid) {
|
|
361
1082
|
const idx = uidIndex.get(uid);
|
|
362
1083
|
if (idx !== undefined) return data[idx];
|
|
363
|
-
|
|
364
|
-
if (doneIdx !== undefined) return doneArchive[doneIdx];
|
|
1084
|
+
if (db) return getJob(uid);
|
|
365
1085
|
return undefined;
|
|
366
1086
|
}
|
|
367
1087
|
|
|
368
1088
|
function hasUser(uid) {
|
|
369
|
-
|
|
1089
|
+
// 优先用内存索引,兜底用 SQLite
|
|
1090
|
+
if (uidIndex.has(uid)) return true;
|
|
1091
|
+
return hasUserInDb(uid);
|
|
370
1092
|
}
|
|
371
1093
|
|
|
372
1094
|
function userExists(uid) {
|
|
373
|
-
|
|
1095
|
+
// 优先用内存索引,兜底用 SQLite
|
|
1096
|
+
if (uidIndex.has(uid)) return true;
|
|
1097
|
+
return hasUserInDb(uid);
|
|
374
1098
|
}
|
|
375
1099
|
|
|
376
1100
|
function addUser(user, append) {
|
|
1101
|
+
const memoryIdx = uidIndex.get(user.uniqueId);
|
|
1102
|
+
if (db && memoryIdx === undefined) {
|
|
1103
|
+
const existingJob = getJobRow(user.uniqueId);
|
|
1104
|
+
if (existingJob) {
|
|
1105
|
+
return updateJobInfo(user.uniqueId, user, false);
|
|
1106
|
+
}
|
|
1107
|
+
addJob(user);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
377
1111
|
const existing = getUser(user.uniqueId);
|
|
378
1112
|
if (existing) {
|
|
1113
|
+
let changed = false;
|
|
379
1114
|
for (const key of Object.keys(user)) {
|
|
380
1115
|
if (key === "uniqueId" || key === "sources") continue;
|
|
381
1116
|
if (user[key] !== undefined && user[key] !== null && user[key] !== "") {
|
|
382
|
-
existing[key]
|
|
1117
|
+
if (existing[key] !== user[key]) {
|
|
1118
|
+
existing[key] = user[key];
|
|
1119
|
+
changed = true;
|
|
1120
|
+
}
|
|
383
1121
|
}
|
|
384
1122
|
}
|
|
1123
|
+
if (changed) save();
|
|
385
1124
|
} else {
|
|
386
1125
|
if (!user.status) user.status = inferStatus(user);
|
|
387
1126
|
if (user.processed) user.processedAt = user.processedAt || Date.now();
|
|
1127
|
+
if (!user.createdAt) user.createdAt = Date.now();
|
|
388
1128
|
if (append) {
|
|
389
1129
|
const idx = data.length;
|
|
390
1130
|
data.push(user);
|
|
@@ -393,23 +1133,30 @@ export function createStore(filePath) {
|
|
|
393
1133
|
data.unshift(user);
|
|
394
1134
|
uidIndex.set(user.uniqueId, 0);
|
|
395
1135
|
}
|
|
1136
|
+
// 同步写入 SQLite
|
|
1137
|
+
addUserToDb(user);
|
|
396
1138
|
markStatsDirty();
|
|
1139
|
+
save();
|
|
397
1140
|
}
|
|
398
1141
|
}
|
|
399
1142
|
|
|
400
1143
|
function getPendingUsers() {
|
|
1144
|
+
if (db) {
|
|
1145
|
+
return getAllJobs().filter((u) => u.status === "pending");
|
|
1146
|
+
}
|
|
401
1147
|
return data.filter((u) => u.status === "pending");
|
|
402
1148
|
}
|
|
403
1149
|
|
|
404
1150
|
function getProcessedUsers() {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
1151
|
+
if (db) {
|
|
1152
|
+
return getAllJobs().filter((u) => u.status === "done");
|
|
1153
|
+
}
|
|
1154
|
+
return data.filter((u) => u.status === "done");
|
|
408
1155
|
}
|
|
409
1156
|
|
|
410
1157
|
function getAllUsers() {
|
|
411
|
-
if (
|
|
412
|
-
return
|
|
1158
|
+
if (db) {
|
|
1159
|
+
return getAllJobs();
|
|
413
1160
|
}
|
|
414
1161
|
return data;
|
|
415
1162
|
}
|
|
@@ -420,6 +1167,269 @@ export function createStore(filePath) {
|
|
|
420
1167
|
locations = null,
|
|
421
1168
|
loggedIn = true,
|
|
422
1169
|
) {
|
|
1170
|
+
if (db) {
|
|
1171
|
+
const now = Date.now();
|
|
1172
|
+
const ongoingRow = db
|
|
1173
|
+
.prepare(
|
|
1174
|
+
`
|
|
1175
|
+
SELECT *
|
|
1176
|
+
FROM jobs
|
|
1177
|
+
WHERE status = 'processing'
|
|
1178
|
+
AND claimed_by = ?
|
|
1179
|
+
AND claimed_at IS NOT NULL
|
|
1180
|
+
AND ? - claimed_at < ?
|
|
1181
|
+
ORDER BY claimed_at DESC
|
|
1182
|
+
LIMIT 1
|
|
1183
|
+
`,
|
|
1184
|
+
)
|
|
1185
|
+
.get(userId, now, expireMs);
|
|
1186
|
+
if (ongoingRow) {
|
|
1187
|
+
db.prepare("UPDATE jobs SET claimed_at = ? WHERE unique_id = ?").run(
|
|
1188
|
+
now,
|
|
1189
|
+
ongoingRow.unique_id,
|
|
1190
|
+
);
|
|
1191
|
+
return {
|
|
1192
|
+
uniqueId: ongoingRow.unique_id,
|
|
1193
|
+
nickname: ongoingRow.nickname,
|
|
1194
|
+
claimedAt: now,
|
|
1195
|
+
claimedBy: userId,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const tier1 = new Set(["PL", "NL", "BE"]);
|
|
1200
|
+
const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
|
|
1201
|
+
const normalizedLocations = Array.isArray(locations)
|
|
1202
|
+
? locations
|
|
1203
|
+
.map((loc) => String(loc).trim().toUpperCase())
|
|
1204
|
+
.filter(Boolean)
|
|
1205
|
+
: [];
|
|
1206
|
+
|
|
1207
|
+
function getLocationGroups() {
|
|
1208
|
+
const selected = normalizedLocations.length
|
|
1209
|
+
? normalizedLocations
|
|
1210
|
+
: null;
|
|
1211
|
+
const tier1List = selected
|
|
1212
|
+
? selected.filter((loc) => tier1.has(loc))
|
|
1213
|
+
: [...tier1];
|
|
1214
|
+
const tier2List = selected
|
|
1215
|
+
? selected.filter((loc) => tier2.has(loc))
|
|
1216
|
+
: [...tier2];
|
|
1217
|
+
const otherList = selected
|
|
1218
|
+
? selected.filter((loc) => !tier1.has(loc) && !tier2.has(loc))
|
|
1219
|
+
: null;
|
|
1220
|
+
const groups = [];
|
|
1221
|
+
if (tier1List.length > 0)
|
|
1222
|
+
groups.push({ type: "include", values: tier1List });
|
|
1223
|
+
if (tier2List.length > 0)
|
|
1224
|
+
groups.push({ type: "include", values: tier2List });
|
|
1225
|
+
if (selected) {
|
|
1226
|
+
if (otherList.length > 0)
|
|
1227
|
+
groups.push({ type: "include", values: otherList });
|
|
1228
|
+
} else {
|
|
1229
|
+
groups.push({ type: "exclude", values: [...tier1, ...tier2] });
|
|
1230
|
+
}
|
|
1231
|
+
return groups;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const locationGroups = getLocationGroups();
|
|
1235
|
+
|
|
1236
|
+
function applyLocationGroup(where, args, group) {
|
|
1237
|
+
if (!group) return;
|
|
1238
|
+
if (group.type === "include") {
|
|
1239
|
+
where.push(
|
|
1240
|
+
`UPPER(COALESCE(guessed_location, '')) IN (${group.values.map(() => "?").join(", ")})`,
|
|
1241
|
+
);
|
|
1242
|
+
args.push(...group.values);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
where.push(
|
|
1246
|
+
`UPPER(COALESCE(guessed_location, '')) NOT IN (${group.values.map(() => "?").join(", ")})`,
|
|
1247
|
+
);
|
|
1248
|
+
args.push(...group.values);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
function queryPendingOne({ requireVideo, group, filters = [] }) {
|
|
1252
|
+
const where = ["status = 'pending'"];
|
|
1253
|
+
const args = [];
|
|
1254
|
+
if (!loggedIn) {
|
|
1255
|
+
where.push("COALESCE(tt_seller, 0) != 1");
|
|
1256
|
+
}
|
|
1257
|
+
if (requireVideo) {
|
|
1258
|
+
where.push("COALESCE(video_count, 0) > 0");
|
|
1259
|
+
}
|
|
1260
|
+
applyLocationGroup(where, args, group);
|
|
1261
|
+
for (const filter of filters) {
|
|
1262
|
+
where.push(filter);
|
|
1263
|
+
}
|
|
1264
|
+
return db
|
|
1265
|
+
.prepare(
|
|
1266
|
+
`
|
|
1267
|
+
SELECT *
|
|
1268
|
+
FROM jobs
|
|
1269
|
+
WHERE ${where.join(" AND ")}
|
|
1270
|
+
ORDER BY follower_count DESC, created_at ASC, unique_id ASC
|
|
1271
|
+
LIMIT 1
|
|
1272
|
+
`,
|
|
1273
|
+
)
|
|
1274
|
+
.get(...args);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function queryPendingByGroup({ requireVideo, group, filters = [] }) {
|
|
1278
|
+
if (group?.type === "include" && group.values.length > 1) {
|
|
1279
|
+
for (const location of group.values) {
|
|
1280
|
+
const row = queryPendingOne({
|
|
1281
|
+
requireVideo,
|
|
1282
|
+
group: { type: "include", values: [location] },
|
|
1283
|
+
filters,
|
|
1284
|
+
});
|
|
1285
|
+
if (row) return row;
|
|
1286
|
+
}
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
return queryPendingOne({ requireVideo, group, filters });
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function findPinnedPending(requireVideo) {
|
|
1293
|
+
const where = ["status = 'pending'", "COALESCE(pinned, 0) = 1"];
|
|
1294
|
+
const args = [];
|
|
1295
|
+
if (!loggedIn) {
|
|
1296
|
+
where.push("COALESCE(tt_seller, 0) != 1");
|
|
1297
|
+
}
|
|
1298
|
+
if (requireVideo) {
|
|
1299
|
+
where.push("COALESCE(video_count, 0) > 0");
|
|
1300
|
+
}
|
|
1301
|
+
if (normalizedLocations.length > 0) {
|
|
1302
|
+
where.push(
|
|
1303
|
+
`UPPER(COALESCE(guessed_location, '')) IN (${normalizedLocations.map(() => "?").join(", ")})`,
|
|
1304
|
+
);
|
|
1305
|
+
args.push(...normalizedLocations);
|
|
1306
|
+
}
|
|
1307
|
+
return db
|
|
1308
|
+
.prepare(
|
|
1309
|
+
`
|
|
1310
|
+
SELECT *
|
|
1311
|
+
FROM jobs
|
|
1312
|
+
WHERE ${where.join(" AND ")}
|
|
1313
|
+
ORDER BY created_at ASC, unique_id ASC
|
|
1314
|
+
LIMIT 1
|
|
1315
|
+
`,
|
|
1316
|
+
)
|
|
1317
|
+
.get(...args);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function findPrioritizedPending(requireVideo) {
|
|
1321
|
+
for (const group of locationGroups) {
|
|
1322
|
+
const seed = queryPendingByGroup({
|
|
1323
|
+
requireVideo,
|
|
1324
|
+
group,
|
|
1325
|
+
filters: [
|
|
1326
|
+
"COALESCE(pinned, 0) = 0",
|
|
1327
|
+
`instr(COALESCE(sources, ''), '"seed"') > 0`,
|
|
1328
|
+
],
|
|
1329
|
+
});
|
|
1330
|
+
if (seed) return seed;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (loggedIn) {
|
|
1334
|
+
for (const group of locationGroups) {
|
|
1335
|
+
const seller = queryPendingByGroup({
|
|
1336
|
+
requireVideo,
|
|
1337
|
+
group,
|
|
1338
|
+
filters: [
|
|
1339
|
+
"COALESCE(pinned, 0) = 0",
|
|
1340
|
+
"tt_seller = 1",
|
|
1341
|
+
"verified = 0",
|
|
1342
|
+
],
|
|
1343
|
+
});
|
|
1344
|
+
if (seller) return seller;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
for (const group of locationGroups) {
|
|
1349
|
+
const follow = queryPendingByGroup({
|
|
1350
|
+
requireVideo,
|
|
1351
|
+
group,
|
|
1352
|
+
filters: [
|
|
1353
|
+
"COALESCE(pinned, 0) = 0",
|
|
1354
|
+
`(
|
|
1355
|
+
instr(COALESCE(sources, ''), '"following"') > 0
|
|
1356
|
+
OR instr(COALESCE(sources, ''), '"follower"') > 0
|
|
1357
|
+
)`,
|
|
1358
|
+
],
|
|
1359
|
+
});
|
|
1360
|
+
if (follow) return follow;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
for (const group of locationGroups) {
|
|
1364
|
+
const other = queryPendingByGroup({
|
|
1365
|
+
requireVideo,
|
|
1366
|
+
group,
|
|
1367
|
+
filters: ["COALESCE(pinned, 0) = 0"],
|
|
1368
|
+
});
|
|
1369
|
+
if (other) return other;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function claimRow(row) {
|
|
1376
|
+
if (!row) return null;
|
|
1377
|
+
db.prepare(
|
|
1378
|
+
"UPDATE jobs SET status = 'processing', claimed_at = ?, claimed_by = ? WHERE unique_id = ?",
|
|
1379
|
+
).run(now, userId, row.unique_id);
|
|
1380
|
+
markStatsDirty();
|
|
1381
|
+
return {
|
|
1382
|
+
uniqueId: row.unique_id,
|
|
1383
|
+
nickname: row.nickname,
|
|
1384
|
+
claimedAt: now,
|
|
1385
|
+
claimedBy: userId,
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const expiredRow = db
|
|
1390
|
+
.prepare(
|
|
1391
|
+
`
|
|
1392
|
+
SELECT *
|
|
1393
|
+
FROM jobs
|
|
1394
|
+
WHERE status = 'processing'
|
|
1395
|
+
AND claimed_at IS NOT NULL
|
|
1396
|
+
AND ? - claimed_at > ?
|
|
1397
|
+
ORDER BY claimed_at ASC
|
|
1398
|
+
LIMIT 1
|
|
1399
|
+
`,
|
|
1400
|
+
)
|
|
1401
|
+
.get(now, expireMs);
|
|
1402
|
+
let expiredCandidate = null;
|
|
1403
|
+
if (expiredRow) {
|
|
1404
|
+
db.prepare(
|
|
1405
|
+
"UPDATE jobs SET status = 'pending', claimed_at = NULL WHERE unique_id = ?",
|
|
1406
|
+
).run(expiredRow.unique_id);
|
|
1407
|
+
expiredCandidate = mapJobRow({
|
|
1408
|
+
...expiredRow,
|
|
1409
|
+
status: "pending",
|
|
1410
|
+
claimed_at: null,
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
for (const requireVideo of [true, false]) {
|
|
1415
|
+
const pinned = findPinnedPending(requireVideo);
|
|
1416
|
+
if (pinned) {
|
|
1417
|
+
return claimRow(pinned);
|
|
1418
|
+
}
|
|
1419
|
+
if (expiredCandidate) {
|
|
1420
|
+
return claimRow({
|
|
1421
|
+
unique_id: expiredCandidate.uniqueId,
|
|
1422
|
+
nickname: expiredCandidate.nickname,
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
const ranked = findPrioritizedPending(requireVideo);
|
|
1426
|
+
if (ranked) {
|
|
1427
|
+
return claimRow(ranked);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
return null;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
423
1433
|
const now = Date.now();
|
|
424
1434
|
|
|
425
1435
|
// 0. 该客户端有未过期的任务,续期返回
|
|
@@ -432,6 +1442,7 @@ export function createStore(filePath) {
|
|
|
432
1442
|
);
|
|
433
1443
|
if (ongoing) {
|
|
434
1444
|
ongoing.claimedAt = now;
|
|
1445
|
+
save();
|
|
435
1446
|
return {
|
|
436
1447
|
uniqueId: ongoing.uniqueId,
|
|
437
1448
|
nickname: ongoing.nickname,
|
|
@@ -529,6 +1540,7 @@ export function createStore(filePath) {
|
|
|
529
1540
|
markStatsDirty();
|
|
530
1541
|
next.claimedAt = now;
|
|
531
1542
|
next.claimedBy = userId;
|
|
1543
|
+
save();
|
|
532
1544
|
return {
|
|
533
1545
|
uniqueId: next.uniqueId,
|
|
534
1546
|
nickname: next.nickname,
|
|
@@ -621,7 +1633,7 @@ export function createStore(filePath) {
|
|
|
621
1633
|
const newUsers = [];
|
|
622
1634
|
for (const d of unique) {
|
|
623
1635
|
if (!hasUser(d.uniqueId)) {
|
|
624
|
-
|
|
1636
|
+
addJob(d);
|
|
625
1637
|
newUsers.push(d.uniqueId);
|
|
626
1638
|
}
|
|
627
1639
|
}
|
|
@@ -715,6 +1727,20 @@ export function createStore(filePath) {
|
|
|
715
1727
|
}
|
|
716
1728
|
|
|
717
1729
|
function commitJob(uniqueId, result) {
|
|
1730
|
+
if (db) {
|
|
1731
|
+
const user = getJob(uniqueId);
|
|
1732
|
+
if (!user) return { saved: false, error: "user not found" };
|
|
1733
|
+
|
|
1734
|
+
updateUserFromResult(user, result);
|
|
1735
|
+
user.claimedAt = null;
|
|
1736
|
+
const newUsers = processDiscoveredUsers(result);
|
|
1737
|
+
const persistRet = updateJobInfo(uniqueId, user, false);
|
|
1738
|
+
if (persistRet.error) {
|
|
1739
|
+
return { saved: false, error: persistRet.error };
|
|
1740
|
+
}
|
|
1741
|
+
return { saved: true, status: user.status, newUsers };
|
|
1742
|
+
}
|
|
1743
|
+
|
|
718
1744
|
const user = getUser(uniqueId);
|
|
719
1745
|
if (!user) return { saved: false, error: "user not found" };
|
|
720
1746
|
|
|
@@ -727,6 +1753,34 @@ export function createStore(filePath) {
|
|
|
727
1753
|
}
|
|
728
1754
|
|
|
729
1755
|
function commitNewExplore(uniqueId, result) {
|
|
1756
|
+
if (db) {
|
|
1757
|
+
const existing = getJob(uniqueId);
|
|
1758
|
+
if (existing) {
|
|
1759
|
+
updateUserFromResult(existing, result);
|
|
1760
|
+
const persistRet = updateJobInfo(uniqueId, existing, false);
|
|
1761
|
+
if (persistRet.error) {
|
|
1762
|
+
return { saved: false, error: persistRet.error };
|
|
1763
|
+
}
|
|
1764
|
+
const newUsers = processDiscoveredUsers(result);
|
|
1765
|
+
return {
|
|
1766
|
+
saved: true,
|
|
1767
|
+
created: false,
|
|
1768
|
+
status: existing.status,
|
|
1769
|
+
newUsers,
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
const userObj = {
|
|
1774
|
+
uniqueId,
|
|
1775
|
+
...(result.userInfo || {}),
|
|
1776
|
+
sources: ["refresh-explore"],
|
|
1777
|
+
};
|
|
1778
|
+
updateUserFromResult(userObj, result);
|
|
1779
|
+
addJob(userObj);
|
|
1780
|
+
const newUsers = processDiscoveredUsers(result);
|
|
1781
|
+
return { saved: true, created: true, status: userObj.status, newUsers };
|
|
1782
|
+
}
|
|
1783
|
+
|
|
730
1784
|
const existing = getUser(uniqueId);
|
|
731
1785
|
if (existing) {
|
|
732
1786
|
updateUserFromResult(existing, result);
|
|
@@ -749,6 +1803,22 @@ export function createStore(filePath) {
|
|
|
749
1803
|
}
|
|
750
1804
|
|
|
751
1805
|
function resetJob(uniqueId) {
|
|
1806
|
+
if (db) {
|
|
1807
|
+
const user = getJob(uniqueId);
|
|
1808
|
+
if (!user) return { saved: false, error: "user not found" };
|
|
1809
|
+
user.status = "pending";
|
|
1810
|
+
user.claimedAt = null;
|
|
1811
|
+
user.processedAt = null;
|
|
1812
|
+
user.processed = false;
|
|
1813
|
+
user.error = null;
|
|
1814
|
+
user.restricted = false;
|
|
1815
|
+
user.noVideo = false;
|
|
1816
|
+
const ret = updateJobInfo(uniqueId, user, false);
|
|
1817
|
+
if (ret.error) return { saved: false, error: ret.error };
|
|
1818
|
+
markStatsDirty();
|
|
1819
|
+
return { saved: true };
|
|
1820
|
+
}
|
|
1821
|
+
|
|
752
1822
|
const user = getUser(uniqueId);
|
|
753
1823
|
if (!user) return { saved: false, error: "user not found" };
|
|
754
1824
|
user.status = "pending";
|
|
@@ -764,6 +1834,15 @@ export function createStore(filePath) {
|
|
|
764
1834
|
}
|
|
765
1835
|
|
|
766
1836
|
function togglePin(uniqueId) {
|
|
1837
|
+
if (db) {
|
|
1838
|
+
const user = getJob(uniqueId);
|
|
1839
|
+
if (!user) return { saved: false, error: "user not found" };
|
|
1840
|
+
const nextPinned = !user.pinned;
|
|
1841
|
+
const ret = updateJobInfo(uniqueId, { pinned: nextPinned }, false);
|
|
1842
|
+
if (ret.error) return { saved: false, error: ret.error };
|
|
1843
|
+
return { saved: true, pinned: nextPinned };
|
|
1844
|
+
}
|
|
1845
|
+
|
|
767
1846
|
const user = getUser(uniqueId);
|
|
768
1847
|
if (!user) return { saved: false, error: "user not found" };
|
|
769
1848
|
user.pinned = !user.pinned;
|
|
@@ -772,6 +1851,35 @@ export function createStore(filePath) {
|
|
|
772
1851
|
}
|
|
773
1852
|
|
|
774
1853
|
function getNextRedoJob(userId) {
|
|
1854
|
+
if (db) {
|
|
1855
|
+
const now = Date.now();
|
|
1856
|
+
const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
|
|
1857
|
+
const targetLocations = ["ES", "PL", "NL", "BE", "DE", "FR", "IT", "IE"];
|
|
1858
|
+
const placeholders = targetLocations.map(() => "?").join(",");
|
|
1859
|
+
const row = db
|
|
1860
|
+
.prepare(
|
|
1861
|
+
`
|
|
1862
|
+
SELECT *
|
|
1863
|
+
FROM jobs
|
|
1864
|
+
WHERE tt_seller = 1
|
|
1865
|
+
AND verified = 0
|
|
1866
|
+
AND location_created IN (${placeholders})
|
|
1867
|
+
ORDER BY COALESCE(refresh_time, ?) ASC
|
|
1868
|
+
LIMIT 1
|
|
1869
|
+
`,
|
|
1870
|
+
)
|
|
1871
|
+
.get(...targetLocations, defaultTime);
|
|
1872
|
+
if (!row) return null;
|
|
1873
|
+
db.prepare(
|
|
1874
|
+
"UPDATE jobs SET refresh_time = ?, updated_at = ? WHERE unique_id = ?",
|
|
1875
|
+
).run(now, now, row.unique_id);
|
|
1876
|
+
return {
|
|
1877
|
+
uniqueId: row.unique_id,
|
|
1878
|
+
nickname: row.nickname,
|
|
1879
|
+
refreshTime: now,
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
|
|
775
1883
|
const now = Date.now();
|
|
776
1884
|
const defaultTime = new Date("2016-01-01T00:00:00Z").getTime();
|
|
777
1885
|
|
|
@@ -793,6 +1901,7 @@ export function createStore(filePath) {
|
|
|
793
1901
|
|
|
794
1902
|
const next = targetUsers[0];
|
|
795
1903
|
next.refreshTime = now;
|
|
1904
|
+
save();
|
|
796
1905
|
return {
|
|
797
1906
|
uniqueId: next.uniqueId,
|
|
798
1907
|
nickname: next.nickname,
|
|
@@ -801,6 +1910,28 @@ export function createStore(filePath) {
|
|
|
801
1910
|
}
|
|
802
1911
|
|
|
803
1912
|
function commitRedoJob(uniqueId, result) {
|
|
1913
|
+
if (db) {
|
|
1914
|
+
const user = getJob(uniqueId);
|
|
1915
|
+
if (!user) return { saved: false, error: "user not found" };
|
|
1916
|
+
user.refreshTime = Date.now();
|
|
1917
|
+
if (result.userInfo) {
|
|
1918
|
+
const info = result.userInfo;
|
|
1919
|
+
for (const key of Object.keys(info)) {
|
|
1920
|
+
if (key === "uniqueId" || key === "sources") continue;
|
|
1921
|
+
if (
|
|
1922
|
+
info[key] !== undefined &&
|
|
1923
|
+
info[key] !== null &&
|
|
1924
|
+
info[key] !== ""
|
|
1925
|
+
) {
|
|
1926
|
+
user[key] = info[key];
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
const ret = updateJobInfo(uniqueId, user, false);
|
|
1931
|
+
if (ret.error) return { saved: false, error: ret.error };
|
|
1932
|
+
return { saved: true };
|
|
1933
|
+
}
|
|
1934
|
+
|
|
804
1935
|
const user = getUser(uniqueId);
|
|
805
1936
|
if (!user) return { saved: false, error: "user not found" };
|
|
806
1937
|
|
|
@@ -871,6 +2002,44 @@ export function createStore(filePath) {
|
|
|
871
2002
|
}
|
|
872
2003
|
|
|
873
2004
|
function getPendingUserUpdateTasks(limit) {
|
|
2005
|
+
if (db) {
|
|
2006
|
+
const l = Math.max(1, parseInt(limit) || 5);
|
|
2007
|
+
const rows = db
|
|
2008
|
+
.prepare(
|
|
2009
|
+
`
|
|
2010
|
+
SELECT *
|
|
2011
|
+
FROM jobs
|
|
2012
|
+
WHERE COALESCE(tt_seller, '') = ''
|
|
2013
|
+
AND COALESCE(user_update_count, 0) <= 0
|
|
2014
|
+
ORDER BY created_at ASC, unique_id ASC
|
|
2015
|
+
LIMIT ?
|
|
2016
|
+
`,
|
|
2017
|
+
)
|
|
2018
|
+
.all(l);
|
|
2019
|
+
if (rows.length === 0) return [];
|
|
2020
|
+
const now = Date.now();
|
|
2021
|
+
const bumpStmt = db.prepare(
|
|
2022
|
+
`
|
|
2023
|
+
UPDATE jobs
|
|
2024
|
+
SET user_update_count = COALESCE(user_update_count, 0) + 1,
|
|
2025
|
+
updated_at = ?
|
|
2026
|
+
WHERE unique_id = ?
|
|
2027
|
+
`,
|
|
2028
|
+
);
|
|
2029
|
+
const bumpTxn = db.transaction((items) => {
|
|
2030
|
+
for (const item of items) {
|
|
2031
|
+
bumpStmt.run(now, item.unique_id);
|
|
2032
|
+
}
|
|
2033
|
+
});
|
|
2034
|
+
bumpTxn(rows);
|
|
2035
|
+
return rows.map((row) => {
|
|
2036
|
+
const mapped = mapJobRow(row);
|
|
2037
|
+
mapped.userUpdateCount = (mapped.userUpdateCount || 0) + 1;
|
|
2038
|
+
mapped.updatedAt = now;
|
|
2039
|
+
return mapped;
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
|
|
874
2043
|
const l = Math.max(1, parseInt(limit) || 5);
|
|
875
2044
|
const pending = data
|
|
876
2045
|
.filter((u) => {
|
|
@@ -893,6 +2062,10 @@ export function createStore(filePath) {
|
|
|
893
2062
|
}
|
|
894
2063
|
|
|
895
2064
|
function updateUserInfo(uniqueId, info) {
|
|
2065
|
+
if (db) {
|
|
2066
|
+
return updateJobInfo(uniqueId, info, true);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
896
2069
|
const user = getUser(uniqueId);
|
|
897
2070
|
if (!user) return { error: "user not found" };
|
|
898
2071
|
for (const key of Object.keys(info)) {
|
|
@@ -908,6 +2081,21 @@ export function createStore(filePath) {
|
|
|
908
2081
|
}
|
|
909
2082
|
|
|
910
2083
|
function batchUpdateUserInfo(updates) {
|
|
2084
|
+
if (db) {
|
|
2085
|
+
const txn = db.transaction((items) =>
|
|
2086
|
+
items.map((item) => updateJobInfo(item.uniqueId, item.info, true)),
|
|
2087
|
+
);
|
|
2088
|
+
return txn(updates).map((result, index) =>
|
|
2089
|
+
result.error
|
|
2090
|
+
? { uniqueId: updates[index].uniqueId, error: result.error }
|
|
2091
|
+
: {
|
|
2092
|
+
uniqueId: updates[index].uniqueId,
|
|
2093
|
+
ok: true,
|
|
2094
|
+
userUpdateCount: result.userUpdateCount,
|
|
2095
|
+
},
|
|
2096
|
+
);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
911
2099
|
const results = [];
|
|
912
2100
|
for (const item of updates) {
|
|
913
2101
|
const user = getUser(item.uniqueId);
|
|
@@ -943,6 +2131,41 @@ export function createStore(filePath) {
|
|
|
943
2131
|
return { registered: 0, skipped: 0 };
|
|
944
2132
|
}
|
|
945
2133
|
|
|
2134
|
+
if (db) {
|
|
2135
|
+
const insertStmt = db.prepare(`
|
|
2136
|
+
INSERT OR IGNORE INTO videos (
|
|
2137
|
+
id,
|
|
2138
|
+
href,
|
|
2139
|
+
author_unique_id,
|
|
2140
|
+
location_created,
|
|
2141
|
+
tt_seller,
|
|
2142
|
+
registered_at,
|
|
2143
|
+
user_update_count
|
|
2144
|
+
)
|
|
2145
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2146
|
+
`);
|
|
2147
|
+
let registered = 0;
|
|
2148
|
+
let skipped = 0;
|
|
2149
|
+
const now = Date.now();
|
|
2150
|
+
const txn = db.transaction((items) => {
|
|
2151
|
+
for (const item of items) {
|
|
2152
|
+
const result = insertStmt.run(
|
|
2153
|
+
item.id,
|
|
2154
|
+
item.href || null,
|
|
2155
|
+
sourceUser,
|
|
2156
|
+
locationCreated || null,
|
|
2157
|
+
ttSeller ? 1 : 0,
|
|
2158
|
+
now,
|
|
2159
|
+
0,
|
|
2160
|
+
);
|
|
2161
|
+
if (result.changes > 0) registered++;
|
|
2162
|
+
else skipped++;
|
|
2163
|
+
}
|
|
2164
|
+
});
|
|
2165
|
+
txn(videoList.filter((item) => item?.id));
|
|
2166
|
+
return { registered, skipped };
|
|
2167
|
+
}
|
|
2168
|
+
|
|
946
2169
|
const existingIds = new Set(videos.map((v) => v.id));
|
|
947
2170
|
let registered = 0;
|
|
948
2171
|
let skipped = 0;
|
|
@@ -969,14 +2192,92 @@ export function createStore(filePath) {
|
|
|
969
2192
|
}
|
|
970
2193
|
|
|
971
2194
|
function getVideos() {
|
|
2195
|
+
if (db) {
|
|
2196
|
+
return getAllVideoRows().map(mapVideoRow);
|
|
2197
|
+
}
|
|
972
2198
|
return videos;
|
|
973
2199
|
}
|
|
974
2200
|
|
|
2201
|
+
function getVideo(videoId) {
|
|
2202
|
+
if (!videoId) return null;
|
|
2203
|
+
if (db) {
|
|
2204
|
+
return mapVideoRow(getVideoRow(videoId));
|
|
2205
|
+
}
|
|
2206
|
+
return videos.find((video) => video.id === videoId) || null;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function getVideosPage(limit, offset) {
|
|
2210
|
+
const safeLimit = Math.max(1, Math.min(200, parseInt(limit) || 50));
|
|
2211
|
+
const safeOffset = Math.max(0, parseInt(offset) || 0);
|
|
2212
|
+
|
|
2213
|
+
if (db) {
|
|
2214
|
+
const rows = db
|
|
2215
|
+
.prepare(
|
|
2216
|
+
`
|
|
2217
|
+
SELECT *
|
|
2218
|
+
FROM videos
|
|
2219
|
+
ORDER BY registered_at DESC, id DESC
|
|
2220
|
+
LIMIT ? OFFSET ?
|
|
2221
|
+
`,
|
|
2222
|
+
)
|
|
2223
|
+
.all(safeLimit, safeOffset);
|
|
2224
|
+
const total = db.prepare("SELECT COUNT(*) as c FROM videos").get().c;
|
|
2225
|
+
return {
|
|
2226
|
+
total,
|
|
2227
|
+
limit: safeLimit,
|
|
2228
|
+
offset: safeOffset,
|
|
2229
|
+
videos: rows.map(mapVideoRow),
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
return {
|
|
2234
|
+
total: videos.length,
|
|
2235
|
+
limit: safeLimit,
|
|
2236
|
+
offset: safeOffset,
|
|
2237
|
+
videos: videos.slice(safeOffset, safeOffset + safeLimit),
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
|
|
975
2241
|
function getVideoCount() {
|
|
2242
|
+
if (db) {
|
|
2243
|
+
return db.prepare("SELECT COUNT(*) as c FROM videos").get().c;
|
|
2244
|
+
}
|
|
976
2245
|
return videos.length;
|
|
977
2246
|
}
|
|
978
2247
|
|
|
979
2248
|
function getPendingCommentTasks(limit) {
|
|
2249
|
+
if (db) {
|
|
2250
|
+
const l = Math.max(1, parseInt(limit) || 1);
|
|
2251
|
+
const rows = db
|
|
2252
|
+
.prepare(
|
|
2253
|
+
`
|
|
2254
|
+
SELECT *
|
|
2255
|
+
FROM videos
|
|
2256
|
+
WHERE user_update_count IS NULL OR user_update_count <= 0
|
|
2257
|
+
ORDER BY tt_seller DESC, registered_at ASC
|
|
2258
|
+
LIMIT ?
|
|
2259
|
+
`,
|
|
2260
|
+
)
|
|
2261
|
+
.all(l);
|
|
2262
|
+
if (rows.length === 0) return [];
|
|
2263
|
+
const bumpStmt = db.prepare(
|
|
2264
|
+
`
|
|
2265
|
+
UPDATE videos
|
|
2266
|
+
SET user_update_count = COALESCE(user_update_count, 0) + 1
|
|
2267
|
+
WHERE id = ?
|
|
2268
|
+
`,
|
|
2269
|
+
);
|
|
2270
|
+
const bumpTxn = db.transaction((items) => {
|
|
2271
|
+
for (const item of items) bumpStmt.run(item.id);
|
|
2272
|
+
});
|
|
2273
|
+
bumpTxn(rows);
|
|
2274
|
+
return rows.map((row) => {
|
|
2275
|
+
const mapped = mapVideoRow(row);
|
|
2276
|
+
mapped.userUpdateCount = (mapped.userUpdateCount || 0) + 1;
|
|
2277
|
+
return mapped;
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
|
|
980
2281
|
// 筛选待处理视频(userUpdateCount <= 0 或 null/undefined)
|
|
981
2282
|
const pending = videos.filter((v) => (v.userUpdateCount || 0) <= 0);
|
|
982
2283
|
// ttSeller=true 优先
|
|
@@ -996,6 +2297,20 @@ export function createStore(filePath) {
|
|
|
996
2297
|
}
|
|
997
2298
|
|
|
998
2299
|
function commitCommentTask(videoId) {
|
|
2300
|
+
if (db) {
|
|
2301
|
+
const video = getVideoRow(videoId);
|
|
2302
|
+
if (!video) return { ok: false, error: "video not found" };
|
|
2303
|
+
const nextCount = (video.user_update_count || 0) + 1;
|
|
2304
|
+
db.prepare(
|
|
2305
|
+
`
|
|
2306
|
+
UPDATE videos
|
|
2307
|
+
SET user_update_count = ?
|
|
2308
|
+
WHERE id = ?
|
|
2309
|
+
`,
|
|
2310
|
+
).run(nextCount, videoId);
|
|
2311
|
+
return { ok: true, userUpdateCount: nextCount };
|
|
2312
|
+
}
|
|
2313
|
+
|
|
999
2314
|
const video = videos.find((v) => v.id === videoId);
|
|
1000
2315
|
if (!video) return { ok: false, error: "video not found" };
|
|
1001
2316
|
video.userUpdateCount = (video.userUpdateCount || 0) + 1;
|
|
@@ -1013,6 +2328,13 @@ export function createStore(filePath) {
|
|
|
1013
2328
|
getPendingUsers,
|
|
1014
2329
|
getProcessedUsers,
|
|
1015
2330
|
getAllUsers,
|
|
2331
|
+
getUserDbCount,
|
|
2332
|
+
getJobsCount,
|
|
2333
|
+
getPendingJobsCount,
|
|
2334
|
+
getPendingJobsUserUpdateCount,
|
|
2335
|
+
getDashboardStats: getDashboardStatsFromDb,
|
|
2336
|
+
getUsersPage: getUsersPageFromDb,
|
|
2337
|
+
getTargetUsers: getTargetUsersFromDb,
|
|
1016
2338
|
getStats,
|
|
1017
2339
|
getStatusGroups,
|
|
1018
2340
|
markGroupsDirty,
|
|
@@ -1030,7 +2352,9 @@ export function createStore(filePath) {
|
|
|
1030
2352
|
deleteClientError,
|
|
1031
2353
|
getClientErrors,
|
|
1032
2354
|
registerVideos,
|
|
2355
|
+
getVideo,
|
|
1033
2356
|
getVideos,
|
|
2357
|
+
getVideosPage,
|
|
1034
2358
|
getVideoCount,
|
|
1035
2359
|
getPendingCommentTasks,
|
|
1036
2360
|
commitCommentTask,
|