tt-help-cli-ycl 1.3.63 → 1.3.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/run-explore.bat +1 -1
- package/src/cli/attach.js +11 -1
- package/src/cli/config.js +46 -3
- package/src/cli/explore.js +25 -8
- package/src/cli/refresh.js +30 -9
- package/src/lib/args.js +11 -2
- package/src/lib/browser/cdp.js +12 -4
- package/src/lib/browser/page.js +41 -0
- 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 +7 -1
- package/src/watch/data-store.js +128 -2
- package/src/watch/public/app.js +293 -42
- package/src/watch/public/index.html +63 -0
- package/src/watch/public/style.css +80 -3
- package/src/watch/server.js +88 -1
|
@@ -57,11 +57,13 @@ export class TikTokScraper {
|
|
|
57
57
|
wafTtl = DEFAULT_WAF_TTL,
|
|
58
58
|
warmUrl = DEFAULT_WARM_URL,
|
|
59
59
|
maxRequestsPerPage = DEFAULT_MAX_REQUESTS_PER_PAGE,
|
|
60
|
+
proxyServer = null,
|
|
60
61
|
} = {}) {
|
|
61
62
|
this.poolSize = poolSize;
|
|
62
63
|
this.wafTtl = wafTtl;
|
|
63
64
|
this.warmUrl = warmUrl;
|
|
64
65
|
this.maxRequestsPerPage = maxRequestsPerPage;
|
|
66
|
+
this.proxyServer = proxyServer;
|
|
65
67
|
this.browser = null;
|
|
66
68
|
this.context = null;
|
|
67
69
|
this.slots = [];
|
|
@@ -77,17 +79,21 @@ export class TikTokScraper {
|
|
|
77
79
|
"未找到本地浏览器(Chrome/Edge),请先安装浏览器或执行 npx playwright install",
|
|
78
80
|
);
|
|
79
81
|
}
|
|
82
|
+
const launchArgs = [
|
|
83
|
+
"--no-sandbox",
|
|
84
|
+
"--disable-setuid-sandbox",
|
|
85
|
+
"--disable-dev-shm-usage",
|
|
86
|
+
];
|
|
87
|
+
if (this.proxyServer) {
|
|
88
|
+
launchArgs.push(`--proxy-server=${this.proxyServer}`);
|
|
89
|
+
}
|
|
80
90
|
this.browser = await chromium.launch({
|
|
81
91
|
headless: true,
|
|
82
92
|
executablePath,
|
|
83
93
|
handleSIGINT: false,
|
|
84
94
|
handleSIGTERM: false,
|
|
85
95
|
handleSIGHUP: false,
|
|
86
|
-
args:
|
|
87
|
-
"--no-sandbox",
|
|
88
|
-
"--disable-setuid-sandbox",
|
|
89
|
-
"--disable-dev-shm-usage",
|
|
90
|
-
],
|
|
96
|
+
args: launchArgs,
|
|
91
97
|
});
|
|
92
98
|
this.context = await this.browser.newContext();
|
|
93
99
|
for (let i = 0; i < this.poolSize; i++) {
|
|
@@ -3,7 +3,7 @@ import { detectCaptcha } from "./modules/captcha-handler.js";
|
|
|
3
3
|
export { ensureBrowserReady };
|
|
4
4
|
import { getUserInfo, collectVideos } from "../videos/core.js";
|
|
5
5
|
import { extractFollowAndFollowers } from "./modules/follow-extractor.js";
|
|
6
|
-
import { extractVideoLocation } from "../lib/scrape.js";
|
|
6
|
+
import { extractVideoLocation, setScraperProxy } from "../lib/scrape.js";
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_TARGET_LOCATIONS_CSV,
|
|
9
9
|
findFirstMatchingLocation,
|
|
@@ -23,6 +23,7 @@ async function processExplore(page, username, options, log) {
|
|
|
23
23
|
maxFollowing = 50,
|
|
24
24
|
maxFollowers = 50,
|
|
25
25
|
location = DEFAULT_TARGET_LOCATIONS_CSV,
|
|
26
|
+
proxyServer = null,
|
|
26
27
|
} = options;
|
|
27
28
|
|
|
28
29
|
const result = {
|
|
@@ -45,6 +46,11 @@ async function processExplore(page, username, options, log) {
|
|
|
45
46
|
|
|
46
47
|
const locationList = normalizeLocationList(location);
|
|
47
48
|
|
|
49
|
+
// 设置 TikTokScraper 的代理,与 CDP 浏览器保持一致
|
|
50
|
+
if (options.proxyServer) {
|
|
51
|
+
setScraperProxy(options.proxyServer);
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
try {
|
|
49
55
|
log(` 访问 @${username} 主页...`);
|
|
50
56
|
const videoList = await collectVideos(page, username, maxVideos, log);
|
package/src/watch/data-store.js
CHANGED
|
@@ -775,6 +775,53 @@ function restoreAttachStuckByCountry(country) {
|
|
|
775
775
|
return { restored: count, country: normalizedCountry };
|
|
776
776
|
}
|
|
777
777
|
|
|
778
|
+
function resetPendingByCountry(country) {
|
|
779
|
+
if (!db) {
|
|
780
|
+
return { reset: 0, country, error: "db not ready" };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const normalizedCountry = String(country == null ? "未知" : country).trim();
|
|
784
|
+
if (!normalizedCountry) {
|
|
785
|
+
return {
|
|
786
|
+
reset: 0,
|
|
787
|
+
country: normalizedCountry,
|
|
788
|
+
error: "country is required",
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const whereSql = `
|
|
793
|
+
status = 'pending'
|
|
794
|
+
AND COALESCE(guessed_location, '未知') = ?
|
|
795
|
+
`;
|
|
796
|
+
const count =
|
|
797
|
+
db
|
|
798
|
+
.prepare(
|
|
799
|
+
`
|
|
800
|
+
SELECT COUNT(*) as c
|
|
801
|
+
FROM jobs
|
|
802
|
+
WHERE ${whereSql}
|
|
803
|
+
`,
|
|
804
|
+
)
|
|
805
|
+
.get(normalizedCountry)?.c || 0;
|
|
806
|
+
|
|
807
|
+
if (!count) {
|
|
808
|
+
return { reset: 0, country: normalizedCountry };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
db.prepare(
|
|
812
|
+
`
|
|
813
|
+
UPDATE jobs
|
|
814
|
+
SET user_update_count = 0,
|
|
815
|
+
updated_at = ?,
|
|
816
|
+
claimed_by = NULL,
|
|
817
|
+
claimed_at = NULL
|
|
818
|
+
WHERE ${whereSql}
|
|
819
|
+
`,
|
|
820
|
+
).run(Date.now(), normalizedCountry);
|
|
821
|
+
|
|
822
|
+
return { reset: count, country: normalizedCountry };
|
|
823
|
+
}
|
|
824
|
+
|
|
778
825
|
function getRawByCountryFromDb() {
|
|
779
826
|
if (!db) return [];
|
|
780
827
|
|
|
@@ -1077,7 +1124,7 @@ function restoreRawJobById(uniqueId) {
|
|
|
1077
1124
|
return { restored: 1, uniqueId: safeId };
|
|
1078
1125
|
}
|
|
1079
1126
|
|
|
1080
|
-
function restoreRawJobsByFilter({ search, location }) {
|
|
1127
|
+
function restoreRawJobsByFilter({ search, location, hasVideo, hasFollower }) {
|
|
1081
1128
|
if (!db) {
|
|
1082
1129
|
return { restored: 0, error: "db not ready" };
|
|
1083
1130
|
}
|
|
@@ -1098,6 +1145,14 @@ function restoreRawJobsByFilter({ search, location }) {
|
|
|
1098
1145
|
args.push(location);
|
|
1099
1146
|
}
|
|
1100
1147
|
|
|
1148
|
+
if (hasVideo) {
|
|
1149
|
+
where.push("COALESCE(video_count, 0) > 0");
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
if (hasFollower) {
|
|
1153
|
+
where.push("COALESCE(follower_count, 0) > 0");
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1101
1156
|
if (where.length === 0) {
|
|
1102
1157
|
return { restored: 0, error: "at least one filter is required" };
|
|
1103
1158
|
}
|
|
@@ -1140,7 +1195,14 @@ function restoreRawJobsByFilter({ search, location }) {
|
|
|
1140
1195
|
return { restored: count };
|
|
1141
1196
|
}
|
|
1142
1197
|
|
|
1143
|
-
function getRawJobsPageFromDb({
|
|
1198
|
+
function getRawJobsPageFromDb({
|
|
1199
|
+
search,
|
|
1200
|
+
location,
|
|
1201
|
+
limit,
|
|
1202
|
+
offset,
|
|
1203
|
+
hasVideo,
|
|
1204
|
+
hasFollower,
|
|
1205
|
+
}) {
|
|
1144
1206
|
if (!db) return null;
|
|
1145
1207
|
|
|
1146
1208
|
const safeLimit = Math.max(1, Math.min(200, parseInt(limit) || 50));
|
|
@@ -1159,6 +1221,12 @@ function getRawJobsPageFromDb({ search, location, limit, offset }) {
|
|
|
1159
1221
|
where.push("COALESCE(guessed_location, '未知') = ?");
|
|
1160
1222
|
args.push(location);
|
|
1161
1223
|
}
|
|
1224
|
+
if (hasVideo) {
|
|
1225
|
+
where.push("COALESCE(video_count, 0) > 0");
|
|
1226
|
+
}
|
|
1227
|
+
if (hasFollower) {
|
|
1228
|
+
where.push("COALESCE(follower_count, 0) > 0");
|
|
1229
|
+
}
|
|
1162
1230
|
|
|
1163
1231
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
1164
1232
|
const total = db
|
|
@@ -1329,6 +1397,62 @@ function getTargetUsersFromDb(targetLocations = []) {
|
|
|
1329
1397
|
};
|
|
1330
1398
|
}
|
|
1331
1399
|
|
|
1400
|
+
function getTargetUsersByCountryFromDb(targetLocations = []) {
|
|
1401
|
+
if (!db) return null;
|
|
1402
|
+
if (!targetLocations.length) {
|
|
1403
|
+
return { countries: [] };
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const placeholders = targetLocations.map(() => "?").join(", ");
|
|
1407
|
+
const rows = db
|
|
1408
|
+
.prepare(
|
|
1409
|
+
`
|
|
1410
|
+
SELECT
|
|
1411
|
+
unique_id,
|
|
1412
|
+
nickname,
|
|
1413
|
+
follower_count,
|
|
1414
|
+
video_count,
|
|
1415
|
+
tt_seller,
|
|
1416
|
+
verified,
|
|
1417
|
+
location_created,
|
|
1418
|
+
latest_video_time,
|
|
1419
|
+
refresh_time,
|
|
1420
|
+
status,
|
|
1421
|
+
sources
|
|
1422
|
+
FROM jobs
|
|
1423
|
+
WHERE tt_seller = 1
|
|
1424
|
+
AND verified = 0
|
|
1425
|
+
AND location_created IN (${placeholders})
|
|
1426
|
+
ORDER BY location_created ASC, COALESCE(latest_video_time, 0) DESC
|
|
1427
|
+
`,
|
|
1428
|
+
)
|
|
1429
|
+
.all(...targetLocations)
|
|
1430
|
+
.map(mapJobRow);
|
|
1431
|
+
|
|
1432
|
+
const countryMap = new Map();
|
|
1433
|
+
for (const row of rows) {
|
|
1434
|
+
const country = row.locationCreated || "未知";
|
|
1435
|
+
if (!countryMap.has(country)) {
|
|
1436
|
+
countryMap.set(country, []);
|
|
1437
|
+
}
|
|
1438
|
+
countryMap.get(country).push(row);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const countries = [];
|
|
1442
|
+
for (const [country, users] of countryMap) {
|
|
1443
|
+
countries.push({
|
|
1444
|
+
country,
|
|
1445
|
+
count: users.length,
|
|
1446
|
+
users,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
return {
|
|
1451
|
+
total: rows.length,
|
|
1452
|
+
countries,
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1332
1456
|
function snakeToCamel(key) {
|
|
1333
1457
|
return key.replace(/_([a-z])/g, (_, ch) => ch.toUpperCase());
|
|
1334
1458
|
}
|
|
@@ -3359,12 +3483,14 @@ export function createStore(filePath) {
|
|
|
3359
3483
|
getRawByCountry: getRawByCountryFromDb,
|
|
3360
3484
|
moveJobsToRawByCountry,
|
|
3361
3485
|
restoreAttachStuckByCountry,
|
|
3486
|
+
resetPendingByCountry,
|
|
3362
3487
|
restoreRawJobsByCountry,
|
|
3363
3488
|
restoreRawJobById,
|
|
3364
3489
|
restoreRawJobsByFilter,
|
|
3365
3490
|
getUsersPage: getUsersPageFromDb,
|
|
3366
3491
|
getRawJobsPage: getRawJobsPageFromDb,
|
|
3367
3492
|
getTargetUsers: getTargetUsersFromDb,
|
|
3493
|
+
getTargetUsersByCountry: getTargetUsersByCountryFromDb,
|
|
3368
3494
|
getStats,
|
|
3369
3495
|
getStatusGroups,
|
|
3370
3496
|
markGroupsDirty,
|
package/src/watch/public/app.js
CHANGED
|
@@ -743,44 +743,9 @@ async function batchResetErrors() {
|
|
|
743
743
|
}
|
|
744
744
|
}
|
|
745
745
|
|
|
746
|
-
document
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
showLoading("正在导出目标用户...");
|
|
750
|
-
try {
|
|
751
|
-
const res = await fetch("/api/target-users", {
|
|
752
|
-
headers: { Accept: "text/csv" },
|
|
753
|
-
});
|
|
754
|
-
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
755
|
-
const blob = await res.blob();
|
|
756
|
-
const ext = blob.size < 200 ? "json" : "csv";
|
|
757
|
-
if (ext === "json") {
|
|
758
|
-
const text = await blob.text();
|
|
759
|
-
const data = JSON.parse(text);
|
|
760
|
-
if (!data.users.length) {
|
|
761
|
-
showToast("暂无目标用户", true);
|
|
762
|
-
return;
|
|
763
|
-
}
|
|
764
|
-
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
765
|
-
const ids = data.users.map((u) => "@" + u.uniqueId).join(", ");
|
|
766
|
-
await navigator.clipboard.writeText(ids);
|
|
767
|
-
showToast(data.users.length + " 个目标用户 ID 已复制到剪贴板");
|
|
768
|
-
}
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
const url = URL.createObjectURL(blob);
|
|
772
|
-
const a = document.createElement("a");
|
|
773
|
-
a.href = url;
|
|
774
|
-
a.download = "target-users.csv";
|
|
775
|
-
a.click();
|
|
776
|
-
URL.revokeObjectURL(url);
|
|
777
|
-
showToast("CSV 文件已开始下载");
|
|
778
|
-
} catch (e) {
|
|
779
|
-
showToast("获取失败: " + e.message, true);
|
|
780
|
-
} finally {
|
|
781
|
-
hideLoading();
|
|
782
|
-
}
|
|
783
|
-
});
|
|
746
|
+
document.getElementById("statTargetCard").addEventListener("click", () => {
|
|
747
|
+
navigateToTarget();
|
|
748
|
+
});
|
|
784
749
|
|
|
785
750
|
// Hash 路由
|
|
786
751
|
window.addEventListener("hashchange", handleRoute);
|
|
@@ -794,6 +759,8 @@ function handleRoute() {
|
|
|
794
759
|
showUserUpdatePage();
|
|
795
760
|
} else if (hash === "#raw") {
|
|
796
761
|
showRawPage();
|
|
762
|
+
} else if (hash === "#target") {
|
|
763
|
+
showTargetPage();
|
|
797
764
|
} else {
|
|
798
765
|
showMainPage();
|
|
799
766
|
}
|
|
@@ -820,6 +787,7 @@ function showPendingPage() {
|
|
|
820
787
|
document.getElementById("pendingPage").classList.add("active");
|
|
821
788
|
document.getElementById("userUpdatePage").classList.remove("active");
|
|
822
789
|
document.getElementById("rawPage").classList.remove("active");
|
|
790
|
+
document.getElementById("targetPage").classList.remove("active");
|
|
823
791
|
fetchPendingByCountry();
|
|
824
792
|
fetchAttachStuckByCountry();
|
|
825
793
|
}
|
|
@@ -829,6 +797,7 @@ function showUserUpdatePage() {
|
|
|
829
797
|
document.getElementById("pendingPage").classList.remove("active");
|
|
830
798
|
document.getElementById("userUpdatePage").classList.add("active");
|
|
831
799
|
document.getElementById("rawPage").classList.remove("active");
|
|
800
|
+
document.getElementById("targetPage").classList.remove("active");
|
|
832
801
|
fetchUserUpdateByCountry();
|
|
833
802
|
fetchAttachStuckByCountry();
|
|
834
803
|
}
|
|
@@ -845,6 +814,7 @@ function showRawPage() {
|
|
|
845
814
|
document.getElementById("pendingPage").classList.remove("active");
|
|
846
815
|
document.getElementById("userUpdatePage").classList.remove("active");
|
|
847
816
|
document.getElementById("rawPage").classList.add("active");
|
|
817
|
+
document.getElementById("targetPage").classList.remove("active");
|
|
848
818
|
fetchRawByCountry();
|
|
849
819
|
fetchRawJobs();
|
|
850
820
|
}
|
|
@@ -854,8 +824,221 @@ function showMainPage() {
|
|
|
854
824
|
document.getElementById("pendingPage").classList.remove("active");
|
|
855
825
|
document.getElementById("userUpdatePage").classList.remove("active");
|
|
856
826
|
document.getElementById("rawPage").classList.remove("active");
|
|
827
|
+
document.getElementById("targetPage").classList.remove("active");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function navigateToTarget() {
|
|
831
|
+
window.location.hash = "#target";
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function showTargetPage() {
|
|
835
|
+
document.getElementById("mainPage").classList.add("hidden");
|
|
836
|
+
document.getElementById("pendingPage").classList.remove("active");
|
|
837
|
+
document.getElementById("userUpdatePage").classList.remove("active");
|
|
838
|
+
document.getElementById("rawPage").classList.remove("active");
|
|
839
|
+
document.getElementById("targetPage").classList.add("active");
|
|
840
|
+
fetchTargetByCountry();
|
|
841
|
+
// 同步统计
|
|
842
|
+
if (currentStats) {
|
|
843
|
+
document.getElementById("targetPageStatTotal").textContent = formatStatNum(
|
|
844
|
+
currentStats.targetUsers || 0,
|
|
845
|
+
{ full: true },
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
let currentTargetData = null;
|
|
851
|
+
|
|
852
|
+
async function fetchTargetByCountry() {
|
|
853
|
+
try {
|
|
854
|
+
const res = await fetch("/api/target-users-by-country");
|
|
855
|
+
const data = await res.json();
|
|
856
|
+
currentTargetData = data;
|
|
857
|
+
renderTargetCountryGrid(data.countries || []);
|
|
858
|
+
renderTargetLocationFilter(data.countries || []);
|
|
859
|
+
renderTargetTable(data.countries || []);
|
|
860
|
+
document.getElementById("targetPageStatTotal").textContent = formatStatNum(
|
|
861
|
+
data.total || 0,
|
|
862
|
+
{ full: true },
|
|
863
|
+
);
|
|
864
|
+
document.getElementById("targetPageStatCountries").textContent = (
|
|
865
|
+
data.countries || []
|
|
866
|
+
).length;
|
|
867
|
+
} catch (e) {
|
|
868
|
+
console.error("获取目标商家数据失败:", e);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function renderTargetCountryGrid(countries) {
|
|
873
|
+
const grid = document.getElementById("targetCountryGrid");
|
|
874
|
+
if (!countries.length) {
|
|
875
|
+
grid.innerHTML =
|
|
876
|
+
'<span style="color:#666;font-size:12px">暂无目标商家</span>';
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const total = countries.reduce((sum, c) => sum + c.count, 0);
|
|
880
|
+
grid.innerHTML = countries
|
|
881
|
+
.map((c) => {
|
|
882
|
+
const pct = ((c.count / total) * 100).toFixed(1);
|
|
883
|
+
const safeCountry = escapeJsString(c.country);
|
|
884
|
+
return `
|
|
885
|
+
<div class="pending-country-item has-target"
|
|
886
|
+
onclick="filterTargetByCountry('${safeCountry}')">
|
|
887
|
+
<div class="country-name">${c.country}</div>
|
|
888
|
+
<div class="country-count">${c.count}</div>
|
|
889
|
+
<div class="country-label">${pct}% 目标商家</div>
|
|
890
|
+
</div>
|
|
891
|
+
`;
|
|
892
|
+
})
|
|
893
|
+
.join("");
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function renderTargetLocationFilter(countries) {
|
|
897
|
+
const sel = document.getElementById("targetLocationFilter");
|
|
898
|
+
if (!sel) return;
|
|
899
|
+
const val = sel.value;
|
|
900
|
+
sel.innerHTML =
|
|
901
|
+
'<option value="">全部国家</option>' +
|
|
902
|
+
countries
|
|
903
|
+
.map(
|
|
904
|
+
(c) =>
|
|
905
|
+
`<option value="${c.country}"${val === c.country ? " selected" : ""}>${c.country} (${c.count})</option>`,
|
|
906
|
+
)
|
|
907
|
+
.join("");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function renderTargetTable(countries) {
|
|
911
|
+
const el = document.getElementById("targetTable");
|
|
912
|
+
const search = document.getElementById("targetSearchInput")
|
|
913
|
+
? document.getElementById("targetSearchInput").value.trim().toLowerCase()
|
|
914
|
+
: "";
|
|
915
|
+
const location = currentTargetLocation;
|
|
916
|
+
|
|
917
|
+
let allUsers = [];
|
|
918
|
+
for (const c of countries) {
|
|
919
|
+
if (location && c.country !== location) continue;
|
|
920
|
+
allUsers = allUsers.concat(c.users);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (search) {
|
|
924
|
+
allUsers = allUsers.filter(
|
|
925
|
+
(u) =>
|
|
926
|
+
(u.uniqueId || "").toLowerCase().includes(search) ||
|
|
927
|
+
(u.nickname || "").toLowerCase().includes(search),
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (!allUsers.length) {
|
|
932
|
+
el.innerHTML =
|
|
933
|
+
'<tr><td colspan="8" style="color:#666;text-align:center;padding:24px">暂无目标商家</td></tr>';
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
el.innerHTML = allUsers
|
|
938
|
+
.map((u) => {
|
|
939
|
+
const nick = (u.nickname || "")
|
|
940
|
+
.replace(/</g, "<")
|
|
941
|
+
.replace(/>/g, ">");
|
|
942
|
+
const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
|
|
943
|
+
const videos = u.videoCount != null ? u.videoCount : "-";
|
|
944
|
+
const location = u.locationCreated || "-";
|
|
945
|
+
const latestVideo = u.latestVideoTime
|
|
946
|
+
? formatTime(u.latestVideoTime * 1000)
|
|
947
|
+
: "-";
|
|
948
|
+
const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
|
|
949
|
+
const sources = (u.sources || []).join(", ");
|
|
950
|
+
|
|
951
|
+
let statusTag = "";
|
|
952
|
+
if (u.status === "done")
|
|
953
|
+
statusTag = '<span class="tag processed">已完成</span>';
|
|
954
|
+
else if (u.status === "processing")
|
|
955
|
+
statusTag = '<span class="tag processing">处理中</span>';
|
|
956
|
+
else if (u.status === "pending")
|
|
957
|
+
statusTag = '<span class="tag pending">待处理</span>';
|
|
958
|
+
else if (u.status === "error")
|
|
959
|
+
statusTag = '<span class="tag error">错误</span>';
|
|
960
|
+
else if (u.status === "restricted")
|
|
961
|
+
statusTag = '<span class="tag error">受限</span>';
|
|
962
|
+
else statusTag = u.status || "-";
|
|
963
|
+
|
|
964
|
+
return `<tr>
|
|
965
|
+
<td class="user-id" data-label="用户名">@${u.uniqueId}</td>
|
|
966
|
+
<td data-label="昵称">${nick}</td>
|
|
967
|
+
<td data-label="粉丝">${fans}</td>
|
|
968
|
+
<td data-label="视频">${videos}</td>
|
|
969
|
+
<td data-label="国家">${location}</td>
|
|
970
|
+
<td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
|
|
971
|
+
<td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
|
|
972
|
+
<td data-label="来源">${sources || "-"}</td>
|
|
973
|
+
<td data-label="状态">${statusTag}</td>
|
|
974
|
+
</tr>`;
|
|
975
|
+
})
|
|
976
|
+
.join("");
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function filterTargetByCountry(country) {
|
|
980
|
+
currentTargetLocation = country;
|
|
981
|
+
const sel = document.getElementById("targetLocationFilter");
|
|
982
|
+
if (sel) sel.value = country;
|
|
983
|
+
if (currentTargetData) {
|
|
984
|
+
renderTargetTable(currentTargetData.countries || []);
|
|
985
|
+
}
|
|
857
986
|
}
|
|
858
987
|
|
|
988
|
+
function onTargetLocationChange() {
|
|
989
|
+
const sel = document.getElementById("targetLocationFilter");
|
|
990
|
+
currentTargetLocation = sel.value;
|
|
991
|
+
if (currentTargetData) {
|
|
992
|
+
renderTargetTable(currentTargetData.countries || []);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function clearTargetFilters() {
|
|
997
|
+
currentTargetLocation = "";
|
|
998
|
+
const searchInput = document.getElementById("targetSearchInput");
|
|
999
|
+
const locationFilter = document.getElementById("targetLocationFilter");
|
|
1000
|
+
if (searchInput) searchInput.value = "";
|
|
1001
|
+
if (locationFilter) locationFilter.value = "";
|
|
1002
|
+
if (currentTargetData) {
|
|
1003
|
+
renderTargetTable(currentTargetData.countries || []);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
let targetSearchTimer = null;
|
|
1008
|
+
|
|
1009
|
+
document.getElementById("targetSearchInput").addEventListener("input", () => {
|
|
1010
|
+
if (targetSearchTimer) clearTimeout(targetSearchTimer);
|
|
1011
|
+
targetSearchTimer = setTimeout(() => {
|
|
1012
|
+
if (currentTargetData) {
|
|
1013
|
+
renderTargetTable(currentTargetData.countries || []);
|
|
1014
|
+
}
|
|
1015
|
+
}, 300);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
document
|
|
1019
|
+
.getElementById("exportTargetCsvBtn")
|
|
1020
|
+
.addEventListener("click", async () => {
|
|
1021
|
+
showLoading("正在导出目标用户...");
|
|
1022
|
+
try {
|
|
1023
|
+
const res = await fetch("/api/target-users-by-country", {
|
|
1024
|
+
headers: { Accept: "text/csv" },
|
|
1025
|
+
});
|
|
1026
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
1027
|
+
const blob = await res.blob();
|
|
1028
|
+
const url = URL.createObjectURL(blob);
|
|
1029
|
+
const a = document.createElement("a");
|
|
1030
|
+
a.href = url;
|
|
1031
|
+
a.download = "target-users-by-country.csv";
|
|
1032
|
+
a.click();
|
|
1033
|
+
URL.revokeObjectURL(url);
|
|
1034
|
+
showToast("CSV 文件已开始下载");
|
|
1035
|
+
} catch (e) {
|
|
1036
|
+
showToast("导出失败: " + e.message, true);
|
|
1037
|
+
} finally {
|
|
1038
|
+
hideLoading();
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
|
|
859
1042
|
async function fetchPendingByCountry() {
|
|
860
1043
|
try {
|
|
861
1044
|
const res = await fetch("/api/pending-by-country");
|
|
@@ -882,7 +1065,10 @@ function renderPendingCountryGrid(countries) {
|
|
|
882
1065
|
return `
|
|
883
1066
|
<div class="pending-country-item${isUnknown ? "" : " has-target"}"
|
|
884
1067
|
onclick="filterByPendingCountry('${safeCountry}')">
|
|
885
|
-
<
|
|
1068
|
+
<div class="country-action-btns">
|
|
1069
|
+
<button class="country-action-btn restore" title="重置为需要预处理" onclick="event.stopPropagation(); resetPendingByCountry('${safeCountry}', ${c.count})">↺</button>
|
|
1070
|
+
<button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
|
|
1071
|
+
</div>
|
|
886
1072
|
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
887
1073
|
<div class="country-count">${c.count}</div>
|
|
888
1074
|
<div class="country-label">${pct}% 待处理</div>
|
|
@@ -936,7 +1122,9 @@ function renderUserUpdateCountryGrid(countries) {
|
|
|
936
1122
|
return `
|
|
937
1123
|
<div class="pending-country-item${isUnknown ? "" : " has-target"}"
|
|
938
1124
|
onclick="filterByUserUpdateCountry('${safeCountry}')">
|
|
939
|
-
<
|
|
1125
|
+
<div class="country-action-btns">
|
|
1126
|
+
<button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
|
|
1127
|
+
</div>
|
|
940
1128
|
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
941
1129
|
<div class="country-count">${c.count}</div>
|
|
942
1130
|
<div class="country-label">${pct}% 待补资料</div>
|
|
@@ -973,7 +1161,9 @@ function renderAttachStuckGrid(gridId, countries) {
|
|
|
973
1161
|
const safeCountry = escapeJsString(c.country);
|
|
974
1162
|
return `
|
|
975
1163
|
<div class="pending-country-item${isUnknown ? "" : " has-target"}">
|
|
976
|
-
<
|
|
1164
|
+
<div class="country-action-btns">
|
|
1165
|
+
<button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
|
|
1166
|
+
</div>
|
|
977
1167
|
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
978
1168
|
<div class="country-count">${c.count}</div>
|
|
979
1169
|
<div class="country-label">${pct}% attach 未成功</div>
|
|
@@ -1058,6 +1248,40 @@ async function moveCountryJobsToRaw(scope, country, count) {
|
|
|
1058
1248
|
}
|
|
1059
1249
|
}
|
|
1060
1250
|
|
|
1251
|
+
async function resetPendingByCountry(country, count) {
|
|
1252
|
+
const countText = count != null ? `将重置 ${formatStatNum(count)} 条。` : "";
|
|
1253
|
+
if (
|
|
1254
|
+
!window.confirm(
|
|
1255
|
+
`确认将 ${country} 下已预处理的待处理任务重置为需要预处理吗?${countText}`,
|
|
1256
|
+
)
|
|
1257
|
+
) {
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
showLoading("正在重置...");
|
|
1261
|
+
try {
|
|
1262
|
+
const res = await fetch("/api/pending-by-country/reset", {
|
|
1263
|
+
method: "POST",
|
|
1264
|
+
headers: { "Content-Type": "application/json" },
|
|
1265
|
+
body: JSON.stringify({ country }),
|
|
1266
|
+
});
|
|
1267
|
+
const data = await res.json();
|
|
1268
|
+
if (!res.ok || data.error) {
|
|
1269
|
+
showToast(data.error || "重置失败", true);
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
showToast(`${country} 的待处理任务已重置,共 ${data.reset} 条`);
|
|
1273
|
+
await fetchStats();
|
|
1274
|
+
await fetchPendingByCountry();
|
|
1275
|
+
if (!document.getElementById("mainPage").classList.contains("hidden")) {
|
|
1276
|
+
fetchUsers();
|
|
1277
|
+
}
|
|
1278
|
+
} catch (e) {
|
|
1279
|
+
showToast("重置失败: " + e.message, true);
|
|
1280
|
+
} finally {
|
|
1281
|
+
hideLoading();
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1061
1285
|
async function fetchRawByCountry() {
|
|
1062
1286
|
try {
|
|
1063
1287
|
const res = await fetch("/api/raw-by-country");
|
|
@@ -1085,7 +1309,9 @@ function renderRawCountryGrid(countries) {
|
|
|
1085
1309
|
return `
|
|
1086
1310
|
<div class="pending-country-item${isUnknown ? "" : " has-target"}"
|
|
1087
1311
|
onclick="filterRawByCountry('${safeCountry}')">
|
|
1088
|
-
<
|
|
1312
|
+
<div class="country-action-btns">
|
|
1313
|
+
<button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
|
|
1314
|
+
</div>
|
|
1089
1315
|
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
1090
1316
|
<div class="country-count">${c.count}</div>
|
|
1091
1317
|
<div class="country-label">${pct}% 毛料库</div>
|
|
@@ -1101,6 +1327,11 @@ async function fetchRawJobs() {
|
|
|
1101
1327
|
const search = document.getElementById("rawSearchInput").value.trim();
|
|
1102
1328
|
if (search) params.set("search", search);
|
|
1103
1329
|
if (currentRawLocation) params.set("location", currentRawLocation);
|
|
1330
|
+
const videoFilter = document.getElementById("rawVideoFilter");
|
|
1331
|
+
if (videoFilter && videoFilter.checked) params.set("hasVideo", "1");
|
|
1332
|
+
const followerFilter = document.getElementById("rawFollowerFilter");
|
|
1333
|
+
if (followerFilter && followerFilter.checked)
|
|
1334
|
+
params.set("hasFollower", "1");
|
|
1104
1335
|
params.set("limit", "200");
|
|
1105
1336
|
const res = await fetch("/api/raw-jobs?" + params.toString());
|
|
1106
1337
|
const data = await res.json();
|
|
@@ -1176,8 +1407,16 @@ function clearRawFilters() {
|
|
|
1176
1407
|
currentRawLocation = "";
|
|
1177
1408
|
const rawSearchInput = document.getElementById("rawSearchInput");
|
|
1178
1409
|
const rawLocationFilter = document.getElementById("rawLocationFilter");
|
|
1410
|
+
const rawVideoFilter = document.getElementById("rawVideoFilter");
|
|
1411
|
+
const rawFollowerFilter = document.getElementById("rawFollowerFilter");
|
|
1179
1412
|
if (rawSearchInput) rawSearchInput.value = "";
|
|
1180
1413
|
if (rawLocationFilter) rawLocationFilter.value = "";
|
|
1414
|
+
if (rawVideoFilter) rawVideoFilter.checked = false;
|
|
1415
|
+
if (rawFollowerFilter) rawFollowerFilter.checked = false;
|
|
1416
|
+
fetchRawJobs();
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function onRawFilterChange() {
|
|
1181
1420
|
fetchRawJobs();
|
|
1182
1421
|
}
|
|
1183
1422
|
|
|
@@ -1211,10 +1450,20 @@ async function restoreRawJob(uniqueId) {
|
|
|
1211
1450
|
async function restoreFilteredRawJobs() {
|
|
1212
1451
|
const search = document.getElementById("rawSearchInput").value.trim();
|
|
1213
1452
|
const location = currentRawLocation;
|
|
1453
|
+
const videoFilter = document.getElementById("rawVideoFilter");
|
|
1454
|
+
const hasVideo = videoFilter && videoFilter.checked;
|
|
1455
|
+
const followerFilter = document.getElementById("rawFollowerFilter");
|
|
1456
|
+
const hasFollower = followerFilter && followerFilter.checked;
|
|
1214
1457
|
let desc = "当前筛选条件";
|
|
1215
1458
|
if (search && location) desc = `搜索="${search}" + 国家=${location}`;
|
|
1216
1459
|
else if (search) desc = `搜索="${search}"`;
|
|
1217
1460
|
else if (location) desc = `国家=${location}`;
|
|
1461
|
+
if (hasVideo || hasFollower) {
|
|
1462
|
+
const tags = [];
|
|
1463
|
+
if (hasVideo) tags.push("有视频");
|
|
1464
|
+
if (hasFollower) tags.push("有粉丝");
|
|
1465
|
+
desc += ` + ${tags.join("、")}`;
|
|
1466
|
+
}
|
|
1218
1467
|
if (
|
|
1219
1468
|
!window.confirm(`确认将毛料库中符合【${desc}】的任务恢复到 jobs 队列吗?`)
|
|
1220
1469
|
) {
|
|
@@ -1225,6 +1474,8 @@ async function restoreFilteredRawJobs() {
|
|
|
1225
1474
|
const body = {};
|
|
1226
1475
|
if (search) body.search = search;
|
|
1227
1476
|
if (location) body.location = location;
|
|
1477
|
+
if (hasVideo) body.hasVideo = true;
|
|
1478
|
+
if (hasFollower) body.hasFollower = true;
|
|
1228
1479
|
const res = await fetch("/api/raw-jobs/restore", {
|
|
1229
1480
|
method: "POST",
|
|
1230
1481
|
headers: { "Content-Type": "application/json" },
|