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 +1 -1
- package/src/cli/attach.js +3 -1
- package/src/cli/refresh.js +16 -4
- package/src/scraper/explore-core.js +36 -19
- package/src/watch/data-store.js +44 -0
- package/src/watch/public/app.js +201 -6
- package/src/watch/public/index.html +3 -6
- package/src/watch/public/style.css +88 -1
- package/src/watch/server.js +23 -0
package/package.json
CHANGED
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(
|
|
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;
|
package/src/cli/refresh.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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;
|
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>
|
|
@@ -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
|
-
|
|
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:
|
|
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 {
|
package/src/watch/server.js
CHANGED
|
@@ -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);
|