tt-help-cli-ycl 1.4.22 → 1.4.24

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.
@@ -42,7 +42,7 @@ async function processExplore(page, username, options, log) {
42
42
  keepFollow: false,
43
43
  locationCreated: null,
44
44
  latestVideoTime: null,
45
- topRecentVideo: null,
45
+ recentEcVideos: null,
46
46
  noVideo: false,
47
47
  restricted: false,
48
48
  error: null,
@@ -97,6 +97,19 @@ async function processExplore(page, username, options, log) {
97
97
  nowSeconds - v.createTime <= SEVEN_DAYS_SECONDS,
98
98
  );
99
99
  if (recentVideos.length > 0) {
100
+ result.recentEcVideos = recentVideos.map((v) => ({
101
+ id: v.id,
102
+ href: v.href,
103
+ playCount: v.playCount,
104
+ createTime: v.createTime,
105
+ isAd: v.isAd,
106
+ }));
107
+ const maxPlay = recentVideos.reduce(
108
+ (max, v) => (v.playCount > max ? v.playCount : max),
109
+ 0,
110
+ );
111
+ log(` 7天内 EC视频: ${recentVideos.length} 个, 最高播放 ${maxPlay}`);
112
+ // 兼容旧字段 topRecentVideo
100
113
  const topVideo = recentVideos.reduce((max, v) =>
101
114
  v.playCount > max.playCount ? v : max,
102
115
  );
@@ -107,9 +120,6 @@ async function processExplore(page, username, options, log) {
107
120
  createTime: topVideo.createTime,
108
121
  isAd: topVideo.isAd,
109
122
  };
110
- log(
111
- ` 7天内 EC视频最高播放: ${topVideo.playCount} 次播放 (${recentVideos.length} 个EC候选)`,
112
- );
113
123
  }
114
124
 
115
125
  if (videoArray.length <= 0) {
@@ -320,7 +320,9 @@ function getTargetUsersFromDb(targetLocations = []) {
320
320
  tt_seller, verified, follower_count, following_count,
321
321
  location_created, latest_video_time, refresh_time,
322
322
  guessed_location, pinned, processed_at, video_count,
323
- no_video, claimed_by, claimed_at, created_at, updated_at
323
+ no_video, claimed_by, claimed_at, created_at, updated_at,
324
+ top_video_play_count, top_video_href, top_video_is_ad,
325
+ recent_ec_videos
324
326
  FROM jobs
325
327
  WHERE tt_seller = 1
326
328
  AND verified = 0
@@ -349,6 +351,8 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
349
351
  search,
350
352
  limit,
351
353
  offset,
354
+ sort,
355
+ sortOrder,
352
356
  } = options;
353
357
  const placeholders = targetLocations.map(() => "?").join(", ");
354
358
  const baseParams = [...targetLocations];
@@ -401,6 +405,7 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
401
405
  top_video_play_count,
402
406
  top_video_href,
403
407
  top_video_is_ad,
408
+ recent_ec_videos,
404
409
  status,
405
410
  sources
406
411
  FROM jobs
@@ -441,6 +446,50 @@ function getTargetUsersByCountryFromDb(targetLocations = [], options = {}) {
441
446
  .prepare(countSql)
442
447
  .get(...params)?.cnt || 0;
443
448
 
449
+ // 服务端排序:当 sort=topVideoPlayCount 时,全量取出后按 EC 最大播放量排序再分页
450
+ if (sort === "topVideoPlayCount") {
451
+ const allRows = getDb()
452
+ .prepare(sql)
453
+ .all(...params)
454
+ .map(mapJobRow);
455
+
456
+ const getMaxPlay = (u) => {
457
+ try {
458
+ const ec =
459
+ typeof u.recentEcVideos === "string"
460
+ ? JSON.parse(u.recentEcVideos)
461
+ : Array.isArray(u.recentEcVideos)
462
+ ? u.recentEcVideos
463
+ : [];
464
+ if (ec.length > 0) {
465
+ return ec.reduce(
466
+ (max, v) => (v.playCount > max ? v.playCount : max),
467
+ 0,
468
+ );
469
+ }
470
+ } catch (e) {}
471
+ return 0;
472
+ };
473
+
474
+ const isAsc = sortOrder === "asc";
475
+ allRows.sort((a, b) => {
476
+ const va = getMaxPlay(a);
477
+ const vb = getMaxPlay(b);
478
+ return isAsc ? va - vb : vb - va;
479
+ });
480
+
481
+ const safeLimit = Math.min(Math.floor(limit), 10000);
482
+ const safeOffset = Math.max(Math.floor(offset), 0);
483
+ const rows = allRows.slice(safeOffset, safeOffset + safeLimit);
484
+
485
+ return {
486
+ total,
487
+ limit: safeLimit,
488
+ offset: safeOffset,
489
+ users: rows,
490
+ };
491
+ }
492
+
444
493
  sql += ` LIMIT ? OFFSET ?`;
445
494
  const safeLimit = Math.min(Math.floor(limit), 10000);
446
495
  const safeOffset = Math.max(Math.floor(offset), 0);
@@ -1514,291 +1563,211 @@ export function createStore(filePath, options = {}) {
1514
1563
  .filter(Boolean)
1515
1564
  : [];
1516
1565
 
1517
- function getLocationGroups() {
1518
- const selected = normalizedLocations.length
1519
- ? normalizedLocations
1520
- : null;
1521
- const tier1List = selected
1522
- ? selected.filter((loc) => tier1.has(loc))
1523
- : [...tier1];
1524
- const tier2List = selected
1525
- ? selected.filter((loc) => tier2.has(loc))
1526
- : [...tier2];
1527
- const otherList = selected
1528
- ? selected.filter((loc) => !tier1.has(loc) && !tier2.has(loc))
1529
- : null;
1530
- const groups = [];
1531
- if (tier1List.length > 0)
1532
- groups.push({ type: "include", values: tier1List });
1533
- if (tier2List.length > 0)
1534
- groups.push({ type: "include", values: tier2List });
1535
- if (selected) {
1536
- if (otherList.length > 0)
1537
- groups.push({ type: "include", values: otherList });
1538
- } else {
1539
- groups.push({ type: "exclude", values: [...tier1, ...tier2] });
1540
- }
1541
- return groups;
1566
+ // ---- 过期 processing 回收 ----
1567
+ const expiredRow = getDb()
1568
+ .prepare(
1569
+ `SELECT * FROM jobs WHERE status = 'processing'
1570
+ AND claimed_at IS NOT NULL AND ? - claimed_at > ?
1571
+ ORDER BY claimed_at ASC LIMIT 1`,
1572
+ )
1573
+ .get(now, expireMs);
1574
+ let expiredCandidate = null;
1575
+ if (expiredRow) {
1576
+ getDb()
1577
+ .prepare(
1578
+ "UPDATE jobs SET status = 'pending', claimed_at = NULL WHERE unique_id = ?",
1579
+ )
1580
+ .run(expiredRow.unique_id);
1581
+ expiredCandidate = expiredRow;
1542
1582
  }
1543
1583
 
1544
- const locationGroups = getLocationGroups();
1584
+ // ---- 构建统一优先级查询(合并原 20+ 次独立 SELECT 为 1 次 UNION) ----
1585
+ const statusCodeWhere = loggedIn
1586
+ ? "(status_code IS NULL OR status_code = 0 OR status_code = 209002)"
1587
+ : "(status_code IS NULL OR status_code = 0)";
1588
+ const notSellerWhere = loggedIn ? "" : "AND COALESCE(tt_seller, 0) != 1";
1589
+
1590
+ const tier1List = normalizedLocations.length
1591
+ ? normalizedLocations.filter((loc) => tier1.has(loc))
1592
+ : [...tier1];
1593
+ const tier2List = normalizedLocations.length
1594
+ ? normalizedLocations.filter((loc) => tier2.has(loc))
1595
+ : [...tier2];
1596
+ const otherFiltered = normalizedLocations.length
1597
+ ? normalizedLocations.filter(
1598
+ (loc) => !tier1.has(loc) && !tier2.has(loc),
1599
+ )
1600
+ : null;
1545
1601
 
1546
- function applyLocationGroup(where, args, group) {
1547
- if (!group) return;
1548
- if (group.type === "include") {
1549
- where.push(
1550
- `(UPPER(COALESCE(guessed_location, '')) IN (${group.values.map(() => "?").join(", ")}) OR COALESCE(guessed_location, '') = '')`,
1551
- );
1552
- args.push(...group.values);
1553
- return;
1554
- }
1555
- where.push(
1556
- `UPPER(COALESCE(guessed_location, '')) NOT IN (${group.values.map(() => "?").join(", ")})`,
1557
- );
1558
- args.push(...group.values);
1559
- }
1602
+ const locIn = (list) => {
1603
+ if (!list || list.length === 0) return null;
1604
+ const ph = list.map(() => "?").join(", ");
1605
+ return {
1606
+ sql: `(UPPER(COALESCE(guessed_location, '')) IN (${ph}) OR COALESCE(guessed_location, '') = '')`,
1607
+ params: [...list],
1608
+ };
1609
+ };
1610
+ const locNotIn = (list) => {
1611
+ if (!list || list.length === 0) return null;
1612
+ const ph = list.map(() => "?").join(", ");
1613
+ return {
1614
+ sql: `UPPER(COALESCE(guessed_location, '')) NOT IN (${ph})`,
1615
+ params: [...list],
1616
+ };
1617
+ };
1618
+ const locOther = () => {
1619
+ if (otherFiltered && otherFiltered.length > 0)
1620
+ return locIn(otherFiltered);
1621
+ if (normalizedLocations.length === 0)
1622
+ return locNotIn([...tier1, ...tier2]);
1623
+ return null;
1624
+ };
1560
1625
 
1561
- function queryPendingOne({ requireVideo, group, filters = [] }) {
1562
- const where = ["status = 'pending'"];
1563
- const args = [];
1564
- if (!loggedIn) {
1565
- where.push("COALESCE(tt_seller, 0) != 1");
1566
- // 未登录:只能领取 status_code 为空或 0 的任务
1567
- where.push("(status_code IS NULL OR status_code = 0)");
1568
- } else {
1569
- // 登录:可以领取 status_code 为空、0、或 209002 的任务
1570
- where.push(
1571
- "(status_code IS NULL OR status_code = 0 OR status_code = 209002)",
1572
- );
1573
- }
1574
- // 其他 status_code 值的任务不被领取
1575
- if (requireVideo) {
1576
- where.push("COALESCE(video_count, 0) > 0");
1577
- }
1578
- applyLocationGroup(where, args, group);
1579
- for (const filter of filters) {
1580
- where.push(filter);
1581
- }
1582
- return getDb()
1583
- .prepare(
1584
- `
1585
- SELECT *
1586
- FROM jobs
1587
- WHERE ${where.join(" AND ")}
1588
- ORDER BY follower_count DESC, created_at ASC, unique_id ASC
1589
- LIMIT 1
1590
- `,
1591
- )
1592
- .get(...args);
1626
+ // 全局 location 过滤(pinned 查询用)
1627
+ let pinnedLocSql = "";
1628
+ const pinnedLocParams = [];
1629
+ if (normalizedLocations.length > 0) {
1630
+ const ph = normalizedLocations.map(() => "?").join(", ");
1631
+ pinnedLocSql = `AND (UPPER(COALESCE(guessed_location, '')) IN (${ph}) OR COALESCE(guessed_location, '') = '')`;
1632
+ pinnedLocParams.push(...normalizedLocations);
1593
1633
  }
1594
1634
 
1595
- function queryPendingByGroup({ requireVideo, group, filters = [] }) {
1596
- if (group?.type === "include" && group.values.length > 1) {
1597
- for (const location of group.values) {
1598
- const row = queryPendingOne({
1599
- requireVideo,
1600
- group: { type: "include", values: [location] },
1601
- filters,
1602
- });
1603
- if (row) return row;
1604
- }
1605
- return null;
1606
- }
1607
- return queryPendingOne({ requireVideo, group, filters });
1608
- }
1635
+ const sqlParts = [];
1636
+ const allParams = [];
1609
1637
 
1610
- function findPinnedPending(requireVideo) {
1611
- const where = ["status = 'pending'", "COALESCE(pinned, 0) = 1"];
1612
- const args = [];
1613
- if (!loggedIn) {
1614
- where.push("COALESCE(tt_seller, 0) != 1");
1615
- }
1616
- if (requireVideo) {
1617
- where.push("COALESCE(video_count, 0) > 0");
1618
- }
1619
- if (normalizedLocations.length > 0) {
1620
- where.push(
1621
- `(UPPER(COALESCE(guessed_location, '')) IN (${normalizedLocations.map(() => "?").join(", ")}) OR COALESCE(guessed_location, '') = '')`,
1622
- );
1623
- args.push(...normalizedLocations);
1624
- }
1625
- return getDb()
1626
- .prepare(
1627
- `
1628
- SELECT *
1629
- FROM jobs
1630
- WHERE ${where.join(" AND ")}
1631
- ORDER BY created_at ASC, unique_id ASC
1632
- LIMIT 1
1633
- `,
1634
- )
1635
- .get(...args);
1636
- }
1638
+ const addPart = (prio, subPrio, conditions, params) => {
1639
+ sqlParts.push(
1640
+ `SELECT *, ${prio} as _prio, ${subPrio} as _sub_prio FROM jobs WHERE ${conditions}`,
1641
+ );
1642
+ allParams.push(...params);
1643
+ };
1637
1644
 
1638
- function findPrioritizedPending(requireVideo) {
1639
- for (const group of locationGroups) {
1640
- const seed = queryPendingByGroup({
1641
- requireVideo,
1642
- group,
1643
- filters: [
1644
- "COALESCE(pinned, 0) = 0",
1645
- `instr(COALESCE(sources, ''), '"seed"') > 0`,
1646
- ],
1647
- });
1648
- if (seed) return seed;
1649
- }
1645
+ // prio 0: pinned + hasVideo
1646
+ addPart(
1647
+ 0,
1648
+ 0,
1649
+ `status = 'pending' AND pinned = 1 AND video_count > 0 ${pinnedLocSql} AND ${statusCodeWhere} ${notSellerWhere}`,
1650
+ pinnedLocParams,
1651
+ );
1652
+ // prio 1: pinned (no video req)
1653
+ addPart(
1654
+ 1,
1655
+ 0,
1656
+ `status = 'pending' AND pinned = 1 ${pinnedLocSql} AND ${statusCodeWhere} ${notSellerWhere}`,
1657
+ pinnedLocParams,
1658
+ );
1650
1659
 
1651
- if (loggedIn) {
1652
- for (const group of locationGroups) {
1653
- const seller = queryPendingByGroup({
1654
- requireVideo,
1655
- group,
1656
- filters: [
1657
- "COALESCE(pinned, 0) = 0",
1658
- "tt_seller = 1",
1659
- "verified = 0",
1660
- ],
1661
- });
1662
- if (seller) return seller;
1663
- }
1664
- }
1660
+ // prio 2-3: expired candidate(回收的超时任务优先)
1661
+ if (expiredCandidate) {
1662
+ addPart(
1663
+ 2,
1664
+ 0,
1665
+ `unique_id = ? AND video_count > 0 AND ${statusCodeWhere} ${notSellerWhere}`,
1666
+ [expiredCandidate.unique_id],
1667
+ );
1668
+ addPart(
1669
+ 3,
1670
+ 0,
1671
+ `unique_id = ? AND ${statusCodeWhere} ${notSellerWhere}`,
1672
+ [expiredCandidate.unique_id],
1673
+ );
1674
+ }
1665
1675
 
1666
- for (const group of locationGroups) {
1667
- const follow = queryPendingByGroup({
1668
- requireVideo,
1669
- group,
1670
- filters: [
1671
- "COALESCE(pinned, 0) = 0",
1672
- `(
1673
- instr(COALESCE(sources, ''), '"following"') > 0
1674
- OR instr(COALESCE(sources, ''), '"follower"') > 0
1675
- OR instr(COALESCE(sources, ''), '"comment"') > 0
1676
- )`,
1677
- ],
1678
- });
1679
- if (follow) return follow;
1680
- }
1676
+ // 非 pinned 优先级:seed → seller → follow → other,每级 × 3 个 location tier × (hasVideo / noVideoReq)
1677
+ const priorities = [
1678
+ {
1679
+ filter: `instr(COALESCE(sources, ''), '"seed"') > 0`,
1680
+ loggedInOnly: false,
1681
+ },
1682
+ { filter: `tt_seller = 1 AND verified = 0`, loggedInOnly: true },
1683
+ {
1684
+ filter: `(instr(COALESCE(sources, ''), '"following"') > 0 OR instr(COALESCE(sources, ''), '"follower"') > 0 OR instr(COALESCE(sources, ''), '"comment"') > 0)`,
1685
+ loggedInOnly: false,
1686
+ },
1687
+ { filter: `1 = 1`, loggedInOnly: false },
1688
+ ];
1681
1689
 
1682
- for (const group of locationGroups) {
1683
- const other = queryPendingByGroup({
1684
- requireVideo,
1685
- group,
1686
- filters: ["COALESCE(pinned, 0) = 0"],
1687
- });
1688
- if (other) return other;
1690
+ let basePrio = 4;
1691
+ for (const p of priorities) {
1692
+ if (p.loggedInOnly && !loggedIn) continue;
1693
+ const tierLocations = [
1694
+ { list: tier1List, fn: locIn },
1695
+ { list: tier2List, fn: locIn },
1696
+ { list: null, fn: locOther },
1697
+ ];
1698
+ let subPrio = 0;
1699
+ for (const tl of tierLocations) {
1700
+ const loc = tl.fn(tl.list);
1701
+ if (!loc) continue;
1702
+ // hasVideo
1703
+ addPart(
1704
+ basePrio,
1705
+ subPrio,
1706
+ `status = 'pending' AND pinned = 0 AND ${p.filter} AND video_count > 0 AND ${loc.sql} AND ${statusCodeWhere} ${notSellerWhere}`,
1707
+ loc.params,
1708
+ );
1709
+ subPrio++;
1710
+ // noVideoReq
1711
+ addPart(
1712
+ basePrio + 1,
1713
+ subPrio,
1714
+ `status = 'pending' AND pinned = 0 AND ${p.filter} AND ${loc.sql} AND ${statusCodeWhere} ${notSellerWhere}`,
1715
+ loc.params,
1716
+ );
1717
+ subPrio++;
1689
1718
  }
1719
+ basePrio += 2;
1720
+ }
1690
1721
 
1691
- return null;
1722
+ let selectedRow = null;
1723
+ if (sqlParts.length > 0) {
1724
+ const unionSql =
1725
+ sqlParts.join(" UNION ALL ") +
1726
+ " ORDER BY _prio, _sub_prio, follower_count DESC, created_at ASC, unique_id ASC LIMIT 1";
1727
+ selectedRow = getDb()
1728
+ .prepare(unionSql)
1729
+ .get(...allParams);
1692
1730
  }
1693
1731
 
1694
- function claimRow(row) {
1695
- if (!row) return null;
1732
+ if (selectedRow) {
1696
1733
  getDb()
1697
1734
  .prepare(
1698
1735
  "UPDATE jobs SET status = 'processing', claimed_at = ?, claimed_by = ? WHERE unique_id = ?",
1699
1736
  )
1700
- .run(now, userId, row.unique_id);
1737
+ .run(now, userId, selectedRow.unique_id);
1701
1738
  markStatsDirty();
1702
1739
  return {
1703
- uniqueId: row.unique_id,
1704
- nickname: row.nickname,
1740
+ uniqueId: selectedRow.unique_id,
1741
+ nickname: selectedRow.nickname,
1705
1742
  claimedAt: now,
1706
1743
  claimedBy: userId,
1707
1744
  };
1708
1745
  }
1709
1746
 
1710
- const expiredRow = getDb()
1711
- .prepare(
1712
- `
1713
- SELECT *
1714
- FROM jobs
1715
- WHERE status = 'processing'
1716
- AND claimed_at IS NOT NULL
1717
- AND ? - claimed_at > ?
1718
- ORDER BY claimed_at ASC
1719
- LIMIT 1
1720
- `,
1721
- )
1722
- .get(now, expireMs);
1723
- let expiredCandidate = null;
1724
- if (expiredRow) {
1725
- getDb()
1726
- .prepare(
1727
- "UPDATE jobs SET status = 'pending', claimed_at = NULL WHERE unique_id = ?",
1728
- )
1729
- .run(expiredRow.unique_id);
1730
- expiredCandidate = mapJobRow({
1731
- ...expiredRow,
1732
- status: "pending",
1733
- claimed_at: null,
1734
- });
1735
- }
1736
-
1737
- for (const requireVideo of [true, false]) {
1738
- const pinned = findPinnedPending(requireVideo);
1739
- if (pinned) {
1740
- return claimRow(pinned);
1741
- }
1742
- if (expiredCandidate) {
1743
- return claimRow({
1744
- unique_id: expiredCandidate.uniqueId,
1745
- nickname: expiredCandidate.nickname,
1746
- });
1747
- }
1748
- const ranked = findPrioritizedPending(requireVideo);
1749
- if (ranked) {
1750
- return claimRow(ranked);
1751
- }
1752
- }
1753
- // 尝试从 raw_jobs 毛料库补充任务(使用 createStore 时配置的 LLM 打分)
1754
- // 使用锁防止多个请求同时触发 LLM refill
1755
- if (refillLock) {
1756
- // 已有 refill 在进行中,等待完成后重新尝试领取
1757
- await refillLock;
1758
- for (const requireVideo of [true, false]) {
1759
- const pinned = findPinnedPending(requireVideo);
1760
- if (pinned) {
1761
- return claimRow(pinned);
1762
- }
1763
- const ranked = findPrioritizedPending(requireVideo);
1764
- if (ranked) {
1765
- return claimRow(ranked);
1766
- }
1767
- }
1768
- return null;
1769
- }
1770
- const refillPromise = (async () => {
1771
- const result = refillJobsFromRaw(
1772
- normalizedLocations.length ? normalizedLocations : null,
1773
- 500,
1774
- refillLlmConfig,
1775
- );
1776
- // refillJobsFromRaw 在 LLM 模式下返回 Promise
1777
- if (result && typeof result.then === "function") {
1778
- return await result;
1779
- }
1780
- return result;
1781
- })();
1782
- // 让并发请求等待同一个 refill,并且无论成功/失败都释放锁
1783
- refillLock = refillPromise.finally(() => {
1784
- refillLock = null;
1785
- });
1786
-
1787
- const refillResult = await refillLock;
1788
- if (refillResult.moved > 0) {
1789
- console.error(
1790
- `[data-store] 从 raw_jobs 补充了 ${refillResult.moved} 条任务到 jobs`,
1791
- );
1792
- for (const requireVideo of [true, false]) {
1793
- const pinned = findPinnedPending(requireVideo);
1794
- if (pinned) {
1795
- return claimRow(pinned);
1796
- }
1797
- const ranked = findPrioritizedPending(requireVideo);
1798
- if (ranked) {
1799
- return claimRow(ranked);
1747
+ // refill 异步化:不阻塞当前请求
1748
+ if (!refillLock) {
1749
+ refillLock = (async () => {
1750
+ try {
1751
+ const result = refillJobsFromRaw(
1752
+ normalizedLocations.length ? normalizedLocations : null,
1753
+ 500,
1754
+ refillLlmConfig,
1755
+ );
1756
+ const r =
1757
+ result && typeof result.then === "function"
1758
+ ? await result
1759
+ : result;
1760
+ if (r.moved > 0) {
1761
+ console.error(
1762
+ `[data-store] 从 raw_jobs 补充了 ${r.moved} 条任务到 jobs`,
1763
+ );
1764
+ }
1765
+ } catch (e) {
1766
+ console.error(`[data-store] refill 异常: ${e.message}`);
1767
+ } finally {
1768
+ refillLock = null;
1800
1769
  }
1801
- }
1770
+ })();
1802
1771
  }
1803
1772
 
1804
1773
  return null;
@@ -2414,6 +2383,7 @@ export function createStore(filePath, options = {}) {
2414
2383
  "uniqueId",
2415
2384
  "sources",
2416
2385
  "topRecentVideo", // 单独处理,不进入通用循环
2386
+ "recentEcVideos", // 单独处理,不进入通用循环
2417
2387
  ];
2418
2388
  for (const key of Object.keys(result)) {
2419
2389
  if (extraFields.includes(key)) continue;
@@ -2431,6 +2401,15 @@ export function createStore(filePath, options = {}) {
2431
2401
  user.topVideoHref = result.topRecentVideo.href || null;
2432
2402
  user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
2433
2403
  }
2404
+ // 将 recentEcVideos 数组序列化为 JSON 字符串
2405
+ if (
2406
+ Array.isArray(result.recentEcVideos) &&
2407
+ result.recentEcVideos.length > 0
2408
+ ) {
2409
+ user.recentEcVideos = JSON.stringify(result.recentEcVideos);
2410
+ } else if (result.recentEcVideos === null) {
2411
+ user.recentEcVideos = null;
2412
+ }
2434
2413
  user.sources = [...new Set([...(user.sources || []), "processed"])];
2435
2414
  }
2436
2415
  if (user.status !== oldStatus) markStatsDirty();
@@ -2658,6 +2637,15 @@ export function createStore(filePath, options = {}) {
2658
2637
  user.topVideoHref = result.topRecentVideo.href || null;
2659
2638
  user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
2660
2639
  }
2640
+ // 将 recentEcVideos 数组序列化为 JSON 字符串
2641
+ if (
2642
+ Array.isArray(result.recentEcVideos) &&
2643
+ result.recentEcVideos.length > 0
2644
+ ) {
2645
+ user.recentEcVideos = JSON.stringify(result.recentEcVideos);
2646
+ } else if (result.recentEcVideos === null) {
2647
+ user.recentEcVideos = null;
2648
+ }
2661
2649
  const newUsers = processDiscoveredUsers(result);
2662
2650
  const ret = updateJobInfo(uniqueId, user, false);
2663
2651
  if (ret.error) return { saved: false, error: ret.error };
@@ -2684,6 +2672,15 @@ export function createStore(filePath, options = {}) {
2684
2672
  user.topVideoHref = result.topRecentVideo.href || null;
2685
2673
  user.topVideoIsAd = result.topRecentVideo.isAd ?? null;
2686
2674
  }
2675
+ // 将 recentEcVideos 数组序列化为 JSON 字符串
2676
+ if (
2677
+ Array.isArray(result.recentEcVideos) &&
2678
+ result.recentEcVideos.length > 0
2679
+ ) {
2680
+ user.recentEcVideos = JSON.stringify(result.recentEcVideos);
2681
+ } else if (result.recentEcVideos === null) {
2682
+ user.recentEcVideos = null;
2683
+ }
2687
2684
  const newUsers = processDiscoveredUsers(result);
2688
2685
  return { saved: true, newUsers };
2689
2686
  }
@@ -49,6 +49,7 @@ export const JOB_EXT_COLUMNS = [
49
49
  "top_video_play_count",
50
50
  "top_video_href",
51
51
  "top_video_is_ad",
52
+ "recent_ec_videos",
52
53
  ];
53
54
 
54
55
  // 完整的 jobs 列 = 核心列 + 扩展列
@@ -91,6 +92,7 @@ export const WRITABLE_JOB_COLUMNS = new Set([
91
92
  "top_video_href",
92
93
  "top_video_is_ad",
93
94
  "user_create_time",
95
+ "recent_ec_videos",
94
96
  ]);
95
97
 
96
98
  // ===== Boolean 列(存储时 0/1,读取时转为 true/false/null) =====
@@ -129,6 +129,7 @@ export function initDb(filePath) {
129
129
  top_video_href: "TEXT",
130
130
  top_video_is_ad: "INTEGER",
131
131
  user_create_time: "INTEGER",
132
+ recent_ec_videos: "TEXT",
132
133
  });
133
134
 
134
135
  // ===== jobs_base 表 =====
@@ -424,6 +425,7 @@ export function initDb(filePath) {
424
425
  `CREATE INDEX IF NOT EXISTS idx_cross_country_source ON cross_country_learnings(source_country, result_status)`,
425
426
  `CREATE INDEX IF NOT EXISTS idx_cross_country_target ON cross_country_learnings(target_country)`,
426
427
  `CREATE INDEX IF NOT EXISTS idx_tags_dead_reason ON tags(dead_reason)`,
428
+ `CREATE INDEX IF NOT EXISTS idx_jobs_recheck ON jobs(status, updated_at) WHERE status = 'done' AND (tt_seller IS NULL OR tt_seller != 1)`,
427
429
  ];
428
430
  for (const sql of indexes) {
429
431
  db.exec(sql);