@iducky/media-agent 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +90 -0
  2. package/SHA256SUMS +94 -0
  3. package/bin/media-agent.mjs +89 -0
  4. package/docs/guides/capabilities.md +103 -0
  5. package/docs/guides/image-text-publishing.md +81 -0
  6. package/docs/guides/installation.md +103 -0
  7. package/docs/guides/runtime.md +13 -0
  8. package/manifest.json +382 -0
  9. package/package.json +42 -0
  10. package/pyproject.toml +20 -0
  11. package/resources/capabilities.json +34 -0
  12. package/resources/configs/accounts.yaml +21 -0
  13. package/resources/configs/profile.template.json +20 -0
  14. package/resources/configs/ranking-profiles.yaml +21 -0
  15. package/resources/configs/toutiao-profile.template.json +22 -0
  16. package/resources/configs/xiaohongshu-profile.template.json +27 -0
  17. package/resources/data/industry-taxonomy.yaml +326 -0
  18. package/skills/douyin-competitor-collect/SKILL.md +177 -0
  19. package/skills/douyin-competitor-collect/agents/openai.yaml +4 -0
  20. package/skills/douyin-competitor-collect/references/output-schema.md +178 -0
  21. package/skills/douyin-creator-image-text-publish/SKILL.md +44 -0
  22. package/skills/douyin-creator-image-text-publish/agents/openai.yaml +4 -0
  23. package/skills/douyin-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
  24. package/skills/douyin-creator-image-text-publish/references/execution-contract.md +46 -0
  25. package/skills/douyin-creator-image-text-publish/references/upstream.md +30 -0
  26. package/skills/douyin-creator-index/SKILL.md +32 -0
  27. package/skills/douyin-creator-index/agents/openai.yaml +4 -0
  28. package/skills/douyin-creator-login/SKILL.md +58 -0
  29. package/skills/douyin-creator-login/agents/openai.yaml +4 -0
  30. package/skills/douyin-creator-publish/SKILL.md +56 -0
  31. package/skills/douyin-creator-publish/agents/openai.yaml +4 -0
  32. package/skills/douyin-enterprise-leads/SKILL.md +26 -0
  33. package/skills/douyin-enterprise-leads/agents/openai.yaml +4 -0
  34. package/skills/douyin-enterprise-leads-login/SKILL.md +36 -0
  35. package/skills/douyin-enterprise-leads-login/agents/openai.yaml +4 -0
  36. package/skills/douyin-enterprise-short-video-export/SKILL.md +29 -0
  37. package/skills/douyin-enterprise-short-video-export/agents/openai.yaml +4 -0
  38. package/skills/douyin-enterprise-video-rankings/SKILL.md +51 -0
  39. package/skills/douyin-enterprise-video-rankings/agents/openai.yaml +4 -0
  40. package/skills/douyin-enterprise-video-rankings/references/industry-taxonomy.md +29 -0
  41. package/skills/douyin-web-login/SKILL.md +102 -0
  42. package/skills/douyin-web-login/agents/openai.yaml +4 -0
  43. package/skills/toutiao-creator-article-draft/SKILL.md +154 -0
  44. package/skills/toutiao-creator-article-draft/agents/openai.yaml +4 -0
  45. package/skills/toutiao-web-login/SKILL.md +96 -0
  46. package/skills/toutiao-web-login/agents/openai.yaml +4 -0
  47. package/skills/xiaohongshu-creator-image-text-publish/SKILL.md +46 -0
  48. package/skills/xiaohongshu-creator-image-text-publish/agents/openai.yaml +4 -0
  49. package/skills/xiaohongshu-creator-image-text-publish/references/LICENSE.social-auto-upload +21 -0
  50. package/skills/xiaohongshu-creator-image-text-publish/references/execution-contract.md +46 -0
  51. package/skills/xiaohongshu-creator-image-text-publish/references/upstream.md +31 -0
  52. package/skills/xiaohongshu-creator-login/SKILL.md +106 -0
  53. package/skills/xiaohongshu-creator-login/agents/openai.yaml +4 -0
  54. package/skills/xiaohongshu-creator-publish/SKILL.md +153 -0
  55. package/skills/xiaohongshu-creator-publish/agents/openai.yaml +4 -0
  56. package/src/media_agent/__init__.py +1 -0
  57. package/src/media_agent/cli.py +28 -0
  58. package/src/media_agent/commands.sh +436 -0
  59. package/src/media_agent/platforms/__init__.py +1 -0
  60. package/src/media_agent/platforms/douyin/__init__.py +1 -0
  61. package/src/media_agent/platforms/douyin/check_login.py +196 -0
  62. package/src/media_agent/platforms/douyin/collect_industry_taxonomy.py +184 -0
  63. package/src/media_agent/platforms/douyin/collect_video_rankings.py +352 -0
  64. package/src/media_agent/platforms/douyin/douyin_full_login.py +391 -0
  65. package/src/media_agent/platforms/douyin/douyin_hotspot_v2.py +454 -0
  66. package/src/media_agent/platforms/douyin/douyin_publish.py +1135 -0
  67. package/src/media_agent/platforms/douyin/enterprise_login.py +128 -0
  68. package/src/media_agent/platforms/douyin/export_short_video.py +70 -0
  69. package/src/media_agent/platforms/douyin/login_controller.py +508 -0
  70. package/src/media_agent/platforms/douyin/validate_industry_taxonomy.py +152 -0
  71. package/src/media_agent/platforms/douyin/validate_rankings.py +254 -0
  72. package/src/media_agent/platforms/toutiao/__init__.py +1 -0
  73. package/src/media_agent/platforms/toutiao/toutiao_check_login.py +54 -0
  74. package/src/media_agent/platforms/toutiao/toutiao_login_controller.py +250 -0
  75. package/src/media_agent/platforms/toutiao/toutiao_login_evidence.py +50 -0
  76. package/src/media_agent/platforms/toutiao/toutiao_login_ipc.py +70 -0
  77. package/src/media_agent/platforms/xiaohongshu/__init__.py +1 -0
  78. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_check_login.py +189 -0
  79. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_controller.py +449 -0
  80. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_evidence.py +64 -0
  81. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_login_ipc.py +120 -0
  82. package/src/media_agent/platforms/xiaohongshu/xiaohongshu_publish.py +925 -0
  83. package/src/media_agent/runtime/__init__.py +1 -0
  84. package/src/media_agent/runtime/account_manager.py +672 -0
  85. package/src/media_agent/runtime/browser.py +5 -0
  86. package/src/media_agent/runtime/paths.py +7 -0
  87. package/src/media_agent/script_map.json +23 -0
  88. package/src/node/config.mjs +48 -0
  89. package/src/node/integrity.mjs +34 -0
  90. package/src/node/skills.mjs +85 -0
  91. package/tools/archive_releases.py +83 -0
  92. package/tools/artifacts.py +56 -0
  93. package/tools/build_release.py +82 -0
  94. package/tools/check_catalog.py +23 -0
  95. package/tools/install_runtime.py +144 -0
  96. package/tools/run_tests.py +21 -0
