@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,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()
|