@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.
- package/README.md +90 -0
- package/SHA256SUMS +94 -0
- package/bin/media-agent.mjs +89 -0
- package/docs/guides/capabilities.md +103 -0
- package/docs/guides/image-text-publishing.md +81 -0
- package/docs/guides/installation.md +103 -0
- package/docs/guides/runtime.md +13 -0
- package/manifest.json +382 -0
- package/package.json +42 -0
- package/pyproject.toml +20 -0
- package/resources/capabilities.json +34 -0
- package/resources/configs/accounts.yaml +21 -0
- package/resources/configs/profile.template.json +20 -0
- package/resources/configs/ranking-profiles.yaml +21 -0
- package/resources/configs/toutiao-profile.template.json +22 -0
- package/resources/configs/xiaohongshu-profile.template.json +27 -0
- package/resources/data/industry-taxonomy.yaml +326 -0
- package/skills/douyin-competitor-collect/SKILL.md +177 -0
- package/skills/douyin-competitor-collect/agents/openai.yaml +4 -0
- package/skills/douyin-competitor-collect/references/output-schema.md +178 -0
- package/skills/douyin-creator-image-text-publish/SKILL.md +44 -0
- package/skills/douyin-creator-image-text-publish/agents/openai.yaml +4 -0
- package/skills/douyin-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
- package/skills/douyin-creator-image-text-publish/references/execution-contract.md +46 -0
- package/skills/douyin-creator-image-text-publish/references/upstream.md +30 -0
- package/skills/douyin-creator-index/SKILL.md +32 -0
- package/skills/douyin-creator-index/agents/openai.yaml +4 -0
- package/skills/douyin-creator-login/SKILL.md +58 -0
- package/skills/douyin-creator-login/agents/openai.yaml +4 -0
- package/skills/douyin-creator-publish/SKILL.md +56 -0
- package/skills/douyin-creator-publish/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-leads/SKILL.md +26 -0
- package/skills/douyin-enterprise-leads/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-leads-login/SKILL.md +36 -0
- package/skills/douyin-enterprise-leads-login/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-short-video-export/SKILL.md +29 -0
- package/skills/douyin-enterprise-short-video-export/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-video-rankings/SKILL.md +51 -0
- package/skills/douyin-enterprise-video-rankings/agents/openai.yaml +4 -0
- package/skills/douyin-enterprise-video-rankings/references/industry-taxonomy.md +29 -0
- package/skills/douyin-web-login/SKILL.md +102 -0
- package/skills/douyin-web-login/agents/openai.yaml +4 -0
- package/skills/toutiao-creator-article-draft/SKILL.md +154 -0
- package/skills/toutiao-creator-article-draft/agents/openai.yaml +4 -0
- package/skills/toutiao-web-login/SKILL.md +96 -0
- package/skills/toutiao-web-login/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-image-text-publish/SKILL.md +46 -0
- package/skills/xiaohongshu-creator-image-text-publish/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/execution-contract.md +46 -0
- package/skills/xiaohongshu-creator-image-text-publish/references/upstream.md +31 -0
- package/skills/xiaohongshu-creator-login/SKILL.md +106 -0
- package/skills/xiaohongshu-creator-login/agents/openai.yaml +4 -0
- package/skills/xiaohongshu-creator-publish/SKILL.md +153 -0
- package/skills/xiaohongshu-creator-publish/agents/openai.yaml +4 -0
- package/src/media_agent/__init__.py +1 -0
- package/src/media_agent/cli.py +28 -0
- package/src/media_agent/commands.sh +436 -0
- package/src/media_agent/platforms/__init__.py +1 -0
- package/src/media_agent/platforms/douyin/__init__.py +1 -0
- package/src/media_agent/platforms/douyin/check_login.py +196 -0
- package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +184 -0
- package/src/media_agent/platforms/douyin/collect_video_rankings.py +352 -0
- package/src/media_agent/platforms/douyin/douyin_full_login.py +391 -0
- package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +454 -0
- package/src/media_agent/platforms/douyin/douyin_publish.py +1135 -0
- package/src/media_agent/platforms/douyin/enterprise_login.py +128 -0
- package/src/media_agent/platforms/douyin/export_short_video.py +70 -0
- package/src/media_agent/platforms/douyin/login_controller.py +508 -0
- package/src/media_agent/platforms/douyin/validate_industry_taxonomy.py +152 -0
- package/src/media_agent/platforms/douyin/validate_rankings.py +254 -0
- package/src/media_agent/platforms/toutiao/__init__.py +1 -0
- package/src/media_agent/platforms/toutiao/toutiao_check_login.py +54 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +250 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_evidence.py +50 -0
- package/src/media_agent/platforms/toutiao/toutiao_login_ipc.py +70 -0
- package/src/media_agent/platforms/xiaohongshu/__init__.py +1 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +189 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +449 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_evidence.py +64 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_ipc.py +120 -0
- package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +925 -0
- package/src/media_agent/runtime/__init__.py +1 -0
- package/src/media_agent/runtime/account_manager.py +672 -0
- package/src/media_agent/runtime/browser.py +5 -0
- package/src/media_agent/runtime/paths.py +7 -0
- package/src/media_agent/script_map.json +23 -0
- package/src/node/config.mjs +48 -0
- package/src/node/integrity.mjs +34 -0
- package/src/node/skills.mjs +85 -0
- package/tools/archive_releases.py +83 -0
- package/tools/artifacts.py +56 -0
- package/tools/build_release.py +82 -0
- package/tools/check_catalog.py +23 -0
- package/tools/install_runtime.py +144 -0
- 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()
|