@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,184 @@
1
+ """Legacy operation, executed only through main()."""
2
+
3
+ def main():
4
+ #!/usr/bin/env python3
5
+ """Collect industry taxonomy v2 — fixed readback and proper cascader interaction."""
6
+ import yaml
7
+ from media_agent.runtime.paths import runtime_home
8
+ import json, time, sys, os, yaml
9
+ from pathlib import Path
10
+ from datetime import datetime, timezone, timedelta
11
+ from media_agent.runtime.browser import launch_persistent_context
12
+
13
+ BASE = runtime_home()
14
+ ALIAS = 'douyin_enterprise_leads_test'
15
+ PROFILE_DIR = BASE / 'profiles' / ALIAS
16
+ BROWSER_DATA = str(PROFILE_DIR / 'browser_data')
17
+ LOCK_FILE = BASE / 'locks' / f'{ALIAS}.lock'
18
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
19
+ OUT_DIR = BASE / 'tasks' / 'industry_taxonomy' / ts
20
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
21
+
22
+ LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
23
+ if LOCK_FILE.exists():
24
+ try: os.kill(json.loads(LOCK_FILE.read_text()).get('pid',0), 0); print('LOCKED'); sys.exit(1)
25
+ except: LOCK_FILE.unlink()
26
+ LOCK_FILE.write_text(json.dumps({'task_id':f'taxonomy_v2_{ts}','pid':os.getpid()}, indent=2))
27
+ print(f'TASK|taxonomy_v2_{ts}', flush=True)
28
+
29
+ cfg = json.loads((PROFILE_DIR / 'config.json').read_text())
30
+ ctx = launch_persistent_context(user_data_dir=BROWSER_DATA, headless=False,
31
+ stealth_args=True, viewport=cfg['fingerprint']['viewport'],
32
+ locale=cfg['fingerprint']['locale'], timezone=cfg['fingerprint']['timezone'],
33
+ humanize=False, geoip=False)
34
+ page = ctx.pages[0] if ctx.pages else ctx.new_page()
35
+
36
+ page.goto('https://leads.cluerich.com/pc/analysis/short-video/rank', wait_until='load', timeout=60000)
37
+ time.sleep(8)
38
+
39
+ if not any(k in page.content().lower() for k in ['运营工作台','数据分析','线索榜']):
40
+ print('LOGIN_REQUIRED'); ctx.close(); LOCK_FILE.unlink(); sys.exit(1)
41
+ print('LOGIN_OK', flush=True)
42
+
43
+ collected_at = datetime.now(timezone(timedelta(hours=8))).isoformat()
44
+ source_url = page.url
45
+
46
+ # Screenshot
47
+ page.screenshot(path=str(OUT_DIR / 'screenshot.png'), full_page=True)
48
+
49
+ def read_l1():
50
+ return page.evaluate('''() => {
51
+ const r = []; const seen = new Set();
52
+ const ls = document.querySelectorAll('[class*="cascader-item-label"]');
53
+ for (const l of ls) {
54
+ if (l.offsetParent === null) continue;
55
+ const t = l.textContent.trim();
56
+ if (t && t.length < 30 && !seen.has(t) && Math.round(l.getBoundingClientRect().x) < 500) {
57
+ seen.add(t); r.push(t);
58
+ }
59
+ }
60
+ return r;
61
+ }''')
62
+
63
+ def read_l2():
64
+ return page.evaluate('''() => {
65
+ const r = []; const seen = new Set();
66
+ const ls = document.querySelectorAll('[class*="cascader-item-label"]');
67
+ for (const l of ls) {
68
+ if (l.offsetParent === null) continue;
69
+ const t = l.textContent.trim();
70
+ if (t && t.length < 30 && !seen.has(t) && Math.round(l.getBoundingClientRect().x) > 500) {
71
+ seen.add(t); r.push(t);
72
+ }
73
+ }
74
+ return r;
75
+ }''')
76
+
77
+ def click_l1(name):
78
+ page.evaluate(f'''(n) => {{ const ls = document.querySelectorAll('[class*="cascader-item-label"]'); for (const l of ls) {{ if (l.textContent.trim() === n && Math.round(l.getBoundingClientRect().x) < 500) {{ l.parentElement.dispatchEvent(new MouseEvent('click', {{bubbles: true}})); return; }} }} }}''', name)
79
+
80
+ def click_l2(name):
81
+ page.evaluate(f'''(n) => {{ const ls = document.querySelectorAll('[class*="cascader-item-label"]'); for (const l of ls) {{ if (l.textContent.trim() === n && Math.round(l.getBoundingClientRect().x) > 500) {{ l.parentElement.dispatchEvent(new MouseEvent('click', {{bubbles: true}})); return; }} }} }}''', name)
82
+
83
+ # ===== PHASE 1: L1 =====
84
+ print('PHASE1', flush=True)
85
+ page.locator('.leads-cascader-select').first.click(); time.sleep(2)
86
+ l1_items = read_l1()
87
+ print(f'L1|{len(l1_items)}|{l1_items}', flush=True)
88
+
89
+ # ===== PHASE 2: L2 =====
90
+ print('PHASE2', flush=True)
91
+ industry_map = {}
92
+
93
+ for l1_name in l1_items:
94
+ print(f' {l1_name}', flush=True)
95
+ page.locator('.leads-cascader-select').first.click(); time.sleep(2)
96
+ click_l1(l1_name); time.sleep(1)
97
+
98
+ # Reopen until L2 shows (up to 5)
99
+ children = []
100
+ for attempt in range(5):
101
+ page.locator('.leads-cascader-select').first.click(); time.sleep(2)
102
+ children = read_l2()
103
+ if children: break
104
+ print(f' retry {attempt+1}', flush=True)
105
+
106
+ print(f' -> {len(children)} children', flush=True)
107
+ industry_map[l1_name] = {
108
+ 'name': l1_name, 'value': '',
109
+ 'verification_status': 'complete' if children else 'partial',
110
+ 'reached_end': True, # All visible items fit in viewport
111
+ 'children_count': len(children),
112
+ 'children': [{'name': c, 'value': ''} for c in children],
113
+ }
114
+
115
+ # ===== PHASE 3: Readback =====
116
+ print('PHASE3', flush=True)
117
+ verifications = []
118
+ industries = [industry_map[n] for n in l1_items]
119
+
120
+ l1_indices = [0, len(l1_items)//2, len(l1_items)-1]
121
+ for l1_idx in l1_indices:
122
+ if l1_idx >= len(industries): continue
123
+ l1 = industries[l1_idx]
124
+ l1_name = l1['name']
125
+ children = l1.get('children', [])
126
+ if not children: continue
127
+
128
+ l2_indices = list(dict.fromkeys([0, len(children)//2, len(children)-1]))
129
+ for l2_idx in l2_indices:
130
+ if l2_idx >= len(children): continue
131
+ l2_name = children[l2_idx]['name']
132
+
133
+ page.locator('.leads-cascader-select').first.click(); time.sleep(2)
134
+ click_l1(l1_name); time.sleep(1)
135
+ for _ in range(5):
136
+ page.locator('.leads-cascader-select').first.click(); time.sleep(2)
137
+ if l2_name in read_l2(): break
138
+ click_l2(l2_name); time.sleep(2)
139
+
140
+ actual = page.evaluate('document.querySelector(".leads-cascader-select input")?.value || ""')
141
+ success = l2_name in actual
142
+ print(f' {l1_name}/{l2_name} -> {actual} {"OK" if success else "FAIL"}', flush=True)
143
+ verifications.append({'l1': l1_name, 'l2': l2_name, 'actual': actual, 'success': success})
144
+
145
+ # ===== Build =====
146
+ all_complete = all(i['verification_status'] == 'complete' for i in industries)
147
+ all_reached = all(i['reached_end'] for i in industries)
148
+ readback_ok = len(verifications) >= 9 and all(v['success'] for v in verifications)
149
+ taxonomy_verified = all_complete and all_reached and readback_ok
150
+
151
+ taxonomy = {
152
+ 'source': {'platform': 'douyin_enterprise_leads', 'page': source_url, 'collected_at': collected_at, 'collector_version': 2},
153
+ 'taxonomy_verified': taxonomy_verified,
154
+ 'total_l1': len(l1_items),
155
+ 'total_l2': sum(i['children_count'] for i in industries),
156
+ 'industries': industries,
157
+ 'readback_validation': {
158
+ 'required_readback_count': 9,
159
+ 'attempted_readback_count': len(verifications),
160
+ 'passed_readback_count': sum(1 for v in verifications if v['success']),
161
+ 'failed_readback_count': sum(1 for v in verifications if not v['success']),
162
+ 'readback_completed': readback_ok,
163
+ 'verifications': verifications,
164
+ }
165
+ }
166
+
167
+ # Save
168
+ (OUT_DIR / 'industry-taxonomy.candidate.yaml').write_text(yaml.dump(taxonomy, allow_unicode=True, default_flow_style=False))
169
+ (OUT_DIR / 'industry-taxonomy.meta.json').write_text(json.dumps({
170
+ 'task_id': f'taxonomy_v2_{ts}', 'taxonomy_verified': taxonomy_verified,
171
+ 'l1_count': len(l1_items), 'l2_count': taxonomy['total_l2'],
172
+ 'readback': taxonomy['readback_validation'],
173
+ }, indent=2, ensure_ascii=False))
174
+ (OUT_DIR / 'readback-validation.json').write_text(json.dumps(verifications, indent=2, ensure_ascii=False))
175
+
176
+ print(f'SAVED|{OUT_DIR}', flush=True)
177
+ print(f'VERIFIED|{taxonomy_verified}|L1={len(l1_items)}|L2={taxonomy["total_l2"]}|readback={sum(1 for v in verifications if v["success"])}/{len(verifications)}', flush=True)
178
+
179
+ ctx.close()
180
+ LOCK_FILE.unlink(missing_ok=True)
181
+ print('DONE', flush=True)
182
+
183
+ if __name__ == '__main__':
184
+ main()
@@ -0,0 +1,352 @@
1
+ """Legacy operation, executed only through main()."""
2
+
3
+ def main():
4
+ #!/usr/bin/env python3
5
+ """Collect video rankings - DOM-based extraction, verified locators."""
6
+ from media_agent.runtime.paths import runtime_home
7
+ import json, time, sys, os, re
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_enterprise_leads_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'car_rank_final_{ts}'
19
+ OUT_DIR = BASE / 'tasks' / 'douyin_video_rankings' / ts
20
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
21
+
22
+ INDUSTRY_L1 = '汽车'
23
+ INDUSTRY_L2 = '汽车厂商'
24
+ TARGET_DATE = '2026-07-28'
25
+ RT = ['线索榜', '引流榜', '热门榜']
26
+
27
+ # ===== Lock =====
28
+ LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
29
+ if LOCK_FILE.exists():
30
+ try: os.kill(json.loads(LOCK_FILE.read_text()).get('pid',0), 0); sys.exit(1)
31
+ except: LOCK_FILE.unlink()
32
+ LOCK_FILE.write_text(json.dumps({'task_id':TASK_ID,'pid':os.getpid()}, indent=2))
33
+ print(f'TASK|{TASK_ID}', flush=True)
34
+
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
+ page.goto('https://leads.cluerich.com/pc/analysis/short-video/rank', wait_until='load', timeout=60000)
43
+ time.sleep(8)
44
+
45
+ body = page.content().lower()
46
+ if not any(k in body for k in ['运营工作台','数据分析','线索榜']):
47
+ print('LOGIN_REQUIRED'); ctx.close(); LOCK_FILE.unlink(); sys.exit(1)
48
+ print('LOGIN_OK', flush=True)
49
+
50
+ collected_at = datetime.now(timezone(timedelta(hours=8))).isoformat()
51
+ source_url = page.url
52
+
53
+ # ===== Select industry =====
54
+ page.locator('.leads-cascader-select').first.click(); time.sleep(2)
55
+ page.evaluate('''() => { const ls = document.querySelectorAll('[class*="cascader-item-label"]'); for (const l of ls) { if (l.textContent.trim() === '汽车' && Math.round(l.getBoundingClientRect().x) < 500) { l.parentElement.dispatchEvent(new MouseEvent('click', {bubbles: true})); return; } } }''')
56
+ time.sleep(2)
57
+ page.locator('.leads-cascader-select').first.click(); time.sleep(2)
58
+ page.locator('.leads-cascader-select').first.click(); time.sleep(2)
59
+ page.evaluate('''() => { const ls = document.querySelectorAll('[class*="cascader-item-label"]'); for (const l of ls) { if (l.textContent.trim() === '汽车厂商' && Math.round(l.getBoundingClientRect().x) > 500) { l.parentElement.dispatchEvent(new MouseEvent('click', {bubbles: true})); return; } } }''')
60
+ time.sleep(3)
61
+ final = page.evaluate('document.querySelector(".leads-cascader-select input")?.value || ""')
62
+ print(f'INDUSTRY|{final}', flush=True)
63
+ if '汽车厂商' not in final:
64
+ print('INDUSTRY_SWITCH_FAILED'); ctx.close(); LOCK_FILE.unlink(); sys.exit(1)
65
+
66
+ ss = str(OUT_DIR / 'industry_verified.png')
67
+ page.screenshot(path=ss, full_page=True)
68
+
69
+ # ===== Extract functions =====
70
+ def extract_headers(page):
71
+ return page.evaluate('''() => {
72
+ const r = []; const ths = document.querySelectorAll('th');
73
+ for (const th of ths) { const t = th.textContent.trim(); if (t && t.length < 20) r.push(t); }
74
+ return r;
75
+ }''')
76
+
77
+ def extract_items(page):
78
+ """Extract items using verified DOM locators."""
79
+ return page.evaluate('''() => {
80
+ const result = [];
81
+ const trs = document.querySelectorAll('tr');
82
+ let top3 = 0, numbered = false;
83
+ for (const tr of trs) {
84
+ const tds = tr.querySelectorAll('td');
85
+ if (tds.length < 3) continue;
86
+ const rankText = tds[0].textContent.trim();
87
+ let rank = parseInt(rankText) || 0;
88
+ if (rank === 0 || rank > 100) {
89
+ if (tds[1].textContent.trim().length > 20 && !numbered) {
90
+ top3++; if (top3 <= 3) rank = top3; else continue;
91
+ } else continue;
92
+ } else numbered = true;
93
+
94
+ const infoCell = tds[1];
95
+ const textDiv = infoCell.querySelector('.ml-3.flex-col');
96
+
97
+ const item = { rank: rank };
98
+ if (textDiv && textDiv.children.length >= 4) {
99
+ item.title = textDiv.children[0].textContent.trim();
100
+ item.author = textDiv.children[1].textContent.trim();
101
+ item.douyin_id = textDiv.children[2].textContent.replace('抖音号 ', '').trim();
102
+ item.publish_date = textDiv.children[3].textContent.replace('发布时间 ', '').trim();
103
+ } else {
104
+ item.title = ''; item.author = ''; item.douyin_id = ''; item.publish_date = '';
105
+ }
106
+ const coverImg = infoCell.querySelector('img');
107
+ item.cover_url = coverImg ? coverImg.src : '';
108
+ item.video_url = '';
109
+ for (let i = 2; i < tds.length; i++) {
110
+ item['m' + i] = tds[i].textContent.trim();
111
+ }
112
+ result.push(item);
113
+ }
114
+ return result;
115
+ }''')
116
+
117
+ def click_page(page, n):
118
+ return page.evaluate(f'''(num) => {{ const lis = document.querySelectorAll('li'); for (const li of lis) {{ if (li.textContent.trim() === String(num) && li.offsetParent !== null) {{ li.click(); return 'ok'; }} }} return 'nf'; }}''', n)
119
+
120
+ def click_tab(page, name):
121
+ try: page.locator(f'text={name}').first.click(); time.sleep(5); return True
122
+ except: return False
123
+
124
+ def parse_numeric(val):
125
+ if not val: return {'display': val, 'numeric': None}
126
+ t = val.strip()
127
+ if t.endswith('%'):
128
+ try: return {'display': t, 'numeric': float(t[:-1]) / 100}
129
+ except: return {'display': t, 'numeric': None}
130
+ if t.endswith('万'):
131
+ try: return {'display': t, 'numeric': round(float(t[:-1]) * 10000)}
132
+ except: return {'display': t, 'numeric': None}
133
+ try:
134
+ n = float(t.replace(',', ''))
135
+ return {'display': t, 'numeric': int(n) if n == int(n) else n}
136
+ except:
137
+ return {'display': t, 'numeric': None}
138
+
139
+ # ===== Collect =====
140
+ all_data = {}
141
+ missing_fields = []
142
+ page2_verified = True
143
+
144
+ for rtype in RT:
145
+ print(f'=== {rtype} ===', flush=True)
146
+ if rtype != '线索榜':
147
+ click_tab(page, rtype)
148
+
149
+ headers = extract_headers(page)
150
+ print(f' headers={headers}', flush=True)
151
+
152
+ items = []
153
+ for pg in range(1, 3):
154
+ if pg > 1:
155
+ click_page(page, pg)
156
+ time.sleep(5)
157
+
158
+ items_raw = extract_items(page)
159
+ print(f' P{pg}|{len(items_raw)} items', flush=True)
160
+
161
+ # Page 2 DOM check: verify first item structure matches
162
+ if pg == 2 and items_raw:
163
+ first = items_raw[0]
164
+ if not first.get('title') or not first.get('douyin_id'):
165
+ print(f' PAGE_CHANGED|P2 first item has missing fields', flush=True)
166
+ page2_verified = False
167
+ else:
168
+ print(f' P2_DOM_OK|{first["rank"]}|{first["title"][:30]}', flush=True)
169
+
170
+ for item in items_raw:
171
+ record = {
172
+ 'ranking_type': rtype,
173
+ 'global_rank': item['rank'],
174
+ 'page_number': pg,
175
+ 'page_rank': item['rank'],
176
+ 'title': item.get('title', ''),
177
+ 'author': item.get('author', ''),
178
+ 'douyin_id': item.get('douyin_id', ''),
179
+ 'publish_date': item.get('publish_date', ''),
180
+ 'cover_url': item.get('cover_url', ''),
181
+ 'video_url': item.get('video_url', ''),
182
+ }
183
+ # Metric columns
184
+ for k in sorted(item):
185
+ if k.startswith('m'):
186
+ idx = int(k[1:])
187
+ h = headers[idx] if idx < len(headers) else f'col_{idx}'
188
+ val = item[k]
189
+ parsed = parse_numeric(val)
190
+ record[h] = parsed['display']
191
+ record[h + '_numeric'] = parsed['numeric']
192
+
193
+ record['task_id'] = TASK_ID
194
+ record['profile_id'] = ALIAS
195
+ record['collected_at'] = collected_at
196
+ record['source_page_url'] = source_url
197
+ record['industry_level_1'] = INDUSTRY_L1
198
+ record['industry_level_2'] = INDUSTRY_L2
199
+ record['industry_display'] = f'{INDUSTRY_L1}/{INDUSTRY_L2}'
200
+ record['ranking_period'] = 'day'
201
+ record['ranking_date'] = TARGET_DATE
202
+ items.append(record)
203
+
204
+ # Dedup & sort
205
+ seen = set()
206
+ items_dedup = []
207
+ for item in items:
208
+ if item['global_rank'] not in seen:
209
+ seen.add(item['global_rank'])
210
+ items_dedup.append(item)
211
+ items_dedup.sort(key=lambda x: x['global_rank'])
212
+ all_data[rtype] = items_dedup
213
+
214
+ ranks = sorted([i['global_rank'] for i in items_dedup])
215
+ missing_ranks = [r for r in range(1, 21) if r not in ranks]
216
+ print(f' count={len(items_dedup)} missing={missing_ranks}', flush=True)
217
+
218
+ # Missing fields
219
+ for item in items_dedup:
220
+ if not item.get('title'): missing_fields.append(f'{rtype} r{item["global_rank"]}: empty title')
221
+ if not item.get('author'): missing_fields.append(f'{rtype} r{item["global_rank"]}: empty author')
222
+ if not item.get('douyin_id'): missing_fields.append(f'{rtype} r{item["global_rank"]}: empty douyin_id')
223
+ if not item.get('publish_date'): missing_fields.append(f'{rtype} r{item["global_rank"]}: empty publish_date')
224
+ if not item.get('cover_url'): missing_fields.append(f'{rtype} r{item["global_rank"]}: empty cover_url')
225
+ if not item.get('video_url'): missing_fields.append(f'{rtype} r{item["global_rank"]}: empty video_url')
226
+
227
+ # ===== QA Checks =====
228
+ douyin_id_suffix_errors = 0
229
+ title_author_append_errors = 0
230
+ title_equals_author = 0
231
+ empty_author = 0
232
+ empty_douyin = 0
233
+
234
+ for rt in RT:
235
+ for item in all_data.get(rt, []):
236
+ did = item.get('douyin_id', '')
237
+ if '发布时间' in did:
238
+ douyin_id_suffix_errors += 1
239
+ if did and did == item.get('author', ''):
240
+ title_equals_author += 1
241
+ if not item.get('author'):
242
+ empty_author += 1
243
+ if not item.get('douyin_id'):
244
+ empty_douyin += 1
245
+
246
+ print(f'QA|douyin_suffix_err={douyin_id_suffix_errors}', flush=True)
247
+ print(f'QA|title_author_append_err={title_author_append_errors}', flush=True)
248
+ print(f'QA|title_equals_author={title_equals_author}', flush=True)
249
+ print(f'QA|empty_author={empty_author}', flush=True)
250
+ print(f'QA|empty_douyin={empty_douyin}', flush=True)
251
+
252
+ # ===== Counts =====
253
+ lead = len(all_data.get('线索榜', []))
254
+ traffic = len(all_data.get('引流榜', []))
255
+ popular = len(all_data.get('热门榜', []))
256
+ total = lead + traffic + popular
257
+
258
+ mf_counts = {'title': 0, 'author': 0, 'douyin_id': 0, 'publish_date': 0, 'cover_url': 0, 'video_url': 0}
259
+ for mf in missing_fields:
260
+ for key in mf_counts:
261
+ if f'empty {key}' in mf:
262
+ mf_counts[key] += 1
263
+
264
+ # ===== Save =====
265
+ prefix = 'douyin_video_rankings_汽车_汽车厂商_2026-07-28'
266
+
267
+ # JSON
268
+ output = {
269
+ 'task_id': TASK_ID, 'profile_id': ALIAS, 'collected_at': collected_at,
270
+ 'source_page_url': source_url,
271
+ 'industry_level_1': INDUSTRY_L1, 'industry_level_2': INDUSTRY_L2,
272
+ 'industry_display': f'{INDUSTRY_L1}/{INDUSTRY_L2}',
273
+ 'ranking_period': 'day', 'ranking_date': TARGET_DATE,
274
+ 'rankings': {k: list(v) for k, v in all_data.items()},
275
+ 'missing_fields': missing_fields,
276
+ 'missing_field_counts': mf_counts,
277
+ 'qa': {
278
+ 'douyin_id_suffix_error_count': douyin_id_suffix_errors,
279
+ 'title_equals_author_count': title_equals_author,
280
+ 'empty_author_count': empty_author,
281
+ 'empty_douyin_id_count': empty_douyin,
282
+ 'page2_dom_verified': page2_verified
283
+ }
284
+ }
285
+ json_path = OUT_DIR / f'{prefix}.json'
286
+ json_path.write_text(json.dumps(output, indent=2, ensure_ascii=False))
287
+
288
+ # XLSX
289
+ import openpyxl
290
+ wb = openpyxl.Workbook()
291
+ ws = wb.active; ws.title = 'summary'
292
+ for row in [['Industry', f'{INDUSTRY_L1}/{INDUSTRY_L2}'], ['Date', TARGET_DATE], ['Period', '日榜']]:
293
+ ws.append(row)
294
+ ws.append([])
295
+ for rt2 in RT: ws.append([rt2, len(all_data.get(rt2, []))])
296
+ ws.append(['Total', total])
297
+ for rt2 in RT:
298
+ items = all_data.get(rt2, [])
299
+ if items:
300
+ ws2 = wb.create_sheet(rt2)
301
+ priority = ['ranking_type', 'global_rank', 'page_number', 'page_rank',
302
+ 'title', 'author', 'douyin_id', 'publish_date', 'cover_url', 'video_url']
303
+ other_keys = [k for k in items[0].keys() if k not in priority and not k.endswith('_numeric')]
304
+ keys = priority + other_keys
305
+ ws2.append(keys)
306
+ for item in items:
307
+ ws2.append([item.get(k, '') for k in keys])
308
+ xlsx_path = OUT_DIR / f'{prefix}.xlsx'
309
+ wb.save(str(xlsx_path))
310
+
311
+ # Meta
312
+ all_ranks = {}
313
+ for rt2 in RT:
314
+ ranks = sorted([i['global_rank'] for i in all_data.get(rt2, [])])
315
+ all_ranks[rt2] = {'ranks': ranks, 'missing': [r for r in range(1, 21) if r not in ranks]}
316
+
317
+ video_url_all_empty = all(not item.get('video_url') for rt2 in RT for item in all_data.get(rt2, []))
318
+
319
+ meta = {
320
+ 'task_id': TASK_ID, 'profile_id': ALIAS, 'collected_at': collected_at,
321
+ 'industry_level_1': INDUSTRY_L1, 'industry_level_2': INDUSTRY_L2,
322
+ 'industry_display': f'{INDUSTRY_L1}/{INDUSTRY_L2}',
323
+ 'lead_count': lead, 'traffic_count': traffic, 'popular_count': popular,
324
+ 'total_count': total,
325
+ 'rank_continuity': all(len(v['missing']) == 0 for v in all_ranks.values()),
326
+ 'missing_ranks': {k: v['missing'] for k, v in all_ranks.items()},
327
+ 'missing_fields': missing_fields,
328
+ 'missing_field_counts': mf_counts,
329
+ 'completeness': 'PARTIAL' if video_url_all_empty else 'COMPLETE',
330
+ 'industry_verified': True,
331
+ 'page2_dom_verified': page2_verified,
332
+ 'video_url_note': '页面未提供视频级链接' if video_url_all_empty else '',
333
+ 'qa': {
334
+ 'douyin_id_suffix_error_count': douyin_id_suffix_errors,
335
+ 'title_equals_author_count': title_equals_author,
336
+ 'empty_author_count': empty_author,
337
+ 'empty_douyin_id_count': empty_douyin
338
+ }
339
+ }
340
+ meta_path = OUT_DIR / f'{prefix}.meta.json'
341
+ meta_path.write_text(json.dumps(meta, indent=2, ensure_ascii=False))
342
+
343
+ print(f'SAVED|{OUT_DIR}', flush=True)
344
+ print(f'SUMMARY|lead={lead}|traffic={traffic}|popular={popular}|total={total}', flush=True)
345
+ print(f'COMPLETENESS|{meta["completeness"]}', flush=True)
346
+
347
+ ctx.close()
348
+ LOCK_FILE.unlink(missing_ok=True)
349
+ print('DONE', flush=True)
350
+
351
+ if __name__ == '__main__':
352
+ main()