tt-help-cli-ycl 1.3.63 → 1.3.64

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.63",
3
+ "version": "1.3.64",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -131,4 +131,4 @@ ECHO Max following: 5
131
131
  ECHO Max followers: 5
132
132
  ECHO Speed: stealth (slowest)
133
133
  ECHO ========================================
134
- CALL tt-help explore stealth --user-id %%USER_ID%% --base-port %%BASE_PORT%% --port-count %%PORT_COUNT%% --max-following 5 --max-followers 5 %%JOB_LOC_ARGS%%
134
+ CALL tt-help explore stealth --user-id %%USER_ID%% --base-port %%BASE_PORT%% --port-count %%PORT_COUNT%% --max-following 100 --max-followers 100 %%JOB_LOC_ARGS%%
@@ -166,7 +166,7 @@ export async function handleExplore(options) {
166
166
 
167
167
  browser = await ensureBrowserReadyCDP(cdpOptions);
168
168
  const { processExplore } = await import("../scraper/explore-core.js");
169
- const { isLoggedIn } = await import("../lib/browser/page.js");
169
+ const { safeCheckLogin } = await import("../lib/browser/page.js");
170
170
 
171
171
  const page = await getOrCreatePage(browser);
172
172
 
@@ -176,8 +176,7 @@ export async function handleExplore(options) {
176
176
  });
177
177
 
178
178
  // 检测登录状态(启动时只检测一次)
179
- let loggedIn = await isLoggedIn(page);
180
- console.error(`登录状态: ${loggedIn ? "已登录" : "未登录"}`);
179
+ let loggedIn = await safeCheckLogin(page);
181
180
 
182
181
  // 全局拦截图片资源,减少内存占用和加载时间