@@ -0,0 +1,391 @@
1
+ """Legacy operation, executed only through main()."""
2
+
3
+ def main():
4
+ #!/usr/bin/env python3
5
+ """
6
+ douyin_creator_personal_test: 完整登录与登录态持久化流程
7
+ """
8
+ from media_agent.runtime.paths import runtime_home
9
+ import json, time, sys, os, signal
10
+ from pathlib import Path
11
+ from datetime import datetime, timezone
12
+ from media_agent.runtime.browser import launch_persistent_context
13
+
14
+ BASE = runtime_home()
15
+ ALIAS = 'douyin_creator_personal_test'
16
+ PROFILE_DIR = BASE / 'profiles' / ALIAS
17
+ BROWSER_DATA = str(PROFILE_DIR / 'browser_data')
18
+ LOCK_FILE = BASE / 'locks' / f'{ALIAS}.lock'
19
+ QR_SCREENSHOT = str(BASE / 'screenshots' / f'{ALIAS}_qr.png')
20
+ FINAL_SCREENSHOT = str(BASE / 'screenshots' / f'{ALIAS}_login_result.png')
21
+ PERSISTENCE_SCREENSHOT = str(BASE / 'screenshots' / f'{ALIAS}_persistence.png')
22
+ TASK_ID = f'full_login_{ALIAS}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
23
+
24
+ # ========== Report accumulator ==========
25
+ R = {'stage': 'init', 'errors': []}
26
+ def report(k, v):
27
+ R[k] = v
28
+
29
+ # ========== Stage 1: Lock & launch ==========
30
+ report('A_profile_path', str(PROFILE_DIR))
31
+ report('A_browser_data', BROWSER_DATA)
32
+
33
+ LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
34
+ if LOCK_FILE.exists():
35
+ # Check if it's a zombie lock (no matching process)
36
+ try:
37
+ lock_data = json.loads(LOCK_FILE.read_text())
38
+ pid = lock_data.get('pid')
39
+ if pid:
40
+ try:
41
+ os.kill(pid, 0) # Signal 0 = existence check
42
+ print(f'ERROR|Lock active: pid={pid}', flush=True)
43
+ sys.exit(1)
44
+ except OSError:
45
+ print(f'WARN|Zombie lock pid={pid}, cleaning', flush=True)
46
+ LOCK_FILE.unlink()
47
+ except:
48
+ LOCK_FILE.unlink()
49
+
50
+ LOCK_FILE.write_text(json.dumps(
51
+ {'task_id': TASK_ID, 'locked_at': datetime.now(timezone.utc).isoformat(),
52
+ 'pid': os.getpid(), 'manual_control': True}, indent=2))
53
+ print(f'LOCK|{TASK_ID}', flush=True)
54
+ report('I_lock_acquired', True)
55
+
56
+ config = json.loads((PROFILE_DIR / 'config.json').read_text())
57
+ fp = config['fingerprint']
58
+ context = launch_persistent_context(
59
+ user_data_dir=BROWSER_DATA,
60
+ headless=False, stealth_args=True, viewport=fp['viewport'],
61
+ locale=fp['locale'], timezone=fp['timezone'], humanize=False, geoip=False,
62
+ )
63
+ print('LAUNCH|Browser started', flush=True)
64
+ page = context.pages[0] if context.pages else context.new_page()
65
+ page.goto('https://creator.douyin.com/', wait_until='domcontentloaded', timeout=30000)
66
+ time.sleep(5)
67
+ report('B_launch_url', page.url)
68
+
69
+ # ========== Check if already logged in ==========
70
+ ch = page.content().lower()
71
+ already_in = '扫码登录' not in ch and '登录/注册' not in ch
72
+ if already_in:
73
+ print('ALREADY_LOGGED_IN|直接进入登录成功检查', flush=True)
74
+ report('B_first_scan', 'skipped_already_logged_in')
75
+ # Jump to login check
76
+ logged_in = True
77
+ # (goto login check section)
78
+ else:
79
+ report('B_first_scan', 'qr_sent')
80
+
81
+ # ========== Stage 2: QR code ==========
82
+ for i in range(30):
83
+ try:
84
+ for div in page.query_selector_all('div'):
85
+ box = div.bounding_box()
86
+ if box and abs(box['width'] - 250) < 10 and abs(box['height'] - 250) < 10:
87
+ div.screenshot(path=QR_SCREENSHOT)
88
+ if Path(QR_SCREENSHOT).exists() and Path(QR_SCREENSHOT).stat().st_size > 2000:
89
+ print(f'QR_OK|{QR_SCREENSHOT}', flush=True); break
90
+ except: pass
91
+ if Path(QR_SCREENSHOT).exists() and Path(QR_SCREENSHOT).stat().st_size > 2000: break
92
+ time.sleep(1)
93
+ print('QR_READY|请扫码,完成后回复:首次扫码完成', flush=True)
94
+ sys.stdout.flush()
95
+
96
+ # ========== Stage 3: Wait for scan ==========
97
+ SIG = BASE / 'locks' / f'{ALIAS}.continue'
98
+ print('WAIT_SCAN', flush=True)
99
+ for i in range(900):
100
+ time.sleep(1)
101
+ if SIG.exists():
102
+ SIG.unlink(missing_ok=True)
103
+ print('SCAN_DONE', flush=True); break
104
+ report('C_verify_triggered', False)
105
+
106
+ # ========== Stage 4: Identity verification popup ==========
107
+ print('WAIT_VERIFY', flush=True)
108
+ verify_found = False
109
+ for i in range(30):
110
+ time.sleep(1)
111
+ try:
112
+ ch = page.content().lower()
113
+ if '身份验证' in ch:
114
+ print('VERIFY_POPUP|身份验证弹窗已出现', flush=True)
115
+ report('C_verify_triggered', True)
116
+ verify_found = True
117
+ break
118
+ except: pass
119
+
120
+ if verify_found:
121
+ # Click "接收短信验证码" using text locator
122
+ try:
123
+ opt = page.locator('text=接收短信验证码').first
124
+ if opt and opt.is_visible():
125
+ opt.click()
126
+ time.sleep(2)
127
+ print('SMS_CLICKED|点击接收短信验证码', flush=True)
128
+ report('D_verify_method', 'sms_code')
129
+ else:
130
+ # Fallback: JS click
131
+ page.evaluate('''() => {
132
+ const els = document.querySelectorAll('div, span, button');
133
+ for (const el of els) {
134
+ if (el.textContent.includes('接收短信验证码')) {
135
+ el.click(); return;
136
+ }
137
+ }
138
+ }''')
139
+ time.sleep(2)
140
+ print('SMS_CLICKED_JS', flush=True)
141
+ report('D_verify_method', 'sms_code_js')
142
+ except Exception as e:
143
+ print(f'SMS_CLICK_ERR|{e}', flush=True)
144
+ report('D_verify_method', f'failed: {e}')
145
+
146
+ # Wait for SMS input field
147
+ print('WAIT_SMS_INPUT', flush=True)
148
+ for i in range(30):
149
+ time.sleep(1)
150
+ try:
151
+ ch = page.content().lower()
152
+ if '验证码' in ch and '请输入' in ch:
153
+ print('SMS_INPUT_READY', flush=True)
154
+ break
155
+ except: pass
156
+
157
+ # Confirm SMS sent to phone number
158
+ print('SMS_PROMPT|验证码输入框已准备好,请发送本次短信验证码。', flush=True)
159
+ sys.stdout.flush()
160
+
161
+ # ========== Stage 5: Wait for SMS code ==========
162
+ SIG_CODE = BASE / 'locks' / f'{ALIAS}.code_done'
163
+ print('WAIT_CODE', flush=True)
164
+ code_received = False
165
+ for i in range(900):
166
+ time.sleep(1)
167
+ if SIG_CODE.exists():
168
+ SIG_CODE.unlink(missing_ok=True)
169
+ code_received = True
170
+ print('CODE_DONE', flush=True)
171
+ break
172
+ # Also check if page changed (auto-login)
173
+ try:
174
+ ch = page.content().lower()
175
+ if '扫码登录' not in ch and '登录/注册' not in ch:
176
+ print('AUTO_LOGIN|页面已跳转', flush=True)
177
+ break
178
+ except: pass
179
+
180
+ if code_received:
181
+ # The code is stored in a temp file (not in logs/state)
182
+ code_file = BASE / 'locks' / f'{ALIAS}.code_value'
183
+ if code_file.exists():
184
+ code = code_file.read_text().strip()
185
+ code_file.unlink()
186
+ print(f'CODE_READ|长度={len(code)}', flush=True)
187
+
188
+ # Fill code using the correct modal locator
189
+ try:
190
+ # Locate the second_verify_mask modal
191
+ modal = page.locator('div[class*="second_verify_mask"]')
192
+ modal_count = modal.count()
193
+ code_input = modal.locator('input#button-input[name="button-input"][maxlength="6"]')
194
+ ci_count = code_input.count()
195
+ ci_visible = code_input.is_visible()
196
+ ci_enabled = code_input.is_enabled()
197
+ print(f'MODAL|count={modal_count}|input_count={ci_count}|visible={ci_visible}|enabled={ci_enabled}', flush=True)
198
+
199
+ if ci_count > 0 and ci_visible:
200
+ code_input.scroll_into_view_if_needed()
201
+ time.sleep(0.3)
202
+ code_input.click()
203
+ time.sleep(0.3)
204
+ code_input.fill(code)
205
+ time.sleep(0.5)
206
+ val = code_input.input_value()
207
+ if len(val) == 6:
208
+ print(f'CODE_FILLED|ok', flush=True)
209
+ report('E_code_submit', 'filled_ok')
210
+ else:
211
+ print(f'CODE_FILLED|len={len(val)}', flush=True)
212
+ report('E_code_submit', f'filled_short_{len(val)}')
213
+ else:
214
+ # Fallback: use direct selector
215
+ print(f'MODAL_LOOKUP_FALLBACK', flush=True)
216
+ code_input2 = page.locator('input#button-input[type="tel"][maxlength="6"]')
217
+ ci2_count = code_input2.count()
218
+ print(f'FALLBACK|count={ci2_count}', flush=True)
219
+ if ci2_count > 0:
220
+ code_input2.first.scroll_into_view_if_needed()
221
+ time.sleep(0.3)
222
+ code_input2.first.click()
223
+ time.sleep(0.3)
224
+ code_input2.first.fill(code)
225
+ time.sleep(0.5)
226
+ val = code_input2.first.input_value()
227
+ if len(val) == 6:
228
+ print(f'CODE_FILLED|ok', flush=True)
229
+ report('E_code_submit', 'filled_ok')
230
+ else:
231
+ print(f'CODE_FILLED|len={len(val)}', flush=True)
232
+ report('E_code_submit', f'filled_short_{len(val)}')
233
+ else:
234
+ print('CODE_INPUT_NOT_FOUND', flush=True)
235
+ report('E_code_submit', 'input_not_found')
236
+ except Exception as e:
237
+ print(f'CODE_FILL_ERR|{e}', flush=True)
238
+ report('E_code_submit', f'error: {e}')
239
+
240
+ # Click 验证 button using JS within the same modal
241
+ try:
242
+ result = page.evaluate('''() => {
243
+ const modal = document.querySelector('div[class*="second_verify_mask"]');
244
+ if (!modal) return 'modal_not_found';
245
+ const els = modal.querySelectorAll('div, span, button, a');
246
+ for (const el of els) {
247
+ const t = el.textContent.trim();
248
+ if (t === '验证' && el.offsetParent !== null) {
249
+ el.click(); return 'clicked';
250
+ }
251
+ }
252
+ return 'not_found';
253
+ }''')
254
+ time.sleep(1)
255
+ print(f'VERIFY_CLICKED|{result}', flush=True)
256
+ report('E_verify_clicked', result)
257
+ except Exception as e:
258
+ print(f'VERIFY_ERR|{e}', flush=True)
259
+ report('E_verify_clicked', f'error: {e}')
260
+
261
+ # ========== Stage 6: Login check (120s) ==========
262
+ print('POST_VERIFY|等待登录...', flush=True)
263
+ logged_in = False
264
+ for i in range(60):
265
+ time.sleep(2)
266
+ try:
267
+ cookies = context.cookies()
268
+ has_sid = any(c['name'] == 'sessionid' for c in cookies)
269
+ has_sid_ss = any(c['name'] == 'sessionid_ss' for c in cookies)
270
+ has_sid_g = any(c['name'] == 'sid_guard' for c in cookies)
271
+ ch = page.content().lower()
272
+ still_login = '扫码登录' in ch or '登录/注册' in ch
273
+ verify_open = '身份验证' in ch
274
+ print(f'POST|{i*2}s|url={page.url}|login_form={still_login}|verify={verify_open}|sid={has_sid}|sid_ss={has_sid_ss}|sid_g={has_sid_g}', flush=True)
275
+ if has_sid and has_sid_ss and not still_login:
276
+ logged_in = True
277
+ print(f'LOGIN_SUCCESS|sessionid出现在{i*2}s', flush=True)
278
+ break
279
+ # Check for console elements
280
+ if not still_login and not verify_open:
281
+ logged_in = True
282
+ print(f'LOGIN_SUCCESS|页面已进入控制台 at {i*2}s', flush=True)
283
+ break
284
+ except: pass
285
+
286
+ report('F_login_url', page.url)
287
+ try:
288
+ ch = page.content().lower()
289
+ report('F_login_form_still', '扫码登录' in ch or '登录/注册' in ch)
290
+ report('F_verify_still', '身份验证' in ch)
291
+ except: pass
292
+
293
+ cookies = context.cookies()
294
+ report('G_cookie_sessionid', any(c['name'] == 'sessionid' for c in cookies))
295
+ report('G_cookie_sessionid_ss', any(c['name'] == 'sessionid_ss' for c in cookies))
296
+ report('G_cookie_sid_guard', any(c['name'] == 'sid_guard' for c in cookies))
297
+ report('G_cookie_uid_tt', any(c['name'] == 'uid_tt' for c in cookies))
298
+ for ck in cookies:
299
+ if ck['name'] in ['sessionid', 'sessionid_ss', 'sid_guard', 'uid_tt', 'has_biz_token', 'passport_csrf_token']:
300
+ print(f'CK|name={ck["name"]}|domain={ck["domain"]}|httponly={ck.get("httpOnly")}|expires={ck.get("expires","session")}', flush=True)
301
+
302
+ # Save state
303
+ state = json.loads((PROFILE_DIR / 'state.json').read_text())
304
+ state['is_logged_in'] = logged_in
305
+ state['session_start_at'] = datetime.now(timezone.utc).isoformat() if logged_in else None
306
+ state['last_login_check'] = datetime.now(timezone.utc).isoformat()
307
+ state['last_activity'] = datetime.now(timezone.utc).isoformat()
308
+ (PROFILE_DIR / 'state.json').write_text(json.dumps(state, indent=2, ensure_ascii=False))
309
+
310
+ # Screenshot
311
+ try:
312
+ page.screenshot(path=FINAL_SCREENSHOT, full_page=True)
313
+ print(f'SCREENSHOT|{FINAL_SCREENSHOT}', flush=True)
314
+ report('K_screenshot', FINAL_SCREENSHOT)
315
+ except: pass
316
+
317
+ report('F_logged_in', logged_in)
318
+
319
+ # ========== Stage 7: Graceful close ==========
320
+ if logged_in:
321
+ print('LOGIN_OK|正常关闭浏览器保存数据...', flush=True)
322
+ try:
323
+ context.close()
324
+ print('CLOSED|浏览器已正常关闭', flush=True)
325
+ report('H_browser_closed', True)
326
+ except Exception as e:
327
+ print(f'CLOSE_ERR|{e}', flush=True)
328
+ report('H_browser_closed', f'error: {e}')
329
+ time.sleep(10)
330
+ print('SLEEP_DONE|等待10秒落盘完成', flush=True)
331
+
332
+ # ========== Stage 8: Re-launch for persistence ==========
333
+ print('VERIFY_PERSISTENCE|重新打开验证免登录...', flush=True)
334
+ LOCK_FILE.unlink(missing_ok=True)
335
+ # Re-acquire lock
336
+ TASK_ID2 = f'persist_{ALIAS}_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
337
+ LOCK_FILE.write_text(json.dumps(
338
+ {'task_id': TASK_ID2, 'locked_at': datetime.now(timezone.utc).isoformat(),
339
+ 'pid': os.getpid(), 'manual_control': True}, indent=2))
340
+ print(f'LOCK2|{TASK_ID2}', flush=True)
341
+
342
+ context2 = launch_persistent_context(
343
+ user_data_dir=BROWSER_DATA,
344
+ headless=False, stealth_args=True, viewport=fp['viewport'],
345
+ locale=fp['locale'], timezone=fp['timezone'], humanize=False, geoip=False,
346
+ )
347
+ page2 = context2.pages[0] if context2.pages else context2.new_page()
348
+ page2.goto('https://creator.douyin.com/', wait_until='domcontentloaded', timeout=30000)
349
+ time.sleep(5)
350
+ print(f'VERIFY_URL|{page2.url}', flush=True)
351
+ report('J_reopen_url', page2.url)
352
+ ch2 = page2.content().lower()
353
+ persisted = '扫码登录' not in ch2 and '登录/注册' not in ch2
354
+ print(f'PERSISTED|{persisted}', flush=True)
355
+ report('J_persisted', persisted)
356
+ cookies2 = context2.cookies()
357
+ report('J_reopen_has_sessionid', any(c['name'] == 'sessionid' for c in cookies2))
358
+ try:
359
+ page2.screenshot(path=PERSISTENCE_SCREENSHOT, full_page=True)
360
+ print(f'VERIFY_SS|{PERSISTENCE_SCREENSHOT}', flush=True)
361
+ report('K_persistence_screenshot', PERSISTENCE_SCREENSHOT)
362
+ except: pass
363
+ context2.close()
364
+ print('VERIFY_DONE', flush=True)
365
+ LOCK_FILE.unlink(missing_ok=True)
366
+ report('I_lock_released', True)
367
+ else:
368
+ print('LOGIN_FAILED|保持浏览器打开30秒后关闭...', flush=True)
369
+ report('H_browser_closed', False)
370
+ for i in range(30):
371
+ time.sleep(1)
372
+ if (BASE / 'locks' / f'{ALIAS}.close').exists():
373
+ break
374
+ try:
375
+ context.close()
376
+ print('CLOSED', flush=True)
377
+ except: pass
378
+ LOCK_FILE.unlink(missing_ok=True)
379
+ report('I_lock_released', True)
380
+
381
+ # ========== Final report ==========
382
+ print('='*60, flush=True)
383
+ print('FINAL_REPORT', flush=True)
384
+ print('='*60, flush=True)
385
+ for k, v in sorted(R.items()):
386
+ print(f' {k}: {v}', flush=True)
387
+ print('='*60, flush=True)
388
+ print('DONE', flush=True)
389
+
390
+ if __name__ == '__main__':
391
+ main()