tt-help-cli-ycl 1.4.20 → 1.4.22

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tt-help-cli-ycl",
3
- "version": "1.4.20",
3
+ "version": "1.4.22",
4
4
  "description": "TikTok user & video data scraper - extract ttSeller, verified, locationCreated from HTML source",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,6 +35,7 @@ import {
35
35
  } from "../scraper/modules/captcha-handler.js";
36
36
  import { detectModals, closeAllModals } from "../lib/modal-handler.js";
37
37
  import { getMacOrUuid } from "../lib/mac-or-uuid.js";
38
+ import { parseVideoInfo } from "../lib/parse-ssr.mjs";
38
39
 
39
40
  // ────────────────────────────────────────
40
41
  // Section 1: 常量 & 配置默认值
@@ -492,6 +493,49 @@ function extractVideoInfo() {
492
493
  return result;
493
494
  }
494
495
 
496
+ /**
497
+ * 从视频页 SSR 数据中提取 challenges(标签/hashtags)
498
+ * @param {Page} page - Playwright page
499
+ * @returns {Promise<{ tagNames: string[], challenges: Array }>}
500
+ */
501
+ async function extractVideoTags(page) {
502
+ try {
503
+ const html = await page.evaluate(() => document.documentElement.outerHTML);
504
+ const videoInfo = parseVideoInfo(html);
505
+ if (
506
+ !videoInfo ||
507
+ !videoInfo.challenges ||
508
+ videoInfo.challenges.length === 0
509
+ ) {
510
+ return { tagNames: [], challenges: [] };
511
+ }
512
+ const tagNames = videoInfo.challenges.map((c) => c.title).filter(Boolean);
513
+ return { tagNames, challenges: videoInfo.challenges };
514
+ } catch (e) {
515
+ console.error(` [tags] 提取失败: ${e.message}`);
516
+ return { tagNames: [], challenges: [] };
517
+ }
518
+ }
519
+
520
+ /**
521
+ * 将视频 tag 保存到服务端(只插入不存在的,不覆盖已有)
522
+ * @returns {Promise<{ inserted: number }>}
523
+ */
524
+ async function saveVideoTags(apiPost, serverUrl, tagNames, country) {
525
+ if (!tagNames || tagNames.length === 0) return { inserted: 0 };
526
+ try {
527
+ const result = await apiPost(`${serverUrl}/api/tags/batch-add-if-new`, {
528
+ tags: tagNames,
529
+ countries: [country],
530
+ source: "video",
531
+ });
532
+ return { inserted: result.inserted || 0 };
533
+ } catch (e) {
534
+ console.error(` [tags] 保存失败: ${e.message}`);
535
+ return { inserted: 0 };
536
+ }
537
+ }
538
+
495
539
  function extractUniqueAuthors(comments) {
496
540
  return [
497
541
  ...new Set(
@@ -668,7 +712,7 @@ async function runAutoMode(options) {
668
712
  if (shuttingDown) break;
669
713
 
670
714
  try {
671
- const { id, href, locationCreated } = task;
715
+ const { id, href, locationCreated, ttSeller } = task;
672
716
  const loc = normalizeLocation(locationCreated);
673
717
  const taskNum = totalTasks() + 1;
674
718
 
@@ -691,6 +735,24 @@ async function runAutoMode(options) {
691
735
  // 导航到视频页
692
736
  await prepareVideoPage(session.page, href);
693
737
 
738
+ // ── 提取视频 tag(仅 ttSeller 视频)──
739
+ if (ttSeller && loc) {
740
+ const { tagNames } = await extractVideoTags(session.page);
741
+ if (tagNames.length > 0) {
742
+ const { inserted } = await saveVideoTags(
743
+ apiPost,
744
+ serverUrl,
745
+ tagNames,
746
+ loc,
747
+ );
748
+ if (inserted > 0) {
749
+ console.error(
750
+ ` [tags] 提取到 ${tagNames.length} 个: ${tagNames.join(", ")} → 新增 ${inserted} 个`,
751
+ );
752
+ }
753
+ }
754
+ }
755
+
694
756
  // 获取评论
695
757
  const result = await fetchUserCommentsAPI(session.page, {
696
758
  maxComments: actualMaxComments,
@@ -138,11 +138,17 @@ export function parseVideoInfo(rawHtml) {
138
138
  const item = vd.itemInfo.itemStruct;
139
139
  const author = item.author || {};
140
140
  const stats = item.stats || {};
141
+ const challenges = (item.challenges || []).map((c) => ({
142
+ id: c.id,
143
+ title: c.title,
144
+ desc: c.desc || "",
145
+ }));
141
146
  return {
142
147
  id: item.id,
143
148
  desc: item.desc || "",
144
149
  createTime: item.createTime || null,
145
150
  locationCreated: item.locationCreated || null,
151
+ challenges,
146
152
  author: {
147
153
  uniqueId: author.uniqueId,
148
154
  nickname: author.nickname,
@@ -158,11 +158,15 @@ async function discoverTagsForCountryCli(
158
158
  ` 🏥 ${country} health: ${health.healthStatus} | deadRatio=${health.deadRatio}% | productiveRatio=${health.productiveRatio}% | saturation=${health.saturation}%\n`,
159
159
  );
160
160
 
161
- // 动态温度调整
162
- if (health.healthStatus === "critical") {
163
- temperature = 0.3;
164
- } else if (health.healthStatus === "warning") {
161
+ // 动态温度调整:卡死时升温打破 LLM 确定性重复
162
+ if (health.consecutiveLowProductiveRounds >= 3) {
163
+ temperature = 1.0;
164
+ } else if (health.consecutiveLowProductiveRounds >= 2) {
165
+ temperature = 0.9;
166
+ } else if (health.healthStatus === "critical") {
165
167
  temperature = 0.5;
168
+ } else if (health.healthStatus === "warning") {
169
+ temperature = 0.6;
166
170
  }
167
171
 
168
172
  // 自动 niche 切换
@@ -166,6 +166,11 @@ export async function fetchTagData(tag, options = {}) {
166
166
  throw new Error("标签页加载失败,TikTok 返回了错误页面");
167
167
  }
168
168
 
169
+ // 检测 CDN/网络拦截:15 秒后仍无任何 API 数据到达,说明被拦截而非真没数据
170
+ if (seenVideoIds.size === 0 && challengeInfo === null) {
171
+ throw new Error("无法获取标签页 API 数据(可能被 CDN 拦截或网络异常)");
172
+ }
173
+
169
174
  let lastCount = 0;
170
175
  let staleRounds = 0;
171
176
  const startTime = Date.now();
package/src/npm-main.js CHANGED
@@ -3,6 +3,7 @@ import { PUBLIC_HELP_TEXT } from "./lib/constants.js";
3
3
  import { handleInfo } from "./cli/info.js";
4
4
  import { handleExplore } from "./cli/explore.js";
5
5
  import { handleAttach } from "./cli/attach.js";
6
+ import { handleRecheck } from "./cli/recheck.js";
6
7
  import { handleConfig, showConfig, showUsage, version } from "./cli/config.js";
7
8
  import { handleOpen } from "./cli/open.js";
8
9
  import { handleComments } from "./cli/comments.js";
@@ -32,6 +33,8 @@ async function main() {
32
33
  return handleInfo(parsed);
33
34
  case "attach":
34
35
  return handleAttach(parsed);
36
+ case "recheck":
37
+ return handleRecheck(parsed);
35
38
  case "watch":
36
39
  case "webserver":
37
40
  case "videostats":
@@ -86,6 +86,7 @@ import {
86
86
  // Tag CRUD
87
87
  import {
88
88
  insertTag,
89
+ insertTagsIfNew,
89
90
  getTagsByStatus,
90
91
  getTagsByCountry,
91
92
  getDeadTags,
@@ -3601,6 +3602,7 @@ export function createStore(filePath, options = {}) {
3601
3602
  getLlmSampleOffsets, // 获取 LLM 采样偏移量状态
3602
3603
  // Tag 发现与打分
3603
3604
  insertTag,
3605
+ insertTagsIfNew,
3604
3606
  getTagsByStatus,
3605
3607
  getTagsByCountry,
3606
3608
  getDeadTags,
@@ -50,11 +50,59 @@ export function insertTag(tag, countries, source = "llm") {
50
50
  }
51
51
  }
52
52
 
53
+ /**
54
+ * 批量插入 tag,只插入不存在的(INSERT OR IGNORE)。
55
+ * 用于非 LLM 来源(如视频页采集),不覆盖已有 tag 的任何字段。
56
+ *
57
+ * @param {string[]} tags - 原始 tag 名列表(可带 # 前缀)
58
+ * @param {string[]} countries - 国家列表
59
+ * @param {string} source - 来源标识,默认 'video'
60
+ * @returns {{ inserted: number, tags: string[] }}
61
+ */
62
+ export function insertTagsIfNew(tags, countries, source = "video") {
63
+ const db = getDb();
64
+ if (!db) return { inserted: 0, tags: [] };
65
+ if (!Array.isArray(tags) || tags.length === 0)
66
+ return { inserted: 0, tags: [] };
67
+ if (!Array.isArray(countries) || countries.length === 0)
68
+ return { inserted: 0, tags: [] };
69
+
70
+ const normalizedList = tags
71
+ .map((t) => t.replace(/^#+/, "").trim().toLowerCase())
72
+ .filter((t) => t.length >= 2);
73
+
74
+ if (normalizedList.length === 0) return { inserted: 0, tags: [] };
75
+
76
+ const countriesJson = JSON.stringify(countries);
77
+ let inserted = 0;
78
+ const insertedTags = [];
79
+
80
+ try {
81
+ const stmt = db.prepare(
82
+ `INSERT OR IGNORE INTO tags (tag, countries, source) VALUES (?, ?, ?)`,
83
+ );
84
+ const txn = db.transaction((items) => {
85
+ for (const tag of items) {
86
+ const result = stmt.run(tag, countriesJson, source);
87
+ if (result.changes > 0) {
88
+ inserted++;
89
+ insertedTags.push(tag);
90
+ }
91
+ }
92
+ });
93
+ txn(normalizedList);
94
+ return { inserted, tags: insertedTags };
95
+ } catch (e) {
96
+ return { inserted, error: e.message };
97
+ }
98
+ }
99
+
53
100
  export function getTagsByStatus(
54
101
  status,
55
102
  limit = 100,
56
103
  offset = 0,
57
104
  country = null,
105
+ search = null,
58
106
  ) {
59
107
  const db = getDb();
60
108
  if (!db) return [];
@@ -64,6 +112,10 @@ export function getTagsByStatus(
64
112
  sql += " AND countries LIKE ?";
65
113
  params.push(`%"${country}"%`);
66
114
  }
115
+ if (search) {
116
+ sql += " AND tag LIKE ?";
117
+ params.push(`%${search}%`);
118
+ }
67
119
  sql +=
68
120
  " ORDER BY refreshed_at DESC NULLS LAST, score ASC, created_at ASC LIMIT ? OFFSET ?";
69
121
  params.push(limit, offset);
@@ -71,21 +123,29 @@ export function getTagsByStatus(
71
123
  return rows.map(parseTagRow);
72
124
  }
73
125
 
74
- export function getTagStats(country = null) {
126
+ export function getTagStats(country = null, search = null) {
75
127
  const db = getDb();
76
128
  if (!db) return null;
129
+ const conditions = [];
130
+ const params = [];
131
+ if (country) {
132
+ conditions.push("countries LIKE ?");
133
+ params.push(`%"${country}"%`);
134
+ }
135
+ if (search) {
136
+ conditions.push("tag LIKE ?");
137
+ params.push(`%${search}%`);
138
+ }
139
+ const whereClause =
140
+ conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
141
+
77
142
  let sql = `SELECT
78
143
  COUNT(*) as total,
79
144
  SUM(CASE WHEN status = 'productive' THEN 1 ELSE 0 END) as productive,
80
145
  SUM(CASE WHEN status = 'dead' THEN 1 ELSE 0 END) as dead,
81
146
  SUM(CASE WHEN status = 'new' THEN 1 ELSE 0 END) as newCount,
82
147
  SUM(CASE WHEN status = 'scoring' THEN 1 ELSE 0 END) as scoring
83
- FROM tags`;
84
- const params = [];
85
- if (country) {
86
- sql += " WHERE countries LIKE ?";
87
- params.push(`%"${country}"%`);
88
- }
148
+ FROM tags${whereClause}`;
89
149
  const row = db.prepare(sql).get(...params);
90
150
  // 获取所有出现过的国家
91
151
  const allRows = db.prepare("SELECT countries FROM tags").all();
@@ -276,15 +336,28 @@ export function reportTagScore(tag, fields) {
276
336
  }
277
337
  }
278
338
 
279
- export function getAllTags(limit = 200, offset = 0, country = null) {
339
+ export function getAllTags(
340
+ limit = 200,
341
+ offset = 0,
342
+ country = null,
343
+ search = null,
344
+ ) {
280
345
  const db = getDb();
281
346
  if (!db) return [];
282
347
  let sql = "SELECT * FROM tags";
283
348
  const params = [];
349
+ const conditions = [];
284
350
  if (country) {
285
- sql += " WHERE countries LIKE ?";
351
+ conditions.push("countries LIKE ?");
286
352
  params.push(`%"${country}"%`);
287
353
  }
354
+ if (search) {
355
+ conditions.push("tag LIKE ?");
356
+ params.push(`%${search}%`);
357
+ }
358
+ if (conditions.length > 0) {
359
+ sql += " WHERE " + conditions.join(" AND ");
360
+ }
288
361
  sql +=
289
362
  " ORDER BY refreshed_at DESC NULLS LAST, score DESC, created_at DESC LIMIT ? OFFSET ?";
290
363
  params.push(limit, offset);
@@ -371,6 +444,7 @@ export function insertDiscoverLog({
371
444
  country,
372
445
  round,
373
446
  strategy,
447
+ niche,
374
448
  tagsGenerated,
375
449
  tagsRaw,
376
450
  tagsAdded,
@@ -382,18 +456,26 @@ export function insertDiscoverLog({
382
456
  const db = getDb();
383
457
  if (!db) return { ok: false, error: "db not ready" };
384
458
 
459
+ // 确保 niche 列存在(兼容旧表)
460
+ try {
461
+ db.prepare("ALTER TABLE discover_log ADD COLUMN niche TEXT").run();
462
+ } catch {
463
+ // 列已存在则忽略
464
+ }
465
+
385
466
  try {
386
467
  const result = db
387
468
  .prepare(
388
469
  `INSERT INTO discover_log
389
- (country, round, strategy, tags_generated, tags_raw, tags_added,
470
+ (country, round, strategy, niche, tags_generated, tags_raw, tags_added,
390
471
  productive_sample, dead_sample, avg_productive_score, avg_dead_score)
391
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
472
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
392
473
  )
393
474
  .run(
394
475
  country,
395
476
  round,
396
477
  strategy || null,
478
+ niche || null,
397
479
  JSON.stringify(tagsGenerated || []),
398
480
  tagsRaw ? JSON.stringify(tagsRaw) : null,
399
481
  tagsAdded || 0,
@@ -714,6 +796,9 @@ export function getCountryHealth(country) {
714
796
  // 推荐的 niche 方向(基于现有 productive tag 的分类分析)
715
797
  const nicheRecommendation = recommendNiche(country, productive);
716
798
 
799
+ // 追踪 LLM 当前在攻击哪个 niche(取最近3轮 discover_log 的 niche 字段,众数)
800
+ const currentNiche = getCurrentNiche(country);
801
+
717
802
  return {
718
803
  country,
719
804
  total,
@@ -732,9 +817,43 @@ export function getCountryHealth(country) {
732
817
  healthStatus,
733
818
  issues,
734
819
  nicheRecommendation,
820
+ currentNiche,
735
821
  };
736
822
  }
737
823
 
824
+ /**
825
+ * 获取 LLM 当前正在攻击的 niche(最近3轮 discover_log 的 niche 众数)
826
+ */
827
+ function getCurrentNiche(country) {
828
+ const db = getDb();
829
+ if (!db) return null;
830
+
831
+ try {
832
+ const rows = db
833
+ .prepare(
834
+ `SELECT niche FROM discover_log
835
+ WHERE country = ? AND niche IS NOT NULL
836
+ ORDER BY round DESC
837
+ LIMIT 3`,
838
+ )
839
+ .all(country);
840
+
841
+ if (rows.length === 0) return null;
842
+
843
+ // 取出现次数最多的 niche
844
+ const counts = {};
845
+ for (const r of rows) {
846
+ const n = r.niche;
847
+ if (n) counts[n] = (counts[n] || 0) + 1;
848
+ }
849
+ const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]);
850
+ return sorted.length > 0 ? sorted[0][0] : null;
851
+ } catch {
852
+ // niche 列尚不存在(旧表),返回 null
853
+ return null;
854
+ }
855
+ }
856
+
738
857
  /**
739
858
  * 基于现有 productive tag 推荐新的 niche 方向
740
859
  * 分析已有 tag 的类别分布,找出未覆盖或覆盖不足的方向
@@ -1121,13 +1240,68 @@ export function insertReflectionRules(rules) {
1121
1240
  return { ok: true, inserted: inserted.length };
1122
1241
  }
1123
1242
 
1243
+ /**
1244
+ * 计算两段规则文本的相似度(简单的词重叠率)
1245
+ * 返回 0-1,越高越相似
1246
+ */
1247
+ function ruleTextSimilarity(a, b) {
1248
+ const wordsA = new Set(
1249
+ a
1250
+ .toLowerCase()
1251
+ .split(/\s+/)
1252
+ .filter((w) => w.length > 2),
1253
+ );
1254
+ const wordsB = new Set(
1255
+ b
1256
+ .toLowerCase()
1257
+ .split(/\s+/)
1258
+ .filter((w) => w.length > 2),
1259
+ );
1260
+ if (wordsA.size === 0 || wordsB.size === 0) return 0;
1261
+ const intersection = [...wordsA].filter((w) => wordsB.has(w)).length;
1262
+ return intersection / Math.max(wordsA.size, wordsB.size);
1263
+ }
1264
+
1124
1265
  /**
1125
1266
  * 废弃旧规则(当新反思覆盖旧规则时)
1267
+ * 加入去重:如果新规则与旧规则语义相似(>70% 词重叠),跳过 supersede
1126
1268
  */
1127
- export function supersedeRules(country, reason = "new reflection") {
1269
+ export function supersedeRules(
1270
+ country,
1271
+ newRules = [],
1272
+ reason = "new reflection",
1273
+ ) {
1128
1274
  const db = getDb();
1129
1275
  if (!db) return { ok: false, error: "db not ready" };
1130
1276
 
1277
+ // 获取当前活跃规则
1278
+ const oldRules = db
1279
+ .prepare(
1280
+ "SELECT id, rule_text FROM reflection_rules WHERE country = ? AND is_active = 1",
1281
+ )
1282
+ .all(country);
1283
+
1284
+ // 规则去重:如果所有新规则与所有旧规则高度相似,跳过
1285
+ if (oldRules.length > 0 && newRules.length > 0) {
1286
+ let allSimilar = true;
1287
+ for (const nr of newRules) {
1288
+ const newText = typeof nr === "string" ? nr : nr.ruleText || "";
1289
+ const hasSimilar = oldRules.some(
1290
+ (or) => ruleTextSimilarity(newText, or.rule_text) > 0.7,
1291
+ );
1292
+ if (!hasSimilar) {
1293
+ allSimilar = false;
1294
+ break;
1295
+ }
1296
+ }
1297
+ if (allSimilar) {
1298
+ console.error(
1299
+ `[reflection] 跳过 supersede: ${newRules.length} 条新规则与 ${oldRules.length} 条旧规则高度相似`,
1300
+ );
1301
+ return { ok: true, superseded: 0, skipped: "rules too similar" };
1302
+ }
1303
+ }
1304
+
1131
1305
  const result = db
1132
1306
  .prepare(
1133
1307
  `UPDATE reflection_rules
@@ -1141,13 +1315,45 @@ export function supersedeRules(country, reason = "new reflection") {
1141
1315
 
1142
1316
  /**
1143
1317
  * 检查是否需要触发反思
1144
- * 条件:最近 N 轮 productive_cnt ≤ threshold
1318
+ * 条件:最近 N 轮 productive_cnt ≤ threshold,且距上次反思 ≥ cooldownRounds 轮
1145
1319
  * @returns {{ shouldReflect: boolean, reason: string, recentRounds: array }}
1146
1320
  */
1147
- export function shouldTriggerReflection(country, lookback = 5, threshold = 1) {
1321
+ export function shouldTriggerReflection(
1322
+ country,
1323
+ lookback = 5,
1324
+ threshold = 1,
1325
+ cooldownRounds = 10,
1326
+ ) {
1148
1327
  const db = getDb();
1149
1328
  if (!db) return { shouldReflect: false, reason: "db not ready" };
1150
1329
 
1330
+ // 冷却期检查:距上次反思不足 cooldownRounds 轮则跳过
1331
+ const lastReflection = db
1332
+ .prepare(
1333
+ `SELECT MAX(rr.created_at) as lastReflectAt
1334
+ FROM reflection_rules rr
1335
+ WHERE rr.country = ? AND rr.is_active = 0 AND rr.superseded_at IS NOT NULL
1336
+ ORDER BY rr.superseded_at DESC
1337
+ LIMIT 1`,
1338
+ )
1339
+ .get(country);
1340
+
1341
+ if (lastReflection?.lastReflectAt) {
1342
+ const roundsSince = db
1343
+ .prepare(
1344
+ `SELECT COUNT(*) as cnt FROM discover_log
1345
+ WHERE country = ? AND created_at > ?`,
1346
+ )
1347
+ .get(country, lastReflection.lastReflectAt);
1348
+ if ((roundsSince?.cnt || 0) < cooldownRounds) {
1349
+ return {
1350
+ shouldReflect: false,
1351
+ reason: `冷却期: 距上次反思仅${roundsSince?.cnt || 0}轮,需≥${cooldownRounds}轮`,
1352
+ recentRounds: [],
1353
+ };
1354
+ }
1355
+ }
1356
+
1151
1357
  // 获取最近 lookback 轮的 discover_log
1152
1358
  const recentLogs = db
1153
1359
  .prepare(
@@ -1202,14 +1408,14 @@ export function shouldTriggerReflection(country, lookback = 5, threshold = 1) {
1202
1408
  }
1203
1409
 
1204
1410
  const avgProductive = totalProductive / lookback;
1205
- // 触发条件:平均 productive ≤ threshold,或连续 ≥2 轮 productive ≤ 1
1411
+ // 触发条件:平均 productive ≤ threshold,或连续 ≥3 轮 productive ≤ 1
1206
1412
  const shouldReflect =
1207
- avgProductive <= threshold || consecutiveLowProductiveRounds >= 2;
1413
+ avgProductive <= threshold || consecutiveLowProductiveRounds >= 3;
1208
1414
 
1209
1415
  return {
1210
1416
  shouldReflect,
1211
1417
  reason: shouldReflect
1212
- ? consecutiveLowProductiveRounds >= 2
1418
+ ? consecutiveLowProductiveRounds >= 3
1213
1419
  ? `连续${consecutiveLowProductiveRounds}轮productive≤1,需要反思换方向`
1214
1420
  : `最近${lookback}轮平均productive=${avgProductive.toFixed(1)} ≤ ${threshold}`
1215
1421
  : `最近${lookback}轮平均productive=${avgProductive.toFixed(1)} > ${threshold}`,
@@ -1222,6 +1428,20 @@ export function shouldTriggerReflection(country, lookback = 5, threshold = 1) {
1222
1428
  };
1223
1429
  }
1224
1430
 
1431
+ /**
1432
+ * 清理所有已废弃的反思规则(is_active=0)
1433
+ */
1434
+ export function cleanupInactiveReflectionRules() {
1435
+ const db = getDb();
1436
+ if (!db) return { ok: false, error: "db not ready" };
1437
+
1438
+ const result = db
1439
+ .prepare("DELETE FROM reflection_rules WHERE is_active = 0")
1440
+ .run();
1441
+ console.error(`[reflection] 🧹 清理了 ${result.changes} 条废弃规则`);
1442
+ return { ok: true, deleted: result.changes };
1443
+ }
1444
+
1225
1445
  /**
1226
1446
  * 获取反思所需的数据摘要(供 LLM 分析)
1227
1447
  */