tt-help-cli-ycl 1.3.83 → 1.3.85

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.
@@ -0,0 +1,101 @@
1
+ import crypto from "node:crypto";
2
+
3
+ const MAX_RETRY_WAIT = 5 * 60 * 1000;
4
+
5
+ async function withRetry(label, fn, opts = {}) {
6
+ const { maxRetries, backoff: initialBackoff = 1000, log = true } = opts;
7
+
8
+ let backoff = initialBackoff;
9
+ let i = 0;
10
+
11
+ while (true) {
12
+ try {
13
+ return await fn();
14
+ } catch (err) {
15
+ if (maxRetries !== undefined && i >= maxRetries) {
16
+ throw err;
17
+ }
18
+ if (log) {
19
+ console.error(
20
+ `[连接] ${label} 失败: ${err.message},${backoff / 1000}秒后重试...`,
21
+ );
22
+ }
23
+ await new Promise((r) => setTimeout(r, backoff));
24
+ if (backoff < MAX_RETRY_WAIT) backoff *= 2;
25
+ i++;
26
+ }
27
+ }
28
+ }
29
+
30
+ function buildHeaders(baseHeaders, clientId, meta) {
31
+ return {
32
+ ...baseHeaders,
33
+ "X-Client-Id": clientId,
34
+ "X-Client-Info": JSON.stringify(meta),
35
+ };
36
+ }
37
+
38
+ export function createApiClient(opts = {}) {
39
+ const clientId = crypto.randomUUID();
40
+ const {
41
+ checkStatus = false,
42
+ maxRetries,
43
+ backoff = 1000,
44
+ log = true,
45
+ meta = {},
46
+ } = opts;
47
+ const retryOpts = { maxRetries, backoff, log };
48
+
49
+ async function apiGet(url) {
50
+ return withRetry(`GET ${url}`, async () => {
51
+ const res = await fetch(url, {
52
+ headers: buildHeaders({}, clientId, meta),
53
+ });
54
+ if (checkStatus && !res.ok) {
55
+ const errText = await res.text();
56
+ throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
57
+ }
58
+ return res.json();
59
+ }, retryOpts);
60
+ }
61
+
62
+ async function apiPost(url, body) {
63
+ return withRetry(`POST ${url}`, async () => {
64
+ const res = await fetch(url, {
65
+ method: "POST",
66
+ headers: buildHeaders(
67
+ { "Content-Type": "application/json" },
68
+ clientId,
69
+ meta,
70
+ ),
71
+ body: JSON.stringify(body),
72
+ });
73
+ if (checkStatus && !res.ok) {
74
+ const errText = await res.text();
75
+ throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
76
+ }
77
+ return res.json();
78
+ }, retryOpts);
79
+ }
80
+
81
+ async function apiPut(url, body) {
82
+ return withRetry(`PUT ${url}`, async () => {
83
+ const res = await fetch(url, {
84
+ method: "PUT",
85
+ headers: buildHeaders(
86
+ { "Content-Type": "application/json" },
87
+ clientId,
88
+ meta,
89
+ ),
90
+ body: body ? JSON.stringify(body) : undefined,
91
+ });
92
+ if (checkStatus && !res.ok) {
93
+ const errText = await res.text();
94
+ throw new Error(`HTTP ${res.status}: ${errText.substring(0, 200)}`);
95
+ }
96
+ return res.json();
97
+ }, retryOpts);
98
+ }
99
+
100
+ return { apiGet, apiPost, apiPut };
101
+ }
package/src/lib/args.js CHANGED
@@ -714,6 +714,186 @@ function parseCommentsArgs(args) {
714
714
  };
715
715
  }
716
716
 
