tt-help-cli-ycl 1.3.64 → 1.3.72

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.
@@ -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,8 @@ async function processExplore(page, username, options, log) {
23
23
  maxFollowing = 50,
24
24
  maxFollowers = 50,
25
25
  location = DEFAULT_TARGET_LOCATIONS_CSV,
26
+ locationMode = "explore", // "explore" | "refresh"
27
+ proxyServer = null,
26
28
  } = options;
27
29
 
28
30
  const result = {
@@ -45,6 +47,11 @@ async function processExplore(page, username, options, log) {
45
47
 
46
48
  const locationList = normalizeLocationList(location);
47
49
 
50
+ // 设置 TikTokScraper 的代理,与 CDP 浏览器保持一致
51
+ if (options.proxyServer) {
52
+ setScraperProxy(options.proxyServer);
53
+ }
54
+
48
55
  try {
49
56
  log(` 访问 @${username} 主页...`);
50
57
  const videoList = await collectVideos(page, username, maxVideos, log);
@@ -85,10 +92,12 @@ async function processExplore(page, username, options, log) {
85
92
  return result;
86
93
  }
87
94
 
88
- // 从最多 5 个视频并发获取 locationCreated。
89
- // 新规则:采样列表里只要有任一国家命中目标列表,就直接采用该国家;否则回退到众数。
90
- const SAMPLE_SIZE = 5;
95
+ // 国家采样判断
96
+ // explore 模式:采样 5 个,优先命中目标国家,不匹配则回退众数 → 写 locationCreated
97
+ // refresh 模式:采样 7 个,纯众数逻辑 → 写 confirmedLocation(二次确认)
98
+ const SAMPLE_SIZE = locationMode === "refresh" ? 7 : 5;
91
99
  let locationCreated = null;
100
+ let confirmedLocation = null;
92
101
  let locationDecision = null;
93
102
  const sampleVideos = videoArray.slice(0, SAMPLE_SIZE);
94
103
  if (sampleVideos.length > 0) {
@@ -102,34 +111,48 @@ async function processExplore(page, username, options, log) {
102
111
  ` 国家采样(${locations.length}个): [${locations.filter(Boolean).join(", ") || "无数据"}]`,
103
112
  );
104
113
  const normalizedLocations = normalizeLocationList(locations, []);
105
- const matchedTargetLocation = findFirstMatchingLocation(
106
- normalizedLocations,
107
- locationList,
108
- );
114
+
115
+ // 统计频率
109
116
  const freq = {};
110
117
  for (const key of normalizedLocations) {
111
118
  freq[key] = (freq[key] || 0) + 1;
112
119
  }
113
120
  const entries = Object.entries(freq).sort((a, b) => b[1] - a[1]);
114
- if (matchedTargetLocation) {
115
- locationCreated = matchedTargetLocation;
116
- locationDecision = "命中目标国家";
117
- } else if (entries.length > 0) {
118
- locationCreated = entries[0][0];
119
- locationDecision = "回退众数";
121
+
122
+ if (locationMode === "refresh") {
123
+ // refresh 模式:纯众数逻辑 → 写 confirmedLocation
124
+ if (entries.length > 0) {
125
+ confirmedLocation = entries[0][0];
126
+ locationDecision = `众数 (${entries[0][1]}次)`;
127
+ }
128
+ } else {
129
+ // explore 模式:优先命中目标国家,不匹配则回退众数
130
+ const matchedTargetLocation = findFirstMatchingLocation(
131
+ normalizedLocations,
132
+ locationList,
133
+ );
134
+ if (matchedTargetLocation) {
135
+ locationCreated = matchedTargetLocation;
136
+ locationDecision = "命中目标国家";
137
+ } else if (entries.length > 0) {
138
+ locationCreated = entries[0][0];
139
+ locationDecision = "回退众数";
140
+ }
120
141
  }
121
142
  }
122
143
 
123
144
  result.locationCreated = locationCreated || null;
145
+ result.confirmedLocation = confirmedLocation || null;
124
146
  log(
125
- ` 国家: ${result.locationCreated || "未知"}${locationDecision ? ` (${locationDecision})` : ""}`,
147
+ ` 国家: ${result.confirmedLocation || result.locationCreated || "未知"}${locationDecision ? ` (${locationDecision})` : ""}`,
126
148
  );
127
149
 
128
- // 国家筛选
129
- const isTargetLocation = isLocationInList(
130
- result.locationCreated,
131
- locationList,
132
- );
150
+ // 国家筛选:refresh 模式用 confirmedLocation,explore 模式用 locationCreated
151
+ const effectiveLocation =
152
+ locationMode === "refresh"
153
+ ? result.confirmedLocation
154
+ : result.locationCreated;
155
+ const isTargetLocation = isLocationInList(effectiveLocation, locationList);
133
156
 
134
157
  if (isTargetLocation) {
135
158
  result.keepFollow = true;
@@ -104,6 +104,8 @@ function initUserDb(filePath) {
104
104
  comment_count INTEGER DEFAULT 0,
105
105
  guessed_location TEXT,
106
106
  location_created TEXT,
107
+ confirmed_location TEXT,
108
+ modified_at INTEGER,
107
109
  follower_count INTEGER DEFAULT 0,
108
110
  following_count INTEGER DEFAULT 0,
109
111
  heart_count INTEGER DEFAULT 0,
@@ -132,6 +134,12 @@ function initUserDb(filePath) {
132
134
  if (!existingJobColumns.has("latest_video_time")) {
133
135
  db.exec(`ALTER TABLE jobs ADD COLUMN latest_video_time INTEGER`);
134
136
  }
137
+ if (!existingJobColumns.has("confirmed_location")) {
138
+ db.exec(`ALTER TABLE jobs ADD COLUMN confirmed_location TEXT`);
139
+ }
140
+ if (!existingJobColumns.has("modified_at")) {
141
+ db.exec(`ALTER TABLE jobs ADD COLUMN modified_at INTEGER`);
142
+ }
135
143
  db.exec(`
136
144
  CREATE TABLE IF NOT EXISTS raw_jobs (
137
145
  unique_id TEXT PRIMARY KEY,
@@ -151,6 +159,8 @@ function initUserDb(filePath) {
151
159
  comment_count INTEGER DEFAULT 0,
152
160
  guessed_location TEXT,
153
161
  location_created TEXT,
162
+ confirmed_location TEXT,
163
+ modified_at INTEGER,
154
164
  follower_count INTEGER DEFAULT 0,
155
165
  following_count INTEGER DEFAULT 0,
156
166
  heart_count INTEGER DEFAULT 0,
@@ -180,6 +190,12 @@ function initUserDb(filePath) {
180
190
  if (!existingRawJobColumns.has("latest_video_time")) {
181
191
  db.exec(`ALTER TABLE raw_jobs ADD COLUMN latest_video_time INTEGER`);
182
192
  }
193
+ if (!existingRawJobColumns.has("confirmed_location")) {
194
+ db.exec(`ALTER TABLE raw_jobs ADD COLUMN confirmed_location TEXT`);
195
+ }
196
+ if (!existingRawJobColumns.has("modified_at")) {
197
+ db.exec(`ALTER TABLE raw_jobs ADD COLUMN modified_at INTEGER`);
198
+ }
183
199
  db.exec(`
184
200
  CREATE TABLE IF NOT EXISTS videos (
185
201
  id TEXT PRIMARY KEY,
@@ -1415,6 +1431,8 @@ function getTargetUsersByCountryFromDb(targetLocations = []) {
1415
1431
  tt_seller,
1416
1432
  verified,
1417
1433
  location_created,
1434
+ confirmed_location,
1435
+ modified_at,
1418
1436
  latest_video_time,
1419
1437
  refresh_time,
1420
1438
  status,
@@ -1490,6 +1508,8 @@ const writableJobColumns = new Set([
1490
1508
  "comment_count",
1491
1509
  "guessed_location",
1492
1510
  "location_created",
1511
+ "confirmed_location",
1512
+ "modified_at",
1493
1513
  "follower_count",
1494
1514
  "following_count",
1495
1515
  "heart_count",
@@ -3201,6 +3221,29 @@ export function createStore(filePath) {
3201
3221
  return { ok: true, userUpdateCount: user.userUpdateCount };
3202
3222
  }
3203
3223
 
3224
+ function updateUserLocation(uniqueId, location) {
3225
+ if (db) {
3226
+ const existing = db
3227
+ .prepare("SELECT * FROM jobs WHERE unique_id = ?")
3228
+ .get(uniqueId);
3229
+ if (!existing) return { error: "user not found" };
3230
+ const now = Date.now();
3231
+ db.prepare(
3232
+ "UPDATE jobs SET location_created = ?, modified_at = ?, updated_at = ? WHERE unique_id = ?",
3233
+ ).run(location, now, now, uniqueId);
3234
+ return { ok: true, location, modifiedAt: now };
3235
+ }
3236
+
3237
+ const user = getUser(uniqueId);
3238
+ if (!user) return { error: "user not found" };
3239
+ user.locationCreated = location;
3240
+ user.modifiedAt = Date.now();
3241
+ user.updatedAt = Date.now();
3242
+ user.userUpdateCount = (user.userUpdateCount || 0) + 1;
3243
+ save();
3244
+ return { ok: true, location, modifiedAt: user.modifiedAt };
3245
+ }
3246
+
3204
3247
  function batchUpdateUserInfo(updates) {
3205
3248
  if (db) {
3206
3249
  const txn = db.transaction((items) =>
@@ -3503,6 +3546,7 @@ export function createStore(filePath) {
3503
3546
  commitRedoJob,
3504
3547
  getPendingUserUpdateTasks,
3505
3548
  updateUserInfo,
3549
+ updateUserLocation,
3506
3550
  batchUpdateUserInfo,
3507
3551
  reportClientError,
3508
3552
  deleteClientError,
@@ -309,7 +309,7 @@ function renderTable(users) {
309
309
  for (const u of users) newUserMap[u.uniqueId] = u;
310
310
 
311
311
  el.innerHTML = users
312
- .map((u) => {
312
+ .map((u, idx) => {
313
313
  const wasStatus = prevUserMap[u.uniqueId]?.status;
314
314
  const nowStatus = u.status;
315
315
  const changed =
@@ -345,6 +345,11 @@ function renderTable(users) {
345
345
  const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
346
346
  const videos = u.videoCount != null ? u.videoCount : "-";
347
347
  const loc = u.locationCreated || "-";
348
+ const confirmedLoc = u.confirmedLocation
349
+ ? u.confirmedLocation === u.locationCreated
350
+ ? `<span style="color:#22c55e">${u.confirmedLocation} ✓</span>`
351
+ : `<span style="color:#ef4444">${u.confirmedLocation} ✗</span>`
352
+ : "-";
348
353
  const latestVideo = u.latestVideoTime
349
354
  ? formatTime(u.latestVideoTime * 1000)
350
355
  : "-";
@@ -377,6 +382,7 @@ function renderTable(users) {
377
382
  ? `<span class="tag error" style="font-size:10px">${u.statusCode}</span>`
378
383
  : "";
379
384
  return `<tr${rowClass}>
385
+ <td style="text-align:right;color:#555;padding-right:8px;font-size:12px">${idx + 1}</td>
380
386
  <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
381
387
  <td data-label="昵称">${nick}</td>
382
388
  <td data-label="粉丝">${fans}</td>
@@ -520,6 +526,123 @@ function closeAddModal() {
520
526
  if (overlay) overlay.remove();
521
527
  }
522
528
 
529
+ // ========== 国家修改模态框 ==========
530
+
531
+ const TARGET_LOCATIONS = [
532
+ "AT",
533
+ "BE",
534
+ "CZ",
535
+ "DE",
536
+ "ES",
537
+ "FR",
538
+ "GR",
539
+ "HU",
540
+ "IE",
541
+ "IT",
542
+ "NL",
543
+ "PL",
544
+ "PT",
545
+ ];
546
+
547
+ function openLocationModal(uniqueId, currentLocation) {
548
+ let overlay = document.getElementById("locationModalOverlay");
549
+ if (overlay) return;
550
+ overlay = document.createElement("div");
551
+ overlay.id = "locationModalOverlay";
552
+ overlay.className = "modal-overlay";
553
+ const options = TARGET_LOCATIONS.map(
554
+ (loc) =>
555
+ `<button class="loc-option ${loc === currentLocation ? "active" : ""}" onclick="selectLocation('${uniqueId}','${loc}')">${loc}</button>`,
556
+ ).join("");
557
+ overlay.innerHTML = `
558
+ <div class="modal" style="max-width:420px">
559
+ <h3>修改用户国家</h3>
560
+ <div class="hint">用户: @${uniqueId},当前国家: ${currentLocation}</div>
561
+ <div class="loc-grid">${options}</div>
562
+ <div class="btn-row" style="margin-top:16px">
563
+ <button class="btn-cancel" onclick="closeLocationModal()">取消</button>
564
+ </div>
565
+ </div>
566
+ `;
567
+ document.body.appendChild(overlay);
568
+ overlay.addEventListener("click", (e) => {
569
+ if (e.target === overlay) closeLocationModal();
570
+ });
571
+ }
572
+
573
+ function closeLocationModal() {
574
+ const overlay = document.getElementById("locationModalOverlay");
575
+ if (overlay) overlay.remove();
576
+ }
577
+
578
+ async function selectLocation(uniqueId, location) {
579
+ closeLocationModal();
580
+ showLoading("正在更新...");
581
+ try {
582
+ const res = await fetch(`/api/user-location/${uniqueId}`, {
583
+ method: "PUT",
584
+ headers: { "Content-Type": "application/json" },
585
+ body: JSON.stringify({ location }),
586
+ });
587
+ const data = await res.json();
588
+ if (data.error) {
589
+ showToast(data.error, true);
590
+ return;
591
+ }
592
+ showToast(`@${uniqueId} 国家已更新为 ${location}`);
593
+
594
+ // 同步更新内存数据:将用户从旧国家组移到新国家组
595
+ if (currentTargetData && currentTargetData.countries) {
596
+ let oldCountry = null;
597
+ let userObj = null;
598
+ for (const country of currentTargetData.countries) {
599
+ const idx = country.users.findIndex((u) => u.uniqueId === uniqueId);
600
+ if (idx !== -1) {
601
+ oldCountry = country;
602
+ userObj = country.users.splice(idx, 1)[0];
603
+ break;
604
+ }
605
+ }
606
+ if (userObj) {
607
+ userObj.locationCreated = location;
608
+ userObj.modifiedAt = Date.now();
609
+ // 找到或创建新国家组
610
+ let newCountry = currentTargetData.countries.find(
611
+ (c) => c.country === location,
612
+ );
613
+ if (!newCountry) {
614
+ newCountry = { country: location, count: 0, users: [] };
615
+ currentTargetData.countries.push(newCountry);
616
+ }
617
+ newCountry.users.push(userObj);
618
+ newCountry.count = newCountry.users.length;
619
+ // 更新旧国家组的 count
620
+ if (oldCountry) oldCountry.count = oldCountry.users.length;
621
+ // 重新渲染左侧国家列表和表格
622
+ renderTargetCountryGrid(currentTargetData.countries);
623
+ renderTargetLocationFilter(currentTargetData.countries);
624
+ renderTargetTable(currentTargetData.countries);
625
+ }
626
+ }
627
+
628
+ // 只更新当前行 DOM
629
+ const tr = document.querySelector(`tr[data-user="${uniqueId}"]`);
630
+ if (tr) {
631
+ const locCell = tr.querySelector(".location-cell");
632
+ if (locCell) {
633
+ const editIcon = ' <span style="font-size:10px;opacity:0.5">✏️</span>';
634
+ locCell.className = "location-cell modified";
635
+ locCell.innerHTML = `${location}${editIcon}`;
636
+ locCell.title = `点击修改国家(已修改: ${formatTime(Date.now())})`;
637
+ }
638
+ }
639
+ } catch (e) {
640
+ showToast("更新失败: " + e.message, true);
641
+ } finally {
642
+ hideLoading();
643
+ }
644
+ }
645
+
523
646
  async function submitAddUsers() {
524
647
  const ta = document.getElementById("modalUserInput");
525
648
  const raw = ta.value.trim();
@@ -881,8 +1004,9 @@ function renderTargetCountryGrid(countries) {
881
1004
  .map((c) => {
882
1005
  const pct = ((c.count / total) * 100).toFixed(1);
883
1006
  const safeCountry = escapeJsString(c.country);
1007
+ const sel = currentTargetLocation === c.country ? " selected" : "";
884
1008
  return `
885
- <div class="pending-country-item has-target"
1009
+ <div class="pending-country-item has-target${sel}"
886
1010
  onclick="filterTargetByCountry('${safeCountry}')">
887
1011
  <div class="country-name">${c.country}</div>
888
1012
  <div class="country-count">${c.count}</div>
@@ -941,12 +1065,24 @@ function renderTargetTable(countries) {
941
1065
  .replace(/>/g, "&gt;");
942
1066
  const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
943
1067
  const videos = u.videoCount != null ? u.videoCount : "-";
1068
+ const location = u.locationCreated || "-";
1069
+ const confirmedLocation = u.confirmedLocation
1070
+ ? u.confirmedLocation === u.locationCreated
1071
+ ? `<span style="color:#22c55e">${u.confirmedLocation} ✓</span>`
1072
+ : `<span style="color:#ef4444">${u.confirmedLocation} ✗</span>`
1073
+ : "-";
944
1074
  const latestVideo = u.latestVideoTime
945
1075
  ? formatTime(u.latestVideoTime * 1000)
946
1076
  : "-";
947
1077
  const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
948
1078
  const sources = (u.sources || []).join(", ");
949
1079
 
1080
+ // 修改过的国家用特殊颜色,带编辑图标
1081
+ const editIcon = ' <span style="font-size:10px;opacity:0.5">✏️</span>';
1082
+ const locationCell = u.modifiedAt
1083
+ ? `<td data-label="国家" class="location-cell modified" onclick="openLocationModal('${u.uniqueId}','${location}')" title="点击修改国家(已修改: ${formatTime(u.modifiedAt)})">${location}${editIcon}</td>`
1084
+ : `<td data-label="国家" class="location-cell" onclick="openLocationModal('${u.uniqueId}','${location}')" title="点击修改国家">${location}${editIcon}</td>`;
1085
+
950
1086
  let statusTag = "";
951
1087
  if (u.status === "done")
952
1088
  statusTag = '<span class="tag processed">已完成</span>';
@@ -960,15 +1096,15 @@ function renderTargetTable(countries) {
960
1096
  statusTag = '<span class="tag error">受限</span>';
961
1097
  else statusTag = u.status || "-";
962
1098
 
963
- return `<tr>
1099
+ return `<tr data-user="${u.uniqueId}">
964
1100
  <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
965
1101
  <td data-label="昵称">${nick}</td>
966
1102
  <td data-label="粉丝">${fans}</td>
967
1103
  <td data-label="视频">${videos}</td>
1104
+ ${locationCell}
1105
+ <td data-label="确认国家" style="font-size:11px">${confirmedLocation}</td>
968
1106
  <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
969
1107
  <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
970
- <td data-label="来源">${sources || "-"}</td>
971
- <td data-label="状态">${statusTag}</td>
972
1108
  </tr>`;
973
1109
  })
974
1110
  .join("");
@@ -978,7 +1114,10 @@ function filterTargetByCountry(country) {
978
1114
  currentTargetLocation = country;
979
1115
  const sel = document.getElementById("targetLocationFilter");
980
1116
  if (sel) sel.value = country;
1117
+ const btn = document.getElementById("targetReprocessBtn");
1118
+ if (btn) btn.style.display = "";
981
1119
  if (currentTargetData) {
1120
+ renderTargetCountryGrid(currentTargetData.countries || []);
982
1121
  renderTargetTable(currentTargetData.countries || []);
983
1122
  }
984
1123
  }
@@ -987,6 +1126,7 @@ function onTargetLocationChange() {
987
1126
  const sel = document.getElementById("targetLocationFilter");
988
1127
  currentTargetLocation = sel.value;
989
1128
  if (currentTargetData) {
1129
+ renderTargetCountryGrid(currentTargetData.countries || []);
990
1130
  renderTargetTable(currentTargetData.countries || []);
991
1131
  }
992
1132
  }
@@ -997,11 +1137,62 @@ function clearTargetFilters() {
997
1137
  const locationFilter = document.getElementById("targetLocationFilter");
998
1138
  if (searchInput) searchInput.value = "";
999
1139
  if (locationFilter) locationFilter.value = "";
1140
+ const btn = document.getElementById("targetReprocessBtn");
1141
+ if (btn) btn.style.display = "none";
1000
1142
  if (currentTargetData) {
1143
+ renderTargetCountryGrid(currentTargetData.countries || []);
1001
1144
  renderTargetTable(currentTargetData.countries || []);
1002
1145
  }
1003
1146
  }
1004
1147
 
1148
+ async function reprocessTargetUsers() {
1149
+ if (!currentTargetLocation || !currentTargetData) return;
1150
+ const country = currentTargetLocation;
1151
+ const countryData = currentTargetData.countries.find((c) => c.country === country);
1152
+ const users = countryData ? countryData.users : [];
1153
+ const search = document.getElementById("targetSearchInput")
1154
+ ? document.getElementById("targetSearchInput").value.trim().toLowerCase()
1155
+ : "";
1156
+ const filtered = search
1157
+ ? users.filter(
1158
+ (u) =>
1159
+ (u.uniqueId || "").toLowerCase().includes(search) ||
1160
+ (u.nickname || "").toLowerCase().includes(search),
1161
+ )
1162
+ : users;
1163
+ if (filtered.length === 0) {
1164
+ showToast("当前筛选结果为空", true);
1165
+ return;
1166
+ }
1167
+ if (
1168
+ !confirm(
1169
+ `确定要重新处理 ${country} 的 ${filtered.length} 个目标商家吗?\n这将重置它们的任务状态,使客户端重新采集。`,
1170
+ )
1171
+ ) {
1172
+ return;
1173
+ }
1174
+ const userIds = filtered.map((u) => u.uniqueId);
1175
+ showLoading("正在批量重置...");
1176
+ try {
1177
+ const res = await fetch("/api/jobs/batch-reset", {
1178
+ method: "POST",
1179
+ headers: { "Content-Type": "application/json" },
1180
+ body: JSON.stringify({ userIds }),
1181
+ });
1182
+ const data = await res.json();
1183
+ if (data.error) {
1184
+ showToast(data.error, true);
1185
+ return;
1186
+ }
1187
+ showToast(`已重置 ${data.reset} / ${data.total} 个用户`);
1188
+ fetchTargetData();
1189
+ } catch (e) {
1190
+ showToast("批量重置失败: " + e.message, true);
1191
+ } finally {
1192
+ hideLoading();
1193
+ }
1194
+ }
1195
+
1005
1196
  let targetSearchTimer = null;
1006
1197
 
1007
1198
  document.getElementById("targetSearchInput").addEventListener("input", () => {
@@ -1354,6 +1545,11 @@ function renderRawJobsTable(users) {
1354
1545
  const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
1355
1546
  const videos = u.videoCount != null ? u.videoCount : "-";
1356
1547
  const loc = u.locationCreated || "-";
1548
+ const confirmedLocRaw = u.confirmedLocation
1549
+ ? u.confirmedLocation === u.locationCreated
1550
+ ? `<span style="color:#22c55e">${u.confirmedLocation} ✓</span>`
1551
+ : `<span style="color:#ef4444">${u.confirmedLocation} ✗</span>`
1552
+ : "-";
1357
1553
  const guessedLoc = u.guessedLocation || "-";
1358
1554
  const sources = (u.sources || []).join(", ");
1359
1555
  const created = u.createdAt ? formatTime(u.createdAt) : "-";
@@ -1364,6 +1560,7 @@ function renderRawJobsTable(users) {
1364
1560
  <td data-label="粉丝">${fans}</td>
1365
1561
  <td data-label="视频">${videos}</td>
1366
1562
  <td data-label="国家">${loc}</td>
1563
+ <td data-label="确认国家" style="font-size:11px">${confirmedLocRaw}</td>
1367
1564
  <td data-label="猜测国家">${guessedLoc}</td>
1368
1565
  <td data-label="来源">${sources || "-"}</td>
1369
1566
  <td data-label="状态">${statusTag}</td>
@@ -120,6 +120,7 @@
120
120
  <table>
121
121
  <thead>
122
122
  <tr>
123
+ <th style="width:40px;text-align:right;color:#666">#</th>
123
124
  <th>用户名</th>
124
125
  <th>昵称</th>
125
126
  <th>粉丝</th>
@@ -308,11 +309,8 @@
308
309
  <h3>目标商家列表</h3>
309
310
  <div class="controls">
310
311
  <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
312
  <button onclick="clearTargetFilters()">清空筛选</button>
313
+ <button id="targetReprocessBtn" onclick="reprocessTargetUsers()" style="display:none">重新处理</button>
316
314
  </div>
317
315
  <div class="table-scroll">
318
316
  <table>
@@ -322,10 +320,10 @@
322
320
  <th>昵称</th>
323
321
  <th>粉丝</th>
324
322
  <th>视频</th>
323
+ <th>国家</th>
324
+ <th>确认国家</th>
325
325
  <th>最近发布</th>
326
326
  <th>最近刷新</th>
327
- <th>来源</th>
328
- <th>状态</th>
329
327
  </tr>
330
328
  </thead>
331
329
  <tbody id="targetTable"></tbody>
@@ -273,6 +273,18 @@ body {
273
273
  border-color: #fe2c55;
274
274
  }
275
275
 
276
+ #targetReprocessBtn {
277
+ background: #dc2626;
278
+ border-color: #dc2626;
279
+ color: #fff;
280
+ }
281
+
282
+ #targetReprocessBtn:hover {
283
+ background: #ef4444;
284
+ border-color: #ef4444;
285
+ color: #fff;
286
+ }
287
+
276
288
  .add-users {
277
289
  display: flex;
278
290
  gap: 8px;
@@ -392,6 +404,58 @@ body {
392
404
  background: #e61944;
393
405
  }
394
406
 
407
+ .loc-grid {
408
+ display: grid;
409
+ grid-template-columns: repeat(4, 1fr);
410
+ gap: 8px;
411
+ margin-top: 12px;
412
+ }
413
+
414
+ .loc-option {
415
+ padding: 10px 8px;
416
+ border: 1px solid #333;
417
+ border-radius: 6px;
418
+ background: #2a2a3a;
419
+ color: #ccc;
420
+ font-size: 13px;
421
+ font-weight: 600;
422
+ cursor: pointer;
423
+ transition: all 0.15s;
424
+ }
425
+
426
+ .loc-option:hover {
427
+ border-color: #fe2c55;
428
+ background: rgba(254, 44, 85, 0.1);
429
+ color: #fe2c55;
430
+ }
431
+
432
+ .loc-option.active {
433
+ border-color: #fe2c55;
434
+ background: rgba(254, 44, 85, 0.15);
435
+ color: #fe2c55;
436
+ }
437
+
438
+ .location-cell {
439
+ cursor: pointer;
440
+ transition: all 0.15s;
441
+ position: relative;
442
+ }
443
+
444
+ .location-cell:hover {
445
+ background: rgba(254, 44, 85, 0.08);
446
+ color: #fe2c55;
447
+ }
448
+
449
+ .location-cell.modified {
450
+ color: #f59e0b !important;
451
+ font-weight: 600 !important;
452
+ }
453
+
454
+ .location-cell.modified:hover {
455
+ color: #fbbf24 !important;
456
+ background: rgba(245, 158, 11, 0.1) !important;
457
+ }
458
+
395
459
  .toast {
396
460
  position: fixed;
397
461
  top: 16px;
@@ -507,8 +571,9 @@ tr.row-flash {
507
571
  }
508
572
 
509
573
  .table-scroll {
510
- max-height: 500px;
574
+ max-height: none;
511
575
  overflow-y: auto;
576
+ height: 100%;
512
577
  }
513
578
 
514
579
  table {
@@ -788,6 +853,25 @@ td.user-id:hover {
788
853
  color: #a78bfa;
789
854
  }
790
855
 
856
+ .pending-country-item.selected {
857
+ background: #7c3aed;
858
+ border-color: #a78bfa;
859
+ box-shadow: 0 0 16px rgba(124, 58, 237, 0.6);
860
+ }
861
+
862
+ .pending-country-item.selected .country-name {
863
+ color: #fff;
864
+ font-weight: 700;
865
+ }
866
+
867
+ .pending-country-item.selected .country-count {
868
+ color: #fff;
869
+ }
870
+
871
+ .pending-country-item.selected .country-label {
872
+ color: #ddd6fe;
873
+ }
874
+
791
875
  .back-btn {
792
876
  padding: 6px 14px;
793
877
  border: 1px solid #333;
@@ -983,7 +1067,10 @@ td.user-id:hover {
983
1067
  .target-page-layout {
984
1068
  display: grid;
985
1069
  grid-template-columns: 320px 1fr;
1070
+ grid-template-rows: 1fr;
986
1071
  gap: 16px;
1072
+ min-height: calc(100vh - 280px);
1073
+ align-items: stretch;
987
1074
  }
988
1075
 
989
1076
  .target-side-card {