tt-help-cli-ycl 1.3.92 → 1.3.94

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.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * 数据库 Schema 初始化与迁移
3
+ *
4
+ * 负责建表、索引、列迁移。所有 DDL 操作集中在此文件,
5
+ * 其他模块只做 CRUD,不关心表结构。
6
+ */
7
+
8
+ import fs from "fs";
9
+ import path from "path";
10
+ import Database from "better-sqlite3";
11
+ import { columnsList, VIDEO_COLUMNS } from "./db-columns.js";
12
+
13
+ // ===== 全局数据库连接(模块级共享) =====
14
+ let db = null;
15
+ let dbPath = null;
16
+
17
+ export function getDb() {
18
+ return db;
19
+ }
20
+
21
+ export function getDbPath() {
22
+ return dbPath;
23
+ }
24
+
25
+ export function normalizeDbFilePath(filePath) {
26
+ if (!filePath) {
27
+ throw new Error("db path is required");
28
+ }
29
+ const resolved = path.resolve(filePath);
30
+ if (path.extname(resolved).toLowerCase() !== ".db") {
31
+ throw new Error(`仅支持 .db 路径,当前为: ${filePath}`);
32
+ }
33
+ return resolved;
34
+ }
35
+
36
+ export function resetDbConnection() {
37
+ if (db) {
38
+ db.close();
39
+ db = null;
40
+ }
41
+ dbPath = null;
42
+ }
43
+
44
+ // ===== 建表与迁移 =====
45
+
46
+ /**
47
+ * 为一张表执行列迁移:检查已有列,补全缺失列
48
+ * @param {string} tableName - 表名
49
+ * @param {Object} requiredColumns - { columnName: columnType }
50
+ */
51
+ function migrateColumns(tableName, requiredColumns) {
52
+ const existing = new Set(
53
+ db
54
+ .prepare(`PRAGMA table_info(${tableName})`)
55
+ .all()
56
+ .map((c) => c.name),
57
+ );
58
+ for (const [column, type] of Object.entries(requiredColumns)) {
59
+ if (!existing.has(column)) {
60
+ db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${column} ${type}`);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * 初始化所有表、索引、迁移
67
+ */
68
+ export function initDb(filePath) {
69
+ dbPath = normalizeDbFilePath(filePath);
70
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
71
+ db = new Database(dbPath);
72
+ db.pragma("journal_mode = WAL");
73
+
74
+ // ===== users 表 =====
75
+ db.exec(`
76
+ CREATE TABLE IF NOT EXISTS users (
77
+ unique_id TEXT PRIMARY KEY,
78
+ tt_seller TEXT,
79
+ verified INTEGER,
80
+ location_created TEXT,
81
+ created_at TEXT,
82
+ updated_at TEXT
83
+ )
84
+ `);
85
+
86
+ // ===== jobs 表 =====
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
+ confirmed_location TEXT,
107
+ modified_at INTEGER,
108
+ follower_count INTEGER DEFAULT 0,
109
+ following_count INTEGER DEFAULT 0,
110
+ heart_count INTEGER DEFAULT 0,
111
+ refresh_time INTEGER,
112
+ processed INTEGER DEFAULT 0,
113
+ processed_at INTEGER,
114
+ created_at INTEGER,
115
+ updated_at INTEGER,
116
+ region TEXT,
117
+ signature TEXT,
118
+ sec_uid TEXT,
119
+ status_code INTEGER
120
+ )
121
+ `);
122
+ migrateColumns("jobs", {
123
+ status_code: "INTEGER",
124
+ latest_video_time: "INTEGER",
125
+ confirmed_location: "TEXT",
126
+ modified_at: "INTEGER",
127
+ bio_link: "TEXT",
128
+ top_video_play_count: "INTEGER",
129
+ top_video_href: "TEXT",
130
+ user_create_time: "INTEGER",
131
+ });
132
+
133
+ // ===== jobs_base 表 =====
134
+ db.exec(`
135
+ CREATE TABLE IF NOT EXISTS jobs_base (
136
+ unique_id TEXT PRIMARY KEY,
137
+ nickname TEXT,
138
+ status TEXT DEFAULT 'pending',
139
+ sources TEXT,
140
+ claimed_by TEXT,
141
+ claimed_at INTEGER,
142
+ error TEXT,
143
+ pinned INTEGER DEFAULT 0,
144
+ no_video INTEGER DEFAULT 0,
145
+ restricted INTEGER DEFAULT 0,
146
+ user_update_count INTEGER DEFAULT 0,
147
+ tt_seller INTEGER,
148
+ verified INTEGER,
149
+ video_count INTEGER DEFAULT 0,
150
+ comment_count INTEGER DEFAULT 0,
151
+ guessed_location TEXT,
152
+ location_created TEXT,
153
+ confirmed_location TEXT,
154
+ modified_at INTEGER,
155
+ follower_count INTEGER DEFAULT 0,
156
+ following_count INTEGER DEFAULT 0,
157
+ heart_count INTEGER DEFAULT 0,
158
+ refresh_time INTEGER,
159
+ processed INTEGER DEFAULT 0,
160
+ processed_at INTEGER,
161
+ created_at INTEGER,
162
+ updated_at INTEGER,
163
+ region TEXT,
164
+ signature TEXT,
165
+ sec_uid TEXT,
166
+ status_code INTEGER,
167
+ latest_video_time INTEGER,
168
+ bio_link TEXT
169
+ )
170
+ `);
171
+ migrateColumns("jobs_base", {
172
+ status_code: "INTEGER",
173
+ latest_video_time: "INTEGER",
174
+ confirmed_location: "TEXT",
175
+ modified_at: "INTEGER",
176
+ bio_link: "TEXT",
177
+ user_create_time: "INTEGER",
178
+ });
179
+
180
+ // ===== raw_jobs 表 =====
181
+ db.exec(`
182
+ CREATE TABLE IF NOT EXISTS raw_jobs (
183
+ unique_id TEXT PRIMARY KEY,
184
+ nickname TEXT,
185
+ status TEXT DEFAULT 'pending',
186
+ sources TEXT,
187
+ claimed_by TEXT,
188
+ claimed_at INTEGER,
189
+ error TEXT,
190
+ pinned INTEGER DEFAULT 0,
191
+ no_video INTEGER DEFAULT 0,
192
+ restricted INTEGER DEFAULT 0,
193
+ user_update_count INTEGER DEFAULT 0,
194
+ tt_seller INTEGER,
195
+ verified INTEGER,
196
+ video_count INTEGER DEFAULT 0,
197
+ comment_count INTEGER DEFAULT 0,
198
+ guessed_location TEXT,
199
+ location_created TEXT,
200
+ confirmed_location TEXT,
201
+ modified_at INTEGER,
202
+ follower_count INTEGER DEFAULT 0,
203
+ following_count INTEGER DEFAULT 0,
204
+ heart_count INTEGER DEFAULT 0,
205
+ refresh_time INTEGER,
206
+ processed INTEGER DEFAULT 0,
207
+ processed_at INTEGER,
208
+ created_at INTEGER,
209
+ updated_at INTEGER,
210
+ region TEXT,
211
+ signature TEXT,
212
+ sec_uid TEXT,
213
+ status_code INTEGER,
214
+ latest_video_time INTEGER
215
+ )
216
+ `);
217
+ migrateColumns("raw_jobs", {
218
+ status_code: "INTEGER",
219
+ latest_video_time: "INTEGER",
220
+ confirmed_location: "TEXT",
221
+ modified_at: "INTEGER",
222
+ bio_link: "TEXT",
223
+ user_create_time: "INTEGER",
224
+ });
225
+
226
+ // ===== videos 表 =====
227
+ db.exec(`
228
+ CREATE TABLE IF NOT EXISTS videos (
229
+ id TEXT PRIMARY KEY,
230
+ href TEXT,
231
+ author_unique_id TEXT,
232
+ location_created TEXT,
233
+ tt_seller INTEGER DEFAULT 0,
234
+ registered_at INTEGER,
235
+ user_update_count INTEGER DEFAULT 0,
236
+ play_count INTEGER,
237
+ digg_count INTEGER,
238
+ comment_count INTEGER,
239
+ share_count INTEGER,
240
+ collect_count INTEGER,
241
+ stats_updated_at INTEGER,
242
+ create_time INTEGER
243
+ )
244
+ `);
245
+ migrateColumns("videos", {
246
+ play_count: "INTEGER",
247
+ digg_count: "INTEGER",
248
+ comment_count: "INTEGER",
249
+ share_count: "INTEGER",
250
+ collect_count: "INTEGER",
251
+ stats_updated_at: "INTEGER",
252
+ create_time: "INTEGER",
253
+ });
254
+
255
+ // ===== tags 表 =====
256
+ db.exec(`
257
+ CREATE TABLE IF NOT EXISTS tags (
258
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
259
+ tag TEXT NOT NULL UNIQUE,
260
+ status TEXT NOT NULL DEFAULT 'new',
261
+ score REAL NOT NULL DEFAULT 0,
262
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
263
+ scored_at TEXT,
264
+ score_count INTEGER NOT NULL DEFAULT 0,
265
+ countries TEXT NOT NULL DEFAULT '[]',
266
+ matched_countries TEXT DEFAULT '[]',
267
+ total_posts INTEGER DEFAULT 0,
268
+ author_count INTEGER DEFAULT 0,
269
+ matched_authors INTEGER DEFAULT 0,
270
+ pushed_users INTEGER DEFAULT 0,
271
+ source TEXT NOT NULL DEFAULT 'llm',
272
+ user_prompt TEXT,
273
+ last_error TEXT
274
+ )
275
+ `);
276
+
277
+ // ===== tags 表新增字段(兼容已存在的表) =====
278
+ migrateColumns("tags", {
279
+ discover_round: "INTEGER DEFAULT 0",
280
+ strategy: "TEXT",
281
+ });
282
+
283
+ // ===== discover_log 表(记录每次 LLM discover 的策略和思考) =====
284
+ db.exec(`
285
+ CREATE TABLE IF NOT EXISTS discover_log (
286
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
287
+ country TEXT NOT NULL,
288
+ round INTEGER NOT NULL DEFAULT 1,
289
+ strategy TEXT,
290
+ tags_generated TEXT NOT NULL DEFAULT '[]',
291
+ tags_added INTEGER DEFAULT 0,
292
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
293
+ productive_sample TEXT,
294
+ dead_sample TEXT,
295
+ avg_productive_score REAL DEFAULT 0,
296
+ avg_dead_score REAL DEFAULT 0
297
+ )
298
+ `);
299
+
300
+ // ===== 索引 =====
301
+ const indexes = [
302
+ `CREATE INDEX IF NOT EXISTS idx_jobs_status_video ON jobs(status, video_count DESC)`,
303
+ `CREATE INDEX IF NOT EXISTS idx_jobs_claimed_by_status ON jobs(claimed_by, status, claimed_at)`,
304
+ `CREATE INDEX IF NOT EXISTS idx_jobs_status_claimed_at ON jobs(status, claimed_at)`,
305
+ `CREATE INDEX IF NOT EXISTS idx_jobs_redo_target ON jobs(tt_seller, verified, location_created, refresh_time)`,
306
+ `CREATE INDEX IF NOT EXISTS idx_jobs_pending_priority ON jobs(status, pinned DESC, guessed_location, follower_count DESC)`,
307
+ `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_pinned ON jobs(created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 1`,
308
+ `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_seller ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND tt_seller = 1 AND verified = 0`,
309
+ `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_follow ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0 AND (instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0)`,
310
+ `CREATE INDEX IF NOT EXISTS idx_jobs_claim_pending_other ON jobs(UPPER(COALESCE(guessed_location, '')), follower_count DESC, created_at ASC, unique_id ASC) WHERE status = 'pending' AND COALESCE(pinned, 0) = 0`,
311
+ `CREATE INDEX IF NOT EXISTS idx_jobs_user_update_queue ON jobs(created_at ASC, unique_id ASC) WHERE (tt_seller IS NULL OR tt_seller = '') AND (user_update_count IS NULL OR user_update_count <= 0)`,
312
+ `CREATE INDEX IF NOT EXISTS idx_jobs_user_update_queue_expr ON jobs(created_at ASC, unique_id ASC) WHERE COALESCE(tt_seller, '') = '' AND COALESCE(user_update_count, 0) <= 0`,
313
+ `CREATE INDEX IF NOT EXISTS idx_videos_comment_queue ON videos(user_update_count, tt_seller DESC, registered_at ASC)`,
314
+ `CREATE INDEX IF NOT EXISTS idx_videos_comment_queue_pending ON videos(tt_seller DESC, registered_at ASC, id) WHERE user_update_count IS NULL OR user_update_count <= 0`,
315
+ `CREATE INDEX IF NOT EXISTS idx_tags_status ON tags(status)`,
316
+ `CREATE INDEX IF NOT EXISTS idx_tags_score ON tags(score DESC)`,
317
+ `CREATE INDEX IF NOT EXISTS idx_discover_log_country ON discover_log(country, round DESC)`,
318
+ ];
319
+ for (const sql of indexes) {
320
+ db.exec(sql);
321
+ }
322
+
323
+ const count = db.prepare("SELECT COUNT(*) as c FROM users").get().c;
324
+ console.log(`[data-store] SQLite users 表初始化完成: ${count} 条`);
325
+ }
326
+
327
+ // ===== 历史数据加载 =====
328
+
329
+ export function loadLegacyUsersFromFiles(userFilePath, doneFilePath) {
330
+ const merged = new Map();
331
+
332
+ const tryLoad = (targetPath, label) => {
333
+ if (!targetPath) return;
334
+ if (!fs.existsSync(targetPath)) return;
335
+ try {
336
+ const parsed = JSON.parse(fs.readFileSync(targetPath, "utf-8"));
337
+ if (!Array.isArray(parsed)) return;
338
+ for (const item of parsed) {
339
+ const uniqueId = item?.uniqueId || item?.unique_id;
340
+ if (!uniqueId) continue;
341
+ merged.set(uniqueId, { ...merged.get(uniqueId), ...item, uniqueId });
342
+ }
343
+ } catch (e) {
344
+ console.error(`[data-store] SQLite 导入 ${label} 失败: ${e.message}`);
345
+ }
346
+ };
347
+
348
+ tryLoad(userFilePath, "result.json");
349
+ tryLoad(doneFilePath, "result-done.json");
350
+
351
+ return [...merged.values()];
352
+ }
353
+
354
+ export function loadLegacyVideosFromFile(videoPath) {
355
+ if (!videoPath) return [];
356
+ if (!fs.existsSync(videoPath)) return [];
357
+
358
+ try {
359
+ const parsed = JSON.parse(fs.readFileSync(videoPath, "utf-8"));
360
+ return Array.isArray(parsed) ? parsed : [];
361
+ } catch (e) {
362
+ console.error(
363
+ `[data-store] SQLite 导入 result-videos.json 失败: ${e.message}`,
364
+ );
365
+ return [];
366
+ }
367
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * 数据库统计查询
3
+ *
4
+ * 仪表盘聚合统计、按国家分组的待处理/补资料/卡住/原始任务统计。
5
+ */
6
+
7
+ import { getDb } from "./db-schema.js";
8
+ import { getRawJobsCount, getUserDbCount } from "./db-crud.js";
9
+
10
+ export function getDashboardStatsFromDb(targetLocations = []) {
11
+ const db = getDb();
12
+ if (!db) return null;
13
+
14
+ const targetPlaceholders = targetLocations.map(() => "?").join(", ");
15
+ const targetParams = targetLocations.length ? targetLocations : [];
16
+
17
+ const aggregateRow = db
18
+ .prepare(
19
+ `
20
+ SELECT
21
+ COUNT(*) as total,
22
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
23
+ SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
24
+ SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as done,
25
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
26
+ SUM(CASE WHEN status = 'restricted' THEN 1 ELSE 0 END) as restricted,
27
+ SUM(CASE WHEN tt_seller = 1 AND verified = 0 ${
28
+ targetLocations.length
29
+ ? `AND location_created IN (${targetPlaceholders})`
30
+ : "AND 1 = 0"
31
+ } THEN 1 ELSE 0 END) as targetUsers,
32
+ SUM(CASE WHEN no_video = 1 THEN 1 ELSE 0 END) as noVideo,
33
+ SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"video"') > 0 THEN 1 ELSE 0 END) as video,
34
+ SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"comment"') > 0 THEN 1 ELSE 0 END) as comment,
35
+ SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"guess"') > 0 THEN 1 ELSE 0 END) as guess,
36
+ SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"following"') > 0 THEN 1 ELSE 0 END) as following,
37
+ SUM(CASE WHEN status != 'done' AND instr(COALESCE(sources, ''), '"follower"') > 0 THEN 1 ELSE 0 END) as follower,
38
+ SUM(CASE
39
+ WHEN status != 'done'
40
+ AND instr(COALESCE(sources, ''), '"video"') = 0
41
+ AND instr(COALESCE(sources, ''), '"comment"') = 0
42
+ AND instr(COALESCE(sources, ''), '"guess"') = 0
43
+ AND instr(COALESCE(sources, ''), '"following"') = 0
44
+ AND instr(COALESCE(sources, ''), '"follower"') = 0
45
+ THEN 1 ELSE 0 END) as seed
46
+ FROM jobs
47
+ `,
48
+ )
49
+ .get(...targetParams);
50
+
51
+ const userUpdateTasksRow = db
52
+ .prepare(
53
+ `SELECT COUNT(*) as userUpdateTasks FROM jobs_base WHERE COALESCE(tt_seller, '') = '' AND COALESCE(user_update_count, 0) <= 0`,
54
+ )
55
+ .get();
56
+
57
+ const countryStats = db
58
+ .prepare(
59
+ `
60
+ SELECT
61
+ COALESCE(location_created, '未知') as country,
62
+ COUNT(*) as count,
63
+ SUM(CASE
64
+ WHEN tt_seller = 1 AND verified = 0 ${
65
+ targetLocations.length
66
+ ? `AND location_created IN (${targetPlaceholders})`
67
+ : "AND 1 = 0"
68
+ }
69
+ THEN 1 ELSE 0 END) as targetCount
70
+ FROM jobs
71
+ WHERE status = 'done'
72
+ GROUP BY COALESCE(location_created, '未知')
73
+ ORDER BY count DESC
74
+ `,
75
+ )
76
+ .all(...targetParams);
77
+
78
+ const targetCountryStats = targetLocations.length
79
+ ? db
80
+ .prepare(
81
+ `
82
+ SELECT location_created as country, COUNT(*) as count
83
+ FROM jobs
84
+ WHERE tt_seller = 1 AND verified = 0 AND location_created IN (${targetPlaceholders})
85
+ GROUP BY location_created
86
+ ORDER BY count DESC
87
+ `,
88
+ )
89
+ .all(...targetLocations)
90
+ : [];
91
+
92
+ const jobsBaseCount = db
93
+ .prepare("SELECT COUNT(*) as total FROM jobs_base")
94
+ .get().total;
95
+
96
+ return {
97
+ totalUsers: aggregateRow.total,
98
+ rawJobs: getRawJobsCount(),
99
+ dbTotalUsers: getUserDbCount(),
100
+ jobsTotal: aggregateRow.total,
101
+ jobsBaseTotal: jobsBaseCount,
102
+ jobsPending: aggregateRow.pending,
103
+ processedUsers: aggregateRow.done,
104
+ pendingUsers: aggregateRow.pending,
105
+ processingUsers: aggregateRow.processing,
106
+ restrictedUsers: aggregateRow.restricted,
107
+ errorUsers: aggregateRow.error,
108
+ targetUsers: aggregateRow.targetUsers,
109
+ userUpdateTasks: userUpdateTasksRow.userUpdateTasks,
110
+ targetCountryStats,
111
+ countryStats,
112
+ sourceStats: {
113
+ seed: aggregateRow.seed || 0,
114
+ video: aggregateRow.video || 0,
115
+ comment: aggregateRow.comment || 0,
116
+ guess: aggregateRow.guess || 0,
117
+ following: aggregateRow.following || 0,
118
+ follower: aggregateRow.follower || 0,
119
+ processed: aggregateRow.done,
120
+ restricted: aggregateRow.restricted,
121
+ error: aggregateRow.error,
122
+ noVideo: aggregateRow.noVideo || 0,
123
+ },
124
+ };
125
+ }
126
+
127
+ export function getPendingByCountryFromDb() {
128
+ const db = getDb();
129
+ if (!db) return [];
130
+ return db
131
+ .prepare(
132
+ `SELECT COALESCE(guessed_location, '未知') as country, COUNT(*) as count
133
+ FROM jobs WHERE status = 'pending'
134
+ GROUP BY COALESCE(guessed_location, '未知')
135
+ ORDER BY count DESC`,
136
+ )
137
+ .all();
138
+ }
139
+
140
+ export function getUserUpdateByCountryFromDb() {
141
+ const db = getDb();
142
+ if (!db) return [];
143
+ return db
144
+ .prepare(
145
+ `SELECT COALESCE(guessed_location, '未知') as country, COUNT(*) as count
146
+ FROM jobs_base
147
+ WHERE tt_seller IS NULL AND COALESCE(user_update_count, 0) <= 0
148
+ GROUP BY COALESCE(guessed_location, '未知')
149
+ ORDER BY count DESC`,
150
+ )
151
+ .all();
152
+ }
153
+
154
+ export function getAttachStuckByCountryFromDb() {
155
+ const db = getDb();
156
+ if (!db) return [];
157
+ return db
158
+ .prepare(
159
+ `SELECT COALESCE(guessed_location, '未知') as country, COUNT(*) as count
160
+ FROM jobs_base
161
+ WHERE tt_seller IS NULL AND COALESCE(user_update_count, 0) = 1
162
+ GROUP BY COALESCE(guessed_location, '未知')
163
+ ORDER BY count DESC`,
164
+ )
165
+ .all();
166
+ }
167
+
168
+ export function getRawByCountryFromDb() {
169
+ const db = getDb();
170
+ if (!db) return [];
171
+ return db
172
+ .prepare(
173
+ `SELECT COALESCE(guessed_location, '未知') as country, COUNT(*) as count
174
+ FROM raw_jobs
175
+ GROUP BY COALESCE(guessed_location, '未知')
176
+ ORDER BY count DESC`,
177
+ )
178
+ .all();
179
+ }
180
+
181
+ export function restoreAttachStuckByCountry(country) {
182
+ const db = getDb();
183
+ if (!db) return { restored: 0, country, error: "db not ready" };
184
+
185
+ const normalizedCountry = String(country == null ? "未知" : country).trim();
186
+ if (!normalizedCountry) {
187
+ return {
188
+ restored: 0,
189
+ country: normalizedCountry,
190
+ error: "country is required",
191
+ };
192
+ }
193
+
194
+ const whereSql = `COALESCE(tt_seller, '') = '' AND COALESCE(user_update_count, 0) = 1 AND COALESCE(guessed_location, '未知') = ?`;
195
+ const count =
196
+ db
197
+ .prepare(`SELECT COUNT(*) as c FROM jobs_base WHERE ${whereSql}`)
198
+ .get(normalizedCountry)?.c || 0;
199
+
200
+ if (!count) return { restored: 0, country: normalizedCountry };
201
+
202
+ db.prepare(
203
+ `UPDATE jobs_base SET user_update_count = 0, updated_at = ?, claimed_by = NULL, claimed_at = NULL WHERE ${whereSql}`,
204
+ ).run(Date.now(), normalizedCountry);
205
+
206
+ return { restored: count, country: normalizedCountry };
207
+ }
208
+
209
+ export function resetPendingByCountry(country) {
210
+ const db = getDb();
211
+ if (!db) return { reset: 0, country, error: "db not ready" };
212
+
213
+ const normalizedCountry = String(country == null ? "未知" : country).trim();
214
+ if (!normalizedCountry) {
215
+ return {
216
+ reset: 0,
217
+ country: normalizedCountry,
218
+ error: "country is required",
219
+ };
220
+ }
221
+
222
+ const whereSql = `status = 'pending' AND COALESCE(guessed_location, '未知') = ?`;
223
+ const count =
224
+ db
225
+ .prepare(`SELECT COUNT(*) as c FROM jobs WHERE ${whereSql}`)
226
+ .get(normalizedCountry)?.c || 0;
227
+
228
+ if (!count) return { reset: 0, country: normalizedCountry };
229
+
230
+ db.prepare(
231
+ `UPDATE jobs SET user_update_count = 0, updated_at = ?, claimed_by = NULL, claimed_at = NULL WHERE ${whereSql}`,
232
+ ).run(Date.now(), normalizedCountry);
233
+
234
+ return { reset: count, country: normalizedCountry };
235
+ }