717
+ function parseTagArgs(args) {
718
+ const tags = [];
719
+ let outputFile = null;
720
+ let authorsOnly = false;
721
+ let videosOnly = false;
722
+ let enrich = null;
723
+ let locations = null;
724
+ let noFilter = false;
725
+ let serverUrl = null;
726
+ let discover = null;
727
+ let discoverCountries = [];
728
+ let discoverCount = 4;
729
+ let discoverPrompt = null;
730
+ let isDiscover = false;
731
+ let isScore = false;
732
+ let isScoreAll = false;
733
+ let scoreTag = null;
734
+ let scoreCountries = null;
735
+
736
+ for (let i = 0; i < args.length; i++) {
737
+ const arg = args[i];
738
+ if (arg === "-o" || arg === "--output") {
739
+ outputFile = args[++i];
740
+ } else if (arg === "-s" || arg === "--server") {
741
+ serverUrl = args[++i];
742
+ } else if (arg === "--authors-only") {
743
+ authorsOnly = true;
744
+ } else if (arg === "--videos-only") {
745
+ videosOnly = true;
746
+ } else if (arg === "--enrich") {
747
+ const next = args[i + 1];
748
+ if (next === "users" || next === "videos") {
749
+ enrich = next;
750
+ i++;
751
+ } else {
752
+ enrich = true;
753
+ }
754
+ } else if (arg === "--locations") {
755
+ locations = args[++i];
756
+ } else if (arg === "--no-filter") {
757
+ noFilter = true;
758
+ } else if (arg === "--discover") {
759
+ // 旧版 --discover 兼容:跟在 tag 命令后面无子命令
760
+ const next = args[i + 1];
761
+ const count = parseInt(next);
762
+ if (!isNaN(count) && count > 0) {
763
+ discover = count;
764
+ i++;
765
+ } else {
766
+ discover = true;
767
+ }
768
+ } else if (arg === "--count") {
769
+ discoverCount = parseInt(args[++i]) || 4;
770
+ } else if (arg === "--countries") {
771
+ scoreCountries = args[++i]
772
+ .split(",")
773
+ .map((s) => s.trim().toUpperCase())
774
+ .filter(Boolean);
775
+ } else if (arg === "-p" || arg === "--prompt") {
776
+ discoverPrompt = args[++i];
777
+ } else if (!arg.startsWith("-")) {
778
+ const cleaned = arg.replace("#", "").trim();
779
+ if (cleaned.toLowerCase() === "discover") {
780
+ isDiscover = true;
781
+ for (let j = i + 1; j < args.length; j++) {
782
+ if (args[j].startsWith("-")) break;
783
+ discoverCountries.push(args[j].trim().toUpperCase());
784
+ }
785
+ i += discoverCountries.length;
786
+ } else if (cleaned.toLowerCase() === "score") {
787
+ isScore = true;
788
+ // score 后的第一个位置参数为 tag 名
789
+ if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
790
+ scoreTag = args[i + 1].replace("#", "").trim().toLowerCase();
791
+ i++;
792
+ }
793
+ // 后续位置参数为 countries
794
+ for (let j = i + 1; j < args.length; j++) {
795
+ if (args[j].startsWith("-")) break;
796
+ if (!scoreCountries) scoreCountries = [];
797
+ scoreCountries.push(args[j].trim().toUpperCase());
798
+ }
799
+ if (scoreCountries) i += scoreCountries.length;
800
+ } else if (cleaned.toLowerCase() === "score-all") {
801
+ isScoreAll = true;
802
+ } else {
803
+ tags.push(cleaned.toLowerCase());
804
+ }
805
+ }
806
+ }
807
+
808
+ if (isDiscover) {
809
+ return {
810
+ subcommand: "tag-discover",
811
+ tagDiscover: {
812
+ countries: discoverCountries,
813
+ count: discoverCount,
814
+ prompt: discoverPrompt,
815
+ serverUrl,
816
+ },
817
+ urls: [],
818
+ outputFormat: "json",
819
+ exploreCount: 0,
820
+ showConfig: false,
821
+ showHelp: false,
822
+ customProxy: null,
823
+ configAction: null,
824
+ configValue: null,
825
+ pipeMode: false,
826
+ filterStr: null,
827
+ };
828
+ }
829
+
830
+ if (isScore) {
831
+ return {
832
+ subcommand: "tag-score",
833
+ tagScore: {
834
+ tag: scoreTag,
835
+ countries: scoreCountries,
836
+ serverUrl,
837
+ },
838
+ urls: [],
839
+ outputFormat: "json",
840
+ exploreCount: 0,
841
+ showConfig: false,
842
+ showHelp: false,
843
+ customProxy: null,
844
+ configAction: null,
845
+ configValue: null,
846
+ pipeMode: false,
847
+ filterStr: null,
848
+ };
849
+ }
850
+
851
+ if (isScoreAll) {
852
+ return {
853
+ subcommand: "tag-score-all",
854
+ tagScoreAll: {
855
+ countries: scoreCountries,
856
+ serverUrl,
857
+ },
858
+ urls: [],
859
+ outputFormat: "json",
860
+ exploreCount: 0,
861
+ showConfig: false,
862
+ showHelp: false,
863
+ customProxy: null,
864
+ configAction: null,
865
+ configValue: null,
866
+ pipeMode: false,
867
+ filterStr: null,
868
+ };
869
+ }
870
+
871
+ return {
872
+ subcommand: "tag",
873
+ tagTags: {
874
+ tags,
875
+ outputFile,
876
+ authorsOnly,
877
+ videosOnly,
878
+ enrich,
879
+ locations,
880
+ noFilter,
881
+ serverUrl,
882
+ discover,
883
+ },
884
+ urls: [],
885
+ outputFormat: "json",
886
+ exploreCount: 0,
887
+ showConfig: false,
888
+ showHelp: false,
889
+ customProxy: null,
890
+ configAction: null,
891
+ configValue: null,
892
+ pipeMode: false,
893
+ filterStr: null,
894
+ };
895
+ }
896
+
717
897
  export function parseArgs() {
718
898
  const args = process.argv.slice(2);
719
899
 
@@ -780,12 +960,8 @@ export function parseArgs() {
780
960
  return parseCommentsArgs(args.slice(1));
781
961
  }
782
962
 
783
- if (args.length > 0 && args[0] === "videostats") {
784
- return parseVideoStatsArgs(args.slice(1));
785
- }
786
-
787
- if (args.length > 0 && args[0] === "db-import") {
788
- return parseDbImportArgs(args.slice(1));
963
+ if (args.length > 0 && args[0] === "tag") {
964
+ return parseTagArgs(args.slice(1));
789
965
  }
790
966
 
791
967
  if (args.length > 0 && args[0] === "refresh") {
@@ -208,6 +208,46 @@ const HELP_TEXT = [
208
208
  " POST /api/tiktok/lookup 同时获取视频和作者信息 { videoUrl: string }",
209
209
  " 示例: tt-help webserver -p 3000",
210
210
  "",
211
+ " tag <标签名> [...] [选项]",
212
+ " 抓取标签页视频和作者(旧版 CLI 模式)",
213
+ " 选项:",
214
+ " -s, --server <URL> 推送到 watch 服务端",
215
+ " --enrich [users|videos] 补充国家/地区信息(默认 videos)",
216
+ ` --locations <国家代码> 目标国家,逗号分隔(默认 ${DEFAULT_TARGET_LOCATIONS_CSV})`,
217
+ " --no-filter 不过滤国家",
218
+ " --discover [数量] LLM 自动发现标签 + 记录有效标签",
219
+ " -o, --output <file> 输出到 JSON 文件",
220
+ " --authors-only 只输出作者列表",
221
+ " --videos-only 只输出视频列表",
222
+ " 示例: tt-help tag ventas --enrich -s http://127.0.0.1:3001",
223
+ "",
224
+ " tag discover <国家> [国家...] [选项]",
225
+ " LLM 生成对应语言的 TikTok 电商标签,存入 tags 表",
226
+ " 选项:",
227
+ " --count <N> 每个国家生成标签数(默认 4)",
228
+ " -p, --prompt <文本> 用户自定义领域提示",
229
+ " -s, --server <URL> 服务端地址(默认 http://127.0.0.1:3000)",
230
+ " 示例: tt-help tag discover ES",
231
+ " tt-help tag discover ES FR DE --count 5",
232
+ ' tt-help tag discover DE -p "手工首饰卖家"',
233
+ "",
234
+ " tag score <标签名> [选项]",
235
+ " 客户端本地打分:抓取标签页 → 查作者国家 → 算分 → 推送用户 → 上报服务端",
236
+ " 选项:",
237
+ " --countries <CSV> 目标国家,逗号分隔(默认 13 个欧洲国家)",
238
+ " -s, --server <URL> 服务端地址(默认 http://127.0.0.1:3000)",
239
+ " 示例: tt-help tag score ventas",
240
+ " tt-help tag score ventas --countries ES",
241
+ "",
242
+ " tag score-all [选项]",
243
+ " 自动循环打分:从服务端 tags 表取 new 标签,逐个本地打分并上报",
244
+ " enrich 浏览器实例在整个循环中复用",
245
+ " 选项:",
246
+ " --countries <CSV> 目标国家,逗号分隔(默认 13 个欧洲国家)",
247
+ " -s, --server <URL> 服务端地址(默认 http://127.0.0.1:3000)",
248
+ " 示例: tt-help tag score-all",
249
+ " tt-help tag score-all --countries ES -s http://127.0.0.1:3001",
250
+ "",
211
251
  " config [show|set|unset|reset]",
212
252
  " config 查看当前配置",
213
253
  " config set <key> <value> 设置配置(key: proxy, server, browser, userId, maxFollowing, maxFollowers, maxVideos, maxComments)",
@@ -225,6 +265,9 @@ const HELP_TEXT = [
225
265
  " tt-help attach -p 5 -i 10",
226
266
  " tt-help watch -o data/result.db",
227
267
  " tt-help videostats data/result.db -p 3",
268
+ " tt-help tag discover ES FR --count 5",
269
+ " tt-help tag score ventas --countries ES",
270
+ " tt-help tag score-all --countries ES,FR -s http://127.0.0.1:3001",
228
271
  ];
229
272
 
230
273
  const PUBLIC_HELP_HIDDEN_HEADERS = new Set([
@@ -116,6 +116,7 @@ export function parseUserInfo(rawHtml) {
116
116
  secUid: u.secUid,
117
117
  ttSeller: u.ttSeller || false,
118
118
  locationCreated: u.locationCreated || null,
119
+ createTime: u.createTime || null,
119
120
  statusCode: 0,
120
121
  };
121
122
  }
@@ -0,0 +1,124 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
2
+ import { resolve, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const TAGS_FILE = resolve(__dirname, '..', '..', 'data', 'productive-tags.json');
7
+
8
+ function loadTags() {
9
+ try {
10
+ if (existsSync(TAGS_FILE)) {
11
+ return JSON.parse(readFileSync(TAGS_FILE, 'utf-8'));
12
+ }
13
+ } catch {}
14
+ return { tags: [], lastUpdated: null };
15
+ }
16
+
17
+ function saveTags(data) {
18
+ const dir = dirname(TAGS_FILE);
19
+ if (!existsSync(dir)) {
20
+ const { mkdirSync } = require('fs');
21
+ mkdirSync(dir, { recursive: true });
22
+ }
23
+ writeFileSync(TAGS_FILE, JSON.stringify(data, null, 2), 'utf-8');
24
+ }
25
+
26
+ export function getProductiveTags() {
27
+ return loadTags().tags;
28
+ }
29
+
30
+ export function recordProductiveTag(tag, country, userCount) {
31
+ const data = loadTags();
32
+ const existing = data.tags.find(t => t.tag === tag);
33
+ if (existing) {
34
+ if (!existing.countries.includes(country)) {
35
+ existing.countries.push(country);
36
+ }
37
+ existing.userCount += userCount;
38
+ existing.lastUsed = new Date().toISOString();
39
+ } else {
40
+ data.tags.push({
41
+ tag,
42
+ countries: [country],
43
+ userCount,
44
+ firstSeen: new Date().toISOString(),
45
+ lastUsed: new Date().toISOString(),
46
+ });
47
+ }
48
+ data.lastUpdated = new Date().toISOString();
49
+ saveTags(data);
50
+ }
51
+
52
+ async function callLLM(prompt) {
53
+ const apiKey = process.env.APIKEY || '';
54
+ const { fetch } = await import('undici');
55
+
56
+ const response = await fetch('http://82.156.52.214:18000/v1/chat/completions', {
57
+ method: 'POST',
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ Authorization: `Bearer ${apiKey}`,
61
+ },
62
+ body: JSON.stringify({
63
+ model: 'zc-fast',
64
+ messages: [{ role: 'user', content: prompt }],
65
+ max_tokens: 1024,
66
+ temperature: 0.7,
67
+ }),
68
+ });
69
+
70
+ const result = await response.json();
71
+ const content = result.choices?.[0]?.message?.content || '';
72
+ return content;
73
+ }
74
+
75
+ function parseTagsFromResponse(content) {
76
+ try {
77
+ const parsed = JSON.parse(content);
78
+ if (Array.isArray(parsed)) return parsed;
79
+ if (Array.isArray(parsed.tags)) return parsed.tags;
80
+ } catch {}
81
+
82
+ const lines = content.split(/[\n,]+/);
83
+ const tags = [];
84
+ for (const line of lines) {
85
+ const cleaned = line.replace(/^[-\d.\s#]+/, '').trim().toLowerCase();
86
+ if (cleaned && /^[a-z0-9_]+$/.test(cleaned) && cleaned.length >= 2) {
87
+ tags.push(cleaned);
88
+ }
89
+ }
90
+ return tags;
91
+ }
92
+
93
+ export async function discoverTags(countries, options = {}) {
94
+ const { language = 'auto', count = 10 } = options;
95
+
96
+ const productiveTags = getProductiveTags();
97
+ const countryStr = Array.isArray(countries) ? countries.join(', ') : countries;
98
+ const langHint = language === 'auto'
99
+ ? ''
100
+ : `Tags should be in ${language} language.`;
101
+
102
+ const historyHint = productiveTags.length > 0
103
+ ? `Previously productive tags for these countries: ${productiveTags.filter(t => t.countries.some(c => countries.includes(c))).map(t => `#${t.tag}`).join(', ')}. Generate new ones, don't repeat these.`
104
+ : '';
105
+
106
+ const prompt = `Generate ${count} TikTok hashtags (lowercase, no spaces, no # symbol) that are likely to be used by online sellers, shop owners, e-commerce merchants, and small businesses in these countries: ${countryStr}.
107
+
108
+ Requirements:
109
+ - Focus on tags that sellers/merchants actually use to promote their products
110
+ - Include local language commerce tags (sell, shop, store, online, vendor, etc. in the local language)
111
+ - Mix broad commerce tags with country-specific tags
112
+ ${langHint}
113
+ ${historyHint}
114
+
115
+ Return ONLY a JSON array of tag strings, nothing else. Example: ["ventas","tiendaonline","vender"]`;
116
+
117
+ process.stderr.write(` [LLM] 正在生成 ${count} 个标签 (目标: ${countryStr})...\n`);
118
+ const content = await callLLM(prompt);
119
+ const tags = parseTagsFromResponse(content);
120
+
121
+ const unique = [...new Set(tags)].slice(0, count);
122
+ process.stderr.write(` [LLM] 生成 ${unique.length} 个标签: ${unique.join(', ')}\n`);
123
+ return unique;
124
+ }