tt-help-cli-ycl 1.3.63 → 1.3.65

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.
@@ -57,11 +57,13 @@ export class TikTokScraper {
57
57
  wafTtl = DEFAULT_WAF_TTL,
58
58
  warmUrl = DEFAULT_WARM_URL,
59
59
  maxRequestsPerPage = DEFAULT_MAX_REQUESTS_PER_PAGE,
60
+ proxyServer = null,
60
61
  } = {}) {
61
62
  this.poolSize = poolSize;
62
63
  this.wafTtl = wafTtl;
63
64
  this.warmUrl = warmUrl;
64
65
  this.maxRequestsPerPage = maxRequestsPerPage;
66
+ this.proxyServer = proxyServer;
65
67
  this.browser = null;
66
68
  this.context = null;
67
69
  this.slots = [];
@@ -77,17 +79,21 @@ export class TikTokScraper {
77
79
  "未找到本地浏览器(Chrome/Edge),请先安装浏览器或执行 npx playwright install",
78
80
  );
79
81
  }
82
+ const launchArgs = [
83
+ "--no-sandbox",
84
+ "--disable-setuid-sandbox",
85
+ "--disable-dev-shm-usage",
86
+ ];
87
+ if (this.proxyServer) {
88
+ launchArgs.push(`--proxy-server=${this.proxyServer}`);
89
+ }
80
90
  this.browser = await chromium.launch({
81
91
  headless: true,
82
92
  executablePath,
83
93
  handleSIGINT: false,
84
94
  handleSIGTERM: false,
85
95
  handleSIGHUP: false,
86
- args: [
87
- "--no-sandbox",
88
- "--disable-setuid-sandbox",
89
- "--disable-dev-shm-usage",
90
- ],
96
+ args: launchArgs,
91
97
  });
92
98
  this.context = await this.browser.newContext();
