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.
- package/package.json +1 -1
- package/src/cli/attach.js +13 -1
- package/src/cli/config.js +46 -3
- package/src/cli/explore.js +22 -4
- package/src/cli/refresh.js +46 -13
- package/src/lib/args.js +11 -2
- package/src/lib/browser/cdp.js +12 -4
- package/src/lib/constants.js +37 -36
- package/src/lib/scrape.js +20 -4
- package/src/lib/tiktok-scraper.mjs +11 -5
- package/src/scraper/explore-core.js +43 -20
- package/src/watch/data-store.js +44 -0
- package/src/watch/public/app.js +202 -5
- package/src/watch/public/index.html +4 -6
- package/src/watch/public/style.css +88 -1
- package/src/watch/server.js +23 -0
|
@@ -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
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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;
|
package/src/watch/data-store.js
CHANGED
|
@@ -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,
|
package/src/watch/public/app.js
CHANGED
|
@@ -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, ">");
|
|
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:
|
|
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 {
|