@iducky/media-agent 1.1.0

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.
Files changed (96) hide show
  1. package/README.md +90 -0
  2. package/SHA256SUMS +94 -0
  3. package/bin/media-agent.mjs +89 -0
  4. package/docs/guides/capabilities.md +103 -0
  5. package/docs/guides/image-text-publishing.md +81 -0
  6. package/docs/guides/installation.md +103 -0
  7. package/docs/guides/runtime.md +13 -0
  8. package/manifest.json +382 -0
  9. package/package.json +42 -0
  10. package/pyproject.toml +20 -0
  11. package/resources/capabilities.json +34 -0
  12. package/resources/configs/accounts.yaml +21 -0
  13. package/resources/configs/profile.template.json +20 -0
  14. package/resources/configs/ranking-profiles.yaml +21 -0
  15. package/resources/configs/toutiao-profile.template.json +22 -0
  16. package/resources/configs/xiaohongshu-profile.template.json +27 -0
  17. package/resources/data/industry-taxonomy.yaml +326 -0
  18. package/skills/douyin-competitor-collect/SKILL.md +177 -0
  19. package/skills/douyin-competitor-collect/agents/openai.yaml +4 -0
  20. package/skills/douyin-competitor-collect/references/output-schema.md +178 -0
  21. package/skills/douyin-creator-image-text-publish/SKILL.md +44 -0
  22. package/skills/douyin-creator-image-text-publish/agents/openai.yaml +4 -0
  23. package/skills/douyin-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
  24. package/skills/douyin-creator-image-text-publish/references/execution-contract.md +46 -0
  25. package/skills/douyin-creator-image-text-publish/references/upstream.md +30 -0
  26. package/skills/douyin-creator-index/SKILL.md +32 -0
  27. package/skills/douyin-creator-index/agents/openai.yaml +4 -0
  28. package/skills/douyin-creator-login/SKILL.md +58 -0
  29. package/skills/douyin-creator-login/agents/openai.yaml +4 -0
  30. package/skills/douyin-creator-publish/SKILL.md +56 -0
  31. package/skills/douyin-creator-publish/agents/openai.yaml +4 -0
  32. package/skills/douyin-enterprise-leads/SKILL.md +26 -0
  33. package/skills/douyin-enterprise-leads/agents/openai.yaml +4 -0
  34. package/skills/douyin-enterprise-leads-login/SKILL.md +36 -0
  35. package/skills/douyin-enterprise-leads-login/agents/openai.yaml +4 -0
  36. package/skills/douyin-enterprise-short-video-export/SKILL.md +29 -0
  37. package/skills/douyin-enterprise-short-video-export/agents/openai.yaml +4 -0
  38. package/skills/douyin-enterprise-video-rankings/SKILL.md +51 -0
  39. package/skills/douyin-enterprise-video-rankings/agents/openai.yaml +4 -0
  40. package/skills/douyin-enterprise-video-rankings/references/industry-taxonomy.md +29 -0
  41. package/skills/douyin-web-login/SKILL.md +102 -0
  42. package/skills/douyin-web-login/agents/openai.yaml +4 -0
  43. package/skills/toutiao-creator-article-draft/SKILL.md +154 -0
  44. package/skills/toutiao-creator-article-draft/agents/openai.yaml +4 -0
  45. package/skills/toutiao-web-login/SKILL.md +96 -0
  46. package/skills/toutiao-web-login/agents/openai.yaml +4 -0
  47. package/skills/xiaohongshu-creator-image-text-publish/SKILL.md +46 -0
  48. package/skills/xiaohongshu-creator-image-text-publish/agents/openai.yaml +4 -0
  49. package/skills/xiaohongshu-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
  50. package/skills/xiaohongshu-creator-image-text-publish/references/execution-contract.md +46 -0
  51. package/skills/xiaohongshu-creator-image-text-publish/references/upstream.md +31 -0
  52. package/skills/xiaohongshu-creator-login/SKILL.md +106 -0
  53. package/skills/xiaohongshu-creator-login/agents/openai.yaml +4 -0
  54. package/skills/xiaohongshu-creator-publish/SKILL.md +153 -0
  55. package/skills/xiaohongshu-creator-publish/agents/openai.yaml +4 -0
  56. package/src/media_agent/__init__.py +1 -0
  57. package/src/media_agent/cli.py +28 -0
  58. package/src/media_agent/commands.sh +436 -0
  59. package/src/media_agent/platforms/__init__.py +1 -0
  60. package/src/media_agent/platforms/douyin/__init__.py +1 -0
  61. package/src/media_agent/platforms/douyin/check_login.py +196 -0
  62. package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +184 -0
  63. package/src/media_agent/platforms/douyin/collect_video_rankings.py +352 -0
  64. package/src/media_agent/platforms/douyin/douyin_full_login.py +391 -0
  65. package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +454 -0
  66. package/src/media_agent/platforms/douyin/douyin_publish.py +1135 -0
  67. package/src/media_agent/platforms/douyin/enterprise_login.py +128 -0
  68. package/src/media_agent/platforms/douyin/export_short_video.py +70 -0
  69. package/src/media_agent/platforms/douyin/login_controller.py +508 -0
  70. package/src/media_agent/platforms/douyin/validate_industry_taxonomy.py +152 -0
  71. package/src/media_agent/platforms/douyin/validate_rankings.py +254 -0
  72. package/src/media_agent/platforms/toutiao/__init__.py +1 -0
  73. package/src/media_agent/platforms/toutiao/toutiao_check_login.py +54 -0
  74. package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +250 -0
  75. package/src/media_agent/platforms/toutiao/toutiao_login_evidence.py +50 -0
  76. package/src/media_agent/platforms/toutiao/toutiao_login_ipc.py +70 -0
  77. package/src/media_agent/platforms/xiaohongshu/__init__.py +1 -0
  78. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +189 -0
  79. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +449 -0
  80. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_evidence.py +64 -0
  81. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_ipc.py +120 -0
  82. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +925 -0
  83. package/src/media_agent/runtime/__init__.py +1 -0
  84. package/src/media_agent/runtime/account_manager.py +672 -0
  85. package/src/media_agent/runtime/browser.py +5 -0
  86. package/src/media_agent/runtime/paths.py +7 -0
  87. package/src/media_agent/script_map.json +23 -0
  88. package/src/node/config.mjs +48 -0
  89. package/src/node/integrity.mjs +34 -0
  90. package/src/node/skills.mjs +85 -0
  91. package/tools/archive_releases.py +83 -0
  92. package/tools/artifacts.py +56 -0
  93. package/tools/build_release.py +82 -0
  94. package/tools/check_catalog.py +23 -0
  95. package/tools/install_runtime.py +144 -0
  96. package/tools/run_tests.py +21 -0
