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.
- package/package.json +1 -1
- package/src/cli/comments.js +49 -24
- package/src/cli/tag.js +239 -94
- package/src/lib/args.js +23 -0
- package/src/lib/browser/cdp.js +4 -1
- package/src/lib/constants.js +15 -0
- package/src/lib/tag-fetcher.js +69 -63
- package/src/watch/data-store.js +537 -2298
- package/src/watch/data-store.js.bak +5091 -0
- package/src/watch/data-store.js.bak2 +5019 -0
- package/src/watch/db-columns.js +160 -0
- package/src/watch/db-crud.js +458 -0
- package/src/watch/db-mappers.js +128 -0
- package/src/watch/db-raw-jobs.js +235 -0
- package/src/watch/db-schema.js +367 -0
- package/src/watch/db-stats.js +235 -0
- package/src/watch/db-tags.js +348 -0
- package/src/watch/llm-scoring.js +235 -0
- package/src/watch/public/app.js +47 -0
- package/src/watch/public/index.html +6 -0
- package/src/watch/server.js +24 -0
- package/src/watch/tag-service.js +142 -11
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 行映射工具 — snake_case ↔ camelCase 转换、Boolean 列处理
|
|
3
|
+
*
|
|
4
|
+
* 所有从 SQLite 读出的行都经过 mapJobRow / mapVideoRow 转换为
|
|
5
|
+
* camelCase + Boolean 格式,写入时通过 normalizeJobValue 反转。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
JOB_BOOLEAN_COLUMNS,
|
|
10
|
+
VIDEO_BOOLEAN_COLUMNS,
|
|
11
|
+
WRITABLE_JOB_COLUMNS,
|
|
12
|
+
} from "./db-columns.js";
|
|
13
|
+
|
|
14
|
+
// ===== 命名转换 =====
|
|
15
|
+
|
|
16
|
+
export function snakeToCamel(key) {
|
|
17
|
+
return key.replace(/_([a-z])/g, (_, ch) => ch.toUpperCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function camelToSnake(key) {
|
|
21
|
+
return key.replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ===== 值规范化 =====
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 将 camelCase 的值规范化为 SQLite 存储格式
|
|
28
|
+
* - sources: Array → JSON 字符串(去重)
|
|
29
|
+
* - Boolean 列: true/false → 1/0
|
|
30
|
+
* - 对象/数组: → JSON 字符串
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeJobValue(column, value) {
|
|
33
|
+
if (value === undefined || value === null) return null;
|
|
34
|
+
if (column === "sources") {
|
|
35
|
+
if (!Array.isArray(value)) return JSON.stringify([]);
|
|
36
|
+
return JSON.stringify([...new Set(value)]);
|
|
37
|
+
}
|
|
38
|
+
if (JOB_BOOLEAN_COLUMNS.has(column)) {
|
|
39
|
+
return value ? 1 : 0;
|
|
40
|
+
}
|
|
41
|
+
// 防御:如果值是对象或数组,转为 JSON 字符串
|
|
42
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 将 user 对象的 Boolean 字段规范化为 SQLite 0/1/null 格式
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeBooleanField(value) {
|
|
50
|
+
if (value === undefined || value === null || value === "") return null;
|
|
51
|
+
return value ? 1 : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ===== 行映射 =====
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 将 SQLite 行(snake_case + 0/1)映射为 JS 对象(camelCase + true/false)
|
|
58
|
+
*/
|
|
59
|
+
export function mapJobRow(row) {
|
|
60
|
+
if (!row) return undefined;
|
|
61
|
+
const mapped = {};
|
|
62
|
+
for (const [key, value] of Object.entries(row)) {
|
|
63
|
+
const camelKey = snakeToCamel(key);
|
|
64
|
+
if (key === "sources") {
|
|
65
|
+
try {
|
|
66
|
+
mapped[camelKey] = value ? JSON.parse(value) : [];
|
|
67
|
+
} catch {
|
|
68
|
+
mapped[camelKey] = [];
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (JOB_BOOLEAN_COLUMNS.has(key)) {
|
|
73
|
+
mapped[camelKey] = value === null || value === undefined ? null : !!value;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
mapped[camelKey] = value;
|
|
77
|
+
}
|
|
78
|
+
return mapped;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 将 SQLite videos 行映射为 JS 对象
|
|
83
|
+
*/
|
|
84
|
+
export function mapVideoRow(row) {
|
|
85
|
+
if (!row) return undefined;
|
|
86
|
+
const mapped = {};
|
|
87
|
+
for (const [key, value] of Object.entries(row)) {
|
|
88
|
+
const camelKey = snakeToCamel(key);
|
|
89
|
+
if (VIDEO_BOOLEAN_COLUMNS.has(key)) {
|
|
90
|
+
mapped[camelKey] = value === null || value === undefined ? null : !!value;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
mapped[camelKey] = value;
|
|
94
|
+
}
|
|
95
|
+
return mapped;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ===== 状态推断 =====
|
|
99
|
+
|
|
100
|
+
export function inferStatus(u) {
|
|
101
|
+
if (u.restricted) return "restricted";
|
|
102
|
+
if (u.error) return "error";
|
|
103
|
+
if (u.processed) return "done";
|
|
104
|
+
return "pending";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ===== 列名别名映射 =====
|
|
108
|
+
// 写入时某些 camelKey 需要映射到不同的 snake 列名
|
|
109
|
+
const COLUMN_ALIASES = {
|
|
110
|
+
bio: "signature",
|
|
111
|
+
create_time: "user_create_time",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 将 camelCase key 转为 snake_case 列名,并处理别名
|
|
116
|
+
*/
|
|
117
|
+
export function resolveColumnName(camelKey) {
|
|
118
|
+
let column = camelToSnake(camelKey);
|
|
119
|
+
if (COLUMN_ALIASES[column]) return COLUMN_ALIASES[column];
|
|
120
|
+
return column;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 检查列名是否可写入
|
|
125
|
+
*/
|
|
126
|
+
export function isWritableColumn(column) {
|
|
127
|
+
return WRITABLE_JOB_COLUMNS.has(column);
|
|
128
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw Jobs 管理
|
|
3
|
+
*
|
|
4
|
+
* 将 jobs/jobs_base 移入 raw_jobs,以及从 raw_jobs 恢复回 jobs。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getDb } from "./db-schema.js";
|
|
8
|
+
import { mapJobRow } from "./db-crud.js";
|
|
9
|
+
|
|
10
|
+
const RAW_JOB_COLUMNS = `
|
|
11
|
+
unique_id, nickname, status, sources, claimed_by, claimed_at,
|
|
12
|
+
error, pinned, no_video, restricted, user_update_count,
|
|
13
|
+
tt_seller, verified, video_count, comment_count,
|
|
14
|
+
guessed_location, location_created, follower_count,
|
|
15
|
+
following_count, heart_count, refresh_time, processed,
|
|
16
|
+
processed_at, created_at, updated_at, region, signature,
|
|
17
|
+
sec_uid, latest_video_time, user_create_time
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
const RESTORE_COLUMNS = `
|
|
21
|
+
unique_id, nickname, status, sources, claimed_by, claimed_at, error,
|
|
22
|
+
pinned, no_video, restricted, user_update_count, tt_seller, verified,
|
|
23
|
+
video_count, comment_count, guessed_location, location_created,
|
|
24
|
+
follower_count, following_count, heart_count, refresh_time,
|
|
25
|
+
processed, processed_at, created_at, updated_at, region, signature, bio_link, sec_uid
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
export function moveJobsToRawByCountry(scope, country) {
|
|
29
|
+
const db = getDb();
|
|
30
|
+
if (!db) return { moved: 0, scope, country, error: "db not ready" };
|
|
31
|
+
|
|
32
|
+
const normalizedScope = String(scope || "").trim();
|
|
33
|
+
const normalizedCountry = String(country == null ? "未知" : country).trim();
|
|
34
|
+
if (!normalizedCountry) {
|
|
35
|
+
return {
|
|
36
|
+
moved: 0,
|
|
37
|
+
scope: normalizedScope,
|
|
38
|
+
country: normalizedCountry,
|
|
39
|
+
error: "country is required",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let sourceTable = "";
|
|
44
|
+
let scopeWhere = "";
|
|
45
|
+
|
|
46
|
+
if (normalizedScope === "pending") {
|
|
47
|
+
sourceTable = "jobs";
|
|
48
|
+
scopeWhere = `status = 'pending'`;
|
|
49
|
+
} else if (normalizedScope === "userUpdate") {
|
|
50
|
+
sourceTable = "jobs_base";
|
|
51
|
+
scopeWhere = `tt_seller IS NULL AND COALESCE(user_update_count, 0) <= 0`;
|
|
52
|
+
} else {
|
|
53
|
+
return {
|
|
54
|
+
moved: 0,
|
|
55
|
+
scope: normalizedScope,
|
|
56
|
+
country: normalizedCountry,
|
|
57
|
+
error: "unsupported scope",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const whereSql = `${scopeWhere} AND COALESCE(guessed_location, '未知') = ?`;
|
|
62
|
+
const count =
|
|
63
|
+
db
|
|
64
|
+
.prepare(`SELECT COUNT(*) as c FROM ${sourceTable} WHERE ${whereSql}`)
|
|
65
|
+
.get(normalizedCountry)?.c || 0;
|
|
66
|
+
|
|
67
|
+
if (!count)
|
|
68
|
+
return { moved: 0, scope: normalizedScope, country: normalizedCountry };
|
|
69
|
+
|
|
70
|
+
const moveTxn = db.transaction((targetCountry) => {
|
|
71
|
+
db.prepare(
|
|
72
|
+
`INSERT OR REPLACE INTO raw_jobs (${RAW_JOB_COLUMNS}) SELECT ${RAW_JOB_COLUMNS} FROM ${sourceTable} WHERE ${whereSql}`,
|
|
73
|
+
).run(targetCountry);
|
|
74
|
+
db.prepare(`DELETE FROM ${sourceTable} WHERE ${whereSql}`).run(
|
|
75
|
+
targetCountry,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
moveTxn(normalizedCountry);
|
|
80
|
+
return { moved: count, scope: normalizedScope, country: normalizedCountry };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function restoreRawJobsByCountry(country) {
|
|
84
|
+
const db = getDb();
|
|
85
|
+
if (!db) return { restored: 0, country, error: "db not ready" };
|
|
86
|
+
|
|
87
|
+
const normalizedCountry = String(country == null ? "未知" : country).trim();
|
|
88
|
+
if (!normalizedCountry) {
|
|
89
|
+
return {
|
|
90
|
+
restored: 0,
|
|
91
|
+
country: normalizedCountry,
|
|
92
|
+
error: "country is required",
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const whereSql = `COALESCE(guessed_location, '未知') = ?`;
|
|
97
|
+
const count =
|
|
98
|
+
db
|
|
99
|
+
.prepare(`SELECT COUNT(*) as c FROM raw_jobs WHERE ${whereSql}`)
|
|
100
|
+
.get(normalizedCountry)?.c || 0;
|
|
101
|
+
|
|
102
|
+
if (!count) return { restored: 0, country: normalizedCountry };
|
|
103
|
+
|
|
104
|
+
const restoreTxn = db.transaction((targetCountry) => {
|
|
105
|
+
db.prepare(
|
|
106
|
+
`INSERT OR REPLACE INTO jobs (${RESTORE_COLUMNS}) SELECT ${RESTORE_COLUMNS} FROM raw_jobs WHERE ${whereSql}`,
|
|
107
|
+
).run(targetCountry);
|
|
108
|
+
db.prepare(`DELETE FROM raw_jobs WHERE ${whereSql}`).run(targetCountry);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
restoreTxn(normalizedCountry);
|
|
112
|
+
return { restored: count, country: normalizedCountry };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function restoreRawJobById(uniqueId) {
|
|
116
|
+
const db = getDb();
|
|
117
|
+
if (!db) return { restored: 0, uniqueId, error: "db not ready" };
|
|
118
|
+
|
|
119
|
+
const safeId = String(uniqueId).trim();
|
|
120
|
+
if (!safeId)
|
|
121
|
+
return { restored: 0, uniqueId: safeId, error: "uniqueId is required" };
|
|
122
|
+
|
|
123
|
+
const exists =
|
|
124
|
+
db
|
|
125
|
+
.prepare("SELECT COUNT(*) as c FROM raw_jobs WHERE unique_id = ?")
|
|
126
|
+
.get(safeId)?.c || 0;
|
|
127
|
+
if (!exists) return { restored: 0, uniqueId: safeId };
|
|
128
|
+
|
|
129
|
+
const restoreTxn = db.transaction(() => {
|
|
130
|
+
db.prepare(
|
|
131
|
+
`INSERT OR REPLACE INTO jobs (${RESTORE_COLUMNS}) SELECT ${RESTORE_COLUMNS} FROM raw_jobs WHERE unique_id = ?`,
|
|
132
|
+
).run(safeId);
|
|
133
|
+
db.prepare("DELETE FROM raw_jobs WHERE unique_id = ?").run(safeId);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
restoreTxn();
|
|
137
|
+
return { restored: 1, uniqueId: safeId };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function restoreRawJobsByFilter({
|
|
141
|
+
search,
|
|
142
|
+
location,
|
|
143
|
+
hasVideo,
|
|
144
|
+
hasFollower,
|
|
145
|
+
}) {
|
|
146
|
+
const db = getDb();
|
|
147
|
+
if (!db) return { restored: 0, error: "db not ready" };
|
|
148
|
+
|
|
149
|
+
const where = [];
|
|
150
|
+
const args = [];
|
|
151
|
+
|
|
152
|
+
if (search) {
|
|
153
|
+
where.push(
|
|
154
|
+
"(LOWER(unique_id) LIKE ? OR LOWER(COALESCE(nickname, '')) LIKE ?)",
|
|
155
|
+
);
|
|
156
|
+
const likeVal = `%${search.toLowerCase()}%`;
|
|
157
|
+
args.push(likeVal, likeVal);
|
|
158
|
+
}
|
|
159
|
+
if (location) {
|
|
160
|
+
where.push("COALESCE(guessed_location, '未知') = ?");
|
|
161
|
+
args.push(location);
|
|
162
|
+
}
|
|
163
|
+
if (hasVideo) where.push("COALESCE(video_count, 0) > 0");
|
|
164
|
+
if (hasFollower) where.push("COALESCE(follower_count, 0) > 0");
|
|
165
|
+
|
|
166
|
+
if (where.length === 0)
|
|
167
|
+
return { restored: 0, error: "at least one filter is required" };
|
|
168
|
+
|
|
169
|
+
const whereSql = where.join(" AND ");
|
|
170
|
+
const count =
|
|
171
|
+
db
|
|
172
|
+
.prepare(`SELECT COUNT(*) as c FROM raw_jobs WHERE ${whereSql}`)
|
|
173
|
+
.get(...args)?.c || 0;
|
|
174
|
+
|
|
175
|
+
if (!count) return { restored: 0 };
|
|
176
|
+
|
|
177
|
+
const restoreTxn = db.transaction(() => {
|
|
178
|
+
db.prepare(
|
|
179
|
+
`INSERT OR REPLACE INTO jobs (${RESTORE_COLUMNS}) SELECT ${RESTORE_COLUMNS} FROM raw_jobs WHERE ${whereSql}`,
|
|
180
|
+
).run(...args);
|
|
181
|
+
db.prepare(`DELETE FROM raw_jobs WHERE ${whereSql}`).run(...args);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
restoreTxn();
|
|
185
|
+
return { restored: count };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function getRawJobsPageFromDb({
|
|
189
|
+
search,
|
|
190
|
+
location,
|
|
191
|
+
limit,
|
|
192
|
+
offset,
|
|
193
|
+
hasVideo,
|
|
194
|
+
hasFollower,
|
|
195
|
+
}) {
|
|
196
|
+
const db = getDb();
|
|
197
|
+
if (!db) return null;
|
|
198
|
+
|
|
199
|
+
const safeLimit = Math.max(1, Math.min(200, parseInt(limit) || 50));
|
|
200
|
+
const safeOffset = Math.max(0, parseInt(offset) || 0);
|
|
201
|
+
const where = [];
|
|
202
|
+
const args = [];
|
|
203
|
+
|
|
204
|
+
if (search) {
|
|
205
|
+
where.push(
|
|
206
|
+
"(LOWER(unique_id) LIKE ? OR LOWER(COALESCE(nickname, '')) LIKE ?)",
|
|
207
|
+
);
|
|
208
|
+
const pattern = `%${String(search).toLowerCase()}%`;
|
|
209
|
+
args.push(pattern, pattern);
|
|
210
|
+
}
|
|
211
|
+
if (location) {
|
|
212
|
+
where.push("COALESCE(guessed_location, '未知') = ?");
|
|
213
|
+
args.push(location);
|
|
214
|
+
}
|
|
215
|
+
if (hasVideo) where.push("COALESCE(video_count, 0) > 0");
|
|
216
|
+
if (hasFollower) where.push("COALESCE(follower_count, 0) > 0");
|
|
217
|
+
|
|
218
|
+
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
219
|
+
const total = db
|
|
220
|
+
.prepare(`SELECT COUNT(*) as c FROM raw_jobs ${whereSql}`)
|
|
221
|
+
.get(...args).c;
|
|
222
|
+
|
|
223
|
+
const rows = db
|
|
224
|
+
.prepare(
|
|
225
|
+
`SELECT * FROM raw_jobs ${whereSql} ORDER BY created_at DESC, unique_id ASC LIMIT ? OFFSET ?`,
|
|
226
|
+
)
|
|
227
|
+
.all(...args, safeLimit, safeOffset);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
total,
|
|
231
|
+
limit: safeLimit,
|
|
232
|
+
offset: safeOffset,
|
|
233
|
+
users: rows.map(mapJobRow),
|
|
234
|
+
};
|
|
235
|
+
}
|