@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,1135 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Douyin Creator Center 发布脚本 — 五阶段发布流水线。
|
|
4
|
+
|
|
5
|
+
Phases:
|
|
6
|
+
--dry-run 检查素材 SHA-256 去重,不执行实际操作
|
|
7
|
+
--prepare <path> 上传素材、配置元数据、定时发布,写账本后保持浏览器
|
|
8
|
+
--confirm-submit 确认提交(点击发布按钮)
|
|
9
|
+
--status 查询发布状态
|
|
10
|
+
--close 关闭浏览器并释放锁
|
|
11
|
+
|
|
12
|
+
Ledger: tasks/douyin_publish/publish-ledger.jsonl
|
|
13
|
+
State machine: discovered → reserved → uploading → scheduled → submitted → reviewing → published/rejected/indeterminate/failed
|
|
14
|
+
SHA-256 dedup: submitted / scheduled / reviewing / published 永久阻止重复提交。
|
|
15
|
+
"""
|
|
16
|
+
from media_agent.runtime.paths import runtime_home
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import signal
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ─── Paths ───────────────────────────────────────────────────────────────────
|
|
31
|
+
BASE = runtime_home()
|
|
32
|
+
DEFAULT_PROFILE_ID = 'douyin_creator_mengjun_ecommerce'
|
|
33
|
+
LOCKS_DIR = BASE / 'locks'
|
|
34
|
+
LEDGER_DIR = BASE / 'tasks' / 'douyin_publish'
|
|
35
|
+
LEDGER_FILE = LEDGER_DIR / 'publish-ledger.jsonl'
|
|
36
|
+
UPLOAD_URL = 'https://creator.douyin.com/creator-micro/content/upload'
|
|
37
|
+
SIGNAL_DIR = BASE / 'signals'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _profile_dir(profile_id):
|
|
41
|
+
return BASE / 'profiles' / profile_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _browser_data(profile_id):
|
|
45
|
+
return str(_profile_dir(profile_id) / 'browser_data')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _lock_file(profile_id):
|
|
49
|
+
return LOCKS_DIR / f'{profile_id}.lock'
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _confirm_file(profile_id):
|
|
53
|
+
return SIGNAL_DIR / f'{profile_id}.confirm'
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _close_file(profile_id):
|
|
57
|
+
return SIGNAL_DIR / f'{profile_id}.close'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ─── Constants ───────────────────────────────────────────────────────────────
|
|
61
|
+
DEDUP_STATES = {'submitted', 'scheduled', 'reviewing', 'published'}
|
|
62
|
+
SUPPORTED_VIDEO_FORMATS = {'.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm', '.m4v', '.3gp'}
|
|
63
|
+
SUPPORTED_IMAGE_FORMATS = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif'}
|
|
64
|
+
SUPPORTED_FORMATS = SUPPORTED_VIDEO_FORMATS | SUPPORTED_IMAGE_FORMATS
|
|
65
|
+
DEFAULT_SCHEDULED_AT = '2026-08-04T23:50:00+08:00'
|
|
66
|
+
TZ_SHANGHAI = timezone(timedelta(hours=8))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def sha256_file(path):
|
|
72
|
+
"""Compute SHA-256 digest of a file."""
|
|
73
|
+
h = hashlib.sha256()
|
|
74
|
+
with open(path, 'rb') as f:
|
|
75
|
+
while True:
|
|
76
|
+
chunk = f.read(65536)
|
|
77
|
+
if not chunk:
|
|
78
|
+
break
|
|
79
|
+
h.update(chunk)
|
|
80
|
+
return h.hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_ledger():
|
|
84
|
+
"""Load all ledger entries as a list of dicts."""
|
|
85
|
+
LEDGER_DIR.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
if not LEDGER_FILE.exists():
|
|
87
|
+
return []
|
|
88
|
+
entries = []
|
|
89
|
+
for line in LEDGER_FILE.read_text().splitlines():
|
|
90
|
+
line = line.strip()
|
|
91
|
+
if line:
|
|
92
|
+
try:
|
|
93
|
+
entries.append(json.loads(line))
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
pass
|
|
96
|
+
return entries
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_ledger_entry(sha256):
|
|
100
|
+
"""Return the most recent ledger entry matching a SHA-256 (or None)."""
|
|
101
|
+
entries = load_ledger()
|
|
102
|
+
for entry in reversed(entries):
|
|
103
|
+
if entry.get('sha256') == sha256:
|
|
104
|
+
return entry
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_duplicate(sha256):
|
|
109
|
+
"""Return (is_blocked, existing_entry) — True if SHA-256 is in a terminal dedup state."""
|
|
110
|
+
entry = find_ledger_entry(sha256)
|
|
111
|
+
if entry and entry.get('state') in DEDUP_STATES:
|
|
112
|
+
return True, entry
|
|
113
|
+
return False, entry
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def append_ledger(entry):
|
|
117
|
+
"""Atomically append a JSON line to the ledger."""
|
|
118
|
+
LEDGER_DIR.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
with open(LEDGER_FILE, 'a') as f:
|
|
120
|
+
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def acquire_lock(profile_id, task_id):
|
|
124
|
+
"""Acquire the profile lock. Returns (ok, existing_owner)."""
|
|
125
|
+
lf = _lock_file(profile_id)
|
|
126
|
+
LOCKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
if lf.exists():
|
|
128
|
+
try:
|
|
129
|
+
old = json.loads(lf.read_text())
|
|
130
|
+
os.kill(old.get('pid', 0), 0)
|
|
131
|
+
return False, old
|
|
132
|
+
except (OSError, json.JSONDecodeError):
|
|
133
|
+
lf.unlink()
|
|
134
|
+
lf.write_text(json.dumps({
|
|
135
|
+
'task_id': task_id,
|
|
136
|
+
'pid': os.getpid(),
|
|
137
|
+
'time': datetime.now().isoformat(),
|
|
138
|
+
'phase': 'publish',
|
|
139
|
+
}, indent=2))
|
|
140
|
+
return True, None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def release_lock(profile_id):
|
|
144
|
+
"""Release the profile lock."""
|
|
145
|
+
lf = _lock_file(profile_id)
|
|
146
|
+
if lf.exists():
|
|
147
|
+
lf.unlink(missing_ok=True)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def now_iso():
|
|
151
|
+
"""Return current time in Asia/Shanghai ISO format."""
|
|
152
|
+
return datetime.now(TZ_SHANGHAI).isoformat()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def log(msg):
|
|
156
|
+
"""Print a structured log line to stdout."""
|
|
157
|
+
print(json.dumps({'ts': now_iso(), **msg}, ensure_ascii=False), flush=True)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def die(code, msg):
|
|
161
|
+
"""Print error and exit."""
|
|
162
|
+
log({'level': 'ERROR', 'exit_code': code, **msg})
|
|
163
|
+
sys.exit(code)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _kwargs(**kw):
|
|
167
|
+
"""Helper to return a dict of kwargs — for readability."""
|
|
168
|
+
return kw
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ─── Browser ─────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def launch_browser(profile_id):
|
|
174
|
+
"""Launch a CloakBrowser persistent context for the profile."""
|
|
175
|
+
from media_agent.runtime.browser import launch_persistent_context
|
|
176
|
+
|
|
177
|
+
pdir = _profile_dir(profile_id)
|
|
178
|
+
cfg = json.loads((pdir / 'config.json').read_text())
|
|
179
|
+
fp = cfg.get('fingerprint', {})
|
|
180
|
+
|
|
181
|
+
ctx = launch_persistent_context(
|
|
182
|
+
user_data_dir=_browser_data(profile_id),
|
|
183
|
+
headless=False,
|
|
184
|
+
stealth_args=True,
|
|
185
|
+
viewport=fp.get('viewport', {'width': 1440, 'height': 900}),
|
|
186
|
+
locale=fp.get('locale', 'zh-CN'),
|
|
187
|
+
timezone=fp.get('timezone', 'Asia/Shanghai'),
|
|
188
|
+
humanize=False,
|
|
189
|
+
geoip=False,
|
|
190
|
+
)
|
|
191
|
+
return ctx, cfg
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ─── Phase: dry-run ──────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def phase_dry_run(profile_id, media_path, scheduled_at=None):
|
|
197
|
+
"""Check media file validity and SHA-256 dedup status."""
|
|
198
|
+
path = Path(media_path).resolve()
|
|
199
|
+
|
|
200
|
+
if not path.exists():
|
|
201
|
+
die(1, _kwargs(phase='dry-run', error='FILE_NOT_FOUND', path=str(path)))
|
|
202
|
+
|
|
203
|
+
if not path.is_file():
|
|
204
|
+
die(1, _kwargs(phase='dry-run', error='NOT_A_FILE', path=str(path)))
|
|
205
|
+
|
|
206
|
+
ext = path.suffix.lower()
|
|
207
|
+
if ext not in SUPPORTED_FORMATS:
|
|
208
|
+
die(1, _kwargs(
|
|
209
|
+
phase='dry-run', error='UNSUPPORTED_FORMAT',
|
|
210
|
+
format=ext, supported=sorted(SUPPORTED_FORMATS),
|
|
211
|
+
))
|
|
212
|
+
|
|
213
|
+
file_size = path.stat().st_size
|
|
214
|
+
sha = sha256_file(str(path))
|
|
215
|
+
blocked, existing = check_duplicate(sha)
|
|
216
|
+
|
|
217
|
+
schedule = scheduled_at or DEFAULT_SCHEDULED_AT
|
|
218
|
+
|
|
219
|
+
result = _kwargs(
|
|
220
|
+
phase='dry-run',
|
|
221
|
+
media_path=str(path),
|
|
222
|
+
file_size=file_size,
|
|
223
|
+
file_size_mb=round(file_size / 1024 / 1024, 2),
|
|
224
|
+
sha256=sha,
|
|
225
|
+
format=ext,
|
|
226
|
+
scheduled_at=schedule,
|
|
227
|
+
duplicate_blocked=blocked,
|
|
228
|
+
profile_id=profile_id,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if blocked:
|
|
232
|
+
result['existing_state'] = existing.get('state')
|
|
233
|
+
result['existing_task_id'] = existing.get('task_id')
|
|
234
|
+
result['existing_timestamp'] = existing.get('timestamp')
|
|
235
|
+
log(result)
|
|
236
|
+
sys.exit(8) # DUPLICATE_BLOCKED
|
|
237
|
+
|
|
238
|
+
log(result)
|
|
239
|
+
return sha, str(path), schedule
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ─── Phase: prepare ──────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
def _wait_for_upload_complete(page, timeout=300):
|
|
245
|
+
"""Wait for the video upload/processing to complete."""
|
|
246
|
+
start = time.time()
|
|
247
|
+
while time.time() - start < timeout:
|
|
248
|
+
# Check multiple indicators
|
|
249
|
+
status = page.evaluate('''() => {
|
|
250
|
+
const body = document.body.textContent || '';
|
|
251
|
+
// Check if upload area is still showing
|
|
252
|
+
const uploadArea = document.querySelector('input[type="file"]');
|
|
253
|
+
const uploadVisible = uploadArea && uploadArea.offsetParent !== null;
|
|
254
|
+
// Check for processing indicators
|
|
255
|
+
const processing = body.includes('上传中') || body.includes('处理中') || body.includes('转码中');
|
|
256
|
+
// Check for edit form (title/description)
|
|
257
|
+
const hasTitle = document.querySelector('input[placeholder*="标题"]') ||
|
|
258
|
+
document.querySelector('textarea[placeholder*="简介"]') ||
|
|
259
|
+
document.querySelector('[class*="title"] input');
|
|
260
|
+
// Check for cover/settings area
|
|
261
|
+
const hasCover = body.includes('封面') || body.includes('选择封面');
|
|
262
|
+
if (hasTitle && hasCover && !uploadVisible) {
|
|
263
|
+
return 'done';
|
|
264
|
+
}
|
|
265
|
+
if (processing) {
|
|
266
|
+
return 'processing';
|
|
267
|
+
}
|
|
268
|
+
return 'waiting';
|
|
269
|
+
}''')
|
|
270
|
+
if status == 'done':
|
|
271
|
+
return True
|
|
272
|
+
if status == 'processing':
|
|
273
|
+
log(_kwargs(step='upload_progress', status='processing', elapsed=round(time.time() - start)))
|
|
274
|
+
time.sleep(3)
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _dismiss_location_permission(page):
|
|
279
|
+
"""Dismiss the location permission popup by choosing '一律不允许'."""
|
|
280
|
+
time.sleep(2)
|
|
281
|
+
dismissed = page.evaluate('''() => {
|
|
282
|
+
const keywords = ['一律不允许', '不允许', '禁止', '拒绝', "Don't Allow", 'Block', 'Deny'];
|
|
283
|
+
for (const kw of keywords) {
|
|
284
|
+
for (const el of document.querySelectorAll('button, span, div, a')) {
|
|
285
|
+
const text = (el.textContent || '').trim();
|
|
286
|
+
if (text === kw || text.includes(kw)) {
|
|
287
|
+
if (el.offsetParent !== null) {
|
|
288
|
+
el.click();
|
|
289
|
+
return 'clicked:' + kw;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return 'not_found';
|
|
295
|
+
}''')
|
|
296
|
+
log(_kwargs(step='location_permission', result=dismissed))
|
|
297
|
+
time.sleep(1)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _set_cover_natural_frame(page):
|
|
301
|
+
"""Select a natural frame as cover, avoiding AI-text overlays."""
|
|
302
|
+
time.sleep(3)
|
|
303
|
+
result = page.evaluate('''() => {
|
|
304
|
+
const coverAreas = document.querySelectorAll('[class*="cover"], [class*="poster"], [class*="thumbnail"]');
|
|
305
|
+
const info = [];
|
|
306
|
+
for (const el of coverAreas) {
|
|
307
|
+
if (el.offsetParent === null) continue;
|
|
308
|
+
const rect = el.getBoundingClientRect();
|
|
309
|
+
info.push({
|
|
310
|
+
tag: el.tagName,
|
|
311
|
+
class: (el.className || '').substring(0, 40),
|
|
312
|
+
text: (el.textContent || '').trim().substring(0, 30),
|
|
313
|
+
bounds: {x: Math.round(rect.x), y: Math.round(rect.y),
|
|
314
|
+
w: Math.round(rect.width), h: Math.round(rect.height)},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return {found: info.length, covers: info.slice(0, 5)};
|
|
318
|
+
}''')
|
|
319
|
+
log(_kwargs(step='cover_inspection', **result))
|
|
320
|
+
|
|
321
|
+
picked = page.evaluate('''() => {
|
|
322
|
+
for (const el of document.querySelectorAll('div, span, button')) {
|
|
323
|
+
const text = (el.textContent || '').trim();
|
|
324
|
+
if ((text.includes('选择封面') || text.includes('推荐封面') || text.includes('默认封面')) &&
|
|
325
|
+
el.offsetParent !== null) {
|
|
326
|
+
el.click();
|
|
327
|
+
return 'clicked:' + text.substring(0, 20);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return 'no_cover_ui';
|
|
331
|
+
}''')
|
|
332
|
+
log(_kwargs(step='cover_selection', result=picked))
|
|
333
|
+
time.sleep(2)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _configure_default_settings(page):
|
|
337
|
+
"""Set default publish settings: public, allow save, no collection, no location, no hotspot, no event, no music."""
|
|
338
|
+
settings_log = []
|
|
339
|
+
|
|
340
|
+
# Ensure visibility is public
|
|
341
|
+
r = page.evaluate('''() => {
|
|
342
|
+
const results = [];
|
|
343
|
+
for (const el of document.querySelectorAll('div, span, label')) {
|
|
344
|
+
const text = (el.textContent || '').trim();
|
|
345
|
+
if ((text === '公开' || text === '私密' || text === '好友可见') && el.offsetParent !== null) {
|
|
346
|
+
results.push({text: text, tag: el.tagName});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return results;
|
|
350
|
+
}''')
|
|
351
|
+
settings_log.append({'visibility_options': r})
|
|
352
|
+
|
|
353
|
+
# Uncheck: 原创声明, 营销声明, 广告声明
|
|
354
|
+
r = page.evaluate('''() => {
|
|
355
|
+
const unchecked = [];
|
|
356
|
+
const declKeywords = ['原创', '营销', '广告', '声明', '品牌'];
|
|
357
|
+
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
|
|
358
|
+
for (const cb of checkboxes) {
|
|
359
|
+
const label = cb.closest('label') || cb.parentElement;
|
|
360
|
+
const text = (label?.textContent || '').trim();
|
|
361
|
+
for (const kw of declKeywords) {
|
|
362
|
+
if (text.includes(kw) && cb.checked && cb.offsetParent !== null) {
|
|
363
|
+
cb.click();
|
|
364
|
+
unchecked.push(kw);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return unchecked;
|
|
370
|
+
}''')
|
|
371
|
+
settings_log.append({'declarations_unchecked': r})
|
|
372
|
+
|
|
373
|
+
# Disable: 加入合集, 添加位置, 关联热点, 参加活动, 添加音乐
|
|
374
|
+
r = page.evaluate('''() => {
|
|
375
|
+
const actions = [];
|
|
376
|
+
const disableKeywords = ['加入合集', '添加位置', '关联热点', '参加活动', '添加音乐', '位置', '合集', '活动', '音乐', '热点'];
|
|
377
|
+
for (const kw of disableKeywords) {
|
|
378
|
+
for (const el of document.querySelectorAll('div, span, button')) {
|
|
379
|
+
const text = (el.textContent || '').trim();
|
|
380
|
+
if (text === kw && el.offsetParent !== null) {
|
|
381
|
+
const parent = el.closest('div, label, section') || el.parentElement;
|
|
382
|
+
if (parent) {
|
|
383
|
+
const cb = parent.querySelector('input[type="checkbox"]');
|
|
384
|
+
if (cb && cb.checked) {
|
|
385
|
+
cb.click();
|
|
386
|
+
actions.push('unchecked:' + kw);
|
|
387
|
+
} else {
|
|
388
|
+
actions.push('found:' + kw + '(already_off)');
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return actions;
|
|
396
|
+
}''')
|
|
397
|
+
settings_log.append({'extras_disabled': r})
|
|
398
|
+
|
|
399
|
+
log(_kwargs(step='configure_settings', settings=settings_log))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _fill_title_description(page, title, description):
|
|
403
|
+
"""Fill in the video title and description."""
|
|
404
|
+
if not title and not description:
|
|
405
|
+
return
|
|
406
|
+
if title:
|
|
407
|
+
r = page.evaluate(f'''(t) => {{
|
|
408
|
+
const selectors = ['input[placeholder*="标题"]', 'input[placeholder*="title"]',
|
|
409
|
+
'textarea[placeholder*="标题"]', '[class*="title"] input', '[class*="title"] textarea'];
|
|
410
|
+
for (const sel of selectors) {{
|
|
411
|
+
for (const inp of document.querySelectorAll(sel)) {{
|
|
412
|
+
if (inp.offsetParent !== null) {{
|
|
413
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set ||
|
|
414
|
+
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
|
415
|
+
setter.call(inp, t);
|
|
416
|
+
inp.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
|
417
|
+
inp.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
|
418
|
+
return 'title_set';
|
|
419
|
+
}}
|
|
420
|
+
}}
|
|
421
|
+
}}
|
|
422
|
+
return 'no_title_input';
|
|
423
|
+
}}''', title)
|
|
424
|
+
log(_kwargs(step='fill_title', result=r))
|
|
425
|
+
if description:
|
|
426
|
+
r = page.evaluate(f'''(d) => {{
|
|
427
|
+
// Try standard textarea first
|
|
428
|
+
for (const ta of document.querySelectorAll('textarea')) {{
|
|
429
|
+
if (ta.offsetParent !== null) {{
|
|
430
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
|
431
|
+
setter.call(ta, d);
|
|
432
|
+
ta.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
|
433
|
+
ta.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
|
434
|
+
return ta.placeholder || 'textarea_set';
|
|
435
|
+
}}
|
|
436
|
+
}}
|
|
437
|
+
// Try contenteditable div (editor-kit-container)
|
|
438
|
+
for (const el of document.querySelectorAll('[class*="editor-kit"], [class*="zone-container"], [contenteditable="true"]')) {{
|
|
439
|
+
if (el.offsetParent !== null) {{
|
|
440
|
+
el.textContent = d;
|
|
441
|
+
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
|
|
442
|
+
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
|
|
443
|
+
return 'editor_set';
|
|
444
|
+
}}
|
|
445
|
+
}}
|
|
446
|
+
return 'no_desc_input';
|
|
447
|
+
}}''', description)
|
|
448
|
+
log(_kwargs(step='fill_desc', result=r))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _set_scheduled_publish(page, scheduled_at):
|
|
452
|
+
"""Select scheduled publish and set the datetime by typing directly."""
|
|
453
|
+
dt_obj = datetime.fromisoformat(scheduled_at)
|
|
454
|
+
datetime_str = dt_obj.strftime('%Y-%m-%d %H:%M')
|
|
455
|
+
|
|
456
|
+
# 1. Scroll to bottom to reveal publish settings
|
|
457
|
+
page.evaluate('window.scrollTo(0, document.documentElement.scrollHeight)')
|
|
458
|
+
time.sleep(1)
|
|
459
|
+
|
|
460
|
+
# 2. Click "定时发布"
|
|
461
|
+
r = page.evaluate('''() => {
|
|
462
|
+
for (const el of document.querySelectorAll('div, span, label, button')) {
|
|
463
|
+
const text = (el.textContent || '').trim();
|
|
464
|
+
if (text === '定时发布' && el.offsetParent !== null) {
|
|
465
|
+
el.click();
|
|
466
|
+
return 'clicked';
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return 'not_found';
|
|
470
|
+
}''')
|
|
471
|
+
log(_kwargs(step='scheduled_publish_option', click_result=r))
|
|
472
|
+
time.sleep(2)
|
|
473
|
+
|
|
474
|
+
# 3. After clicking "定时发布", find the datetime input field next to it
|
|
475
|
+
# The Semi Design DateTimePicker renders as an input-like element
|
|
476
|
+
datetime_str = dt_obj.strftime('%Y-%m-%d %H:%M')
|
|
477
|
+
result = page.evaluate(f'''(datetimeVal) => {{
|
|
478
|
+
// Dump all inputs and text near "定时发布"
|
|
479
|
+
const dump = [];
|
|
480
|
+
for (const el of document.querySelectorAll('input, textarea, [contenteditable="true"]')) {{
|
|
481
|
+
if (el.offsetParent) {{
|
|
482
|
+
dump.push({{tag: el.tagName, type: el.type, value: el.value, placeholder: el.placeholder, className: (el.className||'').substring(0,40)}});
|
|
483
|
+
}}
|
|
484
|
+
}}
|
|
485
|
+
return {{all_inputs: dump, count: dump.length}};
|
|
486
|
+
}}''', datetime_str)
|
|
487
|
+
log(_kwargs(step='datetime_inputs', **result))
|
|
488
|
+
|
|
489
|
+
# 4. Try to find and set the datetime via the input that appears next to "定时发布"
|
|
490
|
+
set_result = page.evaluate(f'''(datetimeVal) => {{
|
|
491
|
+
// Strategy: find the last visible input element that's near the bottom of the page
|
|
492
|
+
// This should be the datetime picker's input field
|
|
493
|
+
const allInputs = [...document.querySelectorAll('input')].filter(el => el.offsetParent);
|
|
494
|
+
// Try inputs near the bottom (y > 800)
|
|
495
|
+
for (const inp of allInputs) {{
|
|
496
|
+
const rect = inp.getBoundingClientRect();
|
|
497
|
+
if (rect.y > 600 && inp.type === 'text') {{
|
|
498
|
+
inp.focus();
|
|
499
|
+
inp.value = '';
|
|
500
|
+
inp.value = datetimeVal;
|
|
501
|
+
inp.dispatchEvent(new Event('input', {{bubbles: true}}));
|
|
502
|
+
inp.dispatchEvent(new Event('change', {{bubbles: true}}));
|
|
503
|
+
inp.dispatchEvent(new Event('blur', {{bubbles: true}}));
|
|
504
|
+
return {{set: true, tag: inp.tagName, rect: rect, value: inp.value}};
|
|
505
|
+
}}
|
|
506
|
+
}}
|
|
507
|
+
// Fallback: try all text inputs
|
|
508
|
+
for (const inp of allInputs) {{
|
|
509
|
+
if (inp.type === 'text' && inp.placeholder !== '填写作品标题') {{
|
|
510
|
+
inp.focus();
|
|
511
|
+
inp.value = '';
|
|
512
|
+
inp.value = datetimeVal;
|
|
513
|
+
inp.dispatchEvent(new Event('input', {{bubbles: true}}));
|
|
514
|
+
inp.dispatchEvent(new Event('change', {{bubbles: true}}));
|
|
515
|
+
inp.dispatchEvent(new Event('blur', {{bubbles: true}}));
|
|
516
|
+
return {{set: true, tag: inp.tagName, fallback: true, value: inp.value}};
|
|
517
|
+
}}
|
|
518
|
+
}}
|
|
519
|
+
return {{set: false, reason: 'no_suitable_input'}};
|
|
520
|
+
}}''', datetime_str)
|
|
521
|
+
log(_kwargs(step='datetime_set', **set_result))
|
|
522
|
+
|
|
523
|
+
time.sleep(1)
|
|
524
|
+
|
|
525
|
+
# 5. Read back
|
|
526
|
+
readback = page.evaluate('''() => {
|
|
527
|
+
const re = /\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}/;
|
|
528
|
+
for (const el of document.querySelectorAll('span, div, input')) {
|
|
529
|
+
const text = (el.textContent || el.value || '').trim();
|
|
530
|
+
if (re.test(text) && el.offsetParent) {
|
|
531
|
+
return {value: text, tag: el.tagName};
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return null;
|
|
535
|
+
}''')
|
|
536
|
+
log(_kwargs(step='datetime_readback', value=readback))
|
|
537
|
+
|
|
538
|
+
# Read back the trigger box value
|
|
539
|
+
readback_dt = page.evaluate('''() => {
|
|
540
|
+
const re = /\\d{4}-\\d{2}-\\d{2}\\s+\\d{2}:\\d{2}/;
|
|
541
|
+
for (const el of document.querySelectorAll('div, span, input')) {
|
|
542
|
+
const text = (el.textContent || '').trim();
|
|
543
|
+
if (re.test(text) && el.offsetParent) {
|
|
544
|
+
return text;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return null;
|
|
548
|
+
}''')
|
|
549
|
+
log(_kwargs(step='datetime_readback', value=readback_dt))
|
|
550
|
+
time.sleep(1)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _readback_title_and_time(page):
|
|
554
|
+
"""Read back the video title and scheduled time from the page."""
|
|
555
|
+
info = page.evaluate('''() => {
|
|
556
|
+
const result = {};
|
|
557
|
+
|
|
558
|
+
const titleInputs = document.querySelectorAll('input[placeholder*="标题"], input[placeholder*="title"], textarea[placeholder*="标题"], input[class*="title"]');
|
|
559
|
+
for (const inp of titleInputs) {
|
|
560
|
+
if (inp.offsetParent !== null) {
|
|
561
|
+
result.title = inp.value || inp.placeholder || '';
|
|
562
|
+
result.title_tag = inp.tagName;
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const timeElements = document.querySelectorAll('div, span');
|
|
568
|
+
for (const el of timeElements) {
|
|
569
|
+
const text = (el.textContent || '').trim();
|
|
570
|
+
if (text.includes('定时') && text.includes(':') && el.offsetParent !== null) {
|
|
571
|
+
result.scheduled_display = text.substring(0, 60);
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
for (const el of document.querySelectorAll('button, div, span')) {
|
|
577
|
+
const text = (el.textContent || '').trim();
|
|
578
|
+
if ((text === '发布' || text.includes('发布')) && el.offsetParent !== null) {
|
|
579
|
+
result.publish_button = text.substring(0, 20);
|
|
580
|
+
result.publish_button_visible = true;
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return result;
|
|
586
|
+
}''')
|
|
587
|
+
log(_kwargs(step='readback', **info))
|
|
588
|
+
return info
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def phase_prepare(profile_id, media_path, scheduled_at, title=None, description=None):
|
|
592
|
+
"""Upload media, configure settings, schedule publish, write ledger, keep browser alive."""
|
|
593
|
+
schedule = scheduled_at or DEFAULT_SCHEDULED_AT
|
|
594
|
+
path = Path(media_path).resolve()
|
|
595
|
+
sha = sha256_file(str(path))
|
|
596
|
+
|
|
597
|
+
blocked, existing = check_duplicate(sha)
|
|
598
|
+
if blocked:
|
|
599
|
+
die(8, _kwargs(
|
|
600
|
+
phase='prepare', error='DUPLICATE_BLOCKED', sha256=sha,
|
|
601
|
+
existing_state=existing.get('state'),
|
|
602
|
+
existing_task_id=existing.get('task_id'),
|
|
603
|
+
))
|
|
604
|
+
|
|
605
|
+
task_id = f'publish_{profile_id}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
|
|
606
|
+
|
|
607
|
+
ok, owner = acquire_lock(profile_id, task_id)
|
|
608
|
+
if not ok:
|
|
609
|
+
die(3, _kwargs(phase='prepare', error='PROFILE_LOCKED', owner=owner))
|
|
610
|
+
|
|
611
|
+
log(_kwargs(phase='prepare', step='launch_browser', task_id=task_id))
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
ctx, cfg = launch_browser(profile_id)
|
|
615
|
+
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
616
|
+
except Exception as e:
|
|
617
|
+
release_lock(profile_id)
|
|
618
|
+
die(1, _kwargs(phase='prepare', error='BROWSER_LAUNCH_FAILED', detail=str(e)[:200]))
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
# ── Navigate to upload page ──
|
|
622
|
+
log(_kwargs(step='navigate', url=UPLOAD_URL))
|
|
623
|
+
page.goto(UPLOAD_URL, wait_until='load', timeout=60000)
|
|
624
|
+
time.sleep(5)
|
|
625
|
+
|
|
626
|
+
current_url = page.url
|
|
627
|
+
if 'login' in current_url.lower() or 'passport' in current_url.lower():
|
|
628
|
+
ctx.close()
|
|
629
|
+
release_lock(profile_id)
|
|
630
|
+
die(2, _kwargs(phase='prepare', error='LOGIN_REQUIRED', current_url=current_url[:120]))
|
|
631
|
+
|
|
632
|
+
log(_kwargs(step='landed', url=current_url[:120], title=page.title()))
|
|
633
|
+
|
|
634
|
+
# ── Upload file ──
|
|
635
|
+
log(_kwargs(step='upload', path=str(path)))
|
|
636
|
+
try:
|
|
637
|
+
file_input = page.locator('input[type="file"]').first
|
|
638
|
+
file_input.set_input_files(str(path))
|
|
639
|
+
log(_kwargs(step='file_set', result='ok'))
|
|
640
|
+
except Exception as e:
|
|
641
|
+
log(_kwargs(step='file_set', result='locator_failed', error=str(e)[:100]))
|
|
642
|
+
# Make inputs visible and retry
|
|
643
|
+
page.evaluate('''() => {
|
|
644
|
+
const inputs = document.querySelectorAll('input[type="file"]');
|
|
645
|
+
for (const inp of inputs) {
|
|
646
|
+
inp.style.display = 'block';
|
|
647
|
+
inp.style.visibility = 'visible';
|
|
648
|
+
inp.style.position = 'static';
|
|
649
|
+
}
|
|
650
|
+
return inputs.length;
|
|
651
|
+
}''')
|
|
652
|
+
try:
|
|
653
|
+
file_input = page.locator('input[type="file"]').first
|
|
654
|
+
file_input.set_input_files(str(path))
|
|
655
|
+
except Exception as e2:
|
|
656
|
+
ctx.close()
|
|
657
|
+
release_lock(profile_id)
|
|
658
|
+
die(1, _kwargs(phase='prepare', error='UPLOAD_FAILED', detail=str(e2)[:200]))
|
|
659
|
+
|
|
660
|
+
time.sleep(3)
|
|
661
|
+
|
|
662
|
+
# ── Wait for upload complete ──
|
|
663
|
+
if not _wait_for_upload_complete(page):
|
|
664
|
+
log(_kwargs(step='upload_wait', warning='timeout — proceeding anyway'))
|
|
665
|
+
|
|
666
|
+
# ── Fill title and description ──
|
|
667
|
+
_fill_title_description(page, title, description)
|
|
668
|
+
|
|
669
|
+
# ── Dismiss location permission ──
|
|
670
|
+
_dismiss_location_permission(page)
|
|
671
|
+
|
|
672
|
+
# ── Set cover ──
|
|
673
|
+
_set_cover_natural_frame(page)
|
|
674
|
+
|
|
675
|
+
# ── Configure default settings ──
|
|
676
|
+
_configure_default_settings(page)
|
|
677
|
+
|
|
678
|
+
# ── Set scheduled publish ──
|
|
679
|
+
if scheduled_at:
|
|
680
|
+
_set_scheduled_publish(page, schedule)
|
|
681
|
+
|
|
682
|
+
# ── Readback ──
|
|
683
|
+
readback = _readback_title_and_time(page)
|
|
684
|
+
|
|
685
|
+
# ── Write ledger entry ──
|
|
686
|
+
ledger_entry = _kwargs(
|
|
687
|
+
task_id=task_id,
|
|
688
|
+
sha256=sha,
|
|
689
|
+
path=str(path),
|
|
690
|
+
state='prepared',
|
|
691
|
+
timestamp=now_iso(),
|
|
692
|
+
scheduled_at=schedule,
|
|
693
|
+
profile_id=profile_id,
|
|
694
|
+
file_size=path.stat().st_size,
|
|
695
|
+
readback=readback,
|
|
696
|
+
pid=os.getpid(),
|
|
697
|
+
)
|
|
698
|
+
append_ledger(ledger_entry)
|
|
699
|
+
log(_kwargs(phase='prepare', step='ledger_written', state='prepared', task_id=task_id))
|
|
700
|
+
|
|
701
|
+
# ── Wait for confirm or close signal ──
|
|
702
|
+
SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
703
|
+
cf = _confirm_file(profile_id)
|
|
704
|
+
clf = _close_file(profile_id)
|
|
705
|
+
for f in [cf, clf]:
|
|
706
|
+
if f.exists():
|
|
707
|
+
f.unlink(missing_ok=True)
|
|
708
|
+
|
|
709
|
+
log(_kwargs(
|
|
710
|
+
phase='prepare', step='awaiting_confirm', task_id=task_id,
|
|
711
|
+
hint='Run publish-confirm to submit, or publish-close to abort',
|
|
712
|
+
))
|
|
713
|
+
|
|
714
|
+
while True:
|
|
715
|
+
if cf.exists():
|
|
716
|
+
cf.unlink(missing_ok=True)
|
|
717
|
+
log(_kwargs(phase='prepare', step='confirm_received'))
|
|
718
|
+
|
|
719
|
+
clicked = page.evaluate('''() => {
|
|
720
|
+
for (const el of document.querySelectorAll('button')) {
|
|
721
|
+
const text = (el.textContent || '').trim();
|
|
722
|
+
if (text === '发布' && el.offsetParent !== null) {
|
|
723
|
+
// Dispatch full mouse event sequence for React button
|
|
724
|
+
el.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true}));
|
|
725
|
+
el.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true}));
|
|
726
|
+
el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
|
|
727
|
+
return 'clicked:button:' + text;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
for (const el of document.querySelectorAll('div, span')) {
|
|
731
|
+
const text = (el.textContent || '').trim();
|
|
732
|
+
if (text === '发布' && el.offsetParent !== null) {
|
|
733
|
+
el.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true}));
|
|
734
|
+
el.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true}));
|
|
735
|
+
el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
|
|
736
|
+
return 'clicked:' + el.tagName + ':' + text;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return 'not_found';
|
|
740
|
+
}''')
|
|
741
|
+
log(_kwargs(step='publish_click', result=clicked))
|
|
742
|
+
time.sleep(5)
|
|
743
|
+
|
|
744
|
+
# Wait for platform auto-navigation (up to 3 minutes)
|
|
745
|
+
# Don't manually navigate - wait for platform to redirect
|
|
746
|
+
url_after = page.url
|
|
747
|
+
log(_kwargs(step='post_click_wait', url=url_after[:120], title=page.title()))
|
|
748
|
+
|
|
749
|
+
# Wait for auto-navigation or platform response
|
|
750
|
+
max_wait = 180 # 3 minutes
|
|
751
|
+
start_wait = time.time()
|
|
752
|
+
navigated = False
|
|
753
|
+
while time.time() - start_wait < max_wait:
|
|
754
|
+
current_url = page.url
|
|
755
|
+
if 'content/manage' in current_url:
|
|
756
|
+
navigated = True
|
|
757
|
+
log(_kwargs(step='auto_navigated', url=current_url[:120], elapsed=time.time()-start_wait))
|
|
758
|
+
break
|
|
759
|
+
# Check for error messages or submit confirmation
|
|
760
|
+
page_text = page.evaluate('() => document.body.innerText.substring(0,500)')
|
|
761
|
+
if '发布成功' in page_text or '提交成功' in page_text or '审核中' in page_text:
|
|
762
|
+
log(_kwargs(step='platform_success_hint', text=page_text[:200]))
|
|
763
|
+
if '失败' in page_text or '错误' in page_text or 'error' in page_text.lower():
|
|
764
|
+
log(_kwargs(step='platform_error_hint', text=page_text[:200]))
|
|
765
|
+
time.sleep(5)
|
|
766
|
+
|
|
767
|
+
if not navigated:
|
|
768
|
+
# Still not navigated - check if page shows any result
|
|
769
|
+
url_after = page.url
|
|
770
|
+
page_text = page.evaluate('() => document.body.innerText.substring(0,1000)')
|
|
771
|
+
log(_kwargs(step='no_navigation', url=url_after[:120], page_text=page_text[:300]))
|
|
772
|
+
# Write indeterminate - keep browser for manual inspection
|
|
773
|
+
ledger_entry['state'] = 'indeterminate'
|
|
774
|
+
ledger_entry['indeterminate_at'] = now_iso()
|
|
775
|
+
ledger_entry['indeterminate_reason'] = 'no_auto_navigation_after_click'
|
|
776
|
+
ledger_entry['post_click_url'] = url_after[:200]
|
|
777
|
+
append_ledger(ledger_entry)
|
|
778
|
+
log(_kwargs(phase='prepare', step='indeterminate', task_id=task_id))
|
|
779
|
+
# Keep browser open - don't break/close
|
|
780
|
+
continue # Stay in loop waiting for close signal
|
|
781
|
+
|
|
782
|
+
# Navigated to content management - always wait and refresh
|
|
783
|
+
time.sleep(10)
|
|
784
|
+
log(_kwargs(step='manage_landed', url=page.url[:120]))
|
|
785
|
+
|
|
786
|
+
# Always refresh once after waiting
|
|
787
|
+
page.reload(timeout=15000)
|
|
788
|
+
time.sleep(5)
|
|
789
|
+
log(_kwargs(step='post_refresh', url=page.url[:120]))
|
|
790
|
+
|
|
791
|
+
# Search for target title
|
|
792
|
+
title_to_find = title
|
|
793
|
+
found = page.evaluate(f'''(targetTitle) => {{
|
|
794
|
+
for (const el of document.querySelectorAll('div, span, a')) {{
|
|
795
|
+
const text = (el.textContent || '').trim();
|
|
796
|
+
if (text.includes(targetTitle)) {{
|
|
797
|
+
return {{found: true, element: text.substring(0, 80)}};
|
|
798
|
+
}}
|
|
799
|
+
}}
|
|
800
|
+
return {{found: false}};
|
|
801
|
+
}}''', title_to_find)
|
|
802
|
+
log(_kwargs(step='verify_work', **found))
|
|
803
|
+
|
|
804
|
+
if found.get('found'):
|
|
805
|
+
ledger_entry['state'] = 'submitted'
|
|
806
|
+
ledger_entry['submitted_at'] = now_iso()
|
|
807
|
+
append_ledger(ledger_entry)
|
|
808
|
+
log(_kwargs(phase='prepare', step='submitted', task_id=task_id))
|
|
809
|
+
else:
|
|
810
|
+
# Poll up to 5 minutes
|
|
811
|
+
start_poll = time.time()
|
|
812
|
+
poll_found = False
|
|
813
|
+
while time.time() - start_poll < 300:
|
|
814
|
+
time.sleep(15)
|
|
815
|
+
page.reload(timeout=15000)
|
|
816
|
+
time.sleep(3)
|
|
817
|
+
found = page.evaluate(f'''(targetTitle) => {{
|
|
818
|
+
for (const el of document.querySelectorAll('div, span, a')) {{
|
|
819
|
+
const text = (el.textContent || '').trim();
|
|
820
|
+
if (text.includes(targetTitle)) {{
|
|
821
|
+
return {{found: true, element: text.substring(0, 80)}};
|
|
822
|
+
}}
|
|
823
|
+
}}
|
|
824
|
+
return {{found: false}};
|
|
825
|
+
}}''', title_to_find)
|
|
826
|
+
log(_kwargs(step='poll_check', **found, elapsed=time.time()-start_poll))
|
|
827
|
+
if found.get('found'):
|
|
828
|
+
poll_found = True
|
|
829
|
+
break
|
|
830
|
+
|
|
831
|
+
if poll_found:
|
|
832
|
+
ledger_entry['state'] = 'submitted'
|
|
833
|
+
ledger_entry['submitted_at'] = now_iso()
|
|
834
|
+
append_ledger(ledger_entry)
|
|
835
|
+
log(_kwargs(phase='prepare', step='submitted_after_poll', task_id=task_id))
|
|
836
|
+
else:
|
|
837
|
+
ledger_entry['state'] = 'failed'
|
|
838
|
+
ledger_entry['failed_at'] = now_iso()
|
|
839
|
+
ledger_entry['failed_reason'] = 'work_not_found_in_manage'
|
|
840
|
+
append_ledger(ledger_entry)
|
|
841
|
+
log(_kwargs(phase='prepare', step='submit_failed', task_id=task_id, reason='work_not_found'))
|
|
842
|
+
break
|
|
843
|
+
|
|
844
|
+
if clf.exists():
|
|
845
|
+
clf.unlink(missing_ok=True)
|
|
846
|
+
log(_kwargs(phase='prepare', step='close_received', task_id=task_id))
|
|
847
|
+
ledger_entry['state'] = 'indeterminate'
|
|
848
|
+
ledger_entry['closed_at'] = now_iso()
|
|
849
|
+
append_ledger(ledger_entry)
|
|
850
|
+
break
|
|
851
|
+
|
|
852
|
+
time.sleep(2)
|
|
853
|
+
|
|
854
|
+
except Exception as e:
|
|
855
|
+
log(_kwargs(phase='prepare', error=str(e)[:300]))
|
|
856
|
+
try:
|
|
857
|
+
append_ledger(_kwargs(
|
|
858
|
+
task_id=task_id, sha256=sha, path=str(path),
|
|
859
|
+
state='failed', timestamp=now_iso(),
|
|
860
|
+
error=str(e)[:200], profile_id=profile_id,
|
|
861
|
+
))
|
|
862
|
+
except Exception:
|
|
863
|
+
pass
|
|
864
|
+
finally:
|
|
865
|
+
try:
|
|
866
|
+
ctx.close()
|
|
867
|
+
except Exception:
|
|
868
|
+
pass
|
|
869
|
+
release_lock(profile_id)
|
|
870
|
+
log(_kwargs(phase='prepare', step='browser_closed', task_id=task_id))
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
# ─── Phase: confirm-submit ───────────────────────────────────────────────────
|
|
874
|
+
|
|
875
|
+
def phase_confirm_submit(profile_id):
|
|
876
|
+
"""Send confirm signal to a running prepare process, or launch browser and click submit."""
|
|
877
|
+
SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
878
|
+
lf = _lock_file(profile_id)
|
|
879
|
+
cf = _confirm_file(profile_id)
|
|
880
|
+
|
|
881
|
+
if lf.exists():
|
|
882
|
+
try:
|
|
883
|
+
lock_data = json.loads(lf.read_text())
|
|
884
|
+
pid = lock_data.get('pid')
|
|
885
|
+
if pid:
|
|
886
|
+
os.kill(pid, 0)
|
|
887
|
+
cf.write_text(json.dumps({
|
|
888
|
+
'action': 'confirm',
|
|
889
|
+
'timestamp': now_iso(),
|
|
890
|
+
}))
|
|
891
|
+
log(_kwargs(phase='confirm-submit', step='signal_sent', target_pid=pid))
|
|
892
|
+
time.sleep(10)
|
|
893
|
+
if not cf.exists():
|
|
894
|
+
log(_kwargs(phase='confirm-submit', step='signal_consumed', status='ok'))
|
|
895
|
+
else:
|
|
896
|
+
log(_kwargs(phase='confirm-submit', step='signal_pending', status='waiting'))
|
|
897
|
+
return
|
|
898
|
+
except (OSError, json.JSONDecodeError):
|
|
899
|
+
log(_kwargs(phase='confirm-submit', step='process_dead', action='launch_new'))
|
|
900
|
+
|
|
901
|
+
log(_kwargs(phase='confirm-submit', step='launch_browser'))
|
|
902
|
+
|
|
903
|
+
try:
|
|
904
|
+
ctx, cfg = launch_browser(profile_id)
|
|
905
|
+
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
906
|
+
except Exception as e:
|
|
907
|
+
die(1, _kwargs(phase='confirm-submit', error='BROWSER_LAUNCH_FAILED', detail=str(e)[:200]))
|
|
908
|
+
|
|
909
|
+
try:
|
|
910
|
+
page.goto(UPLOAD_URL, wait_until='load', timeout=60000)
|
|
911
|
+
time.sleep(5)
|
|
912
|
+
|
|
913
|
+
current_url = page.url
|
|
914
|
+
if 'login' in current_url.lower() or 'passport' in current_url.lower():
|
|
915
|
+
ctx.close()
|
|
916
|
+
die(2, _kwargs(phase='confirm-submit', error='LOGIN_REQUIRED', current_url=current_url[:120]))
|
|
917
|
+
|
|
918
|
+
time.sleep(3)
|
|
919
|
+
inspect = page.evaluate('''() => {
|
|
920
|
+
const buttons = [];
|
|
921
|
+
for (const el of document.querySelectorAll('button, div[role="button"], span')) {
|
|
922
|
+
const text = (el.textContent || '').trim();
|
|
923
|
+
if ((text === '发布' || text.includes('发布') || text === '提交') && el.offsetParent !== null) {
|
|
924
|
+
const rect = el.getBoundingClientRect();
|
|
925
|
+
buttons.push({
|
|
926
|
+
text: text.substring(0, 20),
|
|
927
|
+
tag: el.tagName,
|
|
928
|
+
bounds: {x: Math.round(rect.x), y: Math.round(rect.y),
|
|
929
|
+
w: Math.round(rect.width), h: Math.round(rect.height)},
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return {button_count: buttons.length, buttons: buttons.slice(0, 5)};
|
|
934
|
+
}''')
|
|
935
|
+
log(_kwargs(step='inspect', **inspect))
|
|
936
|
+
|
|
937
|
+
if inspect.get('button_count', 0) == 0:
|
|
938
|
+
log(_kwargs(step='no_publish_button', warning='Upload page may not have a prepared video. Run prepare first.'))
|
|
939
|
+
|
|
940
|
+
clicked = page.evaluate('''() => {
|
|
941
|
+
for (const el of document.querySelectorAll('button, div, span')) {
|
|
942
|
+
const text = (el.textContent || '').trim();
|
|
943
|
+
if ((text === '发布' || text === '确认发布' || text === '提交') &&
|
|
944
|
+
el.offsetParent !== null) {
|
|
945
|
+
el.click();
|
|
946
|
+
return 'clicked:' + text;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return 'not_found';
|
|
950
|
+
}''')
|
|
951
|
+
log(_kwargs(step='publish_click', result=clicked))
|
|
952
|
+
time.sleep(5)
|
|
953
|
+
|
|
954
|
+
url_after = page.url
|
|
955
|
+
log(_kwargs(step='post_submit', url=url_after[:120], title=page.title()))
|
|
956
|
+
|
|
957
|
+
entries = load_ledger()
|
|
958
|
+
if entries:
|
|
959
|
+
latest = entries[-1]
|
|
960
|
+
latest['state'] = 'submitted'
|
|
961
|
+
latest['submitted_at'] = now_iso()
|
|
962
|
+
append_ledger(latest)
|
|
963
|
+
log(_kwargs(step='ledger_updated', task_id=latest.get('task_id')))
|
|
964
|
+
|
|
965
|
+
except Exception as e:
|
|
966
|
+
log(_kwargs(phase='confirm-submit', error=str(e)[:300]))
|
|
967
|
+
finally:
|
|
968
|
+
try:
|
|
969
|
+
ctx.close()
|
|
970
|
+
except Exception:
|
|
971
|
+
pass
|
|
972
|
+
release_lock(profile_id)
|
|
973
|
+
log(_kwargs(phase='confirm-submit', step='browser_closed'))
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
# ─── Phase: status ───────────────────────────────────────────────────────────
|
|
977
|
+
|
|
978
|
+
def phase_status(profile_id, task_id=None):
|
|
979
|
+
"""Query publish status from the ledger."""
|
|
980
|
+
entries = load_ledger()
|
|
981
|
+
|
|
982
|
+
if task_id:
|
|
983
|
+
matching = [e for e in entries if e.get('task_id') == task_id]
|
|
984
|
+
else:
|
|
985
|
+
matching = entries[-10:]
|
|
986
|
+
|
|
987
|
+
if not matching:
|
|
988
|
+
log(_kwargs(phase='status', entries=[], count=0))
|
|
989
|
+
return
|
|
990
|
+
|
|
991
|
+
states = {}
|
|
992
|
+
for e in matching:
|
|
993
|
+
s = e.get('state', 'unknown')
|
|
994
|
+
states[s] = states.get(s, 0) + 1
|
|
995
|
+
|
|
996
|
+
result = _kwargs(
|
|
997
|
+
phase='status',
|
|
998
|
+
total_entries=len(entries),
|
|
999
|
+
shown=len(matching),
|
|
1000
|
+
state_summary=states,
|
|
1001
|
+
entries=matching,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
lf = _lock_file(profile_id)
|
|
1005
|
+
if lf.exists():
|
|
1006
|
+
try:
|
|
1007
|
+
lock_data = json.loads(lf.read_text())
|
|
1008
|
+
pid = lock_data.get('pid')
|
|
1009
|
+
os.kill(pid, 0)
|
|
1010
|
+
result['browser_running'] = True
|
|
1011
|
+
result['browser_pid'] = pid
|
|
1012
|
+
except (OSError, json.JSONDecodeError):
|
|
1013
|
+
result['browser_running'] = False
|
|
1014
|
+
|
|
1015
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1016
|
+
sys.exit(0)
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
# ─── Phase: close ────────────────────────────────────────────────────────────
|
|
1020
|
+
|
|
1021
|
+
def phase_close(profile_id):
|
|
1022
|
+
"""Close the browser and release the lock."""
|
|
1023
|
+
SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
1024
|
+
lf = _lock_file(profile_id)
|
|
1025
|
+
clf = _close_file(profile_id)
|
|
1026
|
+
|
|
1027
|
+
result = _kwargs(phase='close', action='none')
|
|
1028
|
+
|
|
1029
|
+
if lf.exists():
|
|
1030
|
+
try:
|
|
1031
|
+
lock_data = json.loads(lf.read_text())
|
|
1032
|
+
pid = lock_data.get('pid')
|
|
1033
|
+
if pid:
|
|
1034
|
+
try:
|
|
1035
|
+
os.kill(pid, 0)
|
|
1036
|
+
clf.write_text(json.dumps({
|
|
1037
|
+
'action': 'close',
|
|
1038
|
+
'timestamp': now_iso(),
|
|
1039
|
+
}))
|
|
1040
|
+
result['action'] = 'signal_sent'
|
|
1041
|
+
result['pid'] = pid
|
|
1042
|
+
log(result)
|
|
1043
|
+
|
|
1044
|
+
for _ in range(15):
|
|
1045
|
+
time.sleep(2)
|
|
1046
|
+
if not lf.exists():
|
|
1047
|
+
result['lock_released'] = True
|
|
1048
|
+
break
|
|
1049
|
+
try:
|
|
1050
|
+
os.kill(pid, 0)
|
|
1051
|
+
except OSError:
|
|
1052
|
+
result['lock_released'] = True
|
|
1053
|
+
release_lock(profile_id)
|
|
1054
|
+
break
|
|
1055
|
+
|
|
1056
|
+
if not result.get('lock_released'):
|
|
1057
|
+
try:
|
|
1058
|
+
os.kill(pid, signal.SIGTERM)
|
|
1059
|
+
time.sleep(2)
|
|
1060
|
+
except OSError:
|
|
1061
|
+
pass
|
|
1062
|
+
release_lock(profile_id)
|
|
1063
|
+
result['action'] = 'force_killed'
|
|
1064
|
+
result['lock_released'] = True
|
|
1065
|
+
|
|
1066
|
+
log(result)
|
|
1067
|
+
return
|
|
1068
|
+
except OSError:
|
|
1069
|
+
pass
|
|
1070
|
+
except (json.JSONDecodeError, ValueError):
|
|
1071
|
+
pass
|
|
1072
|
+
|
|
1073
|
+
release_lock(profile_id)
|
|
1074
|
+
result['action'] = 'lock_released'
|
|
1075
|
+
result['lock_released'] = True
|
|
1076
|
+
log(result)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
# ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
1080
|
+
|
|
1081
|
+
def main():
|
|
1082
|
+
parser = argparse.ArgumentParser(
|
|
1083
|
+
description='Douyin Creator Center Publish — 五阶段发布流水线',
|
|
1084
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1085
|
+
epilog='''
|
|
1086
|
+
Examples:
|
|
1087
|
+
python3 douyin_publish.py --dry-run --media-path /path/to/video.mp4
|
|
1088
|
+
python3 douyin_publish.py --prepare --media-path /path/to/video.mp4 --scheduled-at 2026-08-04T23:50:00+08:00
|
|
1089
|
+
python3 douyin_publish.py --confirm-submit
|
|
1090
|
+
python3 douyin_publish.py --status
|
|
1091
|
+
python3 douyin_publish.py --status --task-id publish_xxx
|
|
1092
|
+
python3 douyin_publish.py --close
|
|
1093
|
+
''',
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
parser.add_argument('--dry-run', action='store_true', help='Check media without executing')
|
|
1097
|
+
parser.add_argument('--prepare', action='store_true', help='Upload and configure media')
|
|
1098
|
+
parser.add_argument('--confirm-submit', action='store_true', help='Confirm final submission')
|
|
1099
|
+
parser.add_argument('--status', action='store_true', help='Query publish status')
|
|
1100
|
+
parser.add_argument('--close', action='store_true', help='Close browser')
|
|
1101
|
+
parser.add_argument('--media-path', help='Path to media file (required for --dry-run, --prepare)')
|
|
1102
|
+
parser.add_argument('--scheduled-at', help=f'Scheduled publish time (default: {DEFAULT_SCHEDULED_AT})')
|
|
1103
|
+
parser.add_argument('--task-id', default=None, help='Task ID for status query')
|
|
1104
|
+
parser.add_argument('--profile-id', default=DEFAULT_PROFILE_ID, help='Profile ID')
|
|
1105
|
+
parser.add_argument('--title', default=None, help='Video title')
|
|
1106
|
+
parser.add_argument('--description', default=None, help='Video description')
|
|
1107
|
+
args = parser.parse_args()
|
|
1108
|
+
profile_id = args.profile_id
|
|
1109
|
+
|
|
1110
|
+
if args.dry_run:
|
|
1111
|
+
if not args.media_path:
|
|
1112
|
+
die(1, _kwargs(error='--media-path required for --dry-run'))
|
|
1113
|
+
phase_dry_run(profile_id, args.media_path, args.scheduled_at)
|
|
1114
|
+
|
|
1115
|
+
elif args.prepare:
|
|
1116
|
+
if not args.media_path:
|
|
1117
|
+
die(1, _kwargs(error='--media-path required for --prepare'))
|
|
1118
|
+
phase_prepare(profile_id, args.media_path, args.scheduled_at, args.title, args.description)
|
|
1119
|
+
|
|
1120
|
+
elif args.confirm_submit:
|
|
1121
|
+
phase_confirm_submit(profile_id)
|
|
1122
|
+
|
|
1123
|
+
elif args.status:
|
|
1124
|
+
phase_status(profile_id, args.task_id)
|
|
1125
|
+
|
|
1126
|
+
elif args.close:
|
|
1127
|
+
phase_close(profile_id)
|
|
1128
|
+
|
|
1129
|
+
else:
|
|
1130
|
+
parser.print_help()
|
|
1131
|
+
sys.exit(1)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
if __name__ == '__main__':
|
|
1135
|
+
main()
|