tt-help-cli-ycl 1.3.65 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.3.65",
3
+ "version": "1.3.72",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/attach.js CHANGED
@@ -134,7 +134,9 @@ export async function handleAttach(options) {
134
134
  `[Attach] 并行数: ${attachParallel}, 空闲间隔: ${attachInterval}秒, 服务端: ${serverUrl}${countryStr}`,
135
135
  );
136
136
 
137
- const scraper = new TikTokScraper(effectiveProxy || null);
137
+ const scraper = new TikTokScraper({
138
+ proxyServer: effectiveProxy || null,
139
+ });
138
140
  const shutdown = async (signal) => {
139
141
  if (shuttingDown) return;
140
142
  shuttingDown = true;
@@ -380,6 +380,7 @@ export async function handleRefresh(options) {
380
380
  maxFollowing: exploreMaxFollowing || 100,
381
381
  maxFollowers: exploreMaxFollowers || 100,
382
382
  location: exploreLocation,
383
+ locationMode: "refresh",
383
384
  browser,
384
385
  proxyServer: cdpOptions.proxyServer || null,
385
386
  },
@@ -405,6 +406,7 @@ export async function handleRefresh(options) {
405
406
  maxFollowing: exploreMaxFollowing || 100,
406
407
  maxFollowers: exploreMaxFollowers || 100,
407
408
  location: exploreLocation,
409
+ locationMode: "refresh",
408
410
  browser,
409
411
  proxyServer: cdpOptions.proxyServer || null,
410
412
  },
@@ -443,6 +445,7 @@ export async function handleRefresh(options) {
443
445
  maxFollowing: exploreMaxFollowing || 100,
444
446
  maxFollowers: exploreMaxFollowers || 100,
445
447
  location: exploreLocation,
448
+ locationMode: "refresh",
446
449
  browser,
447
450
  proxyServer: cdpOptions.proxyServer || null,
448
451
  },
@@ -525,10 +528,20 @@ export async function handleRefresh(options) {
525
528
 
526
529
  processedCount++;
527
530
 
528
- const guessedLocation = result.locationCreated || null;
531
+ // refresh 模式:confirmedLocation 是二次确认的国家,写入 confirmed_location
532
+ // locationCreated 保持原始值不变
533
+ const refreshLocation =
534
+ result.confirmedLocation || result.locationCreated;
535
+ const guessedLocation = refreshLocation || null;
536
+
537
+ // 把 confirmedLocation 合并到 userInfo 中(通过 commitRedoJob 写入 DB)
538
+ const refreshUserInfo = { ...(result.userInfo || {}) };
539
+ if (result.confirmedLocation) {
540
+ refreshUserInfo.confirmedLocation = result.confirmedLocation;
541
+ }
529
542
 
530
543
  const payload = {
531
- userInfo: result.userInfo || {},
544
+ userInfo: refreshUserInfo,
532
545
  discoveredFollowing: (result.discoveredFollowing || []).map((f) => ({
533
546
  handle: Array.isArray(f) ? f[0] : f,
534
547
  displayName: Array.isArray(f) ? f[1] : null,
@@ -542,7 +555,6 @@ export async function handleRefresh(options) {
542
555
  processed: result.processed,
543
556
  hasFollowData: result.hasFollowData,
544
557
  keepFollow: result.keepFollow,
545
- locationCreated: result.locationCreated,
546
558
  noVideo: result.noVideo,
547
559
  collectedVideos: result.collectedVideos,
548
560
  };
@@ -555,7 +567,7 @@ export async function handleRefresh(options) {
555
567
  {
556
568
  sourceUser: username,
557
569
  videoList: result.videoList,
558
- locationCreated: result.locationCreated,
570
+ locationCreated: refreshLocation,
559
571
  ttSeller: result.userInfo?.ttSeller || false,
560
572
  },
561
573
  );
@@ -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
+ locationMode = "explore", // "explore" | "refresh"
26
27
  proxyServer = null,
27
28
  } = options;
28
29
 
@@ -91,10 +92,12 @@ async function processExplore(page, username, options, log) {
91
92
  return result;
92
93
  }
93
94
 
94
- // 从最多 5 个视频并发获取 locationCreated。
95
- // 新规则:采样列表里只要有任一国家命中目标列表,就直接采用该国家;否则回退到众数。
96
- const SAMPLE_SIZE = 5;
95
+ // 国家采样判断
96
+ // explore 模式:采样 5 个,优先命中目标国家,不匹配则回退众数 → 写 locationCreated
97
+ // refresh 模式:采样 7 个,纯众数逻辑 → 写 confirmedLocation(二次确认)
98
+ const SAMPLE_SIZE = locationMode === "refresh" ? 7 : 5;
97
99
  let locationCreated = null;
100
+ let confirmedLocation = null;
98
101
  let locationDecision = null;
99
102
  const sampleVideos = videoArray.slice(0, SAMPLE_SIZE);
100
103
  if (sampleVideos.length > 0) {
@@ -108,34 +111,48 @@ async function processExplore(page, username, options, log) {
108
111
  ` 国家采样(${locations.length}个): [${locations.filter(Boolean).join(", ") || "无数据"}]`,
109
112
  );
110
113
  const normalizedLocations = normalizeLocationList(locations, []);
111
- const matchedTargetLocation = findFirstMatchingLocation(
112
- normalizedLocations,
113
- locationList,
114
- );
114
+
115
+ // 统计频率
115
116
  const freq = {};
116
117
  for (const key of normalizedLocations) {
117
118
  freq[key] = (freq[key] || 0) + 1;
118
119
  }
119
120
  const entries = Object.entries(freq).sort((a, b) => b[1] - a[1]);
120
- if (matchedTargetLocation) {
121
- locationCreated = matchedTargetLocation;
122
- locationDecision = "命中目标国家";
123
- } else if (entries.length > 0) {
124
- locationCreated = entries[0][0];
125
- 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
+ }
126
141
  }
127
142
  }
128
143
 
129
144
  result.locationCreated = locationCreated || null;
145
+ result.confirmedLocation = confirmedLocation || null;
130
146
  log(
131
- ` 国家: ${result.locationCreated || "未知"}${locationDecision ? ` (${locationDecision})` : ""}`,
147
+ ` 国家: ${result.confirmedLocation || result.locationCreated || "未知"}${locationDecision ? ` (${locationDecision})` : ""}`,
132
148
  );
133
149
 
134
- // 国家筛选
135
- const isTargetLocation = isLocationInList(
136
- result.locationCreated,
137
- locationList,
138
- );
150
+ // 国家筛选:refresh 模式用 confirmedLocation,explore 模式用 locationCreated
151
+ const effectiveLocation =
152
+ locationMode === "refresh"
153
+ ? result.confirmedLocation
154
+ : result.locationCreated;
155
+ const isTargetLocation = isLocationInList(effectiveLocation, locationList);
139
156
 
140
157
  if (isTargetLocation) {
141
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>
@@ -942,12 +1066,23 @@ function renderTargetTable(countries) {
942
1066
  const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
943
1067
  const videos = u.videoCount != null ? u.videoCount : "-";
944
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
+ : "-";
945
1074
  const latestVideo = u.latestVideoTime
946
1075
  ? formatTime(u.latestVideoTime * 1000)
947
1076
  : "-";
948
1077
  const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
949
1078
  const sources = (u.sources || []).join(", ");
950
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
+
951
1086
  let statusTag = "";
952
1087
  if (u.status === "done")
953
1088
  statusTag = '<span class="tag processed">已完成</span>';
@@ -961,16 +1096,15 @@ function renderTargetTable(countries) {
961
1096
  statusTag = '<span class="tag error">受限</span>';
962
1097
  else statusTag = u.status || "-";
963
1098
 
964
- return `<tr>
1099
+ return `<tr data-user="${u.uniqueId}">
965
1100
  <td class="user-id" data-label="用户名">@${u.uniqueId}</td>
966
1101
  <td data-label="昵称">${nick}</td>
967
1102
  <td data-label="粉丝">${fans}</td>
968
1103
  <td data-label="视频">${videos}</td>
969
- <td data-label="国家">${location}</td>
1104
+ ${locationCell}
1105
+ <td data-label="确认国家" style="font-size:11px">${confirmedLocation}</td>
970
1106
  <td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
971
1107
  <td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
972
- <td data-label="来源">${sources || "-"}</td>
973
- <td data-label="状态">${statusTag}</td>
974
1108
  </tr>`;
975
1109
  })
976
1110
  .join("");
@@ -980,7 +1114,10 @@ function filterTargetByCountry(country) {
980
1114
  currentTargetLocation = country;
981
1115
  const sel = document.getElementById("targetLocationFilter");
982
1116
  if (sel) sel.value = country;
1117
+ const btn = document.getElementById("targetReprocessBtn");
1118
+ if (btn) btn.style.display = "";
983
1119
  if (currentTargetData) {
1120
+ renderTargetCountryGrid(currentTargetData.countries || []);
984
1121
  renderTargetTable(currentTargetData.countries || []);
985
1122
  }
986
1123
  }
@@ -989,6 +1126,7 @@ function onTargetLocationChange() {
989
1126
  const sel = document.getElementById("targetLocationFilter");
990
1127
  currentTargetLocation = sel.value;
991
1128
  if (currentTargetData) {
1129
+ renderTargetCountryGrid(currentTargetData.countries || []);
992
1130
  renderTargetTable(currentTargetData.countries || []);
993
1131
  }
994
1132
  }
@@ -999,11 +1137,62 @@ function clearTargetFilters() {
999
1137
  const locationFilter = document.getElementById("targetLocationFilter");
1000
1138
  if (searchInput) searchInput.value = "";
1001
1139
  if (locationFilter) locationFilter.value = "";
1140
+ const btn = document.getElementById("targetReprocessBtn");
1141
+ if (btn) btn.style.display = "none";
1002
1142
  if (currentTargetData) {
1143
+ renderTargetCountryGrid(currentTargetData.countries || []);
1003
1144
  renderTargetTable(currentTargetData.countries || []);
1004
1145
  }
1005
1146
  }
1006
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
+
1007
1196
  let targetSearchTimer = null;
1008
1197
 
1009
1198
  document.getElementById("targetSearchInput").addEventListener("input", () => {
@@ -1356,6 +1545,11 @@ function renderRawJobsTable(users) {
1356
1545
  const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
1357
1546
  const videos = u.videoCount != null ? u.videoCount : "-";
1358
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
+ : "-";
1359
1553
  const guessedLoc = u.guessedLocation || "-";
1360
1554
  const sources = (u.sources || []).join(", ");
1361
1555
  const created = u.createdAt ? formatTime(u.createdAt) : "-";
@@ -1366,6 +1560,7 @@ function renderRawJobsTable(users) {
1366
1560
  <td data-label="粉丝">${fans}</td>
1367
1561
  <td data-label="视频">${videos}</td>
1368
1562
  <td data-label="国家">${loc}</td>
1563
+ <td data-label="确认国家" style="font-size:11px">${confirmedLocRaw}</td>
1369
1564
  <td data-label="猜测国家">${guessedLoc}</td>
1370
1565
  <td data-label="来源">${sources || "-"}</td>
1371
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>
@@ -323,10 +321,9 @@
323
321
  <th>粉丝</th>
324
322
  <th>视频</th>
325
323
  <th>国家</th>
324
+ <th>确认国家</th>
326
325
  <th>最近发布</th>
327
326
  <th>最近刷新</th>
328
- <th>来源</th>
329
- <th>状态</th>
330
327
  </tr>
331
328
  </thead>
332
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 {
@@ -439,6 +439,29 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
439
439
  return;
440
440
  }
441
441
 
442
+ const userLocationMatch = routePath.match(
443
+ /^\/api\/user-location\/([^/]+)$/,
444
+ );
445
+ if (req.method === "PUT" && userLocationMatch) {
446
+ const uniqueId = userLocationMatch[1];
447
+ try {
448
+ const body = await readBody(req);
449
+ const ret = store.updateUserLocation(uniqueId, body.location);
450
+ if (ret.error) {
451
+ sendJSON(res, 404, { error: ret.error });
452
+ return;
453
+ }
454
+ const ts = new Date().toISOString().slice(11, 19);
455
+ console.error(
456
+ `[JOB ${ts}] USER-LOCATION: ${uniqueId} → ${body.location} (modifiedAt=${ret.modifiedAt})`,
457
+ );
458
+ sendJSON(res, 200, ret);
459
+ } catch (e) {
460
+ sendJSON(res, 400, { error: e.message });
461
+ }
462
+ return;
463
+ }
464
+
442
465
  if (req.method === "GET" && routePath === "/api/comment-tasks") {
443
466
  const limit = parseInt(params.limit) || 1;
444
467
  const tasks = store.getPendingCommentTasks(limit);