tt-help-cli-ycl 1.3.95 → 1.3.96
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/tag.js +34 -9
- package/src/lib/api-interceptor.js +2 -0
- package/src/lib/tag-fetcher.js +35 -33
- package/src/scraper/explore-core.js +6 -3
- package/src/watch/data-store.js +27 -0
- package/src/watch/db-tags.js +22 -1
- package/src/watch/public/app.js +85 -9
- package/src/watch/public/index.html +1 -0
- package/src/watch/public/style.css +50 -0
- package/src/watch/server.js +36 -0
- package/src/watch/tag-service.js +43 -22
package/package.json
CHANGED
package/src/cli/tag.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_TARGET_LOCATIONS,
|
|
10
10
|
isLocationInList,
|
|
11
11
|
} from "../lib/target-locations.js";
|
|
12
|
+
import { delay as randomDelay } from "../lib/delay.js";
|
|
12
13
|
import { discoverTags } from "../lib/tag-discover.js";
|
|
13
14
|
import {
|
|
14
15
|
server as cfgServer,
|
|
@@ -121,13 +122,13 @@ async function processTag(
|
|
|
121
122
|
port: port || 9222,
|
|
122
123
|
onProgress: ({ videos, authors }) => {
|
|
123
124
|
process.stderr.write(
|
|
124
|
-
`\r${prefix} #${tag}: ${videos} 视频, ${authors}
|
|
125
|
+
`\r${prefix} #${tag}: ${videos} 视频, ${authors} 作者\x1b[K`,
|
|
125
126
|
);
|
|
126
127
|
},
|
|
127
128
|
});
|
|
128
129
|
|
|
129
130
|
process.stderr.write(
|
|
130
|
-
`\r${prefix} #${tag}: ${result.videoCount} 视频, ${result.uniqueAuthorCount}
|
|
131
|
+
`\r${prefix} #${tag}: ${result.videoCount} 视频, ${result.uniqueAuthorCount} 作者\x1b[K`,
|
|
131
132
|
);
|
|
132
133
|
|
|
133
134
|
let videos = result.videos;
|
|
@@ -154,7 +155,7 @@ async function processTag(
|
|
|
154
155
|
locationCreated &&
|
|
155
156
|
isLocationInList(locationCreated, targetLocations);
|
|
156
157
|
process.stderr.write(
|
|
157
|
-
`\r [${done}/${total}] ${label} → ${loc}${hit ? " ✓" : ""}`,
|
|
158
|
+
`\r [${done}/${total}] ${label} → ${loc}${hit ? " ✓" : ""}\x1b[K`,
|
|
158
159
|
);
|
|
159
160
|
},
|
|
160
161
|
});
|
|
@@ -295,8 +296,8 @@ async function scoreSingleTag(
|
|
|
295
296
|
{ baseUrl, cdpPort, targetCountries, effectiveProxy },
|
|
296
297
|
) {
|
|
297
298
|
const log = (...args) => process.stderr.write(args.join(" ") + "\n");
|
|
298
|
-
const progress = (msg) => process.stderr.write(`\r ${msg}`);
|
|
299
|
-
const clearLine = () => process.stderr.write("\r
|
|
299
|
+
const progress = (msg) => process.stderr.write(`\r ${msg}\x1b[K`);
|
|
300
|
+
const clearLine = () => process.stderr.write("\r\x1b[K");
|
|
300
301
|
|
|
301
302
|
const startTime = Date.now();
|
|
302
303
|
|
|
@@ -613,7 +614,7 @@ export async function handleScoreAll(parsed) {
|
|
|
613
614
|
const cdpOpts = { port: cdpPort };
|
|
614
615
|
if (effectiveProxy) cdpOpts.proxyServer = effectiveProxy;
|
|
615
616
|
const browser = await ensureBrowserReady(cdpOpts);
|
|
616
|
-
|
|
617
|
+
let page = await getOrCreatePage(browser);
|
|
617
618
|
|
|
618
619
|
let totalScored = 0;
|
|
619
620
|
let emptyRounds = 0; // 连续无任务的轮数
|
|
@@ -633,6 +634,15 @@ export async function handleScoreAll(parsed) {
|
|
|
633
634
|
log(` 客户端 ID: ${clientId.substring(0, 8)}...`);
|
|
634
635
|
log("");
|
|
635
636
|
|
|
637
|
+
// Ctrl+C 时关闭浏览器和 scraper
|
|
638
|
+
const cleanup = () => {
|
|
639
|
+
log("\n正在清理资源...");
|
|
640
|
+
enrichScraper.close().catch(() => {});
|
|
641
|
+
killEdgeProcesses(null, cdpPort);
|
|
642
|
+
process.exit(0);
|
|
643
|
+
};
|
|
644
|
+
process.on("SIGINT", cleanup);
|
|
645
|
+
|
|
636
646
|
try {
|
|
637
647
|
while (true) {
|
|
638
648
|
try {
|
|
@@ -724,11 +734,13 @@ export async function handleScoreAll(parsed) {
|
|
|
724
734
|
const tagResult = await fetchTagData(tag, {
|
|
725
735
|
port: cdpPort,
|
|
726
736
|
onProgress: ({ videos, authors }) => {
|
|
727
|
-
process.stderr.write(
|
|
737
|
+
process.stderr.write(
|
|
738
|
+
`\r 抓取中: ${videos} 视频, ${authors} 作者\x1b[K`,
|
|
739
|
+
);
|
|
728
740
|
},
|
|
729
741
|
});
|
|
730
742
|
log(
|
|
731
|
-
`\r 完成: ${tagResult.videoCount} 视频, ${tagResult.uniqueAuthorCount}
|
|
743
|
+
`\r 完成: ${tagResult.videoCount} 视频, ${tagResult.uniqueAuthorCount} 作者\x1b[K`,
|
|
732
744
|
);
|
|
733
745
|
|
|
734
746
|
result.totalPosts = tagResult.totalPosts || 0;
|
|
@@ -751,7 +763,7 @@ export async function handleScoreAll(parsed) {
|
|
|
751
763
|
onProgress: ({ done, total, current, locationCreated }) => {
|
|
752
764
|
if (done % 10 === 0 || done === total) {
|
|
753
765
|
process.stderr.write(
|
|
754
|
-
`\r [${done}/${total}] ${current.split("/").pop().slice(0, 20)} → ${locationCreated || "-"}`,
|
|
766
|
+
`\r [${done}/${total}] ${current.split("/").pop().slice(0, 20)} → ${locationCreated || "-"}\x1b[K`,
|
|
755
767
|
);
|
|
756
768
|
}
|
|
757
769
|
},
|
|
@@ -799,6 +811,19 @@ export async function handleScoreAll(parsed) {
|
|
|
799
811
|
` ${icon} ${result.status} score=${result.score} authors=${result.authorCount} matched=${result.matchedAuthors} (${elapsed}s)${mc ? " " + mc : ""}`,
|
|
800
812
|
);
|
|
801
813
|
log("");
|
|
814
|
+
|
|
815
|
+
// 随机等待 3-7 秒,避免连续访问 TikTok 触发风控
|
|
816
|
+
await randomDelay(3000, 7000);
|
|
817
|
+
|
|
818
|
+
// 每打 200 个 tag 重建一次 page,释放浏览器内存
|
|
819
|
+
if (totalScored % 200 === 0) {
|
|
820
|
+
log(`🔄 已打 ${totalScored} 个标签,重建页面释放内存...`);
|
|
821
|
+
try {
|
|
822
|
+
await page.close();
|
|
823
|
+
} catch {}
|
|
824
|
+
page = await getOrCreatePage(browser);
|
|
825
|
+
log("✅ 页面已重建");
|
|
826
|
+
}
|
|
802
827
|
} catch (e) {
|
|
803
828
|
// 区分网络错误和业务错误
|
|
804
829
|
const isNetworkError =
|
|
@@ -23,6 +23,7 @@ async function processAPIResponse(
|
|
|
23
23
|
href,
|
|
24
24
|
createTime: item.createTime || null,
|
|
25
25
|
playCount: item.stats?.playCount || 0,
|
|
26
|
+
isECVideo: item.isECVideo ? 1 : 0,
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -72,6 +73,7 @@ async function processAPIResponse(
|
|
|
72
73
|
href,
|
|
73
74
|
createTime: item.createTime || null,
|
|
74
75
|
playCount: item.stats?.playCount || 0,
|
|
76
|
+
isECVideo: item.isECVideo ? 1 : 0,
|
|
75
77
|
});
|
|
76
78
|
}
|
|
77
79
|
}
|
package/src/lib/tag-fetcher.js
CHANGED
|
@@ -56,43 +56,45 @@ export async function fetchTagData(tag, options = {}) {
|
|
|
56
56
|
const browser = await ensureBrowserReady(cdpOptions);
|
|
57
57
|
const page = await getOrCreatePage(browser);
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
challengeInfo = body.challengeInfo.challenge;
|
|
74
|
-
}
|
|
59
|
+
let challengeInfo = null;
|
|
60
|
+
const rawVideos = [];
|
|
61
|
+
const seenVideoIds = new Set();
|
|
62
|
+
const authors = new Set();
|
|
63
|
+
|
|
64
|
+
const responseHandler = async (resp) => {
|
|
65
|
+
try {
|
|
66
|
+
const url = resp.url();
|
|
67
|
+
const ct = resp.headers()["content-type"] || "";
|
|
68
|
+
|
|
69
|
+
if (url.includes("/api/challenge/detail/") && ct.includes("json")) {
|
|
70
|
+
const body = await resp.json();
|
|
71
|
+
if (body?.challengeInfo?.challenge) {
|
|
72
|
+
challengeInfo = body.challengeInfo.challenge;
|
|
75
73
|
}
|
|
74
|
+
}
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
if (onProgress) {
|
|
90
|
-
onProgress({ videos: rawVideos.length, authors: authors.size });
|
|
76
|
+
if (url.includes("/api/challenge/item_list/") && ct.includes("json")) {
|
|
77
|
+
const body = await resp.json();
|
|
78
|
+
if (!body?.itemList) return;
|
|
79
|
+
for (const item of body.itemList) {
|
|
80
|
+
const vid = item.id || "";
|
|
81
|
+
if (vid && !seenVideoIds.has(vid)) {
|
|
82
|
+
seenVideoIds.add(vid);
|
|
83
|
+
const uid = item.author?.uniqueId || "";
|
|
84
|
+
if (uid) authors.add(uid);
|
|
85
|
+
rawVideos.push(extractItemData(item));
|
|
91
86
|
}
|
|
92
87
|
}
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
if (onProgress) {
|
|
89
|
+
onProgress({ videos: rawVideos.length, authors: authors.size });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {}
|
|
93
|
+
};
|
|
95
94
|
|
|
95
|
+
page.on("response", responseHandler);
|
|
96
|
+
|
|
97
|
+
try {
|
|
96
98
|
const tagUrl = `${TAG_URL}/${encodeURIComponent(tag)}`;
|
|
97
99
|
const resp = await page.goto(tagUrl, {
|
|
98
100
|
waitUntil: "domcontentloaded",
|
|
@@ -185,7 +187,7 @@ export async function fetchTagData(tag, options = {}) {
|
|
|
185
187
|
uniqueAuthors: [...authors],
|
|
186
188
|
};
|
|
187
189
|
} finally {
|
|
188
|
-
|
|
190
|
+
page.off("response", responseHandler);
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -87,11 +87,14 @@ async function processExplore(page, username, options, log) {
|
|
|
87
87
|
if (result.userInfo) result.userInfo.latestVideoTime = latestCreateTime;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// 找出 7
|
|
90
|
+
// 找出 7 天内发布且 isECVideo=1 且播放量最大的视频
|
|
91
91
|
const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60;
|
|
92
92
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
93
93
|
const recentVideos = videoArray.filter(
|
|
94
|
-
(v) =>
|
|
94
|
+
(v) =>
|
|
95
|
+
v.isECVideo === 1 &&
|
|
96
|
+
v.createTime &&
|
|
97
|
+
nowSeconds - v.createTime <= SEVEN_DAYS_SECONDS,
|
|
95
98
|
);
|
|
96
99
|
if (recentVideos.length > 0) {
|
|
97
100
|
const topVideo = recentVideos.reduce((max, v) =>
|
|
@@ -104,7 +107,7 @@ async function processExplore(page, username, options, log) {
|
|
|
104
107
|
createTime: topVideo.createTime,
|
|
105
108
|
};
|
|
106
109
|
log(
|
|
107
|
-
` 7
|
|
110
|
+
` 7天内 EC视频最高播放: ${topVideo.playCount} 次播放 (${recentVideos.length} 个EC候选)`,
|
|
108
111
|
);
|
|
109
112
|
}
|
|
110
113
|
|
package/src/watch/data-store.js
CHANGED
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
getDeadTags,
|
|
91
91
|
claimTag,
|
|
92
92
|
reportTagScore,
|
|
93
|
+
resetStaleScoringTags,
|
|
93
94
|
getAllTags,
|
|
94
95
|
rawQuery,
|
|
95
96
|
normalizeTags,
|
|
@@ -2674,6 +2675,30 @@ export function createStore(filePath, options = {}) {
|
|
|
2674
2675
|
return { ok: true, location, modifiedAt: user.modifiedAt };
|
|
2675
2676
|
}
|
|
2676
2677
|
|
|
2678
|
+
function setNonSeller(uniqueId) {
|
|
2679
|
+
if (getDb()) {
|
|
2680
|
+
const existing = getDb()
|
|
2681
|
+
.prepare("SELECT * FROM jobs WHERE unique_id = ?")
|
|
2682
|
+
.get(uniqueId);
|
|
2683
|
+
if (!existing) return { error: "user not found" };
|
|
2684
|
+
const now = Date.now();
|
|
2685
|
+
getDb()
|
|
2686
|
+
.prepare(
|
|
2687
|
+
"UPDATE jobs SET tt_seller = 0, updated_at = ? WHERE unique_id = ?",
|
|
2688
|
+
)
|
|
2689
|
+
.run(now, uniqueId);
|
|
2690
|
+
console.error(`[DB] setNonSeller: ${uniqueId} → tt_seller=0`);
|
|
2691
|
+
return { ok: true };
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
const user = getUser(uniqueId);
|
|
2695
|
+
if (!user) return { error: "user not found" };
|
|
2696
|
+
user.ttSeller = false;
|
|
2697
|
+
user.updatedAt = Date.now();
|
|
2698
|
+
save();
|
|
2699
|
+
return { ok: true };
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2677
2702
|
// 将单个 job 移动到 raw_jobs 表(完整字段复制 + 删除原记录)
|
|
2678
2703
|
function moveJobToRaw(uniqueId) {
|
|
2679
2704
|
if (!getDb()) return false;
|
|
@@ -3127,6 +3152,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3127
3152
|
getPendingUserUpdateTasks,
|
|
3128
3153
|
updateUserInfo,
|
|
3129
3154
|
updateUserLocation,
|
|
3155
|
+
setNonSeller,
|
|
3130
3156
|
batchUpdateUserInfo,
|
|
3131
3157
|
reportClientError,
|
|
3132
3158
|
deleteClientError,
|
|
@@ -3154,6 +3180,7 @@ export function createStore(filePath, options = {}) {
|
|
|
3154
3180
|
getDeadTags,
|
|
3155
3181
|
claimTag,
|
|
3156
3182
|
reportTagScore,
|
|
3183
|
+
resetStaleScoringTags,
|
|
3157
3184
|
getAllTags,
|
|
3158
3185
|
normalizeTags,
|
|
3159
3186
|
clearTags,
|
package/src/watch/db-tags.js
CHANGED
|
@@ -64,12 +64,33 @@ export function getDeadTags(country) {
|
|
|
64
64
|
return rows.map(parseTagRow).filter((r) => r.countries.includes(country));
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
export function resetStaleScoringTags(minutes = 30) {
|
|
68
|
+
const db = getDb();
|
|
69
|
+
if (!db) return { ok: false, error: "db not ready" };
|
|
70
|
+
// 清理超时的 scoring 标签:有时间戳的按时间,没时间戳的(旧数据)直接清
|
|
71
|
+
const result = db
|
|
72
|
+
.prepare(
|
|
73
|
+
"UPDATE tags SET status = 'new', scored_at = NULL WHERE status = 'scoring' AND (scored_at IS NULL OR scored_at < datetime('now', ?))",
|
|
74
|
+
)
|
|
75
|
+
.run(`-${minutes} minutes`);
|
|
76
|
+
if (result.changes > 0) {
|
|
77
|
+
console.error(
|
|
78
|
+
`[tags] 清理了 ${result.changes} 个超时 scoring 标签(>${minutes}分钟)`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return { ok: true, reset: result.changes };
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
export function claimTag(tag) {
|
|
68
85
|
const db = getDb();
|
|
69
86
|
if (!db) return { ok: false, error: "db not ready" };
|
|
87
|
+
|
|
88
|
+
// 先清理超时的 scoring 标签,防止死任务堆积
|
|
89
|
+
resetStaleScoringTags();
|
|
90
|
+
|
|
70
91
|
const result = db
|
|
71
92
|
.prepare(
|
|
72
|
-
"UPDATE tags SET status = 'scoring' WHERE tag = ? AND status = 'new'",
|
|
93
|
+
"UPDATE tags SET status = 'scoring', scored_at = datetime('now') WHERE tag = ? AND status = 'new'",
|
|
73
94
|
)
|
|
74
95
|
.run(tag);
|
|
75
96
|
if (result.changes === 0) {
|
package/src/watch/public/app.js
CHANGED
|
@@ -659,16 +659,21 @@ function openLocationModal(uniqueId, currentLocation) {
|
|
|
659
659
|
overlay = document.createElement("div");
|
|
660
660
|
overlay.id = "locationModalOverlay";
|
|
661
661
|
overlay.className = "modal-overlay";
|
|
662
|
+
const safeId = escapeJsString(uniqueId);
|
|
662
663
|
const options = TARGET_LOCATIONS.map(
|
|
663
664
|
(loc) =>
|
|
664
|
-
`<button class="loc-option ${loc === currentLocation ? "active" : ""}" onclick="selectLocation('${
|
|
665
|
+
`<button class="loc-option ${loc === currentLocation ? "active" : ""}" onclick="selectLocation('${safeId}','${loc}')">${loc}</button>`,
|
|
665
666
|
).join("");
|
|
666
667
|
overlay.innerHTML = `
|
|
667
668
|
<div class="modal" style="max-width:420px">
|
|
668
669
|
<h3>修改用户国家</h3>
|
|
669
|
-
<div class="hint">用户: @${
|
|
670
|
+
<div class="hint">用户: @${safeId},当前国家: ${currentLocation}</div>
|
|
670
671
|
<div class="loc-grid">${options}</div>
|
|
671
|
-
<div class="
|
|
672
|
+
<div class="custom-loc-row">
|
|
673
|
+
<input type="text" id="customLocationInput" class="custom-loc-input" placeholder="或手动输入国家代码,如 UK" maxlength="10" onkeydown="if(event.key==='Enter')confirmCustomLocation('${safeId}')">
|
|
674
|
+
</div>
|
|
675
|
+
<div class="btn-row" style="margin-top:12px">
|
|
676
|
+
<button class="btn-submit" onclick="confirmCustomLocation('${safeId}')">确认</button>
|
|
672
677
|
<button class="btn-cancel" onclick="closeLocationModal()">取消</button>
|
|
673
678
|
</div>
|
|
674
679
|
</div>
|
|
@@ -677,6 +682,21 @@ function openLocationModal(uniqueId, currentLocation) {
|
|
|
677
682
|
overlay.addEventListener("click", (e) => {
|
|
678
683
|
if (e.target === overlay) closeLocationModal();
|
|
679
684
|
});
|
|
685
|
+
// 自动聚焦输入框
|
|
686
|
+
setTimeout(() => {
|
|
687
|
+
const input = document.getElementById("customLocationInput");
|
|
688
|
+
if (input) input.focus();
|
|
689
|
+
}, 100);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function confirmCustomLocation(uniqueId) {
|
|
693
|
+
const input = document.getElementById("customLocationInput");
|
|
694
|
+
const val = input ? input.value.trim().toUpperCase() : "";
|
|
695
|
+
if (!val) {
|
|
696
|
+
showToast("请输入国家代码", true);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
selectLocation(uniqueId, val);
|
|
680
700
|
}
|
|
681
701
|
|
|
682
702
|
function closeLocationModal() {
|
|
@@ -726,6 +746,56 @@ async function selectLocation(uniqueId, location) {
|
|
|
726
746
|
}
|
|
727
747
|
}
|
|
728
748
|
|
|
749
|
+
async function confirmNonSeller(uniqueId) {
|
|
750
|
+
if (
|
|
751
|
+
!confirm(
|
|
752
|
+
`确定要将 @${uniqueId} 标记为非商家吗?\n这将把 ta 的商家标识(ttSeller)设为 false。`,
|
|
753
|
+
)
|
|
754
|
+
) {
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
showLoading("正在更新...");
|
|
758
|
+
try {
|
|
759
|
+
const res = await fetch(
|
|
760
|
+
`/api/user-non-seller/${encodeURIComponent(uniqueId)}`,
|
|
761
|
+
{
|
|
762
|
+
method: "PUT",
|
|
763
|
+
headers: { "Content-Type": "application/json" },
|
|
764
|
+
},
|
|
765
|
+
);
|
|
766
|
+
const data = await res.json();
|
|
767
|
+
if (data.error) {
|
|
768
|
+
showToast(data.error, true);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
showToast(`@${uniqueId} 已标记为非商家`);
|
|
772
|
+
|
|
773
|
+
// 从当前列表中移除该用户
|
|
774
|
+
currentTargetUsers = currentTargetUsers.filter(
|
|
775
|
+
(u) => u.uniqueId !== uniqueId,
|
|
776
|
+
);
|
|
777
|
+
currentTargetTotal = Math.max(0, currentTargetTotal - 1);
|
|
778
|
+
renderTargetTable();
|
|
779
|
+
|
|
780
|
+
// 同时更新统计数据
|
|
781
|
+
const statEl = document.getElementById("targetPageStatTotal");
|
|
782
|
+
if (statEl)
|
|
783
|
+
statEl.textContent = formatStatNum(currentTargetTotal, { full: true });
|
|
784
|
+
// 更新主页面统计
|
|
785
|
+
if (currentStats) {
|
|
786
|
+
currentStats.targetUsers = Math.max(
|
|
787
|
+
0,
|
|
788
|
+
(currentStats.targetUsers || 0) - 1,
|
|
789
|
+
);
|
|
790
|
+
flashEl("statTarget", currentStats.targetUsers, { full: true });
|
|
791
|
+
}
|
|
792
|
+
} catch (e) {
|
|
793
|
+
showToast("更新失败: " + e.message, true);
|
|
794
|
+
} finally {
|
|
795
|
+
hideLoading();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
729
799
|
async function submitAddUsers() {
|
|
730
800
|
const ta = document.getElementById("modalUserInput");
|
|
731
801
|
const raw = ta.value.trim();
|
|
@@ -1209,7 +1279,7 @@ function renderTargetTable() {
|
|
|
1209
1279
|
|
|
1210
1280
|
if (displayUsers.length === 0) {
|
|
1211
1281
|
el.innerHTML =
|
|
1212
|
-
'<tr><td colspan="
|
|
1282
|
+
'<tr><td colspan="11" style="text-align:center;color:#888;padding:24px">暂无数据</td></tr>';
|
|
1213
1283
|
if (moreHint) {
|
|
1214
1284
|
moreHint.style.display = "none";
|
|
1215
1285
|
}
|
|
@@ -1224,11 +1294,14 @@ function renderTargetTable() {
|
|
|
1224
1294
|
const fans = u.followerCount != null ? formatNum(u.followerCount) : "-";
|
|
1225
1295
|
const videos = u.videoCount != null ? u.videoCount : "-";
|
|
1226
1296
|
const userLocation = u.locationCreated || "-";
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1297
|
+
// 如果用户手工修改过国家,确认国家列显示"已修正"
|
|
1298
|
+
const confirmedLocation = u.modifiedAt
|
|
1299
|
+
? `<span style="color:#f59e0b;font-weight:600">已修正</span>`
|
|
1300
|
+
: u.confirmedLocation
|
|
1301
|
+
? u.confirmedLocation === u.locationCreated
|
|
1302
|
+
? `<span style="color:#22c55e">${u.confirmedLocation} ✓</span>`
|
|
1303
|
+
: `<span style="color:#ef4444">${u.confirmedLocation} ✗</span>`
|
|
1304
|
+
: "-";
|
|
1232
1305
|
const latestVideo = u.latestVideoTime
|
|
1233
1306
|
? formatTime(u.latestVideoTime * 1000)
|
|
1234
1307
|
: "-";
|
|
@@ -1270,6 +1343,9 @@ function renderTargetTable() {
|
|
|
1270
1343
|
${topPlayCountCell}
|
|
1271
1344
|
<td data-label="最近发布" style="font-size:11px;color:#888">${latestVideo}</td>
|
|
1272
1345
|
<td data-label="最近刷新" style="font-size:11px;color:#888">${refreshTime}</td>
|
|
1346
|
+
<td data-label="操作">
|
|
1347
|
+
<button class="btn-non-seller" onclick="confirmNonSeller('${escapeJsString(u.uniqueId)}')" title="将此用户标记为非商家">设为非商家</button>
|
|
1348
|
+
</td>
|
|
1273
1349
|
</tr>`;
|
|
1274
1350
|
})
|
|
1275
1351
|
.join("");
|
|
@@ -351,6 +351,7 @@
|
|
|
351
351
|
<th class="sortable-target" data-sort="topVideoPlayCount">最大播放量 <span class="sort-icon">↕</span></th>
|
|
352
352
|
<th class="sortable-target" data-sort="latestVideoTime">最近发布 <span class="sort-icon">↕</span></th>
|
|
353
353
|
<th>最近刷新</th>
|
|
354
|
+
<th>操作</th>
|
|
354
355
|
</tr>
|
|
355
356
|
</thead>
|
|
356
357
|
<tbody id="targetTable"></tbody>
|
|
@@ -1389,3 +1389,53 @@ td.user-id:hover {
|
|
|
1389
1389
|
height: 140px;
|
|
1390
1390
|
}
|
|
1391
1391
|
}
|
|
1392
|
+
|
|
1393
|
+
/* 自定义国家输入行 */
|
|
1394
|
+
.custom-loc-row {
|
|
1395
|
+
margin-top: 12px;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
.custom-loc-input {
|
|
1399
|
+
width: 100%;
|
|
1400
|
+
padding: 10px 12px;
|
|
1401
|
+
border: 1px solid #333;
|
|
1402
|
+
border-radius: 6px;
|
|
1403
|
+
background: #0f0f13;
|
|
1404
|
+
color: #e0e0e0;
|
|
1405
|
+
font-size: 13px;
|
|
1406
|
+
font-weight: 600;
|
|
1407
|
+
outline: none;
|
|
1408
|
+
text-transform: uppercase;
|
|
1409
|
+
transition: border-color 0.15s;
|
|
1410
|
+
box-sizing: border-box;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
.custom-loc-input:focus {
|
|
1414
|
+
border-color: #a78bfa;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
.custom-loc-input::placeholder {
|
|
1418
|
+
color: #555;
|
|
1419
|
+
font-weight: 400;
|
|
1420
|
+
text-transform: none;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/* 非商家按钮 */
|
|
1424
|
+
.btn-non-seller {
|
|
1425
|
+
padding: 4px 10px;
|
|
1426
|
+
border: 1px solid #f87171;
|
|
1427
|
+
border-radius: 4px;
|
|
1428
|
+
background: transparent;
|
|
1429
|
+
color: #f87171;
|
|
1430
|
+
font-size: 11px;
|
|
1431
|
+
font-weight: 600;
|
|
1432
|
+
cursor: pointer;
|
|
1433
|
+
transition: all 0.15s;
|
|
1434
|
+
white-space: nowrap;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
.btn-non-seller:hover {
|
|
1438
|
+
background: rgba(248, 113, 113, 0.12);
|
|
1439
|
+
border-color: #ef4444;
|
|
1440
|
+
color: #ef4444;
|
|
1441
|
+
}
|
package/src/watch/server.js
CHANGED
|
@@ -561,6 +561,26 @@ export function startWatchServer(
|
|
|
561
561
|
return;
|
|
562
562
|
}
|
|
563
563
|
|
|
564
|
+
const nonSellerMatch = routePath.match(
|
|
565
|
+
/^\/api\/user-non-seller\/([^/]+)$/,
|
|
566
|
+
);
|
|
567
|
+
if (req.method === "PUT" && nonSellerMatch) {
|
|
568
|
+
const uniqueId = nonSellerMatch[1];
|
|
569
|
+
try {
|
|
570
|
+
const ret = store.setNonSeller(uniqueId);
|
|
571
|
+
if (ret.error) {
|
|
572
|
+
sendJSON(res, 404, { error: ret.error });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const ts = new Date().toISOString().slice(11, 19);
|
|
576
|
+
console.error(`[JOB ${ts}] NON-SELLER: ${uniqueId} → ttSeller=false`);
|
|
577
|
+
sendJSON(res, 200, ret);
|
|
578
|
+
} catch (e) {
|
|
579
|
+
sendJSON(res, 400, { error: e.message });
|
|
580
|
+
}
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
|
|
564
584
|
if (req.method === "GET" && routePath === "/api/comment-tasks") {
|
|
565
585
|
const limit = parseInt(params.limit) || 1;
|
|
566
586
|
const tasks = store.getPendingCommentTasks(limit);
|
|
@@ -1266,6 +1286,22 @@ export function startWatchServer(
|
|
|
1266
1286
|
console.error(`Watch 监控服务已启动:`);
|
|
1267
1287
|
console.error(` 本地访问: http://127.0.0.1:${port}`);
|
|
1268
1288
|
console.error(` 局域网访问: http://${localIP}:${port}`);
|
|
1289
|
+
|
|
1290
|
+
// 启动时清理超时的 scoring 标签
|
|
1291
|
+
try {
|
|
1292
|
+
const { resetStaleScoringTags } = store;
|
|
1293
|
+
if (resetStaleScoringTags) {
|
|
1294
|
+
const result = resetStaleScoringTags(30);
|
|
1295
|
+
if (result.reset > 0) {
|
|
1296
|
+
console.error(
|
|
1297
|
+
`[启动] 已重置 ${result.reset} 个超时的 scoring 标签`,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
} catch (e) {
|
|
1302
|
+
console.error(`[启动] 清理 scoring 标签失败: ${e.message}`);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1269
1305
|
_resolve({ server, port });
|
|
1270
1306
|
});
|
|
1271
1307
|
|
package/src/watch/tag-service.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function callLLM(prompt) {
|
|
|
45
45
|
model: LLM_MODEL,
|
|
46
46
|
messages: [{ role: "user", content: prompt }],
|
|
47
47
|
max_tokens: 1024,
|
|
48
|
-
temperature: 0.
|
|
48
|
+
temperature: 0.3,
|
|
49
49
|
}),
|
|
50
50
|
});
|
|
51
51
|
|
|
@@ -163,7 +163,7 @@ export function buildDiscoverPrompt(
|
|
|
163
163
|
.map((t) => `${t.tag}(score:${Math.round(t.score)})`)
|
|
164
164
|
.join(
|
|
165
165
|
", ",
|
|
166
|
-
)}.
|
|
166
|
+
)}. Use these as reference for the STYLE and TYPE of tag that works — prefer commonly used words like these.`
|
|
167
167
|
: "";
|
|
168
168
|
|
|
169
169
|
// 负样本:该国 dead tag
|
|
@@ -186,7 +186,7 @@ export function buildDiscoverPrompt(
|
|
|
186
186
|
const allExisting = history.allExisting || [];
|
|
187
187
|
const existingHint =
|
|
188
188
|
allExisting.length > 0
|
|
189
|
-
? `\nTags already in database (DO NOT generate these again): ${allExisting.slice(
|
|
189
|
+
? `\nTags already in database (DO NOT generate these again): ${allExisting.slice(0, 50).join(", ")}.`
|
|
190
190
|
: "";
|
|
191
191
|
|
|
192
192
|
const userHint = userPrompt
|
|
@@ -210,39 +210,60 @@ Based on the above, which strategies produced high-scoring tags? Which failed?
|
|
|
210
210
|
Use this analysis to decide your strategy for this round.`;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
const deadRatio =
|
|
214
|
+
history.dead.length > 0 && history.productive.length > 0
|
|
215
|
+
? (
|
|
216
|
+
(history.dead.length /
|
|
217
|
+
(history.dead.length + history.productive.length)) *
|
|
218
|
+
100
|
|
219
|
+
).toFixed(0)
|
|
220
|
+
: null;
|
|
221
|
+
const qualityWarning =
|
|
222
|
+
deadRatio && Number(deadRatio) > 40
|
|
223
|
+
? `\n⚠️ WARNING: Currently ${deadRatio}% of our generated tags fail (no real TikTok posts). This is critically high. You MUST be more conservative — only suggest hashtags you have HIGH CONFIDENCE actually exist on TikTok.`
|
|
224
|
+
: "";
|
|
225
|
+
|
|
213
226
|
return `You are discovering TikTok hashtags used by people who sell things in ${country}.
|
|
214
227
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
-
|
|
220
|
-
-
|
|
228
|
+
⛔ CRITICAL RULE: ONLY suggest hashtags that you are CONFIDENT actually exist on TikTok with real posts.
|
|
229
|
+
Never invent, construct, or guess compound words. If you aren't sure a hashtag is real, DO NOT suggest it.
|
|
230
|
+
|
|
231
|
+
✅ Good examples of REAL TikTok hashtags:
|
|
232
|
+
- Simple, common words: "verkaufen", "handmade", "secondhand", "bakery", "vintage"
|
|
233
|
+
- Common category tags: "shoplocal", "onlineshopping", "handwerker", "trödel"
|
|
234
|
+
- Established brand/community tags: "smallbusiness", "supportlocal", "vendita"
|
|
235
|
+
❌ BAD examples (INVENTED, will fail):
|
|
236
|
+
- Novel compound words: "sourdoughschaleverkauf", "predavamdruhouseoblečenípraha"
|
|
237
|
+
- Hyper-specific location+product: "dublinvintagelamp", "canalistalisboa"
|
|
238
|
+
- Rare technical terms: "briefmarkensammlungankauf", "aquascapingzubehör"
|
|
239
|
+
- Underscore-connected constructs: "mtg_cardhu", "epoxigyanta_alkotás_eladó"
|
|
240
|
+
|
|
241
|
+
Think about the MOST COMMON hashtags that real sellers in ${country} use on TikTok:
|
|
242
|
+
- Common selling verbs in ${langName} (sell, buy, offer, clearance...)
|
|
243
|
+
- Common product categories in ${langName} (shoes, clothes, furniture, food, pets...)
|
|
244
|
+
- Common seller identities (shop, boutique, small business, creator...)
|
|
245
|
+
- Well-known community tags (support local, marketplace, second round...)
|
|
221
246
|
|
|
222
|
-
|
|
223
|
-
Generate ${count} tags that are ALL DIFFERENT from each other and from any existing tags.${productiveHint}${deadHint}${errorHint}${existingHint}${userHint}${strategyReview}
|
|
247
|
+
${qualityWarning}${productiveHint}${deadHint}${errorHint}${existingHint}${userHint}${strategyReview}
|
|
224
248
|
|
|
225
|
-
##
|
|
249
|
+
## Quality check before responding
|
|
226
250
|
|
|
227
|
-
|
|
228
|
-
1. Which tag directions scored highest? What makes them work?
|
|
229
|
-
2. Which directions completely failed? Why?
|
|
230
|
-
3. What seller niches are NOT yet covered? (e.g., if we have "shop" but no "bakery", "petstore", "bookshop"...)
|
|
231
|
-
4. What specific direction will YOU explore this round? Be concrete.
|
|
251
|
+
For each tag you consider, ask yourself: "Have I actually seen this hashtag used on TikTok, or am I just translating a concept?" If you're translating/constructing — DON'T include it. Only include if you're genuinely confident it's a real, used hashtag.
|
|
232
252
|
|
|
233
253
|
## Output format
|
|
234
254
|
|
|
235
255
|
Return ONLY a JSON object with two fields:
|
|
236
256
|
{
|
|
237
|
-
"strategy": "Your analysis
|
|
257
|
+
"strategy": "Your analysis (2-3 sentences). Acknowledge what worked/failed before and explain why your chosen tags are likely real and commonly used.",
|
|
238
258
|
"tags": ["tag1", "tag2", "tag3", "tag4"]
|
|
239
259
|
}
|
|
240
260
|
|
|
241
261
|
Rules:
|
|
242
|
-
-
|
|
243
|
-
-
|
|
244
|
-
-
|
|
245
|
-
-
|
|
262
|
+
- QUALITY over creativity — 3 real, commonly used tags beat 4 invented ones
|
|
263
|
+
- NEVER invent compound words or translate concepts into hashtags
|
|
264
|
+
- Prefer simpler, more common tags over hyper-specific ones
|
|
265
|
+
- Look for tags with broad appeal (many potential posters)
|
|
266
|
+
- Do NOT generate tags that already exist`;
|
|
246
267
|
}
|
|
247
268
|
|
|
248
269
|
// ====== discover: 单国家标签发现 ======
|