tt-help-cli-ycl 1.3.55 → 1.3.57

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.3.55",
3
+ "version": "1.3.57",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/open.js CHANGED
@@ -34,14 +34,13 @@ export async function handleOpen(parsed) {
34
34
  console.error("用法: tt-help open <端口>");
35
35
  console.error("示例: tt-help open 9222");
36
36
  console.error("");
37
- console.error("可用端口: 9222 - 9231 (共 10 个)");
38
- console.error('运行 "tt-help open --list" 查看所有配置');
37
+ console.error('运行 "tt-help open --list" 查看内置浏览器配置');
39
38
  process.exit(1);
40
39
  }
41
40
 
42
41
  const port = parseInt(openPort);
43
- if (isNaN(port) || port < BASE_PORT || port >= BASE_PORT + TOTAL_ACCOUNTS) {
44
- console.error(`端口 ${openPort} 不在有效范围内 (9222 - 9231)`);
42
+ if (isNaN(port) || port < 1 || port > 65535) {
43
+ console.error(`端口 ${openPort} 无效 (请使用 1 - 65535)`);
45
44
  process.exit(1);
46
45
  }
47
46
 
@@ -38,6 +38,7 @@ async function processExplore(page, username, options, log) {
38
38
  keepFollow: false,
39
39
  locationCreated: null,
40
40
  noVideo: false,
41
+ restricted: false,
41
42
  error: null,
42
43
  };
43
44
 
@@ -172,6 +173,7 @@ async function processExplore(page, username, options, log) {
172
173
  result.videoList = videoArray;
173
174
  } else {
174
175
  // 国家不匹配
176
+ result.restricted = true;
175
177
  result.keepFollow = false;
176
178
  result.discoveredFollowing = [];
177
179
  result.discoveredFollowers = [];
@@ -1168,6 +1168,17 @@ function getRawJobsPageFromDb({ search, location, limit, offset }) {
1168
1168
  };
1169
1169
  }
1170
1170
 
1171
+ // 调试接口:直接执行 SQL 查询,返回原始数据
1172
+ function rawQuery(sql, params = []) {
1173
+ if (!db) return { error: "db not ready" };
1174
+ try {
1175
+ const rows = db.prepare(sql).all(...params);
1176
+ return { rows };
1177
+ } catch (e) {
1178
+ return { error: e.message };
1179
+ }
1180
+ }
1181
+
1171
1182
  function getUsersPageFromDb({
1172
1183
  status,
1173
1184
  search,
@@ -1838,12 +1849,15 @@ export function createStore(filePath) {
1838
1849
  const args = [];
1839
1850
  if (!loggedIn) {
1840
1851
  where.push("COALESCE(tt_seller, 0) != 1");
1841
- // 未登录用户只能领取 statusCode 为空的任务(209002 只能被登录用户领取)
1842
- where.push("status_code IS NULL");
1852
+ // 未登录:只能领取 status_code 为空或 0 的任务
1853
+ where.push("(status_code IS NULL OR status_code = 0)");
1843
1854
  } else {
1844
- // 登录用户可以领取 statusCode 为空 或 statusCode=209002 的任务
1845
- where.push("status_code IS NULL OR status_code = 209002");
1855
+ // 登录:可以领取 status_code 为空、0、或 209002 的任务
1856
+ where.push(
1857
+ "(status_code IS NULL OR status_code = 0 OR status_code = 209002)",
1858
+ );
1846
1859
  }
1860
+ // 其他 status_code 值的任务不被领取
1847
1861
  if (requireVideo) {
1848
1862
  where.push("COALESCE(video_count, 0) > 0");
1849
1863
  }
@@ -2020,124 +2034,433 @@ export function createStore(filePath) {
2020
2034
  return null;
2021
2035
  }
2022
2036
 
2023
- const now = Date.now();
2037
+ if (!db) {
2038
+ const now = Date.now();
2024
2039
 
2025
- // 0. 该客户端有未过期的任务,续期返回
2026
- const ongoing = data.find(
2027
- (u) =>
2028
- u.status === "processing" &&
2029
- u.claimedBy === userId &&
2030
- u.claimedAt &&
2031
- now - u.claimedAt < expireMs,
2032
- );
2033
- if (ongoing) {
2034
- ongoing.claimedAt = now;
2035
- save();
2036
- return {
2037
- uniqueId: ongoing.uniqueId,
2038
- nickname: ongoing.nickname,
2039
- claimedAt: ongoing.claimedAt,
2040
- claimedBy: userId,
2041
- };
2042
- }
2040
+ // 0. 该客户端有未过期的任务,续期返回
2041
+ const ongoing = data.find(
2042
+ (u) =>
2043
+ u.status === "processing" &&
2044
+ u.claimedBy === userId &&
2045
+ u.claimedAt &&
2046
+ now - u.claimedAt < expireMs,
2047
+ );
2048
+ if (ongoing) {
2049
+ ongoing.claimedAt = now;
2050
+ save();
2051
+ return {
2052
+ uniqueId: ongoing.uniqueId,
2053
+ nickname: ongoing.nickname,
2054
+ claimedAt: ongoing.claimedAt,
2055
+ claimedBy: userId,
2056
+ };
2057
+ }
2043
2058
 
2044
- // 按猜测国家梯队排序
2045
- const tier1 = new Set(["PL", "NL", "BE"]);
2046
- const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
2047
- function locationTier(u) {
2048
- const loc = (u.guessedLocation || "").toUpperCase();
2049
- if (tier1.has(loc)) return 0;
2050
- if (tier2.has(loc)) return 1;
2051
- return 2;
2052
- }
2059
+ // 按猜测国家梯队排序
2060
+ const tier1 = new Set(["PL", "NL", "BE"]);
2061
+ const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
2062
+ function locationTier(u) {
2063
+ const loc = (u.guessedLocation || "").toUpperCase();
2064
+ if (tier1.has(loc)) return 0;
2065
+ if (tier2.has(loc)) return 1;
2066
+ return 2;
2067
+ }
2068
+
2069
+ // 国家过滤:如果指定了 locations,只保留 guessedLocation 在列表中的用户
2070
+ function locationFilter(u) {
2071
+ if (!locations || locations.length === 0) return true;
2072
+ return isLocationInList(u.guessedLocation, locations);
2073
+ }
2074
+
2075
+ // 从候选列表中按优先级取第一个:pinned > 超时回收 > seed > ttSeller(仅登录) > follow > other
2076
+ function pickCandidate(candidates) {
2077
+ let next = candidates.find((u) => u.pinned);
2078
+
2079
+ if (!next) {
2080
+ const expired = data.find(
2081
+ (u) =>
2082
+ u.status === "processing" &&
2083
+ u.claimedAt &&
2084
+ now - u.claimedAt > expireMs,
2085
+ );
2086
+ if (expired) {
2087
+ expired.status = "pending";
2088
+ markStatsDirty();
2089
+ delete expired.claimedAt;
2090
+ next = expired;
2091
+ }
2092
+ }
2093
+
2094
+ if (!next) {
2095
+ const seed = candidates.filter(
2096
+ (u) => u.sources && u.sources.includes("seed"),
2097
+ );
2098
+ seed.sort((a, b) => locationTier(a) - locationTier(b));
2099
+ next = seed[0] || null;
2100
+ }
2101
+
2102
+ // 未登录时跳过 ttSeller 优先级
2103
+ if (!next && loggedIn) {
2104
+ const ttSeller = candidates.filter(
2105
+ (u) => u.ttSeller === true && u.verified === false,
2106
+ );
2107
+ ttSeller.sort((a, b) => locationTier(a) - locationTier(b));
2108
+ next = ttSeller[0] || null;
2109
+ }
2110
+
2111
+ if (!next) {
2112
+ const follow = candidates.filter(
2113
+ (u) =>
2114
+ u.sources &&
2115
+ (u.sources.includes("following") ||
2116
+ u.sources.includes("follower")),
2117
+ );
2118
+ follow.sort((a, b) => locationTier(a) - locationTier(b));
2119
+ next = follow[0] || null;
2120
+ }
2121
+
2122
+ if (!next) {
2123
+ candidates.sort((a, b) => locationTier(a) - locationTier(b));
2124
+ next = candidates[0] || null;
2125
+ }
2126
+
2127
+ return next;
2128
+ }
2129
+
2130
+ // 先在有视频的 pending 用户中找;找不到再用全部 pending 用户兜底
2131
+ let pending = data.filter((u) => u.status === "pending");
2132
+ // 应用国家过滤
2133
+ if (locations && locations.length > 0) {
2134
+ pending = pending.filter(locationFilter);
2135
+ }
2136
+ // 未登录客户端不能领取 ttSeller 用户
2137
+ if (!loggedIn) {
2138
+ pending = pending.filter((u) => u.ttSeller !== true);
2139
+ }
2140
+ // status_code 过滤:只领取空值、0 或 209002 的任务
2141
+ pending = pending.filter(
2142
+ (u) =>
2143
+ u.statusCode == null ||
2144
+ u.statusCode === 0 ||
2145
+ (loggedIn && u.statusCode === 209002),
2146
+ );
2147
+ let hasVideo = pending.filter((u) => u.videoCount > 0);
2148
+ const next = pickCandidate(hasVideo) || pickCandidate(pending);
2053
2149
 
2054
- // 国家过滤:如果指定了 locations,只保留 guessedLocation 在列表中的用户
2055
- function locationFilter(u) {
2056
- if (!locations || locations.length === 0) return true;
2057
- return isLocationInList(u.guessedLocation, locations);
2150
+ if (next) {
2151
+ next.status = "processing";
2152
+ markStatsDirty();
2153
+ next.claimedAt = now;
2154
+ next.claimedBy = userId;
2155
+ save();
2156
+ return {
2157
+ uniqueId: next.uniqueId,
2158
+ nickname: next.nickname,
2159
+ claimedAt: next.claimedAt,
2160
+ claimedBy: userId,
2161
+ };
2162
+ }
2163
+ return null;
2058
2164
  }
2059
2165
 
2060
- // 从候选列表中按优先级取第一个:pinned > 超时回收 > seed > ttSeller(仅登录) > follow > other
2061
- function pickCandidate(candidates) {
2062
- let next = candidates.find((u) => u.pinned);
2166
+ return null;
2167
+ }
2063
2168
 
2064
- if (!next) {
2065
- const expired = data.find(
2066
- (u) =>
2067
- u.status === "processing" &&
2068
- u.claimedAt &&
2069
- now - u.claimedAt > expireMs,
2070
- );
2071
- if (expired) {
2072
- expired.status = "pending";
2073
- markStatsDirty();
2074
- delete expired.claimedAt;
2075
- next = expired;
2169
+ function debugClaimNextJob(
2170
+ userId,
2171
+ expireMs = 5 * 60 * 1000,
2172
+ locations = null,
2173
+ loggedIn = true,
2174
+ ) {
2175
+ if (db) {
2176
+ const now = Date.now();
2177
+ const info = {
2178
+ path: "db",
2179
+ userId,
2180
+ expireMs,
2181
+ loggedIn,
2182
+ };
2183
+
2184
+ const ongoingRow = db
2185
+ .prepare(
2186
+ `
2187
+ SELECT *
2188
+ FROM jobs
2189
+ WHERE status = 'processing'
2190
+ AND claimed_by = ?
2191
+ AND claimed_at IS NOT NULL
2192
+ AND ? - claimed_at < ?
2193
+ ORDER BY claimed_at DESC
2194
+ LIMIT 1
2195
+ `,
2196
+ )
2197
+ .get(userId, now, expireMs);
2198
+ info.ongoing = ongoingRow
2199
+ ? {
2200
+ uniqueId: ongoingRow.unique_id,
2201
+ claimedBy: ongoingRow.claimed_by,
2202
+ claimedAt: ongoingRow.claimed_at,
2203
+ }
2204
+ : null;
2205
+
2206
+ const tier1 = new Set(["PL", "NL", "BE"]);
2207
+ const tier2 = new Set(["DE", "FR", "IT", "IE", "ES"]);
2208
+ const normalizedLocations = Array.isArray(locations)
2209
+ ? locations
2210
+ .map((loc) => String(loc).trim().toUpperCase())
2211
+ .filter(Boolean)
2212
+ : [];
2213
+
2214
+ function getLocationGroups() {
2215
+ const selected = normalizedLocations.length
2216
+ ? normalizedLocations
2217
+ : null;
2218
+ const tier1List = selected
2219
+ ? selected.filter((loc) => tier1.has(loc))
2220
+ : [...tier1];
2221
+ const tier2List = selected
2222
+ ? selected.filter((loc) => tier2.has(loc))
2223
+ : [...tier2];
2224
+ const otherList = selected
2225
+ ? selected.filter((loc) => !tier1.has(loc) && !tier2.has(loc))
2226
+ : null;
2227
+ const groups = [];
2228
+ if (tier1List.length > 0)
2229
+ groups.push({ type: "include", values: tier1List });
2230
+ if (tier2List.length > 0)
2231
+ groups.push({ type: "include", values: tier2List });
2232
+ if (selected) {
2233
+ if (otherList.length > 0)
2234
+ groups.push({ type: "include", values: otherList });
2235
+ } else {
2236
+ groups.push({ type: "exclude", values: [...tier1, ...tier2] });
2076
2237
  }
2238
+ return groups;
2077
2239
  }
2078
2240
 
2079
- if (!next) {
2080
- const seed = candidates.filter(
2081
- (u) => u.sources && u.sources.includes("seed"),
2241
+ const locationGroups = getLocationGroups();
2242
+ info.locationGroups = locationGroups;
2243
+
2244
+ function applyLocationGroup(where, args, group) {
2245
+ if (!group) return;
2246
+ if (group.type === "include") {
2247
+ where.push(
2248
+ `UPPER(COALESCE(guessed_location, '')) IN (${group.values.map(() => "?").join(", ")})`,
2249
+ );
2250
+ args.push(...group.values);
2251
+ return;
2252
+ }
2253
+ where.push(
2254
+ `UPPER(COALESCE(guessed_location, '')) NOT IN (${group.values.map(() => "?").join(", ")})`,
2082
2255
  );
2083
- seed.sort((a, b) => locationTier(a) - locationTier(b));
2084
- next = seed[0] || null;
2256
+ args.push(...group.values);
2085
2257
  }
2086
2258
 
2087
- // 未登录时跳过 ttSeller 优先级
2088
- if (!next && loggedIn) {
2089
- const ttSeller = candidates.filter(
2090
- (u) => u.ttSeller === true && u.verified === false,
2091
- );
2092
- ttSeller.sort((a, b) => locationTier(a) - locationTier(b));
2093
- next = ttSeller[0] || null;
2259
+ function queryPendingOne({ requireVideo, group, filters = [] }) {
2260
+ const where = ["status = 'pending'"];
2261
+ const args = [];
2262
+ if (!loggedIn) {
2263
+ where.push("COALESCE(tt_seller, 0) != 1");
2264
+ where.push("(status_code IS NULL OR status_code = 0)");
2265
+ } else {
2266
+ where.push(
2267
+ "(status_code IS NULL OR status_code = 0 OR status_code = 209002)",
2268
+ );
2269
+ }
2270
+ if (requireVideo) {
2271
+ where.push("COALESCE(video_count, 0) > 0");
2272
+ }
2273
+ applyLocationGroup(where, args, group);
2274
+ for (const filter of filters) {
2275
+ where.push(filter);
2276
+ }
2277
+ const sql = `
2278
+ SELECT *
2279
+ FROM jobs
2280
+ WHERE ${where.join(" AND ")}
2281
+ ORDER BY follower_count DESC, created_at ASC, unique_id ASC
2282
+ LIMIT 1
2283
+ `;
2284
+ const row = db.prepare(sql).get(...args);
2285
+ return { row, sql, args };
2094
2286
  }
2095
2287
 
2096
- if (!next) {
2097
- const follow = candidates.filter(
2098
- (u) =>
2099
- u.sources &&
2100
- (u.sources.includes("following") || u.sources.includes("follower")),
2101
- );
2102
- follow.sort((a, b) => locationTier(a) - locationTier(b));
2103
- next = follow[0] || null;
2288
+ function queryPendingByGroup({ requireVideo, group, filters = [] }) {
2289
+ if (group?.type === "include" && group.values.length > 1) {
2290
+ for (const location of group.values) {
2291
+ const ret = queryPendingOne({
2292
+ requireVideo,
2293
+ group: { type: "include", values: [location] },
2294
+ filters,
2295
+ });
2296
+ if (ret.row) return ret;
2297
+ }
2298
+ return { row: null, sql: null, args: [] };
2299
+ }
2300
+ return queryPendingOne({ requireVideo, group, filters });
2104
2301
  }
2105
2302
 
2106
- if (!next) {
2107
- candidates.sort((a, b) => locationTier(a) - locationTier(b));
2108
- next = candidates[0] || null;
2303
+ function findPinnedPending(requireVideo) {
2304
+ const where = ["status = 'pending'", "COALESCE(pinned, 0) = 1"];
2305
+ const args = [];
2306
+ if (!loggedIn) {
2307
+ where.push("COALESCE(tt_seller, 0) != 1");
2308
+ }
2309
+ if (requireVideo) {
2310
+ where.push("COALESCE(video_count, 0) > 0");
2311
+ }
2312
+ if (normalizedLocations.length > 0) {
2313
+ where.push(
2314
+ `UPPER(COALESCE(guessed_location, '')) IN (${normalizedLocations.map(() => "?").join(", ")})`,
2315
+ );
2316
+ args.push(...normalizedLocations);
2317
+ }
2318
+ const sql = `
2319
+ SELECT *
2320
+ FROM jobs
2321
+ WHERE ${where.join(" AND ")}
2322
+ ORDER BY created_at ASC, unique_id ASC
2323
+ LIMIT 1
2324
+ `;
2325
+ const row = db.prepare(sql).get(...args);
2326
+ return { row, sql, args };
2109
2327
  }
2110
2328
 
2111
- return next;
2112
- }
2329
+ const expiredSql = `
2330
+ SELECT *
2331
+ FROM jobs
2332
+ WHERE status = 'processing'
2333
+ AND claimed_at IS NOT NULL
2334
+ AND ? - claimed_at > ?
2335
+ ORDER BY claimed_at ASC
2336
+ LIMIT 1
2337
+ `;
2338
+ const expiredRow = db.prepare(expiredSql).get(now, expireMs);
2339
+ info.expired = expiredRow
2340
+ ? {
2341
+ uniqueId: expiredRow.unique_id,
2342
+ claimedBy: expiredRow.claimed_by,
2343
+ claimedAt: expiredRow.claimed_at,
2344
+ diffMs: now - expiredRow.claimed_at,
2345
+ }
2346
+ : null;
2113
2347
 
2114
- // 先在有视频的 pending 用户中找;找不到再用全部 pending 用户兜底
2115
- let pending = data.filter((u) => u.status === "pending");
2116
- // 应用国家过滤
2117
- if (locations && locations.length > 0) {
2118
- pending = pending.filter(locationFilter);
2119
- }
2120
- // 未登录客户端不能领取 ttSeller 用户
2121
- if (!loggedIn) {
2122
- pending = pending.filter((u) => u.ttSeller !== true);
2123
- }
2124
- let hasVideo = pending.filter((u) => u.videoCount > 0);
2125
- const next = pickCandidate(hasVideo) || pickCandidate(pending);
2348
+ info.requireVideoPasses = [];
2349
+ for (const requireVideo of [true, false]) {
2350
+ const pass = { requireVideo };
2351
+ const pinned = findPinnedPending(requireVideo);
2352
+ pass.pinned = pinned.row
2353
+ ? {
2354
+ uniqueId: pinned.row.unique_id,
2355
+ sql: pinned.sql,
2356
+ args: pinned.args,
2357
+ }
2358
+ : null;
2126
2359
 
2127
- if (next) {
2128
- next.status = "processing";
2129
- markStatsDirty();
2130
- next.claimedAt = now;
2131
- next.claimedBy = userId;
2132
- save();
2133
- return {
2134
- uniqueId: next.uniqueId,
2135
- nickname: next.nickname,
2136
- claimedAt: next.claimedAt,
2137
- claimedBy: userId,
2138
- };
2360
+ if (!pass.pinned) {
2361
+ for (const group of locationGroups) {
2362
+ const seed = queryPendingByGroup({
2363
+ requireVideo,
2364
+ group,
2365
+ filters: [
2366
+ "COALESCE(pinned, 0) = 0",
2367
+ `instr(COALESCE(sources, ''), '"seed"') > 0`,
2368
+ ],
2369
+ });
2370
+ if (seed.row) {
2371
+ pass.seed = {
2372
+ uniqueId: seed.row.unique_id,
2373
+ group,
2374
+ sql: seed.sql,
2375
+ args: seed.args,
2376
+ };
2377
+ break;
2378
+ }
2379
+ }
2380
+ }
2381
+
2382
+ if (!pass.pinned && !pass.seed && loggedIn) {
2383
+ for (const group of locationGroups) {
2384
+ const seller = queryPendingByGroup({
2385
+ requireVideo,
2386
+ group,
2387
+ filters: [
2388
+ "COALESCE(pinned, 0) = 0",
2389
+ "tt_seller = 1",
2390
+ "verified = 0",
2391
+ ],
2392
+ });
2393
+ if (seller.row) {
2394
+ pass.seller = {
2395
+ uniqueId: seller.row.unique_id,
2396
+ group,
2397
+ sql: seller.sql,
2398
+ args: seller.args,
2399
+ };
2400
+ break;
2401
+ }
2402
+ }
2403
+ }
2404
+
2405
+ if (!pass.pinned && !pass.seed && !pass.seller) {
2406
+ for (const group of locationGroups) {
2407
+ const follow = queryPendingByGroup({
2408
+ requireVideo,
2409
+ group,
2410
+ filters: [
2411
+ "COALESCE(pinned, 0) = 0",
2412
+ `(
2413
+ instr(COALESCE(sources, ''), '"following"') > 0
2414
+ OR instr(COALESCE(sources, ''), '"follower"') > 0
2415
+ )`,
2416
+ ],
2417
+ });
2418
+ if (follow.row) {
2419
+ pass.follow = {
2420
+ uniqueId: follow.row.unique_id,
2421
+ group,
2422
+ sql: follow.sql,
2423
+ args: follow.args,
2424
+ };
2425
+ break;
2426
+ }
2427
+ }
2428
+ }
2429
+
2430
+ if (!pass.pinned && !pass.seed && !pass.seller && !pass.follow) {
2431
+ for (const group of locationGroups) {
2432
+ const other = queryPendingByGroup({
2433
+ requireVideo,
2434
+ group,
2435
+ filters: ["COALESCE(pinned, 0) = 0"],
2436
+ });
2437
+ if (other.row) {
2438
+ pass.other = {
2439
+ uniqueId: other.row.unique_id,
2440
+ group,
2441
+ sql: other.sql,
2442
+ args: other.args,
2443
+ };
2444
+ break;
2445
+ }
2446
+ }
2447
+ }
2448
+
2449
+ info.requireVideoPasses.push(pass);
2450
+ }
2451
+
2452
+ return info;
2139
2453
  }
2140
- return null;
2454
+
2455
+ return {
2456
+ path: "memory",
2457
+ userId,
2458
+ expireMs,
2459
+ loggedIn,
2460
+ totalUsers: data.length,
2461
+ processingUsers: data.filter((u) => u.status === "processing").length,
2462
+ pendingUsers: data.filter((u) => u.status === "pending").length,
2463
+ };
2141
2464
  }
2142
2465
 
2143
2466
  function processDiscoveredUsers(result) {
@@ -2246,6 +2569,7 @@ export function createStore(filePath) {
2246
2569
  }
2247
2570
  }
2248
2571
  }
2572
+ user.restricted = true;
2249
2573
  user.processed = true;
2250
2574
  user.processedAt = Date.now();
2251
2575
  user.sources = [...new Set([...(user.sources || []), "restricted"])];
@@ -3024,7 +3348,9 @@ export function createStore(filePath) {
3024
3348
  getVideoCount,
3025
3349
  getPendingCommentTasks,
3026
3350
  commitCommentTask,
3351
+ debugClaimNextJob,
3027
3352
  stopBackup,
3353
+ rawQuery,
3028
3354
  data,
3029
3355
  };
3030
3356
  }