183
182
  await page.route("**/*", (route) => {
@@ -224,7 +223,7 @@ export async function handleExplore(options) {
224
223
  await page.goto(STARTUP_TIKTOK_URL, {
225
224
  waitUntil: "domcontentloaded",
226
225
  });
227
- loggedIn = await isLoggedIn(page);
226
+ loggedIn = await safeCheckLogin(page);
228
227
  console.error(
229
228
  `[健康检查] 新账户登录状态: ${loggedIn ? "已登录" : "未登录"}`,
230
229
  );
@@ -68,6 +68,8 @@ export async function withBrowserRecovery(fn, browser, page, cdpOptions, port) {
68
68
 
69
69
  const DOM_CHECK_TIMEOUT = 20000; // 单次 DOM 检测超时 20 秒
70
70
  const DOM_CHECK_RETRIES = 3; // DOM 检测最大重试次数
71
+ const SAFE_CHECK_ROUNDS = 2; // 安全登录检测轮数(首次检测 + 额外1轮确认)
72
+ const SAFE_CHECK_INTERVAL = 5000; // 安全登录检测每轮间隔 5 秒
71
73
 
72
74
  /**
73
75
  * 判断登录状态:Cookie 为主,DOM 验真为辅。
@@ -102,6 +104,45 @@ export async function isLoggedIn(page) {
102
104
  return true;
103
105
  }
104
106
 
107
+ /**
108
+ * 安全登录检测:发现未登录时多检测几轮,避免因 TikTok 页面渲染延迟导致误判。
109
+ * - 首次检测为已登录 → 直接返回 true
110
+ * - 首次检测为未登录 → 等待后重新导航并检测,连续 SAFE_CHECK_ROUNDS 轮都为未登录才确认
111
+ * - 任何一轮检测为已登录 → 立即返回 true
112
+ */
113
+ export async function safeCheckLogin(page) {
114
+ // 第一轮检测
115
+ let loggedIn = await isLoggedIn(page);
116
+ if (loggedIn) {
117
+ console.error(`[安全登录检测] 第 1 轮: 已登录 ✓`);
118
+ return true;
119
+ }
120
+ console.error(
121
+ `[安全登录检测] 第 1 轮: 未登录 ✗,等待 ${SAFE_CHECK_INTERVAL / 1000}s 后重检...`,
122
+ );
123
+
124
+ // 后续轮次:等待后重新导航到 TikTok 页面再检测
125
+ for (let round = 2; round <= SAFE_CHECK_ROUNDS; round++) {
126
+ await new Promise((r) => setTimeout(r, SAFE_CHECK_INTERVAL));
127
+ // 重新导航到 TikTok 页面,确保页面状态刷新
128
+ await page.goto("https://www.tiktok.com", {
129
+ waitUntil: "domcontentloaded",
130
+ });
131
+ loggedIn = await isLoggedIn(page);
132
+ if (loggedIn) {
133
+ console.error(`[安全登录检测] 第 ${round} 轮: 已登录 ✓`);
134
+ return true;
135
+ }
136
+ console.error(`[安全登录检测] 第 ${round} 轮: 未登录 ✗`);
137
+ }
138
+
139
+ // 连续 SAFE_CHECK_ROUNDS 轮都为未登录
140
+ console.error(
141
+ `[安全登录检测] 连续 ${SAFE_CHECK_ROUNDS} 轮均为未登录,确认为未登录`,
142
+ );
143
+ return false;
144
+ }
145
+
105
146
  /**
106
147
  * 通过 DOM 元素判断登录状态(验真方案)
107
148
  * 使用 locator API + state: 'attached' 来避免 CDP 连接下 waitForSelector 的可见性问题
@@ -775,6 +775,53 @@ function restoreAttachStuckByCountry(country) {
775
775
  return { restored: count, country: normalizedCountry };
776
776
  }
777
777
 
778
+ function resetPendingByCountry(country) {
779
+ if (!db) {
780
+ return { reset: 0, country, error: "db not ready" };
781
+ }
782
+
783
+ const normalizedCountry = String(country == null ? "未知" : country).trim();
784
+ if (!normalizedCountry) {
785
+ return {
786
+ reset: 0,
787
+ country: normalizedCountry,
788
+ error: "country is required",
789
+ };
790
+ }
791
+
792
+ const whereSql = `
793
+ status = 'pending'
794
+ AND COALESCE(guessed_location, '未知') = ?
795
+ `;
796
+ const count =
797
+ db
798
+ .prepare(
799
+ `
800
+ SELECT COUNT(*) as c
801
+ FROM jobs
802
+ WHERE ${whereSql}
803
+ `,
804
+ )
805
+ .get(normalizedCountry)?.c || 0;
806
+
807
+ if (!count) {
808
+ return { reset: 0, country: normalizedCountry };
809
+ }
810
+
811
+ db.prepare(
812
+ `
813
+ UPDATE jobs
814
+ SET user_update_count = 0,
815
+ updated_at = ?,
816
+ claimed_by = NULL,
817
+ claimed_at = NULL
818
+ WHERE ${whereSql}
819
+ `,
820
+ ).run(Date.now(), normalizedCountry);
821
+
822
+ return { reset: count, country: normalizedCountry };
823
+ }
824
+
778
825
  function getRawByCountryFromDb() {
779
826
  if (!db) return [];
780
827
 
@@ -1077,7 +1124,7 @@ function restoreRawJobById(uniqueId) {
1077
1124
  return { restored: 1, uniqueId: safeId };
1078
1125
  }
1079
1126
 
1080
- function restoreRawJobsByFilter({ search, location }) {
1127
+ function restoreRawJobsByFilter({ search, location, hasVideo, hasFollower }) {
1081
1128
  if (!db) {
1082
1129
  return { restored: 0, error: "db not ready" };
1083
1130
  }
@@ -1098,6 +1145,14 @@ function restoreRawJobsByFilter({ search, location }) {
1098
1145
  args.push(location);
1099
1146
  }
1100
1147
 
1148
+ if (hasVideo) {
1149
+ where.push("COALESCE(video_count, 0) > 0");
1150
+ }
1151
+
1152
+ if (hasFollower) {
1153
+ where.push("COALESCE(follower_count, 0) > 0");
1154
+ }
1155
+
1101
1156
  if (where.length === 0) {
1102
1157
  return { restored: 0, error: "at least one filter is required" };
1103
1158
  }
@@ -1140,7 +1195,14 @@ function restoreRawJobsByFilter({ search, location }) {
1140
1195
  return { restored: count };
1141
1196
  }
1142
1197
 
1143
- function getRawJobsPageFromDb({ search, location, limit, offset }) {
1198
+ function getRawJobsPageFromDb({
1199
+ search,
1200
+ location,
1201
+ limit,
1202
+ offset,
1203
+ hasVideo,
1204
+ hasFollower,
1205
+ }) {
1144
1206
  if (!db) return null;
1145
1207
 
1146
1208
  const safeLimit = Math.max(1, Math.min(200, parseInt(limit) || 50));
@@ -1159,6 +1221,12 @@ function getRawJobsPageFromDb({ search, location, limit, offset }) {
1159
1221
  where.push("COALESCE(guessed_location, '未知') = ?");
1160
1222
  args.push(location);
1161
1223
  }
1224
+ if (hasVideo) {
1225
+ where.push("COALESCE(video_count, 0) > 0");
1226
+ }
1227
+ if (hasFollower) {
1228
+ where.push("COALESCE(follower_count, 0) > 0");
1229
+ }
1162
1230
 
1163
1231
  const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
1164
1232
  const total = db
@@ -1329,6 +1397,62 @@ function getTargetUsersFromDb(targetLocations = []) {
1329
1397
  };
1330
1398
  }
1331
1399
 
1400
+ function getTargetUsersByCountryFromDb(targetLocations = []) {
1401
+ if (!db) return null;
1402
+ if (!targetLocations.length) {
1403
+ return { countries: [] };
1404
+ }
1405
+
1406
+ const placeholders = targetLocations.map(() => "?").join(", ");
1407
+ const rows = db
1408
+ .prepare(
1409
+ `
1410
+ SELECT
1411
+ unique_id,
1412
+ nickname,
1413
+ follower_count,
1414
+ video_count,
1415
+ tt_seller,
1416
+ verified,
1417
+ location_created,
1418
+ latest_video_time,
1419
+ refresh_time,
1420
+ status,
1421
+ sources
1422
+ FROM jobs
1423
+ WHERE tt_seller = 1
1424
+ AND verified = 0
1425
+ AND location_created IN (${placeholders})
1426
+ ORDER BY location_created ASC, COALESCE(latest_video_time, 0) DESC
1427
+ `,
1428
+ )
1429
+ .all(...targetLocations)
1430
+ .map(mapJobRow);
1431
+
1432
+ const countryMap = new Map();
1433
+ for (const row of rows) {
1434
+ const country = row.locationCreated || "未知";
1435
+ if (!countryMap.has(country)) {
1436
+ countryMap.set(country, []);
1437
+ }
1438
+ countryMap.get(country).push(row);
1439
+ }
1440
+
1441
+ const countries = [];
1442
+ for (const [country, users] of countryMap) {
1443
+ countries.push({
1444
+ country,
1445
+ count: users.length,
1446
+ users,
1447
+ });
1448
+ }
1449
+
1450
+ return {
1451
+ total: rows.length,
1452
+ countries,
1453
+ };
1454
+ }
1455
+
1332
1456
  function snakeToCamel(key) {
1333
1457
  return key.replace(/_([a-z])/g, (_, ch) => ch.toUpperCase());
1334
1458
  }
@@ -3359,12 +3483,14 @@ export function createStore(filePath) {
3359
3483
  getRawByCountry: getRawByCountryFromDb,
3360
3484
  moveJobsToRawByCountry,
3361
3485
  restoreAttachStuckByCountry,
3486
+ resetPendingByCountry,
3362
3487
  restoreRawJobsByCountry,
3363
3488
  restoreRawJobById,
3364
3489
  restoreRawJobsByFilter,
3365
3490
  getUsersPage: getUsersPageFromDb,
3366
3491
  getRawJobsPage: getRawJobsPageFromDb,
3367
3492
  getTargetUsers: getTargetUsersFromDb,
3493
+ getTargetUsersByCountry: getTargetUsersByCountryFromDb,
3368
3494
  getStats,
3369
3495
  getStatusGroups,
3370
3496
  markGroupsDirty,
@@ -743,44 +743,9 @@ async function batchResetErrors() {
743
743
  }
744
744
  }
745
745
 
746
- document
747
- .getElementById("statTargetCard")
748
- .addEventListener("click", async () => {
749
- showLoading("正在导出目标用户...");
750
- try {
751
- const res = await fetch("/api/target-users", {
752
- headers: { Accept: "text/csv" },
753
- });
754
- if (!res.ok) throw new Error("HTTP " + res.status);
755
- const blob = await res.blob();
756
- const ext = blob.size < 200 ? "json" : "csv";
757
- if (ext === "json") {
758
- const text = await blob.text();
759
- const data = JSON.parse(text);
760
- if (!data.users.length) {
761
- showToast("暂无目标用户", true);
762
- return;
763
- }
764
- if (navigator.clipboard && navigator.clipboard.writeText) {
765
- const ids = data.users.map((u) => "@" + u.uniqueId).join(", ");
766
- await navigator.clipboard.writeText(ids);
767
- showToast(data.users.length + " 个目标用户 ID 已复制到剪贴板");
768
- }
769
- return;
770
- }
771
- const url = URL.createObjectURL(blob);
772
- const a = document.createElement("a");
773
- a.href = url;
774
- a.download = "target-users.csv";
775
- a.click();
776
- URL.revokeObjectURL(url);
777
- showToast("CSV 文件已开始下载");
778
- } catch (e) {
779
- showToast("获取失败: " + e.message, true);
780
- } finally {
781
- hideLoading();
782
- }
783
- });
746
+ document.getElementById("statTargetCard").addEventListener("click", () => {
747
+ navigateToTarget();
748
+ });
784
749
 
785
750
  // Hash 路由
786
751
  window.addEventListener("hashchange", handleRoute);
@@ -794,6 +759,8 @@ function handleRoute() {
794
759
  showUserUpdatePage();
795
760
  } else if (hash === "#raw") {
796
761
  showRawPage();
762
+ } else if (hash === "#target") {
763
+ showTargetPage();
797
764
  } else {
798
765
  showMainPage();
799
766
  }
@@ -820,6 +787,7 @@ function showPendingPage() {
820
787
  document.getElementById("pendingPage").classList.add("active");
821
788
  document.getElementById("userUpdatePage").classList.remove("active");
822
789
  document.getElementById("rawPage").classList.remove("active");
790
+ document.getElementById("targetPage").classList.remove("active");
823
791
  fetchPendingByCountry();
824
792
  fetchAttachStuckByCountry();
825
793
  }
@@ -829,6 +797,7 @@ function showUserUpdatePage() {
829
797
  document.getElementById("pendingPage").classList.remove("active");
830
798
  document.getElementById("userUpdatePage").classList.add("active");
831
799
  document.getElementById("rawPage").classList.remove("active");
800
+ document.getElementById("targetPage").classList.remove("active");
832
801
  fetchUserUpdateByCountry();
833
802
  fetchAttachStuckByCountry();
834
803
  }
@@ -845,6 +814,7 @@ function showRawPage() {
845
814
  document.getElementById("pendingPage").classList.remove("active");
846
815
  document.getElementById("userUpdatePage").classList.remove("active");
847
816
  document.getElementById("rawPage").classList.add("active");
817
+ document.getElementById("targetPage").classList.remove("active");
848
818
  fetchRawByCountry();
849
819
  fetchRawJobs();
850
820
  }
@@ -854,8 +824,219 @@ function showMainPage() {
854
824
  document.getElementById("pendingPage").classList.remove("active");
855
825
  document.getElementById("userUpdatePage").classList.remove("active");
856
826
  document.getElementById("rawPage").classList.remove("active");
827
+ document.getElementById("targetPage").classList.remove("active");
828
+ }
829
+
830
+ function navigateToTarget() {
831
+ window.location.hash = "#target";
832
+ }
833
+
834
+ function showTargetPage() {
835
+ document.getElementById("mainPage").classList.add("hidden");
836
+ document.getElementById("pendingPage").classList.remove("active");
837
+ document.getElementById("userUpdatePage").classList.remove("active");
838
+ document.getElementById("rawPage").classList.remove("active");
839
+ document.getElementById("targetPage").classList.add("active");
840
+ fetchTargetByCountry();
841
+ // 同步统计
842
+ if (currentStats) {
843
+ document.getElementById("targetPageStatTotal").textContent = formatStatNum(
844
+ currentStats.targetUsers || 0,
845
+ { full: true },
846
+ );
847
+ }
848
+ }
849
+
850
+ let currentTargetData = null;
851
+
852
+ async function fetchTargetByCountry() {
853
+ try {
854
+ const res = await fetch("/api/target-users-by-country");
855
+ const data = await res.json();
856
+ currentTargetData = data;
857
+ renderTargetCountryGrid(data.countries || []);
858
+ renderTargetLocationFilter(data.countries || []);
859
+ renderTargetTable(data.countries || []);
860
+ document.getElementById("targetPageStatTotal").textContent = formatStatNum(
861
+ data.total || 0,
862
+ { full: true },
863
+ );
864
+ document.getElementById("targetPageStatCountries").textContent = (
865
+ data.countries || []
866
+ ).length;
867
+ } catch (e) {
868
+ console.error("获取目标商家数据失败:", e);
869
+ }
870
+ }
871
+
872
+ function renderTargetCountryGrid(countries) {
873
+ const grid = document.getElementById("targetCountryGrid");
874
+ if (!countries.length) {
875
+ grid.innerHTML =
876
+ '<span style="color:#666;font-size:12px">暂无目标商家</span>';
877
+ return;
878
+ }
879
+ const total = countries.reduce((sum, c) => sum + c.count, 0);
880
+ grid.innerHTML = countries
881
+ .map((c) => {
882
+ const pct = ((c.count / total) * 100).toFixed(1);
883
+ const safeCountry = escapeJsString(c.country);
884
+ return `
885
+ <div class="pending-country-item has-target"
886
+ onclick="filterTargetByCountry('${safeCountry}')">
887
+ <div class="country-name">${c.country}</div>
888
+ <div class="country-count">${c.count}</div>
889
+ <div class="country-label">${pct}% 目标商家</div>
890
+ </div>
891
+ `;
892
+ })
893
+ .join("");
894
+ }
895
+
896
+ function renderTargetLocationFilter(countries) {
897
+ const sel = document.getElementById("targetLocationFilter");
898
+ if (!sel) return;
899
+ const val = sel.value;
900
+ sel.innerHTML =
901
+ '<option value="">全部国家</option>' +
902
+ countries
903
+ .map(
904
+ (c) =>
905
+ `<option value="${c.country}"${val === c.country ? " selected" : ""}>${c.country} (${c.count})</option>`,
906
+ )
907
+ .join("");
908
+ }
909
+
910
+ function renderTargetTable(countries) {
911
+ const el = document.getElementById("targetTable");
912
+ const search = document.getElementById("targetSearchInput")
913
+ ? document.getElementById("targetSearchInput").value.trim().toLowerCase()
914
+ : "";
915
+ const location = currentTargetLocation;
916
+
917
+ let allUsers = [];
918
+ for (const c of countries) {
919
+ if (location && c.country !== location) continue;
920
+ allUsers = allUsers.concat(c.users);
921
+ }
922
+
923
+ if (search) {
924
+ allUsers = allUsers.filter(
925
+ (u) =>
926
+ (u.uniqueId || "").toLowerCase().includes(search) ||
927
+ (u.nickname || "").toLowerCase().includes(search),
928
+ );
929
+ }
930
+
931
+ if (!allUsers.length) {
932
+ el.innerHTML =
933
+ '<tr><td colspan="8" style="color:#666;text-align:center;padding:24px">暂无目标商家</td></tr>';
934
+ return;
935
+ }
936
+
937
+ el.innerHTML = allUsers
938
+ .map((u) => {
939
+ const nick = (u.nickname || "")
940
+ .replace(/</g, "&lt;")
941
+ .replace(/>/g, "&gt;");
942
+ const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
943
+ const videos = u.videoCount != null ? u.videoCount : "-";
944
+ const latestVideo = u.latestVideoTime
945
+ ? formatTime(u.latestVideoTime * 1000)
946
+ : "-";
947
+ const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
948
+ const sources = (u.sources || []).join(", ");
949
+
950
+ let statusTag = "";
951
+ if (u.status === "done")
952
+ statusTag = '<span class="tag processed">已完成</span>';
953
+ else if (u.status === "processing")
954
+ statusTag = '<span class="tag processing">处理中</span>';
955
+ else if (u.status === "pending")
956
+ statusTag = '<span class="tag pending">待处理</span>';
957
+ else if (u.status === "error")
958
+ statusTag = '<span class="tag error">错误</span>';
959
+ else if (u.status === "restricted")
960
+ statusTag = '<span class="tag error">受限</span>';
961
+ else statusTag = u.status || "-";
962
+
963
+ return `<tr>
964
+ <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
965
+ <td data-label="昵称">${nick}</td>
966
+ <td data-label="粉丝">${fans}</td>
967
+ <td data-label="视频">${videos}</td>
968
+ <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
969
+ <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
970
+ <td data-label="来源">${sources || "-"}</td>
971
+ <td data-label="状态">${statusTag}</td>
972
+ </tr>`;
973
+ })
974
+ .join("");
975
+ }
976
+
977
+ function filterTargetByCountry(country) {
978
+ currentTargetLocation = country;
979
+ const sel = document.getElementById("targetLocationFilter");
980
+ if (sel) sel.value = country;
981
+ if (currentTargetData) {
982
+ renderTargetTable(currentTargetData.countries || []);
983
+ }
857
984
  }
858
985
 
986
+ function onTargetLocationChange() {
987
+ const sel = document.getElementById("targetLocationFilter");
988
+ currentTargetLocation = sel.value;
989
+ if (currentTargetData) {
990
+ renderTargetTable(currentTargetData.countries || []);
991
+ }
992
+ }
993
+
994
+ function clearTargetFilters() {
995
+ currentTargetLocation = "";
996
+ const searchInput = document.getElementById("targetSearchInput");
997
+ const locationFilter = document.getElementById("targetLocationFilter");
998
+ if (searchInput) searchInput.value = "";
999
+ if (locationFilter) locationFilter.value = "";
1000
+ if (currentTargetData) {
1001
+ renderTargetTable(currentTargetData.countries || []);
1002
+ }
1003
+ }
1004
+
1005
+ let targetSearchTimer = null;
1006
+
1007
+ document.getElementById("targetSearchInput").addEventListener("input", () => {
1008
+ if (targetSearchTimer) clearTimeout(targetSearchTimer);
1009
+ targetSearchTimer = setTimeout(() => {
1010
+ if (currentTargetData) {
1011
+ renderTargetTable(currentTargetData.countries || []);
1012
+ }
1013
+ }, 300);
1014
+ });
1015
+
1016
+ document
1017
+ .getElementById("exportTargetCsvBtn")
1018
+ .addEventListener("click", async () => {
1019
+ showLoading("正在导出目标用户...");
1020
+ try {
1021
+ const res = await fetch("/api/target-users-by-country", {
1022
+ headers: { Accept: "text/csv" },
1023
+ });
1024
+ if (!res.ok) throw new Error("HTTP " + res.status);
1025
+ const blob = await res.blob();
1026
+ const url = URL.createObjectURL(blob);
1027
+ const a = document.createElement("a");
1028
+ a.href = url;
1029
+ a.download = "target-users-by-country.csv";
1030
+ a.click();
1031
+ URL.revokeObjectURL(url);
1032
+ showToast("CSV 文件已开始下载");
1033
+ } catch (e) {
1034
+ showToast("导出失败: " + e.message, true);
1035
+ } finally {
1036
+ hideLoading();
1037
+ }
1038
+ });
1039
+
859
1040
  async function fetchPendingByCountry() {
860
1041
  try {
861
1042
  const res = await fetch("/api/pending-by-country");
@@ -882,7 +1063,10 @@ function renderPendingCountryGrid(countries) {
882
1063
  return `
883
1064
  <div class="pending-country-item${isUnknown ? "" : " has-target"}"
884
1065
  onclick="filterByPendingCountry('${safeCountry}')">
885
- <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
1066
+ <div class="country-action-btns">
1067
+ <button class="country-action-btn restore" title="重置为需要预处理" onclick="event.stopPropagation(); resetPendingByCountry('${safeCountry}', ${c.count})">↺</button>
1068
+ <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
1069
+ </div>
886
1070
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
887
1071
  <div class="country-count">${c.count}</div>
888
1072
  <div class="country-label">${pct}% 待处理</div>
@@ -936,7 +1120,9 @@ function renderUserUpdateCountryGrid(countries) {
936
1120
  return `
937
1121
  <div class="pending-country-item${isUnknown ? "" : " has-target"}"
938
1122
  onclick="filterByUserUpdateCountry('${safeCountry}')">
939
- <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
1123
+ <div class="country-action-btns">
1124
+ <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
1125
+ </div>
940
1126
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
941
1127
  <div class="country-count">${c.count}</div>
942
1128
  <div class="country-label">${pct}% 待补资料</div>
@@ -973,7 +1159,9 @@ function renderAttachStuckGrid(gridId, countries) {
973
1159
  const safeCountry = escapeJsString(c.country);
974
1160
  return `
975
1161
  <div class="pending-country-item${isUnknown ? "" : " has-target"}">
976
- <button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
1162
+ <div class="country-action-btns">
1163
+ <button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
1164
+ </div>
977
1165
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
978
1166
  <div class="country-count">${c.count}</div>
979
1167
  <div class="country-label">${pct}% attach 未成功</div>
@@ -1058,6 +1246,40 @@ async function moveCountryJobsToRaw(scope, country, count) {
1058
1246
  }
1059
1247
  }
1060
1248
 
1249
+ async function resetPendingByCountry(country, count) {
1250
+ const countText = count != null ? `将重置 ${formatStatNum(count)} 条。` : "";
1251
+ if (
1252
+ !window.confirm(
1253
+ `确认将 ${country} 下已预处理的待处理任务重置为需要预处理吗?${countText}`,
1254
+ )
1255
+ ) {
1256
+ return;
1257
+ }
1258
+ showLoading("正在重置...");
1259
+ try {
1260
+ const res = await fetch("/api/pending-by-country/reset", {
1261
+ method: "POST",
1262
+ headers: { "Content-Type": "application/json" },
1263
+ body: JSON.stringify({ country }),
1264
+ });
1265
+ const data = await res.json();
1266
+ if (!res.ok || data.error) {
1267
+ showToast(data.error || "重置失败", true);
1268
+ return;
1269
+ }
1270
+ showToast(`${country} 的待处理任务已重置,共 ${data.reset} 条`);
1271
+ await fetchStats();
1272
+ await fetchPendingByCountry();
1273
+ if (!document.getElementById("mainPage").classList.contains("hidden")) {
1274
+ fetchUsers();
1275
+ }
1276
+ } catch (e) {
1277
+ showToast("重置失败: " + e.message, true);
1278
+ } finally {
1279
+ hideLoading();
1280
+ }
1281
+ }
1282
+
1061
1283
  async function fetchRawByCountry() {
1062
1284
  try {
1063
1285
  const res = await fetch("/api/raw-by-country");
@@ -1085,7 +1307,9 @@ function renderRawCountryGrid(countries) {
1085
1307
  return `
1086
1308
  <div class="pending-country-item${isUnknown ? "" : " has-target"}"
1087
1309
  onclick="filterRawByCountry('${safeCountry}')">
1088
- <button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
1310
+ <div class="country-action-btns">
1311
+ <button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
1312
+ </div>
1089
1313
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
1090
1314
  <div class="country-count">${c.count}</div>
1091
1315
  <div class="country-label">${pct}% 毛料库</div>
@@ -1101,6 +1325,11 @@ async function fetchRawJobs() {
1101
1325
  const search = document.getElementById("rawSearchInput").value.trim();
1102
1326
  if (search) params.set("search", search);
1103
1327
  if (currentRawLocation) params.set("location", currentRawLocation);
1328
+ const videoFilter = document.getElementById("rawVideoFilter");
1329
+ if (videoFilter && videoFilter.checked) params.set("hasVideo", "1");
1330
+ const followerFilter = document.getElementById("rawFollowerFilter");
1331
+ if (followerFilter && followerFilter.checked)
1332
+ params.set("hasFollower", "1");
1104
1333
  params.set("limit", "200");
1105
1334
  const res = await fetch("/api/raw-jobs?" + params.toString());
1106
1335
  const data = await res.json();
@@ -1176,8 +1405,16 @@ function clearRawFilters() {
1176
1405
  currentRawLocation = "";
1177
1406
  const rawSearchInput = document.getElementById("rawSearchInput");
1178
1407
  const rawLocationFilter = document.getElementById("rawLocationFilter");
1408
+ const rawVideoFilter = document.getElementById("rawVideoFilter");
1409
+ const rawFollowerFilter = document.getElementById("rawFollowerFilter");
1179
1410
  if (rawSearchInput) rawSearchInput.value = "";
1180
1411
  if (rawLocationFilter) rawLocationFilter.value = "";
1412
+ if (rawVideoFilter) rawVideoFilter.checked = false;
1413
+ if (rawFollowerFilter) rawFollowerFilter.checked = false;
1414
+ fetchRawJobs();
1415
+ }
1416
+
1417
+ function onRawFilterChange() {
1181
1418
  fetchRawJobs();
1182
1419
  }
1183
1420
 
@@ -1211,10 +1448,20 @@ async function restoreRawJob(uniqueId) {
1211
1448
  async function restoreFilteredRawJobs() {
1212
1449
  const search = document.getElementById("rawSearchInput").value.trim();
1213
1450
  const location = currentRawLocation;
1451
+ const videoFilter = document.getElementById("rawVideoFilter");
1452
+ const hasVideo = videoFilter && videoFilter.checked;
1453
+ const followerFilter = document.getElementById("rawFollowerFilter");
1454
+ const hasFollower = followerFilter && followerFilter.checked;
1214
1455
  let desc = "当前筛选条件";
1215
1456
  if (search && location) desc = `搜索="${search}" + 国家=${location}`;
1216
1457
  else if (search) desc = `搜索="${search}"`;
1217
1458
  else if (location) desc = `国家=${location}`;
1459
+ if (hasVideo || hasFollower) {
1460
+ const tags = [];
1461
+ if (hasVideo) tags.push("有视频");
1462
+ if (hasFollower) tags.push("有粉丝");
1463
+ desc += ` + ${tags.join("、")}`;
1464
+ }
1218
1465
  if (
1219
1466
  !window.confirm(`确认将毛料库中符合【${desc}】的任务恢复到 jobs 队列吗?`)
1220
1467
  ) {
@@ -1225,6 +1472,8 @@ async function restoreFilteredRawJobs() {
1225
1472
  const body = {};
1226
1473
  if (search) body.search = search;
1227
1474
  if (location) body.location = location;
1475
+ if (hasVideo) body.hasVideo = true;
1476
+ if (hasFollower) body.hasFollower = true;
1228
1477
  const res = await fetch("/api/raw-jobs/restore", {
1229
1478
  method: "POST",
1230
1479
  headers: { "Content-Type": "application/json" },
@@ -245,6 +245,13 @@
245
245
  style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
246
246
  <option value="">全部国家</option>
247
247
  </select>
248
+ <label style="display:inline-flex;align-items:center;gap:3px;font-size:12px;color:#999;cursor:pointer;">
249
+ <input type="checkbox" id="rawVideoFilter" onchange="onRawFilterChange()" style="accent-color:#22c55e;"> 有视频
250
+ </label>
251
+ <label style="display:inline-flex;align-items:center;gap:3px;font-size:12px;color:#999;cursor:pointer;">
252
+ <input type="checkbox" id="rawFollowerFilter" onchange="onRawFilterChange()" style="accent-color:#22c55e;">
253
+ 有粉丝
254
+ </label>
248
255
  <button onclick="clearRawFilters()">清空筛选</button>
249
256
  <button onclick="restoreFilteredRawJobs()"
250
257
  style="background:#22c55e;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;">恢复当前筛选到
@@ -272,6 +279,61 @@
272
279
  </div>
273
280
  </div>
274
281
  </div>
282
+ <div id="targetPage">
283
+ <div class="stats" style="margin-bottom:16px">
284
+ <div class="stat-card">
285
+ <div class="label">目标商家</div>
286
+ <div class="value target" id="targetPageStatTotal">0</div>
287
+ </div>
288
+ <div class="stat-card clickable pending-card" onclick="navigateToMain()" style="background:rgba(167,139,250,0.1)">
289
+ <div class="label">← 返回主页面</div>
290
+ </div>
291
+ <div class="stat-card">
292
+ <div class="label">目标国家</div>
293
+ <div class="value" id="targetPageStatCountries" style="font-size:17px;color:#a78bfa">0</div>
294
+ </div>
295
+ <div class="stat-card clickable" id="exportTargetCsvBtn" style="background:rgba(34,197,94,0.12);cursor:pointer">
296
+ <div class="label">导出 CSV</div>
297
+ </div>
298
+ </div>
299
+ <div class="target-page-layout">
300
+ <div class="target-side-card">
301
+ <h3>🎯 目标商家国家分布</h3>
302
+ <div class="pending-country-grid" id="targetCountryGrid">
303
+ <span style="color:#666;font-size:12px">加载中...</span>
304
+ </div>
305
+ <div class="muted-tip">点击国家卡片可筛选列表。</div>
306
+ </div>
307
+ <div class="target-table-card">
308
+ <h3>目标商家列表</h3>
309
+ <div class="controls">
310
+ <input type="text" id="targetSearchInput" placeholder="搜索目标商家用户名 / 昵称...">
311
+ <select id="targetLocationFilter" onchange="onTargetLocationChange()"
312
+ style="padding:6px 10px;border:1px solid #7c3aed;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
313
+ <option value="">全部国家</option>
314
+ </select>
315
+ <button onclick="clearTargetFilters()">清空筛选</button>
316
+ </div>
317
+ <div class="table-scroll">
318
+ <table>
319
+ <thead>
320
+ <tr>
321
+ <th>用户名</th>
322
+ <th>昵称</th>
323
+ <th>粉丝</th>
324
+ <th>视频</th>
325
+ <th>最近发布</th>
326
+ <th>最近刷新</th>
327
+ <th>来源</th>
328
+ <th>状态</th>
329
+ </tr>
330
+ </thead>
331
+ <tbody id="targetTable"></tbody>
332
+ </table>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ </div>
275
337
  <script src="app.js"></script>
276
338
  </body>
277
339
 
@@ -634,6 +634,14 @@ td.user-id:hover {
634
634
  display: block;
635
635
  }
636
636
 
637
+ #targetPage {
638
+ display: none;
639
+ }
640
+
641
+ #targetPage.active {
642
+ display: block;
643
+ }
644
+
637
645
  #mainPage.hidden {
638
646
  display: none;
639
647
  }
@@ -670,10 +678,15 @@ td.user-id:hover {
670
678
  position: relative;
671
679
  }
672
680
 
673
- .country-action-btn {
681
+ .country-action-btns {
674
682
  position: absolute;
675
- top: 10px;
676
- right: 10px;
683
+ top: 8px;
684
+ right: 8px;
685
+ display: flex;
686
+ gap: 4px;
687
+ }
688
+
689
+ .country-action-btn {
677
690
  width: 28px;
678
691
  height: 28px;
679
692
  border: 1px solid rgba(248, 113, 113, 0.35);
@@ -937,6 +950,66 @@ td.user-id:hover {
937
950
  color: #a78bfa;
938
951
  }
939
952
 
953
+ .target-country-group {
954
+ background: #1a1a24;
955
+ border-radius: 8px;
956
+ padding: 20px;
957
+ margin-bottom: 20px;
958
+ }
959
+
960
+ .target-country-header {
961
+ display: flex;
962
+ align-items: center;
963
+ gap: 12px;
964
+ margin-bottom: 16px;
965
+ padding-bottom: 12px;
966
+ border-bottom: 1px solid #2a2a3a;
967
+ }
968
+
969
+ .target-country-header .country-flag {
970
+ font-size: 20px;
971
+ font-weight: 700;
972
+ color: #a78bfa;
973
+ }
974
+
975
+ .target-country-header .country-user-count {
976
+ font-size: 13px;
977
+ color: #888;
978
+ background: #2a2a3a;
979
+ padding: 2px 10px;
980
+ border-radius: 10px;
981
+ }
982
+
983
+ .target-page-layout {
984
+ display: grid;
985
+ grid-template-columns: 320px 1fr;
986
+ gap: 16px;
987
+ }
988
+
989
+ .target-side-card {
990
+ background: #1a1a24;
991
+ border-radius: 8px;
992
+ padding: 16px;
993
+ }
994
+
995
+ .target-side-card h3 {
996
+ font-size: 14px;
997
+ color: #a78bfa;
998
+ margin-bottom: 12px;
999
+ }
1000
+
1001
+ .target-table-card {
1002
+ background: #1a1a24;
1003
+ border-radius: 8px;
1004
+ padding: 16px;
1005
+ }
1006
+
1007
+ .target-table-card h3 {
1008
+ font-size: 14px;
1009
+ color: #888;
1010
+ margin-bottom: 12px;
1011
+ }
1012
+
940
1013
  @media (max-width: 768px) {
941
1014
  body {
942
1015
  padding: 8px;
@@ -977,6 +1050,10 @@ td.user-id:hover {
977
1050
  grid-template-columns: 1fr;
978
1051
  }
979
1052
 
1053
+ .target-page-layout {
1054
+ grid-template-columns: 1fr;
1055
+ }
1056
+
980
1057
  .table-wrap {
981
1058
  padding: 10px;
982
1059
  }
@@ -526,6 +526,66 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
526
526
  return;
527
527
  }
528
528
 
529
+ if (
530
+ req.method === "GET" &&
531
+ routePath === "/api/target-users-by-country"
532
+ ) {
533
+ const result = store.getTargetUsersByCountry(DEFAULT_TARGET_LOCATIONS);
534
+ if (req.headers["accept"]?.includes("text/csv")) {
535
+ const columns = [
536
+ "uniqueId",
537
+ "nickname",
538
+ "followerCount",
539
+ "videoCount",
540
+ "locationCreated",
541
+ "latestVideoTime",
542
+ "refreshTime",
543
+ "status",
544
+ "sources",
545
+ ];
546
+ const allUsers = [];
547
+ for (const country of result.countries) {
548
+ for (const u of country.users) {
549
+ allUsers.push({
550
+ uniqueId: u.uniqueId,
551
+ nickname: u.nickname || "",
552
+ followerCount: u.followerCount ?? 0,
553
+ videoCount: u.videoCount ?? 0,
554
+ locationCreated: u.locationCreated || "",
555
+ latestVideoTime: u.latestVideoTime
556
+ ? new Date(u.latestVideoTime * 1000)
557
+ .toISOString()
558
+ .slice(0, 19)
559
+ .replace("T", " ")
560
+ : "",
561
+ refreshTime: u.refreshTime
562
+ ? new Date(u.refreshTime)
563
+ .toISOString()
564
+ .slice(0, 19)
565
+ .replace("T", " ")
566
+ : "",
567
+ status: u.status || "",
568
+ sources: (u.sources || []).join(";"),
569
+ });
570
+ }
571
+ }
572
+ res.writeHead(200, {
573
+ "Content-Type": "text/csv; charset=utf-8",
574
+ "Content-Disposition":
575
+ 'attachment; filename="target-users-by-country.csv"',
576
+ });
577
+ const BOM = "\uFEFF";
578
+ const header = columns.join(",");
579
+ const lines = allUsers.map((r) =>
580
+ columns.map((c) => csvEscape(r[c])).join(","),
581
+ );
582
+ res.end(BOM + [header, ...lines].join("\r\n"));
583
+ } else {
584
+ sendJSON(res, 200, result);
585
+ }
586
+ return;
587
+ }
588
+
529
589
  if (req.method === "GET" && routePath === "/api/client-errors") {
530
590
  sendJSON(res, 200, { clients: store.getClientErrors() });
531
591
  return;
@@ -564,6 +624,8 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
564
624
  location: params.location,
565
625
  limit: params.limit,
566
626
  offset: params.offset,
627
+ hasVideo: params.hasVideo,
628
+ hasFollower: params.hasFollower,
567
629
  });
568
630
  sendJSON(res, 200, result);
569
631
  return;
@@ -590,10 +652,17 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
590
652
  let result;
591
653
  if (body.uniqueId) {
592
654
  result = store.restoreRawJobById(body.uniqueId);
593
- } else if (body.search || body.location) {
655
+ } else if (
656
+ body.search ||
657
+ body.location ||
658
+ body.hasVideo ||
659
+ body.hasFollower
660
+ ) {
594
661
  result = store.restoreRawJobsByFilter({
595
662
  search: body.search || "",
596
663
  location: body.location || "",
664
+ hasVideo: body.hasVideo,
665
+ hasFollower: body.hasFollower,
597
666
  });
598
667
  } else if (body.country) {
599
668
  result = store.restoreRawJobsByCountry(body.country);
@@ -629,6 +698,24 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
629
698
  return;
630
699
  }
631
700
 
701
+ if (
702
+ req.method === "POST" &&
703
+ routePath === "/api/pending-by-country/reset"
704
+ ) {
705
+ try {
706
+ const body = await readBody(req);
707
+ const result = store.resetPendingByCountry(body.country);
708
+ if (result.error) {
709
+ sendJSON(res, 400, result);
710
+ return;
711
+ }
712
+ sendJSON(res, 200, result);
713
+ } catch (e) {
714
+ sendJSON(res, 400, { error: e.message });
715
+ }
716
+ return;
717
+ }
718
+
632
719
  if (
633
720
  req.method === "DELETE" &&
634
721
  routePath.startsWith("/api/client-error/")