93
99
  for (let i = 0; i < this.poolSize; i++) {
@@ -3,7 +3,7 @@ import { detectCaptcha } from "./modules/captcha-handler.js";
3
3
  export { ensureBrowserReady };
4
4
  import { getUserInfo, collectVideos } from "../videos/core.js";
5
5
  import { extractFollowAndFollowers } from "./modules/follow-extractor.js";
6
- import { extractVideoLocation } from "../lib/scrape.js";
6
+ import { extractVideoLocation, setScraperProxy } from "../lib/scrape.js";
7
7
  import {
8
8
  DEFAULT_TARGET_LOCATIONS_CSV,
9
9
  findFirstMatchingLocation,
@@ -23,6 +23,7 @@ async function processExplore(page, username, options, log) {
23
23
  maxFollowing = 50,
24
24
  maxFollowers = 50,
25
25
  location = DEFAULT_TARGET_LOCATIONS_CSV,
26
+ proxyServer = null,
26
27
  } = options;
27
28
 
28
29
  const result = {
@@ -45,6 +46,11 @@ async function processExplore(page, username, options, log) {
45
46
 
46
47
  const locationList = normalizeLocationList(location);
47
48
 
49
+ // 设置 TikTokScraper 的代理,与 CDP 浏览器保持一致
50
+ if (options.proxyServer) {
51
+ setScraperProxy(options.proxyServer);
52
+ }
53
+
48
54
  try {
49
55
  log(` 访问 @${username} 主页...`);
50
56
  const videoList = await collectVideos(page, username, maxVideos, log);
@@ -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,221 @@ 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 location = u.locationCreated || "-";
945
+ const latestVideo = u.latestVideoTime
946
+ ? formatTime(u.latestVideoTime * 1000)
947
+ : "-";
948
+ const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
949
+ const sources = (u.sources || []).join(", ");
950
+
951
+ let statusTag = "";
952
+ if (u.status === "done")
953
+ statusTag = '<span class="tag processed">已完成</span>';
954
+ else if (u.status === "processing")
955
+ statusTag = '<span class="tag processing">处理中</span>';
956
+ else if (u.status === "pending")
957
+ statusTag = '<span class="tag pending">待处理</span>';
958
+ else if (u.status === "error")
959
+ statusTag = '<span class="tag error">错误</span>';
960
+ else if (u.status === "restricted")
961
+ statusTag = '<span class="tag error">受限</span>';
962
+ else statusTag = u.status || "-";
963
+
964
+ return `<tr>
965
+ <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
966
+ <td data-label="昵称">${nick}</td>
967
+ <td data-label="粉丝">${fans}</td>
968
+ <td data-label="视频">${videos}</td>
969
+ <td data-label="国家">${location}</td>
970
+ <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
971
+ <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
972
+ <td data-label="来源">${sources || "-"}</td>
973
+ <td data-label="状态">${statusTag}</td>
974
+ </tr>`;
975
+ })
976
+ .join("");
977
+ }
978
+
979
+ function filterTargetByCountry(country) {
980
+ currentTargetLocation = country;
981
+ const sel = document.getElementById("targetLocationFilter");
982
+ if (sel) sel.value = country;
983
+ if (currentTargetData) {
984
+ renderTargetTable(currentTargetData.countries || []);
985
+ }
857
986
  }
858
987
 
988
+ function onTargetLocationChange() {
989
+ const sel = document.getElementById("targetLocationFilter");
990
+ currentTargetLocation = sel.value;
991
+ if (currentTargetData) {
992
+ renderTargetTable(currentTargetData.countries || []);
993
+ }
994
+ }
995
+
996
+ function clearTargetFilters() {
997
+ currentTargetLocation = "";
998
+ const searchInput = document.getElementById("targetSearchInput");
999
+ const locationFilter = document.getElementById("targetLocationFilter");
1000
+ if (searchInput) searchInput.value = "";
1001
+ if (locationFilter) locationFilter.value = "";
1002
+ if (currentTargetData) {
1003
+ renderTargetTable(currentTargetData.countries || []);
1004
+ }
1005
+ }
1006
+
1007
+ let targetSearchTimer = null;
1008
+
1009
+ document.getElementById("targetSearchInput").addEventListener("input", () => {
1010
+ if (targetSearchTimer) clearTimeout(targetSearchTimer);
1011
+ targetSearchTimer = setTimeout(() => {
1012
+ if (currentTargetData) {
1013
+ renderTargetTable(currentTargetData.countries || []);
1014
+ }
1015
+ }, 300);
1016
+ });
1017
+
1018
+ document
1019
+ .getElementById("exportTargetCsvBtn")
1020
+ .addEventListener("click", async () => {
1021
+ showLoading("正在导出目标用户...");
1022
+ try {
1023
+ const res = await fetch("/api/target-users-by-country", {
1024
+ headers: { Accept: "text/csv" },
1025
+ });
1026
+ if (!res.ok) throw new Error("HTTP " + res.status);
1027
+ const blob = await res.blob();
1028
+ const url = URL.createObjectURL(blob);
1029
+ const a = document.createElement("a");
1030
+ a.href = url;
1031
+ a.download = "target-users-by-country.csv";
1032
+ a.click();
1033
+ URL.revokeObjectURL(url);
1034
+ showToast("CSV 文件已开始下载");
1035
+ } catch (e) {
1036
+ showToast("导出失败: " + e.message, true);
1037
+ } finally {
1038
+ hideLoading();
1039
+ }
1040
+ });
1041
+
859
1042
  async function fetchPendingByCountry() {
860
1043
  try {
861
1044
  const res = await fetch("/api/pending-by-country");
@@ -882,7 +1065,10 @@ function renderPendingCountryGrid(countries) {
882
1065
  return `
883
1066
  <div class="pending-country-item${isUnknown ? "" : " has-target"}"
884
1067
  onclick="filterByPendingCountry('${safeCountry}')">
885
- <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
1068
+ <div class="country-action-btns">
1069
+ <button class="country-action-btn restore" title="重置为需要预处理" onclick="event.stopPropagation(); resetPendingByCountry('${safeCountry}', ${c.count})">↺</button>
1070
+ <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
1071
+ </div>
886
1072
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
887
1073
  <div class="country-count">${c.count}</div>
888
1074
  <div class="country-label">${pct}% 待处理</div>
@@ -936,7 +1122,9 @@ function renderUserUpdateCountryGrid(countries) {
936
1122
  return `
937
1123
  <div class="pending-country-item${isUnknown ? "" : " has-target"}"
938
1124
  onclick="filterByUserUpdateCountry('${safeCountry}')">
939
- <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
1125
+ <div class="country-action-btns">
1126
+ <button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
1127
+ </div>
940
1128
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
941
1129
  <div class="country-count">${c.count}</div>
942
1130
  <div class="country-label">${pct}% 待补资料</div>
@@ -973,7 +1161,9 @@ function renderAttachStuckGrid(gridId, countries) {
973
1161
  const safeCountry = escapeJsString(c.country);
974
1162
  return `
975
1163
  <div class="pending-country-item${isUnknown ? "" : " has-target"}">
976
- <button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
1164
+ <div class="country-action-btns">
1165
+ <button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
1166
+ </div>
977
1167
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
978
1168
  <div class="country-count">${c.count}</div>
979
1169
  <div class="country-label">${pct}% attach 未成功</div>
@@ -1058,6 +1248,40 @@ async function moveCountryJobsToRaw(scope, country, count) {
1058
1248
  }
1059
1249
  }
1060
1250
 
1251
+ async function resetPendingByCountry(country, count) {
1252
+ const countText = count != null ? `将重置 ${formatStatNum(count)} 条。` : "";
1253
+ if (
1254
+ !window.confirm(
1255
+ `确认将 ${country} 下已预处理的待处理任务重置为需要预处理吗?${countText}`,
1256
+ )
1257
+ ) {
1258
+ return;
1259
+ }
1260
+ showLoading("正在重置...");
1261
+ try {
1262
+ const res = await fetch("/api/pending-by-country/reset", {
1263
+ method: "POST",
1264
+ headers: { "Content-Type": "application/json" },
1265
+ body: JSON.stringify({ country }),
1266
+ });
1267
+ const data = await res.json();
1268
+ if (!res.ok || data.error) {
1269
+ showToast(data.error || "重置失败", true);
1270
+ return;
1271
+ }
1272
+ showToast(`${country} 的待处理任务已重置,共 ${data.reset} 条`);
1273
+ await fetchStats();
1274
+ await fetchPendingByCountry();
1275
+ if (!document.getElementById("mainPage").classList.contains("hidden")) {
1276
+ fetchUsers();
1277
+ }
1278
+ } catch (e) {
1279
+ showToast("重置失败: " + e.message, true);
1280
+ } finally {
1281
+ hideLoading();
1282
+ }
1283
+ }
1284
+
1061
1285
  async function fetchRawByCountry() {
1062
1286
  try {
1063
1287
  const res = await fetch("/api/raw-by-country");
@@ -1085,7 +1309,9 @@ function renderRawCountryGrid(countries) {
1085
1309
  return `
1086
1310
  <div class="pending-country-item${isUnknown ? "" : " has-target"}"
1087
1311
  onclick="filterRawByCountry('${safeCountry}')">
1088
- <button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
1312
+ <div class="country-action-btns">
1313
+ <button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
1314
+ </div>
1089
1315
  <div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
1090
1316
  <div class="country-count">${c.count}</div>
1091
1317
  <div class="country-label">${pct}% 毛料库</div>
@@ -1101,6 +1327,11 @@ async function fetchRawJobs() {
1101
1327
  const search = document.getElementById("rawSearchInput").value.trim();
1102
1328
  if (search) params.set("search", search);
1103
1329
  if (currentRawLocation) params.set("location", currentRawLocation);
1330
+ const videoFilter = document.getElementById("rawVideoFilter");
1331
+ if (videoFilter && videoFilter.checked) params.set("hasVideo", "1");
1332
+ const followerFilter = document.getElementById("rawFollowerFilter");
1333
+ if (followerFilter && followerFilter.checked)
1334
+ params.set("hasFollower", "1");
1104
1335
  params.set("limit", "200");
1105
1336
  const res = await fetch("/api/raw-jobs?" + params.toString());
1106
1337
  const data = await res.json();
@@ -1176,8 +1407,16 @@ function clearRawFilters() {
1176
1407
  currentRawLocation = "";
1177
1408
  const rawSearchInput = document.getElementById("rawSearchInput");
1178
1409
  const rawLocationFilter = document.getElementById("rawLocationFilter");
1410
+ const rawVideoFilter = document.getElementById("rawVideoFilter");
1411
+ const rawFollowerFilter = document.getElementById("rawFollowerFilter");
1179
1412
  if (rawSearchInput) rawSearchInput.value = "";
1180
1413
  if (rawLocationFilter) rawLocationFilter.value = "";
1414
+ if (rawVideoFilter) rawVideoFilter.checked = false;
1415
+ if (rawFollowerFilter) rawFollowerFilter.checked = false;
1416
+ fetchRawJobs();
1417
+ }
1418
+
1419
+ function onRawFilterChange() {
1181
1420
  fetchRawJobs();
1182
1421
  }
1183
1422
 
@@ -1211,10 +1450,20 @@ async function restoreRawJob(uniqueId) {
1211
1450
  async function restoreFilteredRawJobs() {
1212
1451
  const search = document.getElementById("rawSearchInput").value.trim();
1213
1452
  const location = currentRawLocation;
1453
+ const videoFilter = document.getElementById("rawVideoFilter");
1454
+ const hasVideo = videoFilter && videoFilter.checked;
1455
+ const followerFilter = document.getElementById("rawFollowerFilter");
1456
+ const hasFollower = followerFilter && followerFilter.checked;
1214
1457
  let desc = "当前筛选条件";
1215
1458
  if (search && location) desc = `搜索="${search}" + 国家=${location}`;
1216
1459
  else if (search) desc = `搜索="${search}"`;
1217
1460
  else if (location) desc = `国家=${location}`;
1461
+ if (hasVideo || hasFollower) {
1462
+ const tags = [];
1463
+ if (hasVideo) tags.push("有视频");
1464
+ if (hasFollower) tags.push("有粉丝");
1465
+ desc += ` + ${tags.join("、")}`;
1466
+ }
1218
1467
  if (
1219
1468
  !window.confirm(`确认将毛料库中符合【${desc}】的任务恢复到 jobs 队列吗?`)
1220
1469
  ) {
@@ -1225,6 +1474,8 @@ async function restoreFilteredRawJobs() {
1225
1474
  const body = {};
1226
1475
  if (search) body.search = search;
1227
1476
  if (location) body.location = location;
1477
+ if (hasVideo) body.hasVideo = true;
1478
+ if (hasFollower) body.hasFollower = true;
1228
1479
  const res = await fetch("/api/raw-jobs/restore", {
1229
1480
  method: "POST",
1230
1481
  headers: { "Content-Type": "application/json" },