@@ -0,0 +1,454 @@
1
+ """Legacy operation, executed only through main()."""
2
+
3
+ def main():
4
+ #!/usr/bin/env python3
5
+ """Douyin热点数据完整采集 v2 - 独立容器分页 + 趋势修复"""
6
+ from media_agent.runtime.paths import runtime_home
7
+ import json, time, sys, os, csv
8
+ from pathlib import Path
9
+ from datetime import datetime, timezone, timedelta
10
+ from media_agent.runtime.browser import launch_persistent_context
11
+
12
+ BASE = runtime_home()
13
+ ALIAS = 'douyin_creator_personal_test'
14
+ PROFILE_DIR = BASE / 'profiles' / ALIAS
15
+ BROWSER_DATA = str(PROFILE_DIR / 'browser_data')
16
+ LOCK_FILE = BASE / 'locks' / f'{ALIAS}.lock'
17
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
18
+ TASK_ID = f'hotspot_v2_{ALIAS}_{ts}'
19
+ OUT_DIR = BASE / 'tasks' / 'douyin_index' / ts
20
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
21
+
22
+ # ===== Lock =====
23
+ LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
24
+ if LOCK_FILE.exists():
25
+ try:
26
+ d = json.loads(LOCK_FILE.read_text()); pid = d.get('pid')
27
+ if pid:
28
+ try: os.kill(pid, 0); print('ERROR|Lock active', flush=True); sys.exit(1)
29
+ except OSError: LOCK_FILE.unlink()
30
+ except: LOCK_FILE.unlink()
31
+ LOCK_FILE.write_text(json.dumps({'task_id':TASK_ID,'pid':os.getpid()}, indent=2))
32
+ print(f'LOCK|{TASK_ID}', flush=True)
33
+
34
+ # ===== Launch =====
35
+ cfg = json.loads((PROFILE_DIR / 'config.json').read_text())
36
+ ctx = launch_persistent_context(user_data_dir=BROWSER_DATA, headless=False,
37
+ stealth_args=True, viewport=cfg['fingerprint']['viewport'],
38
+ locale=cfg['fingerprint']['locale'], timezone=cfg['fingerprint']['timezone'],
39
+ humanize=False, geoip=False)
40
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
41
+
42
+ # ===== Navigate =====
43
+ page.goto('https://creator.douyin.com/', wait_until='load', timeout=60000)
44
+ time.sleep(5)
45
+ create_center = page.locator('text=创作中心').first
46
+ if create_center and create_center.is_visible(): create_center.click(); time.sleep(2)
47
+ hot_index = page.locator('text=抖音指数').first
48
+ if hot_index and hot_index.is_visible(): hot_index.click(); time.sleep(5)
49
+ try:
50
+ confirm = page.locator('text=确认').first
51
+ if confirm and confirm.is_visible(timeout=3000): confirm.click(); time.sleep(3)
52
+ except: pass
53
+
54
+ collected_at = datetime.now(timezone(timedelta(hours=8))).isoformat()
55
+ source_url = page.url
56
+ page_title = page.title()
57
+ print(f'URL|{source_url}', flush=True)
58
+
59
+ # ===== Stage 1: Inspect pagination structure =====
60
+ print('=== STAGE 1: INSPECT ===', flush=True)
61
+ inspect = page.evaluate('''() => {
62
+ const result = {};
63
+ // Count titles
64
+ const titles = document.querySelectorAll('*');
65
+ let rtCount = 0, rsCount = 0;
66
+ for (const el of titles) {
67
+ if (el.textContent.trim() === '抖音实时热点') rtCount++;
68
+ if (el.textContent.trim() === '抖音飙升热点') rsCount++;
69
+ }
70
+ result.title_rt_count = rtCount;
71
+ result.title_rs_count = rsCount;
72
+
73
+ // Find pagination areas
74
+ const paginations = document.querySelectorAll('[class*="pagination"], [class*="page-list"], [class*="pager"]');
75
+ result.pagination_count = paginations.length;
76
+ result.paginations = [];
77
+ for (const p of paginations) {
78
+ const rect = p.getBoundingClientRect();
79
+ result.paginations.push({
80
+ x: Math.round(rect.x), y: Math.round(rect.y),
81
+ w: Math.round(rect.width), h: Math.round(rect.height),
82
+ visible: rect.width > 0 && rect.height > 0,
83
+ text: p.textContent.trim().substring(0, 50)
84
+ });
85
+ }
86
+
87
+ // Find rising title and its ancestor containers
88
+ const rsTitle = Array.from(document.querySelectorAll('*')).find(el => el.textContent.trim() === '抖音飙升热点');
89
+ if (rsTitle) {
90
+ result.rs_title_found = true;
91
+ let parent = rsTitle.parentElement;
92
+ for (let i = 0; i < 10 && parent; i++) {
93
+ const hasTable = parent.querySelector('table') || parent.querySelector('[role="table"]');
94
+ const hasPagination = parent.querySelector('[class*="pagination"], [class*="page-list"], [class*="pager"]');
95
+ const hasLi = parent.querySelectorAll('li').length;
96
+ const rect = parent.getBoundingClientRect();
97
+ if (hasTable || hasPagination || hasLi > 3) {
98
+ result.rs_ancestor = {
99
+ level: i,
100
+ tag: parent.tagName,
101
+ className: parent.className?.substring(0, 80),
102
+ hasTable: !!hasTable,
103
+ hasPagination: !!hasPagination,
104
+ liCount: hasLi,
105
+ x: Math.round(rect.x), y: Math.round(rect.y),
106
+ w: Math.round(rect.width)
107
+ };
108
+ if (hasPagination) break;
109
+ }
110
+ parent = parent.parentElement;
111
+ }
112
+ }
113
+
114
+ return result;
115
+ }''')
116
+ print(f'INSPECT|{json.dumps(inspect, ensure_ascii=False)}', flush=True)
117
+
118
+ # ===== Stage 2: Extract with per-container pagination =====
119
+ print('=== STAGE 2: EXTRACT ===', flush=True)
120
+
121
+ def parse_index(text):
122
+ t = text.strip()
123
+ if '万' in t:
124
+ return round(float(t.replace('万', '').strip()) * 10000), t
125
+ try: return round(float(t.replace(',', ''))), t
126
+ except: return 0, t
127
+
128
+ def parse_trend_dom(page):
129
+ """Parse trend from DOM using JS to inspect icon/SVG/class."""
130
+ return page.evaluate('''() => {
131
+ const result = { realtime: [], rising: [] };
132
+ // Find all trend cells
133
+ const allCells = document.querySelectorAll('td');
134
+ for (const td of allCells) {
135
+ const text = td.textContent.trim();
136
+ if (text === '--' || text === '-') {
137
+ // Determine which table this belongs to
138
+ const parent = td.closest('table, [role="table"], [class*="table"]');
139
+ const parentText = parent ? parent.textContent.substring(0, 50) : '';
140
+ const listType = parentText.includes('飙升') ? 'rising' : 'realtime';
141
+ const row = td.closest('tr');
142
+ const rankCell = row ? row.querySelector('td:first-child, [class*="rank"]') : null;
143
+ const rank = rankCell ? parseInt(rankCell.textContent.trim()) : 0;
144
+ if (rank > 0) {
145
+ result[listType].push({rank, trend: 'flat'});
146
+ }
147
+ }
148
+ // Check for up/down arrow
149
+ const svg = td.querySelector('svg');
150
+ if (svg) {
151
+ const svgClass = svg.getAttribute('class') || '';
152
+ const svgStyle = svg.getAttribute('style') || '';
153
+ const parent = td.closest('table, [role="table"], [class*="table"]');
154
+ const parentText = parent ? parent.textContent.substring(0, 50) : '';
155
+ const listType = parentText.includes('飙升') ? 'rising' : 'realtime';
156
+ const row = td.closest('tr');
157
+ const rankCell = row ? row.querySelector('td:first-child, [class*="rank"]') : null;
158
+ const rank = rankCell ? parseInt(rankCell.textContent.trim()) : 0;
159
+ if (rank > 0) {
160
+ if (svgClass.includes('up') || svgClass.includes('rise') || svgStyle.includes('deg(0)') || svgStyle.includes('deg(180)')) {
161
+ result[listType].push({rank, trend: 'up'});
162
+ } else if (svgClass.includes('down') || svgClass.includes('fall') || svgStyle.includes('rotate(180)')) {
163
+ result[listType].push({rank, trend: 'down'});
164
+ }
165
+ }
166
+ }
167
+ // Check color
168
+ const color = window.getComputedStyle(td).color;
169
+ if (color === 'rgb(255, 0, 0)' || color === 'rgb(229, 28, 35)') {
170
+ const parent = td.closest('table, [role="table"], [class*="table"]');
171
+ const parentText = parent ? parent.textContent.substring(0, 50) : '';
172
+ const listType = parentText.includes('飙升') ? 'rising' : 'realtime';
173
+ const row = td.closest('tr');
174
+ const rankCell = row ? row.querySelector('td:first-child, [class*="rank"]') : null;
175
+ const rank = rankCell ? parseInt(rankCell.textContent.trim()) : 0;
176
+ if (rank > 0) result[listType].push({rank, trend: 'up'});
177
+ }
178
+ }
179
+ return result;
180
+ }''')
181
+
182
+ def extract_page_data(page):
183
+ """Extract items from current page for both tables."""
184
+ return page.evaluate('''() => {
185
+ const result = { realtime: [], rising: [] };
186
+ const all = document.body.innerText;
187
+
188
+ function parseSection(text) {
189
+ const lines = text.split('\\n').map(l => l.trim()).filter(l => l);
190
+ const items = [];
191
+ let i = 0;
192
+ while (i < lines.length) {
193
+ const line = lines[i];
194
+ if (['排名','热点名称','热点指数','热点指数变化','共30条记录','1','2','3','抖音实时热点','抖音飙升热点'].includes(line)) {
195
+ i++; continue;
196
+ }
197
+ let rank = 0, name = '', idxText = '';
198
+ if (/^\\d{1,2}$/.test(line)) {
199
+ rank = parseInt(line); i++;
200
+ if (i >= lines.length) break;
201
+ name = lines[i]; i++;
202
+ if (i >= lines.length) break;
203
+ idxText = lines[i]; i++;
204
+ // Skip trend line
205
+ if (i < lines.length && /^[▲▼↑↓\\-]{1,2}$/.test(lines[i])) i++;
206
+ } else {
207
+ rank = items.length + 1;
208
+ name = line; i++;
209
+ if (i >= lines.length) break;
210
+ idxText = lines[i]; i++;
211
+ if (i < lines.length && /^[▲▼↑↓\\-]{1,2}$/.test(lines[i])) i++;
212
+ }
213
+ if (rank > 0 && name && idxText) {
214
+ items.push({rank, name, idxText});
215
+ }
216
+ }
217
+ return items;
218
+ }
219
+
220
+ const rtIdx = all.indexOf('抖音实时热点');
221
+ const rsIdx = all.indexOf('抖音飙升热点');
222
+ if (rtIdx > -1) {
223
+ const end = rsIdx > -1 ? rsIdx : all.length;
224
+ result.realtime = parseSection(all.substring(rtIdx, end));
225
+ }
226
+ if (rsIdx > -1) {
227
+ result.rising = parseSection(all.substring(rsIdx));
228
+ }
229
+ return result;
230
+ }''')
231
+
232
+ def click_pagination(page, page_num, side='left'):
233
+ """Click pagination by side. side='left' for realtime, 'right' for rising."""
234
+ return page.evaluate('''(args) => {
235
+ const num = args[0], side = args[1];
236
+ const lis = Array.from(document.querySelectorAll('li'));
237
+ const pageLis = lis.filter(li => {
238
+ const t = li.textContent.trim();
239
+ return t === String(num) && li.offsetParent !== null && li.offsetParent.offsetParent !== null;
240
+ });
241
+ if (pageLis.length === 0) return 'not_found';
242
+ if (side === 'right') {
243
+ pageLis.sort((a, b) => b.getBoundingClientRect().x - a.getBoundingClientRect().x);
244
+ } else {
245
+ pageLis.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
246
+ }
247
+ pageLis[0].click();
248
+ return 'clicked_' + side;
249
+ }''', [page_num, side])
250
+
251
+ def wait_for_page_change(page, prev_first_item, timeout=10):
252
+ """Wait until the first item in the table changes."""
253
+ for i in range(timeout * 2):
254
+ time.sleep(0.5)
255
+ data = extract_page_data(page)
256
+ curr_first = data['rising'][0]['name'] if data['rising'] else ''
257
+ if curr_first and curr_first != prev_first_item:
258
+ return True
259
+ return False
260
+
261
+ # Collect all pages - separate pagination for each table
262
+ all_rt = []
263
+ all_rs = []
264
+
265
+ # First collect realtime (pages 1-3)
266
+ for pg in [1, 2, 3]:
267
+ print(f'=== RT PAGE {pg} ===', flush=True)
268
+ if pg > 1:
269
+ result = click_pagination(page, pg, 'left')
270
+ print(f'CLICK_RT|{pg}|{result}', flush=True)
271
+ time.sleep(5)
272
+ data = extract_page_data(page)
273
+ print(f'RT_PAGE_{pg}|{len(data["realtime"])} items|first={data["realtime"][0]["name"][:40] if data["realtime"] else "none"}', flush=True)
274
+ for item in data['realtime']:
275
+ idx_num, idx_disp = parse_index(item['idxText'])
276
+ all_rt.append({
277
+ 'list_type': 'realtime', 'rank': item['rank'],
278
+ 'hot_name': item['name'], 'hot_index': idx_num,
279
+ 'hot_index_display': idx_disp, 'trend': 'unknown',
280
+ 'collected_at': collected_at, 'source_url': source_url
281
+ })
282
+
283
+ # Go back to page 1 for rising
284
+ click_pagination(page, 1, 'left')
285
+ time.sleep(3)
286
+
287
+ # Then collect rising (pages 1-3)
288
+ for pg in [1, 2, 3]:
289
+ print(f'=== RS PAGE {pg} ===', flush=True)
290
+ if pg > 1:
291
+ result = click_pagination(page, pg, 'right')
292
+ print(f'CLICK_RS|{pg}|{result}', flush=True)
293
+ time.sleep(5)
294
+ data = extract_page_data(page)
295
+ print(f'RS_PAGE_{pg}|{len(data["rising"])} items|first={data["rising"][0]["name"][:40] if data["rising"] else "none"}', flush=True)
296
+ for item in data['rising']:
297
+ idx_num, idx_disp = parse_index(item['idxText'])
298
+ all_rs.append({
299
+ 'list_type': 'rising', 'rank': item['rank'],
300
+ 'hot_name': item['name'], 'hot_index': idx_num,
301
+ 'hot_index_display': idx_disp, 'trend': 'unknown',
302
+ 'collected_at': collected_at, 'source_url': source_url
303
+ })
304
+
305
+ # ===== Stage 3: Trend detection =====
306
+ print('=== STAGE 3: TREND ===', flush=True)
307
+ trends = parse_trend_dom(page)
308
+ print(f'TREND_DETECTED|RT={len(trends["realtime"])}|RS={len(trends["rising"])}', flush=True)
309
+
310
+ # Apply trends
311
+ for item in all_rt:
312
+ for t in trends['realtime']:
313
+ if t['rank'] == item['rank']:
314
+ item['trend'] = t['trend']
315
+ break
316
+ for item in all_rs:
317
+ for t in trends['rising']:
318
+ if t['rank'] == item['rank']:
319
+ item['trend'] = t['trend']
320
+ break
321
+
322
+ # ===== Validation =====
323
+ errors = []
324
+ # Deduplicate
325
+ seen_rt = set()
326
+ all_rt_dedup = []
327
+ for r in all_rt:
328
+ if r['rank'] not in seen_rt:
329
+ seen_rt.add(r['rank']); all_rt_dedup.append(r)
330
+ seen_rs = set()
331
+ all_rs_dedup = []
332
+ for r in all_rs:
333
+ if r['rank'] not in seen_rs:
334
+ seen_rs.add(r['rank']); all_rs_dedup.append(r)
335
+
336
+ all_rt = all_rt_dedup; all_rs = all_rs_dedup
337
+ all_rt.sort(key=lambda x: x['rank']); all_rs.sort(key=lambda x: x['rank'])
338
+
339
+ truncated = sum(1 for r in all_rt + all_rs if r['hot_name'].endswith('...') or r['hot_name'].endswith('…'))
340
+ unknown_trend = sum(1 for r in all_rt + all_rs if r['trend'] == 'unknown')
341
+
342
+ if len(all_rt) < 30: errors.append(f'realtime: {len(all_rt)}/30')
343
+ if len(all_rs) < 30: errors.append(f'rising: {len(all_rs)}/30')
344
+ for lst, label in [(all_rt,'realtime'),(all_rs,'rising')]:
345
+ ranks = set()
346
+ for r in lst:
347
+ if r['rank'] in ranks: errors.append(f'{label} dup rank {r["rank"]}')
348
+ ranks.add(r['rank'])
349
+ if not r['hot_name']: errors.append(f'{label} rank {r["rank"]} empty name')
350
+ for rk in range(1, 31):
351
+ if rk not in ranks: errors.append(f'{label} missing rank {rk}')
352
+
353
+ completeness = 'complete' if len(all_rt) >= 30 and len(all_rs) >= 30 else 'partial'
354
+
355
+ print(f'TOTAL|RT={len(all_rt)}|RS={len(all_rs)}|TRUNC={truncated}|UNKNOWN={unknown_trend}', flush=True)
356
+ print(f'COMPLETENESS|{completeness}', flush=True)
357
+
358
+ # ===== Save =====
359
+ output = {
360
+ 'source': 'douyin_creator_center', 'page': 'douyin_index',
361
+ 'profile_id': ALIAS, 'collected_at': collected_at,
362
+ 'source_url': source_url,
363
+ 'lists': {'realtime': all_rt, 'rising': all_rs},
364
+ 'errors': errors
365
+ }
366
+
367
+ json_path = OUT_DIR / 'douyin_hotspots.json'
368
+ json_path.write_text(json.dumps(output, indent=2, ensure_ascii=False))
369
+
370
+ csv_path = OUT_DIR / 'douyin_hotspots.csv'
371
+ with open(csv_path, 'w', newline='', encoding='utf-8') as f:
372
+ w = csv.writer(f)
373
+ w.writerow(['list_type','rank','hot_name','hot_index','hot_index_display','trend','collected_at','source_url'])
374
+ for lt in [all_rt, all_rs]:
375
+ for r in lt:
376
+ w.writerow([r['list_type'],r['rank'],r['hot_name'],r['hot_index'],r['hot_index_display'],r['trend'],r['collected_at'],r['source_url']])
377
+
378
+ csv_lines = sum(1 for _ in open(csv_path)) - 1
379
+ if csv_lines != len(all_rt) + len(all_rs):
380
+ errors.append(f'CSV({csv_lines}) != JSON({len(all_rt)+len(all_rs)})')
381
+
382
+ meta = {
383
+ 'profile_id': ALIAS, 'page_title': page_title, 'source_url': source_url,
384
+ 'collected_at': collected_at,
385
+ 'displayed_realtime_total': 30, 'displayed_rising_total': 30,
386
+ 'collected_realtime_count': len(all_rt), 'collected_rising_count': len(all_rs),
387
+ 'truncated_name_count': truncated, 'unknown_trend_count': unknown_trend,
388
+ 'completeness': completeness, 'errors': errors,
389
+ 'json_path': str(json_path), 'csv_path': str(csv_path),
390
+ 'screenshot_path': str(OUT_DIR / 'douyin_hotspots.png'),
391
+ 'report_path': str(OUT_DIR / 'douyin_hotspots_report.md')
392
+ }
393
+ meta_path = OUT_DIR / 'douyin_hotspots.meta.json'
394
+ meta_path.write_text(json.dumps(meta, indent=2, ensure_ascii=False))
395
+
396
+ # Screenshot
397
+ ss = OUT_DIR / 'douyin_hotspots.png'
398
+ page.evaluate("""() => {
399
+ const imgs = document.querySelectorAll('img');
400
+ for (const img of imgs) {
401
+ if (img.width < 100 && img.height < 100) { img.style.filter = 'blur(8px)'; img.style.opacity = '0.3'; }
402
+ }
403
+ }""")
404
+ time.sleep(0.5)
405
+ page.screenshot(path=str(ss))
406
+
407
+ # Report
408
+ report = [
409
+ f'# 抖音指数与实时热点日报|{datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M")}',
410
+ '', '## 采集信息',
411
+ f'- 采集时间: {collected_at}', f'- 数据来源: {source_url}',
412
+ f'- Profile: {ALIAS}', f'- 实时热点: {len(all_rt)} 条', f'- 飙升热点: {len(all_rs)} 条',
413
+ f'- 完整性: {completeness}', f'- 截断标题: {truncated} 条', f'- 未知趋势: {unknown_trend} 条',
414
+ '', '## 抖音实时热点',
415
+ '| 排名 | 热点名称 | 热点指数 | 指数数值 | 趋势 |',
416
+ '|------|---------|---------|---------|------|',
417
+ ]
418
+ for r in all_rt:
419
+ report.append(f'| {r["rank"]} | {r["hot_name"]} | {r["hot_index_display"]} | {r["hot_index"]} | {r["trend"]} |')
420
+ report.extend([
421
+ '', '## 抖音飙升热点',
422
+ '| 排名 | 热点名称 | 热点指数 | 指数数值 | 趋势 |',
423
+ '|------|---------|---------|---------|------|',
424
+ ])
425
+ for r in all_rs:
426
+ report.append(f'| {r["rank"]} | {r["hot_name"]} | {r["hot_index_display"]} | {r["hot_index"]} | {r["trend"]} |')
427
+ report.extend([
428
+ '', '## 数据质量',
429
+ f'- 截断标题: {truncated}', f'- 未知趋势: {unknown_trend}',
430
+ f'- 异常: {errors if errors else "无"}',
431
+ '', '## 文件',
432
+ f'- JSON: {json_path}', f'- CSV: {csv_path}',
433
+ f'- Meta: {meta_path}', f'- 截图: {ss}',
434
+ ])
435
+ report_path = OUT_DIR / 'douyin_hotspots_report.md'
436
+ report_path.write_text('\n'.join(report))
437
+
438
+ print(f'JSON|{json_path}', flush=True)
439
+ print(f'CSV|{csv_path}', flush=True)
440
+ print(f'META|{meta_path}', flush=True)
441
+ print(f'SS|{ss}', flush=True)
442
+ print(f'REPORT|{report_path}', flush=True)
443
+
444
+ if completeness == 'complete' and not errors:
445
+ print('FINAL|READY_FOR_FEISHU', flush=True)
446
+ else:
447
+ print('FINAL|PARTIAL', flush=True)
448
+
449
+ ctx.close()
450
+ LOCK_FILE.unlink(missing_ok=True)
451
+ print('DONE', flush=True)
452
+
453
+ if __name__ == '__main__':
454
+ main()