@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,925 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
小红书创作服务平台发布脚本 — 五阶段发布流水线。
|
|
4
|
+
|
|
5
|
+
注意:浏览器自动化部分为 IMPLEMENTED_UNVERIFIED,实现依据为
|
|
6
|
+
docs/xiaohongshu-creator-publishing-handoff.md 的实测流程,
|
|
7
|
+
待 Level 2 在线实测后固化;CLI 底座(账本去重、素材校验、门禁、
|
|
8
|
+
退出码)已经 Level 1 离线测试验证。
|
|
9
|
+
|
|
10
|
+
Phases:
|
|
11
|
+
--dry-run 检查素材 SHA-256 去重,不执行实际操作(不开浏览器、不取锁、不写账本)
|
|
12
|
+
--prepare 上传素材、填写并读回标题、写账本后保持浏览器等待确认
|
|
13
|
+
--confirm-submit 确认提交(单次真实点击"发布")
|
|
14
|
+
--status 查询发布状态
|
|
15
|
+
--close 关闭浏览器并释放锁
|
|
16
|
+
|
|
17
|
+
Ledger: tasks/xiaohongshu_publish/publish-ledger.jsonl
|
|
18
|
+
State machine: discovered → prepared → submitted → reviewing → published/rejected/indeterminate/failed
|
|
19
|
+
SHA-256 dedup: submitted / reviewing / published 永久阻止重复提交。
|
|
20
|
+
"""
|
|
21
|
+
from media_agent.runtime.paths import runtime_home
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import signal
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
from datetime import datetime, timedelta, timezone
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ─── Paths ───────────────────────────────────────────────────────────────────
|
|
35
|
+
BASE = runtime_home()
|
|
36
|
+
LOCKS_DIR = BASE / 'locks'
|
|
37
|
+
LEDGER_DIR = BASE / 'tasks' / 'xiaohongshu_publish'
|
|
38
|
+
LEDGER_FILE = LEDGER_DIR / 'publish-ledger.jsonl'
|
|
39
|
+
PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/video-publish'
|
|
40
|
+
NOTE_MANAGER_URL = 'https://creator.xiaohongshu.com/new/note-manager'
|
|
41
|
+
SIGNAL_DIR = BASE / 'signals'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _profile_dir(profile_id):
|
|
45
|
+
return BASE / 'profiles' / profile_id
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _browser_data(profile_id):
|
|
49
|
+
return str(_profile_dir(profile_id) / 'browser_data')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _lock_file(profile_id):
|
|
53
|
+
return LOCKS_DIR / f'{profile_id}.lock'
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _confirm_file(profile_id):
|
|
57
|
+
return SIGNAL_DIR / f'{profile_id}.confirm'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _close_file(profile_id):
|
|
61
|
+
return SIGNAL_DIR / f'{profile_id}.close'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ─── Constants ───────────────────────────────────────────────────────────────
|
|
65
|
+
DEDUP_STATES = {'submitted', 'reviewing', 'published'}
|
|
66
|
+
SUPPORTED_VIDEO_FORMATS = {'.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv', '.webm', '.m4v', '.3gp'}
|
|
67
|
+
TZ_SHANGHAI = timezone(timedelta(hours=8))
|
|
68
|
+
NOTE_POLL_INTERVAL = 18 # 交接文档要求 15–20 秒间隔只读检查
|
|
69
|
+
NOTE_POLL_TIMEOUT = 300 # 最长 5 分钟
|
|
70
|
+
SUCCESS_PAGE_WAIT = 180 # 点击后等待同域跳转最长 180 秒
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def sha256_file(path):
|
|
76
|
+
"""Compute SHA-256 digest of a file."""
|
|
77
|
+
h = hashlib.sha256()
|
|
78
|
+
with open(path, 'rb') as f:
|
|
79
|
+
while True:
|
|
80
|
+
chunk = f.read(65536)
|
|
81
|
+
if not chunk:
|
|
82
|
+
break
|
|
83
|
+
h.update(chunk)
|
|
84
|
+
return h.hexdigest()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_ledger():
|
|
88
|
+
"""Load all ledger entries as a list of dicts."""
|
|
89
|
+
LEDGER_DIR.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
if not LEDGER_FILE.exists():
|
|
91
|
+
return []
|
|
92
|
+
entries = []
|
|
93
|
+
for line in LEDGER_FILE.read_text().splitlines():
|
|
94
|
+
line = line.strip()
|
|
95
|
+
if line:
|
|
96
|
+
try:
|
|
97
|
+
entries.append(json.loads(line))
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
pass
|
|
100
|
+
return entries
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def find_ledger_entry(sha256):
|
|
104
|
+
"""Return the most recent ledger entry matching a SHA-256 (or None)."""
|
|
105
|
+
for entry in reversed(load_ledger()):
|
|
106
|
+
if entry.get('sha256') == sha256:
|
|
107
|
+
return entry
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check_duplicate(sha256):
|
|
112
|
+
"""Return (is_blocked, existing_entry) — True if SHA-256 is in a terminal dedup state."""
|
|
113
|
+
entry = find_ledger_entry(sha256)
|
|
114
|
+
if entry and entry.get('state') in DEDUP_STATES:
|
|
115
|
+
return True, entry
|
|
116
|
+
return False, entry
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def append_ledger(entry):
|
|
120
|
+
"""Append a JSON line to the ledger."""
|
|
121
|
+
LEDGER_DIR.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
with open(LEDGER_FILE, 'a') as f:
|
|
123
|
+
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def acquire_lock(profile_id, task_id):
|
|
127
|
+
"""Acquire the profile lock. Returns (ok, existing_owner)."""
|
|
128
|
+
lf = _lock_file(profile_id)
|
|
129
|
+
LOCKS_DIR.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
if lf.exists():
|
|
131
|
+
try:
|
|
132
|
+
old = json.loads(lf.read_text())
|
|
133
|
+
os.kill(old.get('pid', 0), 0)
|
|
134
|
+
return False, old
|
|
135
|
+
except (OSError, json.JSONDecodeError):
|
|
136
|
+
lf.unlink()
|
|
137
|
+
lf.write_text(json.dumps({
|
|
138
|
+
'task_id': task_id,
|
|
139
|
+
'pid': os.getpid(),
|
|
140
|
+
'time': datetime.now().isoformat(),
|
|
141
|
+
'phase': 'publish',
|
|
142
|
+
}, indent=2))
|
|
143
|
+
return True, None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def release_lock(profile_id):
|
|
147
|
+
"""Release the profile lock."""
|
|
148
|
+
lf = _lock_file(profile_id)
|
|
149
|
+
if lf.exists():
|
|
150
|
+
lf.unlink(missing_ok=True)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def now_iso():
|
|
154
|
+
"""Return current time in Asia/Shanghai ISO format."""
|
|
155
|
+
return datetime.now(TZ_SHANGHAI).isoformat()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def log(msg):
|
|
159
|
+
"""Print a structured log line to stdout."""
|
|
160
|
+
print(json.dumps({'ts': now_iso(), **msg}, ensure_ascii=False), flush=True)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def die(code, msg):
|
|
164
|
+
"""Print error and exit."""
|
|
165
|
+
log({'level': 'ERROR', 'exit_code': code, **msg})
|
|
166
|
+
sys.exit(code)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _kwargs(**kw):
|
|
170
|
+
"""Helper to return a dict of kwargs — for readability."""
|
|
171
|
+
return kw
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _validate_media(media_path):
|
|
175
|
+
"""Validate media file existence and format. Returns resolved Path."""
|
|
176
|
+
path = Path(media_path).resolve()
|
|
177
|
+
if not path.exists():
|
|
178
|
+
die(1, _kwargs(error='FILE_NOT_FOUND', path=str(path)))
|
|
179
|
+
if not path.is_file():
|
|
180
|
+
die(1, _kwargs(error='NOT_A_FILE', path=str(path)))
|
|
181
|
+
ext = path.suffix.lower()
|
|
182
|
+
if ext not in SUPPORTED_VIDEO_FORMATS:
|
|
183
|
+
die(1, _kwargs(
|
|
184
|
+
error='UNSUPPORTED_FORMAT',
|
|
185
|
+
format=ext, supported=sorted(SUPPORTED_VIDEO_FORMATS),
|
|
186
|
+
))
|
|
187
|
+
return path
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ─── Browser ─────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def launch_browser(profile_id):
|
|
193
|
+
"""Launch a CloakBrowser persistent context for the profile."""
|
|
194
|
+
from media_agent.runtime.browser import launch_persistent_context
|
|
195
|
+
|
|
196
|
+
pdir = _profile_dir(profile_id)
|
|
197
|
+
cfg = json.loads((pdir / 'config.json').read_text())
|
|
198
|
+
fp = cfg.get('fingerprint', {})
|
|
199
|
+
|
|
200
|
+
ctx = launch_persistent_context(
|
|
201
|
+
user_data_dir=_browser_data(profile_id),
|
|
202
|
+
headless=False,
|
|
203
|
+
stealth_args=True,
|
|
204
|
+
viewport=fp.get('viewport', {'width': 1440, 'height': 900}),
|
|
205
|
+
locale=fp.get('locale', 'zh-CN'),
|
|
206
|
+
timezone=fp.get('timezone', 'Asia/Shanghai'),
|
|
207
|
+
humanize=False,
|
|
208
|
+
geoip=False,
|
|
209
|
+
)
|
|
210
|
+
return ctx, cfg
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _is_login_page(url):
|
|
214
|
+
"""Detect login/passport redirect."""
|
|
215
|
+
low = url.lower()
|
|
216
|
+
return 'login' in low or 'passport' in low
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ─── Phase: dry-run ──────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
def phase_dry_run(profile_id, media_path):
|
|
222
|
+
"""Check media file validity and SHA-256 dedup status.
|
|
223
|
+
|
|
224
|
+
纯离线检查:不开浏览器、不取锁、不写账本。
|
|
225
|
+
"""
|
|
226
|
+
path = _validate_media(media_path)
|
|
227
|
+
file_size = path.stat().st_size
|
|
228
|
+
sha = sha256_file(str(path))
|
|
229
|
+
blocked, existing = check_duplicate(sha)
|
|
230
|
+
|
|
231
|
+
result = _kwargs(
|
|
232
|
+
phase='dry-run',
|
|
233
|
+
media_path=str(path),
|
|
234
|
+
file_size=file_size,
|
|
235
|
+
file_size_mb=round(file_size / 1024 / 1024, 2),
|
|
236
|
+
sha256=sha,
|
|
237
|
+
format=path.suffix.lower(),
|
|
238
|
+
duplicate_blocked=blocked,
|
|
239
|
+
profile_id=profile_id,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if blocked:
|
|
243
|
+
result['existing_state'] = existing.get('state')
|
|
244
|
+
result['existing_task_id'] = existing.get('task_id')
|
|
245
|
+
result['existing_timestamp'] = existing.get('timestamp')
|
|
246
|
+
log(result)
|
|
247
|
+
sys.exit(8) # DUPLICATE_BLOCKED
|
|
248
|
+
|
|
249
|
+
log(result)
|
|
250
|
+
return sha, str(path)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ─── Prepare helpers ─────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
def _wait_for_upload_complete(page, timeout=300):
|
|
256
|
+
"""Wait for the video upload/processing to complete."""
|
|
257
|
+
start = time.time()
|
|
258
|
+
while time.time() - start < timeout:
|
|
259
|
+
status = page.evaluate('''() => {
|
|
260
|
+
const body = document.body.textContent || '';
|
|
261
|
+
const processing = body.includes('上传中') || body.includes('视频上传中') || body.includes('处理中');
|
|
262
|
+
// 完成证据:发布按钮出现/启用、视频预览或智能封面
|
|
263
|
+
let publishReady = false;
|
|
264
|
+
for (const el of document.querySelectorAll('button, div, span')) {
|
|
265
|
+
const text = (el.textContent || '').trim();
|
|
266
|
+
if (text === '发布' && el.offsetParent !== null) { publishReady = true; break; }
|
|
267
|
+
}
|
|
268
|
+
const preview = document.querySelector('video');
|
|
269
|
+
if (publishReady && !processing) { return 'done'; }
|
|
270
|
+
if (preview && !processing) { return 'done'; }
|
|
271
|
+
if (processing) { return 'processing'; }
|
|
272
|
+
return 'waiting';
|
|
273
|
+
}''')
|
|
274
|
+
if status == 'done':
|
|
275
|
+
return True
|
|
276
|
+
if status == 'processing':
|
|
277
|
+
log(_kwargs(step='upload_progress', status='processing', elapsed=round(time.time() - start)))
|
|
278
|
+
time.sleep(3)
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _dismiss_popups(page):
|
|
283
|
+
"""位置权限选"一律不允许";"要恢复页面吗"提示关闭而非恢复。"""
|
|
284
|
+
time.sleep(2)
|
|
285
|
+
dismissed = page.evaluate('''() => {
|
|
286
|
+
const actions = [];
|
|
287
|
+
const denyKeywords = ['一律不允许', '不允许', '禁止', '拒绝', "Don't Allow", 'Block', 'Deny'];
|
|
288
|
+
for (const kw of denyKeywords) {
|
|
289
|
+
for (const el of document.querySelectorAll('button, span, div, a')) {
|
|
290
|
+
const text = (el.textContent || '').trim();
|
|
291
|
+
if ((text === kw || text.includes(kw)) && el.offsetParent !== null) {
|
|
292
|
+
el.click();
|
|
293
|
+
actions.push('denied:' + kw);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (actions.length) break;
|
|
298
|
+
}
|
|
299
|
+
// "要恢复页面吗?" → 点关闭图标,绝不点"恢复"
|
|
300
|
+
for (const el of document.querySelectorAll('[class*="close"], [aria-label="关闭"], [class*="Close"]')) {
|
|
301
|
+
const rect = el.getBoundingClientRect();
|
|
302
|
+
if (el.offsetParent !== null && rect.width > 0 && rect.width < 60) {
|
|
303
|
+
const nearby = (el.closest('div')?.textContent || '');
|
|
304
|
+
if (nearby.includes('恢复页面')) {
|
|
305
|
+
el.click();
|
|
306
|
+
actions.push('restore_prompt_closed');
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return actions;
|
|
312
|
+
}''')
|
|
313
|
+
log(_kwargs(step='dismiss_popups', result=dismissed))
|
|
314
|
+
time.sleep(1)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _fill_title(page, title):
|
|
318
|
+
"""Fill the note title input."""
|
|
319
|
+
r = page.evaluate('''(t) => {
|
|
320
|
+
const selectors = [
|
|
321
|
+
'input[placeholder*="填写标题"]',
|
|
322
|
+
'input[placeholder*="标题"]',
|
|
323
|
+
'#publishInput',
|
|
324
|
+
'[class*="title"] input',
|
|
325
|
+
'[class*="c-input_titleInput"] input',
|
|
326
|
+
];
|
|
327
|
+
for (const sel of selectors) {
|
|
328
|
+
for (const inp of document.querySelectorAll(sel)) {
|
|
329
|
+
if (inp.offsetParent !== null) {
|
|
330
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set ||
|
|
331
|
+
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
|
332
|
+
setter.call(inp, t);
|
|
333
|
+
inp.dispatchEvent(new Event('input', { bubbles: true }));
|
|
334
|
+
inp.dispatchEvent(new Event('change', { bubbles: true }));
|
|
335
|
+
return 'title_set';
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return 'no_title_input';
|
|
340
|
+
}''', title)
|
|
341
|
+
log(_kwargs(step='fill_title', result=r))
|
|
342
|
+
return r == 'title_set'
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _readback_title(page):
|
|
346
|
+
"""Read back the title value from the page."""
|
|
347
|
+
value = page.evaluate('''() => {
|
|
348
|
+
for (const inp of document.querySelectorAll('input, textarea')) {
|
|
349
|
+
if (inp.offsetParent !== null && inp.value && inp.value.trim()) {
|
|
350
|
+
const ph = inp.placeholder || '';
|
|
351
|
+
if (ph.includes('标题')) { return inp.value; }
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const inp of document.querySelectorAll('input, textarea')) {
|
|
355
|
+
if (inp.offsetParent !== null && inp.value && inp.value.trim()) {
|
|
356
|
+
return inp.value;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return '';
|
|
360
|
+
}''')
|
|
361
|
+
log(_kwargs(step='title_readback', value=(value or '')[:60]))
|
|
362
|
+
return value or ''
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _inspect_publish_button(page):
|
|
366
|
+
"""发布按钮必须唯一、可见且启用。"""
|
|
367
|
+
info = page.evaluate('''() => {
|
|
368
|
+
const buttons = [];
|
|
369
|
+
for (const el of document.querySelectorAll('button, [role="button"]')) {
|
|
370
|
+
const text = (el.textContent || '').trim();
|
|
371
|
+
if (text === '发布' && el.offsetParent !== null) {
|
|
372
|
+
buttons.push({
|
|
373
|
+
tag: el.tagName,
|
|
374
|
+
disabled: el.disabled === true || el.getAttribute('aria-disabled') === 'true',
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return {count: buttons.length, buttons: buttons};
|
|
379
|
+
}''')
|
|
380
|
+
log(_kwargs(step='publish_button_inspect', **info))
|
|
381
|
+
ready = info.get('count') == 1 and not any(b.get('disabled') for b in info.get('buttons', []))
|
|
382
|
+
return ready
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _verify_note_in_manager(page, title):
|
|
386
|
+
"""在"笔记管理 → 全部"按标题匹配目标卡片并读取状态。
|
|
387
|
+
|
|
388
|
+
返回 dict:{note_found, note_status, evidence}。
|
|
389
|
+
note_status ∈ reviewing / published / rejected / unknown。
|
|
390
|
+
"""
|
|
391
|
+
info = page.evaluate('''(targetTitle) => {
|
|
392
|
+
const cards = document.querySelectorAll('[class*="note"], [class*="card"], li, tr');
|
|
393
|
+
for (const card of cards) {
|
|
394
|
+
const text = (card.textContent || '');
|
|
395
|
+
if (!text.includes(targetTitle)) { continue; }
|
|
396
|
+
const status = text.includes('未通过') ? 'rejected'
|
|
397
|
+
: text.includes('审核中') ? 'reviewing'
|
|
398
|
+
: text.includes('已发布') ? 'published'
|
|
399
|
+
: 'unknown';
|
|
400
|
+
return {
|
|
401
|
+
note_found: true,
|
|
402
|
+
note_status: status,
|
|
403
|
+
evidence: text.replace(/\\s+/g, ' ').trim().substring(0, 120),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
return {note_found: false, note_status: 'unknown', evidence: ''};
|
|
407
|
+
}''', title)
|
|
408
|
+
return info
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ─── Phase: prepare ──────────────────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
def phase_prepare(profile_id, media_path, title):
|
|
414
|
+
"""Upload media, fill title, write ledger, keep browser awaiting confirm."""
|
|
415
|
+
path = _validate_media(media_path)
|
|
416
|
+
sha = sha256_file(str(path))
|
|
417
|
+
|
|
418
|
+
blocked, existing = check_duplicate(sha)
|
|
419
|
+
if blocked:
|
|
420
|
+
die(8, _kwargs(
|
|
421
|
+
phase='prepare', error='DUPLICATE_BLOCKED', sha256=sha,
|
|
422
|
+
existing_state=existing.get('state'),
|
|
423
|
+
existing_task_id=existing.get('task_id'),
|
|
424
|
+
))
|
|
425
|
+
|
|
426
|
+
task_id = f'xhs_publish_{profile_id}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
|
|
427
|
+
|
|
428
|
+
ok, owner = acquire_lock(profile_id, task_id)
|
|
429
|
+
if not ok:
|
|
430
|
+
die(3, _kwargs(phase='prepare', error='PROFILE_LOCKED', owner=owner))
|
|
431
|
+
|
|
432
|
+
log(_kwargs(phase='prepare', step='launch_browser', task_id=task_id))
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
ctx, cfg = launch_browser(profile_id)
|
|
436
|
+
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
437
|
+
except Exception as e:
|
|
438
|
+
release_lock(profile_id)
|
|
439
|
+
die(1, _kwargs(phase='prepare', error='BROWSER_LAUNCH_FAILED', detail=str(e)[:200]))
|
|
440
|
+
|
|
441
|
+
ledger_entry = None
|
|
442
|
+
try:
|
|
443
|
+
# ── 直接进入视频发布页(不点击"发布视频笔记"入口,避免触发原生文件选择器)──
|
|
444
|
+
log(_kwargs(step='navigate', url=PUBLISH_URL))
|
|
445
|
+
page.goto(PUBLISH_URL, wait_until='load', timeout=60000)
|
|
446
|
+
time.sleep(5)
|
|
447
|
+
|
|
448
|
+
current_url = page.url
|
|
449
|
+
if _is_login_page(current_url):
|
|
450
|
+
ctx.close()
|
|
451
|
+
release_lock(profile_id)
|
|
452
|
+
die(2, _kwargs(phase='prepare', error='LOGIN_REQUIRED', current_url=current_url[:120]))
|
|
453
|
+
|
|
454
|
+
log(_kwargs(step='landed', url=current_url[:120], title=page.title()))
|
|
455
|
+
|
|
456
|
+
# ── 上传:直接对 file input 设置路径,绕开原生文件选择器 ──
|
|
457
|
+
log(_kwargs(step='upload', path=str(path)))
|
|
458
|
+
try:
|
|
459
|
+
file_input = page.locator('input[type="file"]').first
|
|
460
|
+
file_input.set_input_files(str(path))
|
|
461
|
+
log(_kwargs(step='file_set', result='ok', native_chooser_bypassed=True))
|
|
462
|
+
except Exception as e:
|
|
463
|
+
log(_kwargs(step='file_set', result='locator_failed', error=str(e)[:100]))
|
|
464
|
+
page.evaluate('''() => {
|
|
465
|
+
const inputs = document.querySelectorAll('input[type="file"]');
|
|
466
|
+
for (const inp of inputs) {
|
|
467
|
+
inp.style.display = 'block';
|
|
468
|
+
inp.style.visibility = 'visible';
|
|
469
|
+
inp.style.position = 'static';
|
|
470
|
+
}
|
|
471
|
+
return inputs.length;
|
|
472
|
+
}''')
|
|
473
|
+
try:
|
|
474
|
+
file_input = page.locator('input[type="file"]').first
|
|
475
|
+
file_input.set_input_files(str(path))
|
|
476
|
+
log(_kwargs(step='file_set', result='ok_retry', native_chooser_bypassed=True))
|
|
477
|
+
except Exception as e2:
|
|
478
|
+
ctx.close()
|
|
479
|
+
release_lock(profile_id)
|
|
480
|
+
die(1, _kwargs(phase='prepare', error='UPLOAD_FAILED', detail=str(e2)[:200]))
|
|
481
|
+
|
|
482
|
+
time.sleep(3)
|
|
483
|
+
|
|
484
|
+
# ── 处理位置权限等弹窗 ──
|
|
485
|
+
_dismiss_popups(page)
|
|
486
|
+
|
|
487
|
+
# ── 等待上传与平台处理完成 ──
|
|
488
|
+
if not _wait_for_upload_complete(page):
|
|
489
|
+
log(_kwargs(step='upload_wait', warning='timeout — proceeding anyway'))
|
|
490
|
+
|
|
491
|
+
# ── 填写标题并读回校验 ──
|
|
492
|
+
filled = _fill_title(page, title)
|
|
493
|
+
time.sleep(1)
|
|
494
|
+
readback = _readback_title(page)
|
|
495
|
+
title_ok = filled and readback.strip() == title.strip()
|
|
496
|
+
if not title_ok:
|
|
497
|
+
log(_kwargs(step='title_readback_mismatch', expected=title[:40], actual=readback[:40]))
|
|
498
|
+
|
|
499
|
+
# ── Write ledger entry ──
|
|
500
|
+
ledger_entry = _kwargs(
|
|
501
|
+
task_id=task_id,
|
|
502
|
+
sha256=sha,
|
|
503
|
+
path=str(path),
|
|
504
|
+
state='prepared',
|
|
505
|
+
timestamp=now_iso(),
|
|
506
|
+
profile_id=profile_id,
|
|
507
|
+
title=title,
|
|
508
|
+
title_readback=readback,
|
|
509
|
+
title_ok=title_ok,
|
|
510
|
+
file_size=path.stat().st_size,
|
|
511
|
+
click_count=0,
|
|
512
|
+
pid=os.getpid(),
|
|
513
|
+
)
|
|
514
|
+
append_ledger(ledger_entry)
|
|
515
|
+
log(_kwargs(phase='prepare', step='ledger_written', state='prepared', task_id=task_id))
|
|
516
|
+
|
|
517
|
+
# ── Wait for confirm or close signal ──
|
|
518
|
+
SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
519
|
+
cf = _confirm_file(profile_id)
|
|
520
|
+
clf = _close_file(profile_id)
|
|
521
|
+
for f in [cf, clf]:
|
|
522
|
+
if f.exists():
|
|
523
|
+
f.unlink(missing_ok=True)
|
|
524
|
+
|
|
525
|
+
log(_kwargs(
|
|
526
|
+
phase='prepare', step='awaiting_confirm', task_id=task_id,
|
|
527
|
+
hint='Run xhs-publish-confirm to submit, or xhs-publish-close to abort',
|
|
528
|
+
))
|
|
529
|
+
|
|
530
|
+
while True:
|
|
531
|
+
if cf.exists():
|
|
532
|
+
cf.unlink(missing_ok=True)
|
|
533
|
+
log(_kwargs(phase='prepare', step='confirm_received'))
|
|
534
|
+
|
|
535
|
+
# 门禁:按钮必须唯一、可见且启用,否则不点击继续等待
|
|
536
|
+
if not _inspect_publish_button(page):
|
|
537
|
+
log(_kwargs(step='publish_button_not_ready', warning='waiting, no click performed'))
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
# 单次真实点击"发布"
|
|
541
|
+
clicked = page.evaluate('''() => {
|
|
542
|
+
for (const el of document.querySelectorAll('button, [role="button"], div, span')) {
|
|
543
|
+
const text = (el.textContent || '').trim();
|
|
544
|
+
if (text === '发布' && el.offsetParent !== null) {
|
|
545
|
+
el.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true}));
|
|
546
|
+
el.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true}));
|
|
547
|
+
el.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
|
|
548
|
+
return 'clicked:' + el.tagName + ':' + text;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return 'not_found';
|
|
552
|
+
}''')
|
|
553
|
+
ledger_entry['click_count'] = 1
|
|
554
|
+
log(_kwargs(step='publish_click', result=clicked, click_count=1))
|
|
555
|
+
time.sleep(5)
|
|
556
|
+
|
|
557
|
+
# ── 等待发布成功页(最长 180 秒)──
|
|
558
|
+
success_seen = False
|
|
559
|
+
start_wait = time.time()
|
|
560
|
+
while time.time() - start_wait < SUCCESS_PAGE_WAIT:
|
|
561
|
+
current_url = page.url
|
|
562
|
+
page_text = page.evaluate('() => document.body.innerText.substring(0,500)')
|
|
563
|
+
if '/publish/success' in current_url or '发布成功' in page_text:
|
|
564
|
+
success_seen = True
|
|
565
|
+
log(_kwargs(step='success_page', url=current_url[:120]))
|
|
566
|
+
break
|
|
567
|
+
if '失败' in page_text or '错误' in page_text:
|
|
568
|
+
log(_kwargs(step='platform_error_hint', text=page_text[:200]))
|
|
569
|
+
time.sleep(5)
|
|
570
|
+
|
|
571
|
+
if not success_seen:
|
|
572
|
+
# 成功页证据不足 → indeterminate,保持浏览器,禁止重发
|
|
573
|
+
ledger_entry['state'] = 'indeterminate'
|
|
574
|
+
ledger_entry['indeterminate_at'] = now_iso()
|
|
575
|
+
ledger_entry['indeterminate_reason'] = 'no_success_page_evidence'
|
|
576
|
+
ledger_entry['result'] = 'PUBLISH_INDETERMINATE'
|
|
577
|
+
append_ledger(ledger_entry)
|
|
578
|
+
log(_kwargs(phase='prepare', step='indeterminate', task_id=task_id))
|
|
579
|
+
continue # Stay in loop waiting for close signal
|
|
580
|
+
|
|
581
|
+
# 成功页只证明平台接收提交 → SUBMITTED / VERIFYING_NOTE,不得直接判 PUBLISHED
|
|
582
|
+
ledger_entry['state'] = 'submitted'
|
|
583
|
+
ledger_entry['submitted_at'] = now_iso()
|
|
584
|
+
ledger_entry['success_page_seen'] = True
|
|
585
|
+
append_ledger(ledger_entry)
|
|
586
|
+
log(_kwargs(phase='prepare', step='VERIFYING_NOTE', task_id=task_id,
|
|
587
|
+
note='success page only proves receipt, verifying in note manager'))
|
|
588
|
+
|
|
589
|
+
# 等约 10 秒让作品列表同步
|
|
590
|
+
time.sleep(10)
|
|
591
|
+
|
|
592
|
+
# ── VERIFYING_NOTE: 进入 note-manager → 全部 核验 ──
|
|
593
|
+
page.goto(NOTE_MANAGER_URL, wait_until='load', timeout=60000)
|
|
594
|
+
time.sleep(5)
|
|
595
|
+
page.evaluate('''() => {
|
|
596
|
+
for (const el of document.querySelectorAll('div, span, a, li')) {
|
|
597
|
+
const text = (el.textContent || '').trim();
|
|
598
|
+
if (text === '全部' && el.offsetParent !== null) { el.click(); return 'clicked'; }
|
|
599
|
+
}
|
|
600
|
+
return 'not_found';
|
|
601
|
+
}''')
|
|
602
|
+
time.sleep(3)
|
|
603
|
+
log(_kwargs(step='note_manager_landed', url=page.url[:120], section='笔记管理/全部'))
|
|
604
|
+
|
|
605
|
+
verify = _verify_note_in_manager(page, title)
|
|
606
|
+
log(_kwargs(step='note_verify', **verify))
|
|
607
|
+
|
|
608
|
+
if not verify.get('note_found'):
|
|
609
|
+
# 轮询最长 5 分钟(15–20 秒间隔只读检查)
|
|
610
|
+
start_poll = time.time()
|
|
611
|
+
while time.time() - start_poll < NOTE_POLL_TIMEOUT:
|
|
612
|
+
time.sleep(NOTE_POLL_INTERVAL)
|
|
613
|
+
page.reload(timeout=15000)
|
|
614
|
+
time.sleep(3)
|
|
615
|
+
page.evaluate('''() => {
|
|
616
|
+
for (const el of document.querySelectorAll('div, span, a, li')) {
|
|
617
|
+
const text = (el.textContent || '').trim();
|
|
618
|
+
if (text === '全部' && el.offsetParent !== null) { el.click(); return 'clicked'; }
|
|
619
|
+
}
|
|
620
|
+
return 'not_found';
|
|
621
|
+
}''')
|
|
622
|
+
time.sleep(2)
|
|
623
|
+
verify = _verify_note_in_manager(page, title)
|
|
624
|
+
log(_kwargs(step='note_poll', **verify, elapsed=round(time.time() - start_poll)))
|
|
625
|
+
if verify.get('note_found'):
|
|
626
|
+
break
|
|
627
|
+
|
|
628
|
+
if verify.get('note_found'):
|
|
629
|
+
note_status = verify.get('note_status')
|
|
630
|
+
ledger_entry['note_found'] = True
|
|
631
|
+
ledger_entry['section'] = '笔记管理/全部'
|
|
632
|
+
ledger_entry['status_evidence'] = verify.get('evidence')
|
|
633
|
+
if note_status in ('published', 'rejected', 'reviewing'):
|
|
634
|
+
ledger_entry['state'] = note_status
|
|
635
|
+
append_ledger(ledger_entry)
|
|
636
|
+
log(_kwargs(phase='prepare', step='final_state', state=note_status, task_id=task_id))
|
|
637
|
+
else:
|
|
638
|
+
# 卡片已落地但状态标签无法识别 → submitted(已接收)
|
|
639
|
+
append_ledger(ledger_entry)
|
|
640
|
+
log(_kwargs(phase='prepare', step='final_state', state='submitted',
|
|
641
|
+
note='card_found_status_unknown', task_id=task_id))
|
|
642
|
+
else:
|
|
643
|
+
# 5 分钟仍未找到 → PUBLISH_INDETERMINATE,保持浏览器,禁止重发
|
|
644
|
+
ledger_entry['state'] = 'indeterminate'
|
|
645
|
+
ledger_entry['indeterminate_at'] = now_iso()
|
|
646
|
+
ledger_entry['indeterminate_reason'] = 'note_not_found_after_polling'
|
|
647
|
+
ledger_entry['result'] = 'PUBLISH_INDETERMINATE'
|
|
648
|
+
ledger_entry['note_found'] = False
|
|
649
|
+
append_ledger(ledger_entry)
|
|
650
|
+
log(_kwargs(phase='prepare', step='indeterminate', task_id=task_id,
|
|
651
|
+
reason='note_not_found', no_resubmit=True))
|
|
652
|
+
continue # Keep browser for manual inspection
|
|
653
|
+
break
|
|
654
|
+
|
|
655
|
+
if clf.exists():
|
|
656
|
+
clf.unlink(missing_ok=True)
|
|
657
|
+
log(_kwargs(phase='prepare', step='close_received', task_id=task_id))
|
|
658
|
+
ledger_entry['state'] = 'indeterminate'
|
|
659
|
+
ledger_entry['closed_at'] = now_iso()
|
|
660
|
+
append_ledger(ledger_entry)
|
|
661
|
+
break
|
|
662
|
+
|
|
663
|
+
time.sleep(2)
|
|
664
|
+
|
|
665
|
+
except Exception as e:
|
|
666
|
+
log(_kwargs(phase='prepare', error=str(e)[:300]))
|
|
667
|
+
try:
|
|
668
|
+
append_ledger(_kwargs(
|
|
669
|
+
task_id=task_id, sha256=sha, path=str(path),
|
|
670
|
+
state='failed', timestamp=now_iso(),
|
|
671
|
+
error=str(e)[:200], profile_id=profile_id,
|
|
672
|
+
))
|
|
673
|
+
except Exception:
|
|
674
|
+
pass
|
|
675
|
+
finally:
|
|
676
|
+
try:
|
|
677
|
+
ctx.close()
|
|
678
|
+
except Exception:
|
|
679
|
+
pass
|
|
680
|
+
release_lock(profile_id)
|
|
681
|
+
log(_kwargs(phase='prepare', step='browser_closed', task_id=task_id))
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
# ─── Phase: confirm-submit ───────────────────────────────────────────────────
|
|
685
|
+
|
|
686
|
+
def phase_confirm_submit(profile_id):
|
|
687
|
+
"""Send confirm signal to a running prepare process, or launch browser and click submit once."""
|
|
688
|
+
SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
689
|
+
lf = _lock_file(profile_id)
|
|
690
|
+
cf = _confirm_file(profile_id)
|
|
691
|
+
|
|
692
|
+
if lf.exists():
|
|
693
|
+
try:
|
|
694
|
+
lock_data = json.loads(lf.read_text())
|
|
695
|
+
pid = lock_data.get('pid')
|
|
696
|
+
if pid:
|
|
697
|
+
os.kill(pid, 0)
|
|
698
|
+
cf.write_text(json.dumps({
|
|
699
|
+
'action': 'confirm',
|
|
700
|
+
'timestamp': now_iso(),
|
|
701
|
+
}))
|
|
702
|
+
log(_kwargs(phase='confirm-submit', step='signal_sent', target_pid=pid))
|
|
703
|
+
time.sleep(10)
|
|
704
|
+
if not cf.exists():
|
|
705
|
+
log(_kwargs(phase='confirm-submit', step='signal_consumed', status='ok'))
|
|
706
|
+
else:
|
|
707
|
+
log(_kwargs(phase='confirm-submit', step='signal_pending', status='waiting'))
|
|
708
|
+
return
|
|
709
|
+
except (OSError, json.JSONDecodeError):
|
|
710
|
+
log(_kwargs(phase='confirm-submit', step='process_dead', action='launch_new'))
|
|
711
|
+
|
|
712
|
+
log(_kwargs(phase='confirm-submit', step='launch_browser'))
|
|
713
|
+
|
|
714
|
+
try:
|
|
715
|
+
ctx, cfg = launch_browser(profile_id)
|
|
716
|
+
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
|
717
|
+
except Exception as e:
|
|
718
|
+
die(1, _kwargs(phase='confirm-submit', error='BROWSER_LAUNCH_FAILED', detail=str(e)[:200]))
|
|
719
|
+
|
|
720
|
+
try:
|
|
721
|
+
page.goto(PUBLISH_URL, wait_until='load', timeout=60000)
|
|
722
|
+
time.sleep(5)
|
|
723
|
+
|
|
724
|
+
current_url = page.url
|
|
725
|
+
if _is_login_page(current_url):
|
|
726
|
+
ctx.close()
|
|
727
|
+
die(2, _kwargs(phase='confirm-submit', error='LOGIN_REQUIRED', current_url=current_url[:120]))
|
|
728
|
+
|
|
729
|
+
if not _inspect_publish_button(page):
|
|
730
|
+
log(_kwargs(step='no_publish_button',
|
|
731
|
+
warning='Upload page may not have a prepared video. Run xhs-publish-prepare first.'))
|
|
732
|
+
|
|
733
|
+
clicked = page.evaluate('''() => {
|
|
734
|
+
for (const el of document.querySelectorAll('button, [role="button"], div, span')) {
|
|
735
|
+
const text = (el.textContent || '').trim();
|
|
736
|
+
if (text === '发布' && el.offsetParent !== null) {
|
|
737
|
+
el.click();
|
|
738
|
+
return 'clicked:' + text;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return 'not_found';
|
|
742
|
+
}''')
|
|
743
|
+
log(_kwargs(step='confirm_fallback_click', result=clicked, click_count=1 if clicked != 'not_found' else 0))
|
|
744
|
+
time.sleep(5)
|
|
745
|
+
log(_kwargs(step='post_submit', url=page.url[:120], title=page.title()))
|
|
746
|
+
|
|
747
|
+
entries = load_ledger()
|
|
748
|
+
if entries:
|
|
749
|
+
latest = entries[-1]
|
|
750
|
+
latest['state'] = 'submitted'
|
|
751
|
+
latest['submitted_at'] = now_iso()
|
|
752
|
+
append_ledger(latest)
|
|
753
|
+
log(_kwargs(step='ledger_updated', task_id=latest.get('task_id')))
|
|
754
|
+
|
|
755
|
+
except Exception as e:
|
|
756
|
+
log(_kwargs(phase='confirm-submit', error=str(e)[:300]))
|
|
757
|
+
finally:
|
|
758
|
+
try:
|
|
759
|
+
ctx.close()
|
|
760
|
+
except Exception:
|
|
761
|
+
pass
|
|
762
|
+
release_lock(profile_id)
|
|
763
|
+
log(_kwargs(phase='confirm-submit', step='browser_closed'))
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# ─── Phase: status ───────────────────────────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
def phase_status(profile_id, task_id=None):
|
|
769
|
+
"""Query publish status from the ledger."""
|
|
770
|
+
entries = load_ledger()
|
|
771
|
+
|
|
772
|
+
if task_id:
|
|
773
|
+
matching = [e for e in entries if e.get('task_id') == task_id]
|
|
774
|
+
else:
|
|
775
|
+
matching = entries[-10:]
|
|
776
|
+
|
|
777
|
+
if not matching:
|
|
778
|
+
log(_kwargs(phase='status', entries=[], count=0))
|
|
779
|
+
return
|
|
780
|
+
|
|
781
|
+
states = {}
|
|
782
|
+
for e in matching:
|
|
783
|
+
s = e.get('state', 'unknown')
|
|
784
|
+
states[s] = states.get(s, 0) + 1
|
|
785
|
+
|
|
786
|
+
result = _kwargs(
|
|
787
|
+
phase='status',
|
|
788
|
+
total_entries=len(entries),
|
|
789
|
+
shown=len(matching),
|
|
790
|
+
state_summary=states,
|
|
791
|
+
entries=matching,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
lf = _lock_file(profile_id)
|
|
795
|
+
if lf.exists():
|
|
796
|
+
try:
|
|
797
|
+
lock_data = json.loads(lf.read_text())
|
|
798
|
+
pid = lock_data.get('pid')
|
|
799
|
+
os.kill(pid, 0)
|
|
800
|
+
result['browser_running'] = True
|
|
801
|
+
result['browser_pid'] = pid
|
|
802
|
+
except (OSError, json.JSONDecodeError):
|
|
803
|
+
result['browser_running'] = False
|
|
804
|
+
|
|
805
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
806
|
+
sys.exit(0)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
# ─── Phase: close ────────────────────────────────────────────────────────────
|
|
810
|
+
|
|
811
|
+
def phase_close(profile_id):
|
|
812
|
+
"""Close the browser and release the lock."""
|
|
813
|
+
SIGNAL_DIR.mkdir(parents=True, exist_ok=True)
|
|
814
|
+
lf = _lock_file(profile_id)
|
|
815
|
+
clf = _close_file(profile_id)
|
|
816
|
+
|
|
817
|
+
result = _kwargs(phase='close', action='none')
|
|
818
|
+
|
|
819
|
+
if lf.exists():
|
|
820
|
+
try:
|
|
821
|
+
lock_data = json.loads(lf.read_text())
|
|
822
|
+
pid = lock_data.get('pid')
|
|
823
|
+
if pid:
|
|
824
|
+
try:
|
|
825
|
+
os.kill(pid, 0)
|
|
826
|
+
clf.write_text(json.dumps({
|
|
827
|
+
'action': 'close',
|
|
828
|
+
'timestamp': now_iso(),
|
|
829
|
+
}))
|
|
830
|
+
result['action'] = 'signal_sent'
|
|
831
|
+
result['pid'] = pid
|
|
832
|
+
log(result)
|
|
833
|
+
|
|
834
|
+
for _ in range(15):
|
|
835
|
+
time.sleep(2)
|
|
836
|
+
if not lf.exists():
|
|
837
|
+
result['lock_released'] = True
|
|
838
|
+
break
|
|
839
|
+
try:
|
|
840
|
+
os.kill(pid, 0)
|
|
841
|
+
except OSError:
|
|
842
|
+
result['lock_released'] = True
|
|
843
|
+
release_lock(profile_id)
|
|
844
|
+
break
|
|
845
|
+
|
|
846
|
+
if not result.get('lock_released'):
|
|
847
|
+
try:
|
|
848
|
+
os.kill(pid, signal.SIGTERM)
|
|
849
|
+
time.sleep(2)
|
|
850
|
+
except OSError:
|
|
851
|
+
pass
|
|
852
|
+
release_lock(profile_id)
|
|
853
|
+
result['action'] = 'force_killed'
|
|
854
|
+
result['lock_released'] = True
|
|
855
|
+
|
|
856
|
+
log(result)
|
|
857
|
+
return
|
|
858
|
+
except OSError:
|
|
859
|
+
pass
|
|
860
|
+
except (json.JSONDecodeError, ValueError):
|
|
861
|
+
pass
|
|
862
|
+
|
|
863
|
+
release_lock(profile_id)
|
|
864
|
+
result['action'] = 'lock_released'
|
|
865
|
+
result['lock_released'] = True
|
|
866
|
+
log(result)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
870
|
+
|
|
871
|
+
def main():
|
|
872
|
+
parser = argparse.ArgumentParser(
|
|
873
|
+
description='小红书创作服务平台发布 — 五阶段发布流水线(浏览器自动化部分 IMPLEMENTED_UNVERIFIED)',
|
|
874
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
875
|
+
epilog='''
|
|
876
|
+
Examples:
|
|
877
|
+
python3 xiaohongshu_publish.py --dry-run --profile-id <id> --media-path /path/to/video.mp4
|
|
878
|
+
python3 xiaohongshu_publish.py --prepare --profile-id <id> --media-path /path/to/video.mp4 --title "标题"
|
|
879
|
+
python3 xiaohongshu_publish.py --confirm-submit --profile-id <id>
|
|
880
|
+
python3 xiaohongshu_publish.py --status --profile-id <id> [--task-id xhs_publish_xxx]
|
|
881
|
+
python3 xiaohongshu_publish.py --close --profile-id <id>
|
|
882
|
+
''',
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
parser.add_argument('--dry-run', action='store_true', help='Check media without executing')
|
|
886
|
+
parser.add_argument('--prepare', action='store_true', help='Upload and fill title')
|
|
887
|
+
parser.add_argument('--confirm-submit', action='store_true', help='Confirm final submission')
|
|
888
|
+
parser.add_argument('--status', action='store_true', help='Query publish status')
|
|
889
|
+
parser.add_argument('--close', action='store_true', help='Close browser')
|
|
890
|
+
parser.add_argument('--media-path', help='Path to media file (required for --dry-run, --prepare)')
|
|
891
|
+
parser.add_argument('--title', default=None, help='Note title (required for --prepare, generated by Agent)')
|
|
892
|
+
parser.add_argument('--task-id', default=None, help='Task ID for status query')
|
|
893
|
+
parser.add_argument('--profile-id', required=True, help='Profile ID')
|
|
894
|
+
args = parser.parse_args()
|
|
895
|
+
profile_id = args.profile_id
|
|
896
|
+
|
|
897
|
+
if args.dry_run:
|
|
898
|
+
if not args.media_path:
|
|
899
|
+
die(1, _kwargs(error='MEDIA_PATH_REQUIRED', detail='--media-path required for --dry-run'))
|
|
900
|
+
phase_dry_run(profile_id, args.media_path)
|
|
901
|
+
|
|
902
|
+
elif args.prepare:
|
|
903
|
+
if not args.media_path:
|
|
904
|
+
die(1, _kwargs(error='MEDIA_PATH_REQUIRED', detail='--media-path required for --prepare'))
|
|
905
|
+
if not args.title or not args.title.strip():
|
|
906
|
+
die(1, _kwargs(error='TITLE_REQUIRED',
|
|
907
|
+
detail='--title required for --prepare; title is generated by Agent from video content'))
|
|
908
|
+
phase_prepare(profile_id, args.media_path, args.title)
|
|
909
|
+
|
|
910
|
+
elif args.confirm_submit:
|
|
911
|
+
phase_confirm_submit(profile_id)
|
|
912
|
+
|
|
913
|
+
elif args.status:
|
|
914
|
+
phase_status(profile_id, args.task_id)
|
|
915
|
+
|
|
916
|
+
elif args.close:
|
|
917
|
+
phase_close(profile_id)
|
|
918
|
+
|
|
919
|
+
else:
|
|
920
|
+
parser.print_help()
|
|
921
|
+
sys.exit(1)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
if __name__ == '__main__':
|
|
925
|
+
main()
|