tt-help-cli-ycl 1.3.63 → 1.3.64
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/explore.js +3 -4
- package/src/lib/browser/page.js +41 -0
- package/src/watch/data-store.js +128 -2
- package/src/watch/public/app.js +291 -42
- package/src/watch/public/index.html +62 -0
- package/src/watch/public/style.css +80 -3
- package/src/watch/server.js +88 -1
package/package.json
CHANGED
package/scripts/run-explore.bat
CHANGED
|
@@ -131,4 +131,4 @@ ECHO Max following: 5
|
|
|
131
131
|
ECHO Max followers: 5
|
|
132
132
|
ECHO Speed: stealth (slowest)
|
|
133
133
|
ECHO ========================================
|
|
134
|
-
CALL tt-help explore stealth --user-id %%USER_ID%% --base-port %%BASE_PORT%% --port-count %%PORT_COUNT%% --max-following
|
|
134
|
+
CALL tt-help explore stealth --user-id %%USER_ID%% --base-port %%BASE_PORT%% --port-count %%PORT_COUNT%% --max-following 100 --max-followers 100 %%JOB_LOC_ARGS%%
|
package/src/cli/explore.js
CHANGED
|
@@ -166,7 +166,7 @@ export async function handleExplore(options) {
|
|
|
166
166
|
|
|
167
167
|
browser = await ensureBrowserReadyCDP(cdpOptions);
|
|
168
168
|
const { processExplore } = await import("../scraper/explore-core.js");
|
|
169
|
-
const {
|
|
169
|
+
const { safeCheckLogin } = await import("../lib/browser/page.js");
|
|
170
170
|
|
|
171
171
|
const page = await getOrCreatePage(browser);
|
|
172
172
|
|
|
@@ -176,8 +176,7 @@ export async function handleExplore(options) {
|
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
// 检测登录状态(启动时只检测一次)
|
|
179
|
-
let loggedIn = await
|
|
180
|
-
console.error(`登录状态: ${loggedIn ? "已登录" : "未登录"}`);
|
|
179
|
+
let loggedIn = await safeCheckLogin(page);
|
|
181
180
|
|
|
182
181
|
// 全局拦截图片资源,减少内存占用和加载时间
|
|
183
182
|
await page.route("**/*", (route) => {
|
|
@@ -224,7 +223,7 @@ export async function handleExplore(options) {
|
|
|
224
223
|
await page.goto(STARTUP_TIKTOK_URL, {
|
|
225
224
|
waitUntil: "domcontentloaded",
|
|
226
225
|
});
|
|
227
|
-
loggedIn = await
|
|
226
|
+
loggedIn = await safeCheckLogin(page);
|
|
228
227
|
console.error(
|
|
229
228
|
`[健康检查] 新账户登录状态: ${loggedIn ? "已登录" : "未登录"}`,
|
|
230
229
|
);
|
package/src/lib/browser/page.js
CHANGED
|
@@ -68,6 +68,8 @@ export async function withBrowserRecovery(fn, browser, page, cdpOptions, port) {
|
|
|
68
68
|
|
|
69
69
|
const DOM_CHECK_TIMEOUT = 20000; // 单次 DOM 检测超时 20 秒
|
|
70
70
|
const DOM_CHECK_RETRIES = 3; // DOM 检测最大重试次数
|
|
71
|
+
const SAFE_CHECK_ROUNDS = 2; // 安全登录检测轮数(首次检测 + 额外1轮确认)
|
|
72
|
+
const SAFE_CHECK_INTERVAL = 5000; // 安全登录检测每轮间隔 5 秒
|
|
71
73
|
|
|
72
74
|
/**
|
|
73
75
|
* 判断登录状态:Cookie 为主,DOM 验真为辅。
|
|
@@ -102,6 +104,45 @@ export async function isLoggedIn(page) {
|
|
|
102
104
|
return true;
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/**
|
|
108
|
+
* 安全登录检测:发现未登录时多检测几轮,避免因 TikTok 页面渲染延迟导致误判。
|
|
109
|
+
* - 首次检测为已登录 → 直接返回 true
|
|
110
|
+
* - 首次检测为未登录 → 等待后重新导航并检测,连续 SAFE_CHECK_ROUNDS 轮都为未登录才确认
|
|
111
|
+
* - 任何一轮检测为已登录 → 立即返回 true
|
|
112
|
+
*/
|
|
113
|
+
export async function safeCheckLogin(page) {
|
|
114
|
+
// 第一轮检测
|
|
115
|
+
let loggedIn = await isLoggedIn(page);
|
|
116
|
+
if (loggedIn) {
|
|
117
|
+
console.error(`[安全登录检测] 第 1 轮: 已登录 ✓`);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
console.error(
|
|
121
|
+
`[安全登录检测] 第 1 轮: 未登录 ✗,等待 ${SAFE_CHECK_INTERVAL / 1000}s 后重检...`,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// 后续轮次:等待后重新导航到 TikTok 页面再检测
|
|
125
|
+
for (let round = 2; round <= SAFE_CHECK_ROUNDS; round++) {
|
|
126
|
+
await new Promise((r) => setTimeout(r, SAFE_CHECK_INTERVAL));
|
|
127
|
+
// 重新导航到 TikTok 页面,确保页面状态刷新
|
|
128
|
+
await page.goto("https://www.tiktok.com", {
|
|
129
|
+
waitUntil: "domcontentloaded",
|
|
130
|
+
});
|
|
131
|
+
loggedIn = await isLoggedIn(page);
|
|
132
|
+
if (loggedIn) {
|
|
133
|
+
console.error(`[安全登录检测] 第 ${round} 轮: 已登录 ✓`);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
console.error(`[安全登录检测] 第 ${round} 轮: 未登录 ✗`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 连续 SAFE_CHECK_ROUNDS 轮都为未登录
|
|
140
|
+
console.error(
|
|
141
|
+
`[安全登录检测] 连续 ${SAFE_CHECK_ROUNDS} 轮均为未登录,确认为未登录`,
|
|
142
|
+
);
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
105
146
|
/**
|
|
106
147
|
* 通过 DOM 元素判断登录状态(验真方案)
|
|
107
148
|
* 使用 locator API + state: 'attached' 来避免 CDP 连接下 waitForSelector 的可见性问题
|
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,219 @@ 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 latestVideo = u.latestVideoTime
|
|
945
|
+
? formatTime(u.latestVideoTime * 1000)
|
|
946
|
+
: "-";
|
|
947
|
+
const refreshTime = u.refreshTime ? formatTime(u.refreshTime) : "-";
|
|
948
|
+
const sources = (u.sources || []).join(", ");
|
|
949
|
+
|
|
950
|
+
let statusTag = "";
|
|
951
|
+
if (u.status === "done")
|
|
952
|
+
statusTag = '<span class="tag processed">已完成</span>';
|
|
953
|
+
else if (u.status === "processing")
|
|
954
|
+
statusTag = '<span class="tag processing">处理中</span>';
|
|
955
|
+
else if (u.status === "pending")
|
|
956
|
+
statusTag = '<span class="tag pending">待处理</span>';
|
|
957
|
+
else if (u.status === "error")
|
|
958
|
+
statusTag = '<span class="tag error">错误</span>';
|
|
959
|
+
else if (u.status === "restricted")
|
|
960
|
+
statusTag = '<span class="tag error">受限</span>';
|
|
961
|
+
else statusTag = u.status || "-";
|
|
962
|
+
|
|
963
|
+
return `<tr>
|
|
964
|
+
<td class="user-id" data-label="用户名">@${u.uniqueId}</td>
|
|
965
|
+
<td data-label="昵称">${nick}</td>
|
|
966
|
+
<td data-label="粉丝">${fans}</td>
|
|
967
|
+
<td data-label="视频">${videos}</td>
|
|
968
|
+
<td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
|
|
969
|
+
<td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
|
|
970
|
+
<td data-label="来源">${sources || "-"}</td>
|
|
971
|
+
<td data-label="状态">${statusTag}</td>
|
|
972
|
+
</tr>`;
|
|
973
|
+
})
|
|
974
|
+
.join("");
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function filterTargetByCountry(country) {
|
|
978
|
+
currentTargetLocation = country;
|
|
979
|
+
const sel = document.getElementById("targetLocationFilter");
|
|
980
|
+
if (sel) sel.value = country;
|
|
981
|
+
if (currentTargetData) {
|
|
982
|
+
renderTargetTable(currentTargetData.countries || []);
|
|
983
|
+
}
|
|
857
984
|
}
|
|
858
985
|
|
|
986
|
+
function onTargetLocationChange() {
|
|
987
|
+
const sel = document.getElementById("targetLocationFilter");
|
|
988
|
+
currentTargetLocation = sel.value;
|
|
989
|
+
if (currentTargetData) {
|
|
990
|
+
renderTargetTable(currentTargetData.countries || []);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function clearTargetFilters() {
|
|
995
|
+
currentTargetLocation = "";
|
|
996
|
+
const searchInput = document.getElementById("targetSearchInput");
|
|
997
|
+
const locationFilter = document.getElementById("targetLocationFilter");
|
|
998
|
+
if (searchInput) searchInput.value = "";
|
|
999
|
+
if (locationFilter) locationFilter.value = "";
|
|
1000
|
+
if (currentTargetData) {
|
|
1001
|
+
renderTargetTable(currentTargetData.countries || []);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
let targetSearchTimer = null;
|
|
1006
|
+
|
|
1007
|
+
document.getElementById("targetSearchInput").addEventListener("input", () => {
|
|
1008
|
+
if (targetSearchTimer) clearTimeout(targetSearchTimer);
|
|
1009
|
+
targetSearchTimer = setTimeout(() => {
|
|
1010
|
+
if (currentTargetData) {
|
|
1011
|
+
renderTargetTable(currentTargetData.countries || []);
|
|
1012
|
+
}
|
|
1013
|
+
}, 300);
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
document
|
|
1017
|
+
.getElementById("exportTargetCsvBtn")
|
|
1018
|
+
.addEventListener("click", async () => {
|
|
1019
|
+
showLoading("正在导出目标用户...");
|
|
1020
|
+
try {
|
|
1021
|
+
const res = await fetch("/api/target-users-by-country", {
|
|
1022
|
+
headers: { Accept: "text/csv" },
|
|
1023
|
+
});
|
|
1024
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
1025
|
+
const blob = await res.blob();
|
|
1026
|
+
const url = URL.createObjectURL(blob);
|
|
1027
|
+
const a = document.createElement("a");
|
|
1028
|
+
a.href = url;
|
|
1029
|
+
a.download = "target-users-by-country.csv";
|
|
1030
|
+
a.click();
|
|
1031
|
+
URL.revokeObjectURL(url);
|
|
1032
|
+
showToast("CSV 文件已开始下载");
|
|
1033
|
+
} catch (e) {
|
|
1034
|
+
showToast("导出失败: " + e.message, true);
|
|
1035
|
+
} finally {
|
|
1036
|
+
hideLoading();
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
|
|
859
1040
|
async function fetchPendingByCountry() {
|
|
860
1041
|
try {
|
|
861
1042
|
const res = await fetch("/api/pending-by-country");
|
|
@@ -882,7 +1063,10 @@ function renderPendingCountryGrid(countries) {
|
|
|
882
1063
|
return `
|
|
883
1064
|
<div class="pending-country-item${isUnknown ? "" : " has-target"}"
|
|
884
1065
|
onclick="filterByPendingCountry('${safeCountry}')">
|
|
885
|
-
<
|
|
1066
|
+
<div class="country-action-btns">
|
|
1067
|
+
<button class="country-action-btn restore" title="重置为需要预处理" onclick="event.stopPropagation(); resetPendingByCountry('${safeCountry}', ${c.count})">↺</button>
|
|
1068
|
+
<button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('pending', '${safeCountry}', ${c.count})">✕</button>
|
|
1069
|
+
</div>
|
|
886
1070
|
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
887
1071
|
<div class="country-count">${c.count}</div>
|
|
888
1072
|
<div class="country-label">${pct}% 待处理</div>
|
|
@@ -936,7 +1120,9 @@ function renderUserUpdateCountryGrid(countries) {
|
|
|
936
1120
|
return `
|
|
937
1121
|
<div class="pending-country-item${isUnknown ? "" : " has-target"}"
|
|
938
1122
|
onclick="filterByUserUpdateCountry('${safeCountry}')">
|
|
939
|
-
<
|
|
1123
|
+
<div class="country-action-btns">
|
|
1124
|
+
<button class="country-action-btn" title="移到毛料库,暂不处理" onclick="event.stopPropagation(); moveCountryJobsToRaw('userUpdate', '${safeCountry}', ${c.count})">✕</button>
|
|
1125
|
+
</div>
|
|
940
1126
|
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
941
1127
|
<div class="country-count">${c.count}</div>
|
|
942
1128
|
<div class="country-label">${pct}% 待补资料</div>
|
|
@@ -973,7 +1159,9 @@ function renderAttachStuckGrid(gridId, countries) {
|
|
|
973
1159
|
const safeCountry = escapeJsString(c.country);
|
|
974
1160
|
return `
|
|
975
1161
|
<div class="pending-country-item${isUnknown ? "" : " has-target"}">
|
|
976
|
-
<
|
|
1162
|
+
<div class="country-action-btns">
|
|
1163
|
+
<button class="country-action-btn restore" title="恢复为待补资料" onclick="event.stopPropagation(); restoreAttachStuckByCountry('${safeCountry}', ${c.count})">↺</button>
|
|
1164
|
+
</div>
|
|
977
1165
|
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
978
1166
|
<div class="country-count">${c.count}</div>
|
|
979
1167
|
<div class="country-label">${pct}% attach 未成功</div>
|
|
@@ -1058,6 +1246,40 @@ async function moveCountryJobsToRaw(scope, country, count) {
|
|
|
1058
1246
|
}
|
|
1059
1247
|
}
|
|
1060
1248
|
|
|
1249
|
+
async function resetPendingByCountry(country, count) {
|
|
1250
|
+
const countText = count != null ? `将重置 ${formatStatNum(count)} 条。` : "";
|
|
1251
|
+
if (
|
|
1252
|
+
!window.confirm(
|
|
1253
|
+
`确认将 ${country} 下已预处理的待处理任务重置为需要预处理吗?${countText}`,
|
|
1254
|
+
)
|
|
1255
|
+
) {
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
showLoading("正在重置...");
|
|
1259
|
+
try {
|
|
1260
|
+
const res = await fetch("/api/pending-by-country/reset", {
|
|
1261
|
+
method: "POST",
|
|
1262
|
+
headers: { "Content-Type": "application/json" },
|
|
1263
|
+
body: JSON.stringify({ country }),
|
|
1264
|
+
});
|
|
1265
|
+
const data = await res.json();
|
|
1266
|
+
if (!res.ok || data.error) {
|
|
1267
|
+
showToast(data.error || "重置失败", true);
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
showToast(`${country} 的待处理任务已重置,共 ${data.reset} 条`);
|
|
1271
|
+
await fetchStats();
|
|
1272
|
+
await fetchPendingByCountry();
|
|
1273
|
+
if (!document.getElementById("mainPage").classList.contains("hidden")) {
|
|
1274
|
+
fetchUsers();
|
|
1275
|
+
}
|
|
1276
|
+
} catch (e) {
|
|
1277
|
+
showToast("重置失败: " + e.message, true);
|
|
1278
|
+
} finally {
|
|
1279
|
+
hideLoading();
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1061
1283
|
async function fetchRawByCountry() {
|
|
1062
1284
|
try {
|
|
1063
1285
|
const res = await fetch("/api/raw-by-country");
|
|
@@ -1085,7 +1307,9 @@ function renderRawCountryGrid(countries) {
|
|
|
1085
1307
|
return `
|
|
1086
1308
|
<div class="pending-country-item${isUnknown ? "" : " has-target"}"
|
|
1087
1309
|
onclick="filterRawByCountry('${safeCountry}')">
|
|
1088
|
-
<
|
|
1310
|
+
<div class="country-action-btns">
|
|
1311
|
+
<button class="country-action-btn restore" title="恢复到 jobs 队列" onclick="event.stopPropagation(); restoreRawJobsByCountry('${safeCountry}', ${c.count})">↺</button>
|
|
1312
|
+
</div>
|
|
1089
1313
|
<div class="country-name">${isUnknown ? "🌍 " : ""}${c.country}</div>
|
|
1090
1314
|
<div class="country-count">${c.count}</div>
|
|
1091
1315
|
<div class="country-label">${pct}% 毛料库</div>
|
|
@@ -1101,6 +1325,11 @@ async function fetchRawJobs() {
|
|
|
1101
1325
|
const search = document.getElementById("rawSearchInput").value.trim();
|
|
1102
1326
|
if (search) params.set("search", search);
|
|
1103
1327
|
if (currentRawLocation) params.set("location", currentRawLocation);
|
|
1328
|
+
const videoFilter = document.getElementById("rawVideoFilter");
|
|
1329
|
+
if (videoFilter && videoFilter.checked) params.set("hasVideo", "1");
|
|
1330
|
+
const followerFilter = document.getElementById("rawFollowerFilter");
|
|
1331
|
+
if (followerFilter && followerFilter.checked)
|
|
1332
|
+
params.set("hasFollower", "1");
|
|
1104
1333
|
params.set("limit", "200");
|
|
1105
1334
|
const res = await fetch("/api/raw-jobs?" + params.toString());
|
|
1106
1335
|
const data = await res.json();
|
|
@@ -1176,8 +1405,16 @@ function clearRawFilters() {
|
|
|
1176
1405
|
currentRawLocation = "";
|
|
1177
1406
|
const rawSearchInput = document.getElementById("rawSearchInput");
|
|
1178
1407
|
const rawLocationFilter = document.getElementById("rawLocationFilter");
|
|
1408
|
+
const rawVideoFilter = document.getElementById("rawVideoFilter");
|
|
1409
|
+
const rawFollowerFilter = document.getElementById("rawFollowerFilter");
|
|
1179
1410
|
if (rawSearchInput) rawSearchInput.value = "";
|
|
1180
1411
|
if (rawLocationFilter) rawLocationFilter.value = "";
|
|
1412
|
+
if (rawVideoFilter) rawVideoFilter.checked = false;
|
|
1413
|
+
if (rawFollowerFilter) rawFollowerFilter.checked = false;
|
|
1414
|
+
fetchRawJobs();
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function onRawFilterChange() {
|
|
1181
1418
|
fetchRawJobs();
|
|
1182
1419
|
}
|
|
1183
1420
|
|
|
@@ -1211,10 +1448,20 @@ async function restoreRawJob(uniqueId) {
|
|
|
1211
1448
|
async function restoreFilteredRawJobs() {
|
|
1212
1449
|
const search = document.getElementById("rawSearchInput").value.trim();
|
|
1213
1450
|
const location = currentRawLocation;
|
|
1451
|
+
const videoFilter = document.getElementById("rawVideoFilter");
|
|
1452
|
+
const hasVideo = videoFilter && videoFilter.checked;
|
|
1453
|
+
const followerFilter = document.getElementById("rawFollowerFilter");
|
|
1454
|
+
const hasFollower = followerFilter && followerFilter.checked;
|
|
1214
1455
|
let desc = "当前筛选条件";
|
|
1215
1456
|
if (search && location) desc = `搜索="${search}" + 国家=${location}`;
|
|
1216
1457
|
else if (search) desc = `搜索="${search}"`;
|
|
1217
1458
|
else if (location) desc = `国家=${location}`;
|
|
1459
|
+
if (hasVideo || hasFollower) {
|
|
1460
|
+
const tags = [];
|
|
1461
|
+
if (hasVideo) tags.push("有视频");
|
|
1462
|
+
if (hasFollower) tags.push("有粉丝");
|
|
1463
|
+
desc += ` + ${tags.join("、")}`;
|
|
1464
|
+
}
|
|
1218
1465
|
if (
|
|
1219
1466
|
!window.confirm(`确认将毛料库中符合【${desc}】的任务恢复到 jobs 队列吗?`)
|
|
1220
1467
|
) {
|
|
@@ -1225,6 +1472,8 @@ async function restoreFilteredRawJobs() {
|
|
|
1225
1472
|
const body = {};
|
|
1226
1473
|
if (search) body.search = search;
|
|
1227
1474
|
if (location) body.location = location;
|
|
1475
|
+
if (hasVideo) body.hasVideo = true;
|
|
1476
|
+
if (hasFollower) body.hasFollower = true;
|
|
1228
1477
|
const res = await fetch("/api/raw-jobs/restore", {
|
|
1229
1478
|
method: "POST",
|
|
1230
1479
|
headers: { "Content-Type": "application/json" },
|
|
@@ -245,6 +245,13 @@
|
|
|
245
245
|
style="padding:6px 10px;border:1px solid #333;border-radius:6px;background:#2a2a3a;color:#ccc;font-size:12px;cursor:pointer;outline:none;">
|
|
246
246
|
<option value="">全部国家</option>
|
|
247
247
|
</select>
|
|
248
|
+
<label style="display:inline-flex;align-items:center;gap:3px;font-size:12px;color:#999;cursor:pointer;">
|
|
249
|
+
<input type="checkbox" id="rawVideoFilter" onchange="onRawFilterChange()" style="accent-color:#22c55e;"> 有视频
|
|
250
|
+
</label>
|
|
251
|
+
<label style="display:inline-flex;align-items:center;gap:3px;font-size:12px;color:#999;cursor:pointer;">
|
|
252
|
+
<input type="checkbox" id="rawFollowerFilter" onchange="onRawFilterChange()" style="accent-color:#22c55e;">
|
|
253
|
+
有粉丝
|
|
254
|
+
</label>
|
|
248
255
|
<button onclick="clearRawFilters()">清空筛选</button>
|
|
249
256
|
<button onclick="restoreFilteredRawJobs()"
|
|
250
257
|
style="background:#22c55e;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;">恢复当前筛选到
|
|
@@ -272,6 +279,61 @@
|
|
|
272
279
|
</div>
|
|
273
280
|
</div>
|
|
274
281
|
</div>
|
|
282
|
+
<div id="targetPage">
|
|
283
|
+
<div class="stats" style="margin-bottom:16px">
|
|
284
|
+
<div class="stat-card">
|
|
285
|
+
<div class="label">目标商家</div>
|
|
286
|
+
<div class="value target" id="targetPageStatTotal">0</div>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="stat-card clickable pending-card" onclick="navigateToMain()" style="background:rgba(167,139,250,0.1)">
|
|
289
|
+
<div class="label">← 返回主页面</div>
|
|
290
|
+
</div>
|
|
291
|
+
<div class="stat-card">
|
|
292
|
+
<div class="label">目标国家</div>
|
|
293
|
+
<div class="value" id="targetPageStatCountries" style="font-size:17px;color:#a78bfa">0</div>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="stat-card clickable" id="exportTargetCsvBtn" style="background:rgba(34,197,94,0.12);cursor:pointer">
|
|
296
|
+
<div class="label">导出 CSV</div>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
<div class="target-page-layout">
|
|
300
|
+
<div class="target-side-card">
|
|
301
|
+
<h3>🎯 目标商家国家分布</h3>
|
|
302
|
+
<div class="pending-country-grid" id="targetCountryGrid">
|
|
303
|
+
<span style="color:#666;font-size:12px">加载中...</span>
|
|
304
|
+
</div>
|
|
305
|
+
<div class="muted-tip">点击国家卡片可筛选列表。</div>
|
|
306
|
+
</div>
|
|
307
|
+
<div class="target-table-card">
|
|
308
|
+
<h3>目标商家列表</h3>
|
|
309
|
+
<div class="controls">
|
|
310
|
+
<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
|
+
<button onclick="clearTargetFilters()">清空筛选</button>
|
|
316
|
+
</div>
|
|
317
|
+
<div class="table-scroll">
|
|
318
|
+
<table>
|
|
319
|
+
<thead>
|
|
320
|
+
<tr>
|
|
321
|
+
<th>用户名</th>
|
|
322
|
+
<th>昵称</th>
|
|
323
|
+
<th>粉丝</th>
|
|
324
|
+
<th>视频</th>
|
|
325
|
+
<th>最近发布</th>
|
|
326
|
+
<th>最近刷新</th>
|
|
327
|
+
<th>来源</th>
|
|
328
|
+
<th>状态</th>
|
|
329
|
+
</tr>
|
|
330
|
+
</thead>
|
|
331
|
+
<tbody id="targetTable"></tbody>
|
|
332
|
+
</table>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
275
337
|
<script src="app.js"></script>
|
|
276
338
|
</body>
|
|
277
339
|
|
|
@@ -634,6 +634,14 @@ td.user-id:hover {
|
|
|
634
634
|
display: block;
|
|
635
635
|
}
|
|
636
636
|
|
|
637
|
+
#targetPage {
|
|
638
|
+
display: none;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
#targetPage.active {
|
|
642
|
+
display: block;
|
|
643
|
+
}
|
|
644
|
+
|
|
637
645
|
#mainPage.hidden {
|
|
638
646
|
display: none;
|
|
639
647
|
}
|
|
@@ -670,10 +678,15 @@ td.user-id:hover {
|
|
|
670
678
|
position: relative;
|
|
671
679
|
}
|
|
672
680
|
|
|
673
|
-
.country-action-
|
|
681
|
+
.country-action-btns {
|
|
674
682
|
position: absolute;
|
|
675
|
-
top:
|
|
676
|
-
right:
|
|
683
|
+
top: 8px;
|
|
684
|
+
right: 8px;
|
|
685
|
+
display: flex;
|
|
686
|
+
gap: 4px;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
.country-action-btn {
|
|
677
690
|
width: 28px;
|
|
678
691
|
height: 28px;
|
|
679
692
|
border: 1px solid rgba(248, 113, 113, 0.35);
|
|
@@ -937,6 +950,66 @@ td.user-id:hover {
|
|
|
937
950
|
color: #a78bfa;
|
|
938
951
|
}
|
|
939
952
|
|
|
953
|
+
.target-country-group {
|
|
954
|
+
background: #1a1a24;
|
|
955
|
+
border-radius: 8px;
|
|
956
|
+
padding: 20px;
|
|
957
|
+
margin-bottom: 20px;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
.target-country-header {
|
|
961
|
+
display: flex;
|
|
962
|
+
align-items: center;
|
|
963
|
+
gap: 12px;
|
|
964
|
+
margin-bottom: 16px;
|
|
965
|
+
padding-bottom: 12px;
|
|
966
|
+
border-bottom: 1px solid #2a2a3a;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
.target-country-header .country-flag {
|
|
970
|
+
font-size: 20px;
|
|
971
|
+
font-weight: 700;
|
|
972
|
+
color: #a78bfa;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
.target-country-header .country-user-count {
|
|
976
|
+
font-size: 13px;
|
|
977
|
+
color: #888;
|
|
978
|
+
background: #2a2a3a;
|
|
979
|
+
padding: 2px 10px;
|
|
980
|
+
border-radius: 10px;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
.target-page-layout {
|
|
984
|
+
display: grid;
|
|
985
|
+
grid-template-columns: 320px 1fr;
|
|
986
|
+
gap: 16px;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
.target-side-card {
|
|
990
|
+
background: #1a1a24;
|
|
991
|
+
border-radius: 8px;
|
|
992
|
+
padding: 16px;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.target-side-card h3 {
|
|
996
|
+
font-size: 14px;
|
|
997
|
+
color: #a78bfa;
|
|
998
|
+
margin-bottom: 12px;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
.target-table-card {
|
|
1002
|
+
background: #1a1a24;
|
|
1003
|
+
border-radius: 8px;
|
|
1004
|
+
padding: 16px;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
.target-table-card h3 {
|
|
1008
|
+
font-size: 14px;
|
|
1009
|
+
color: #888;
|
|
1010
|
+
margin-bottom: 12px;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
940
1013
|
@media (max-width: 768px) {
|
|
941
1014
|
body {
|
|
942
1015
|
padding: 8px;
|
|
@@ -977,6 +1050,10 @@ td.user-id:hover {
|
|
|
977
1050
|
grid-template-columns: 1fr;
|
|
978
1051
|
}
|
|
979
1052
|
|
|
1053
|
+
.target-page-layout {
|
|
1054
|
+
grid-template-columns: 1fr;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
980
1057
|
.table-wrap {
|
|
981
1058
|
padding: 10px;
|
|
982
1059
|
}
|
package/src/watch/server.js
CHANGED
|
@@ -526,6 +526,66 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
526
526
|
return;
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
+
if (
|
|
530
|
+
req.method === "GET" &&
|
|
531
|
+
routePath === "/api/target-users-by-country"
|
|
532
|
+
) {
|
|
533
|
+
const result = store.getTargetUsersByCountry(DEFAULT_TARGET_LOCATIONS);
|
|
534
|
+
if (req.headers["accept"]?.includes("text/csv")) {
|
|
535
|
+
const columns = [
|
|
536
|
+
"uniqueId",
|
|
537
|
+
"nickname",
|
|
538
|
+
"followerCount",
|
|
539
|
+
"videoCount",
|
|
540
|
+
"locationCreated",
|
|
541
|
+
"latestVideoTime",
|
|
542
|
+
"refreshTime",
|
|
543
|
+
"status",
|
|
544
|
+
"sources",
|
|
545
|
+
];
|
|
546
|
+
const allUsers = [];
|
|
547
|
+
for (const country of result.countries) {
|
|
548
|
+
for (const u of country.users) {
|
|
549
|
+
allUsers.push({
|
|
550
|
+
uniqueId: u.uniqueId,
|
|
551
|
+
nickname: u.nickname || "",
|
|
552
|
+
followerCount: u.followerCount ?? 0,
|
|
553
|
+
videoCount: u.videoCount ?? 0,
|
|
554
|
+
locationCreated: u.locationCreated || "",
|
|
555
|
+
latestVideoTime: u.latestVideoTime
|
|
556
|
+
? new Date(u.latestVideoTime * 1000)
|
|
557
|
+
.toISOString()
|
|
558
|
+
.slice(0, 19)
|
|
559
|
+
.replace("T", " ")
|
|
560
|
+
: "",
|
|
561
|
+
refreshTime: u.refreshTime
|
|
562
|
+
? new Date(u.refreshTime)
|
|
563
|
+
.toISOString()
|
|
564
|
+
.slice(0, 19)
|
|
565
|
+
.replace("T", " ")
|
|
566
|
+
: "",
|
|
567
|
+
status: u.status || "",
|
|
568
|
+
sources: (u.sources || []).join(";"),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
res.writeHead(200, {
|
|
573
|
+
"Content-Type": "text/csv; charset=utf-8",
|
|
574
|
+
"Content-Disposition":
|
|
575
|
+
'attachment; filename="target-users-by-country.csv"',
|
|
576
|
+
});
|
|
577
|
+
const BOM = "\uFEFF";
|
|
578
|
+
const header = columns.join(",");
|
|
579
|
+
const lines = allUsers.map((r) =>
|
|
580
|
+
columns.map((c) => csvEscape(r[c])).join(","),
|
|
581
|
+
);
|
|
582
|
+
res.end(BOM + [header, ...lines].join("\r\n"));
|
|
583
|
+
} else {
|
|
584
|
+
sendJSON(res, 200, result);
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
529
589
|
if (req.method === "GET" && routePath === "/api/client-errors") {
|
|
530
590
|
sendJSON(res, 200, { clients: store.getClientErrors() });
|
|
531
591
|
return;
|
|
@@ -564,6 +624,8 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
564
624
|
location: params.location,
|
|
565
625
|
limit: params.limit,
|
|
566
626
|
offset: params.offset,
|
|
627
|
+
hasVideo: params.hasVideo,
|
|
628
|
+
hasFollower: params.hasFollower,
|
|
567
629
|
});
|
|
568
630
|
sendJSON(res, 200, result);
|
|
569
631
|
return;
|
|
@@ -590,10 +652,17 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
590
652
|
let result;
|
|
591
653
|
if (body.uniqueId) {
|
|
592
654
|
result = store.restoreRawJobById(body.uniqueId);
|
|
593
|
-
} else if (
|
|
655
|
+
} else if (
|
|
656
|
+
body.search ||
|
|
657
|
+
body.location ||
|
|
658
|
+
body.hasVideo ||
|
|
659
|
+
body.hasFollower
|
|
660
|
+
) {
|
|
594
661
|
result = store.restoreRawJobsByFilter({
|
|
595
662
|
search: body.search || "",
|
|
596
663
|
location: body.location || "",
|
|
664
|
+
hasVideo: body.hasVideo,
|
|
665
|
+
hasFollower: body.hasFollower,
|
|
597
666
|
});
|
|
598
667
|
} else if (body.country) {
|
|
599
668
|
result = store.restoreRawJobsByCountry(body.country);
|
|
@@ -629,6 +698,24 @@ export function startWatchServer(dataAnchor, port = 3000, existingStore) {
|
|
|
629
698
|
return;
|
|
630
699
|
}
|
|
631
700
|
|
|
701
|
+
if (
|
|
702
|
+
req.method === "POST" &&
|
|
703
|
+
routePath === "/api/pending-by-country/reset"
|
|
704
|
+
) {
|
|
705
|
+
try {
|
|
706
|
+
const body = await readBody(req);
|
|
707
|
+
const result = store.resetPendingByCountry(body.country);
|
|
708
|
+
if (result.error) {
|
|
709
|
+
sendJSON(res, 400, result);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
sendJSON(res, 200, result);
|
|
713
|
+
} catch (e) {
|
|
714
|
+
sendJSON(res, 400, { error: e.message });
|
|
715
|
+
}
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
632
719
|
if (
|
|
633
720
|
req.method === "DELETE" &&
|
|
634
721
|
routePath.startsWith("/api/client-error/")
|