@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,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Xiaohongshu Creator persistent login controller with Unix Socket IPC.
|
|
3
|
+
Usage: python3 xiaohongshu_login_controller.py <profile_id>
|
|
4
|
+
Commands via socket: login-start, login-status, login-scan-done, login-refresh-qr, login-close
|
|
5
|
+
"""
|
|
6
|
+
from media_agent.runtime.paths import runtime_home
|
|
7
|
+
import errno
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from datetime import datetime, timezone, timedelta
|
|
16
|
+
from media_agent.runtime.browser import launch_persistent_context
|
|
17
|
+
from media_agent.platforms.xiaohongshu.xiaohongshu_login_evidence import (
|
|
18
|
+
DASHBOARD_KEYWORDS,
|
|
19
|
+
LOGIN_KEYWORDS,
|
|
20
|
+
classify_login_evidence,
|
|
21
|
+
post_scan_timeout_state,
|
|
22
|
+
scan_poll_schedule,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
BASE = runtime_home()
|
|
26
|
+
ALIAS = PROFILE_DIR = LOCK_FILE = SOCK_FILE = SCREENSHOT_DIR = cfg = None
|
|
27
|
+
|
|
28
|
+
def configure(profile_id):
|
|
29
|
+
global ALIAS, PROFILE_DIR, LOCK_FILE, SOCK_FILE, SCREENSHOT_DIR, cfg
|
|
30
|
+
ALIAS = profile_id
|
|
31
|
+
|
|
32
|
+
PROFILE_DIR = BASE / 'profiles' / ALIAS
|
|
33
|
+
LOCK_FILE = BASE / 'locks' / f'{ALIAS}.lock'
|
|
34
|
+
SOCK_FILE = BASE / 'controllers' / f'{ALIAS}.sock'
|
|
35
|
+
SCREENSHOT_DIR = BASE / 'screenshots'
|
|
36
|
+
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
SOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
cfg = json.loads((PROFILE_DIR / 'config.json').read_text())
|
|
40
|
+
|
|
41
|
+
tz = timezone(timedelta(hours=8))
|
|
42
|
+
|
|
43
|
+
# Global state
|
|
44
|
+
controller_state = 'INIT'
|
|
45
|
+
browser = None
|
|
46
|
+
context = None
|
|
47
|
+
page = None
|
|
48
|
+
task_id = None
|
|
49
|
+
request_id = None
|
|
50
|
+
|
|
51
|
+
def now_iso():
|
|
52
|
+
return datetime.now(tz).isoformat()
|
|
53
|
+
|
|
54
|
+
def log(msg):
|
|
55
|
+
print(json.dumps(msg, ensure_ascii=False), flush=True)
|
|
56
|
+
|
|
57
|
+
def process_alive(pid):
|
|
58
|
+
if not isinstance(pid, int) or pid <= 1:
|
|
59
|
+
return False
|
|
60
|
+
try:
|
|
61
|
+
os.kill(pid, 0)
|
|
62
|
+
return True
|
|
63
|
+
except OSError as exc:
|
|
64
|
+
if exc.errno == errno.ESRCH:
|
|
65
|
+
return False
|
|
66
|
+
if exc.errno == errno.EPERM:
|
|
67
|
+
return True
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
def acquire_lock():
|
|
71
|
+
if LOCK_FILE.exists():
|
|
72
|
+
try:
|
|
73
|
+
old = json.loads(LOCK_FILE.read_text())
|
|
74
|
+
except (OSError, json.JSONDecodeError):
|
|
75
|
+
return False
|
|
76
|
+
if process_alive(old.get('pid')):
|
|
77
|
+
return False
|
|
78
|
+
try:
|
|
79
|
+
LOCK_FILE.unlink()
|
|
80
|
+
except OSError:
|
|
81
|
+
return False
|
|
82
|
+
LOCK_FILE.write_text(json.dumps({'pid': os.getpid(), 'time': now_iso(), 'task_id': task_id}))
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def release_lock():
|
|
86
|
+
try:
|
|
87
|
+
LOCK_FILE.unlink(missing_ok=True)
|
|
88
|
+
except:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
def check_logged_in():
|
|
92
|
+
"""Check if current page shows dashboard."""
|
|
93
|
+
global page
|
|
94
|
+
url = page.url
|
|
95
|
+
body = page.evaluate('() => document.body.innerText')
|
|
96
|
+
dashboard = [kw for kw in DASHBOARD_KEYWORDS if kw in body]
|
|
97
|
+
has_login = any(kw in body for kw in LOGIN_KEYWORDS)
|
|
98
|
+
return classify_login_evidence(
|
|
99
|
+
url=url,
|
|
100
|
+
dashboard_evidence=dashboard,
|
|
101
|
+
login_form_detected=has_login,
|
|
102
|
+
qr_detected='APP扫一扫登录' in body or '扫码登录' in body,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def navigate_to_qr():
|
|
106
|
+
"""Navigate to login page and switch to QR scan mode. Returns state."""
|
|
107
|
+
global page, context
|
|
108
|
+
page.goto('https://creator.xiaohongshu.com/', wait_until='load', timeout=30000)
|
|
109
|
+
time.sleep(3)
|
|
110
|
+
|
|
111
|
+
url = page.url
|
|
112
|
+
body = page.evaluate('() => document.body.innerText')
|
|
113
|
+
# Check if already logged in
|
|
114
|
+
if '/new/home' in url or ('创作服务平台' in body and '发布笔记' in body):
|
|
115
|
+
return 'logged_in'
|
|
116
|
+
|
|
117
|
+
# Check if on SMS login page
|
|
118
|
+
if '短信登录' in body:
|
|
119
|
+
return 'sms_login_found'
|
|
120
|
+
|
|
121
|
+
# Check if QR mode already
|
|
122
|
+
if 'APP扫一扫登录' in body:
|
|
123
|
+
return 'qr_already'
|
|
124
|
+
|
|
125
|
+
return 'sms_login_found'
|
|
126
|
+
|
|
127
|
+
def switch_to_qr():
|
|
128
|
+
"""Click the QR icon in the login card. Returns True if successful."""
|
|
129
|
+
global page
|
|
130
|
+
result = page.evaluate('''() => {
|
|
131
|
+
// Find login card with SMS login content
|
|
132
|
+
for (const el of document.querySelectorAll('div[class*="login"], div[class*="card"], div[class*="panel"]')) {
|
|
133
|
+
const rect = el.getBoundingClientRect();
|
|
134
|
+
const text = el.textContent || '';
|
|
135
|
+
if (rect.width > 300 && rect.height > 200 && text.includes('短信登录')) {
|
|
136
|
+
// Find the QR icon in the top-right corner of this card
|
|
137
|
+
const icons = el.querySelectorAll('img, svg');
|
|
138
|
+
for (const icon of icons) {
|
|
139
|
+
const ir = icon.getBoundingClientRect();
|
|
140
|
+
if (ir.width > 15 && ir.width < 80 && ir.height > 15 && ir.height < 80
|
|
141
|
+
&& ir.x > rect.x + rect.width - 100) {
|
|
142
|
+
icon.dispatchEvent(new MouseEvent('click', {bubbles: true}));
|
|
143
|
+
return 'clicked_card_icon';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Try clicking the last small element in the card
|
|
147
|
+
const children = el.querySelectorAll('*');
|
|
148
|
+
for (let i = children.length - 1; i >= 0; i--) {
|
|
149
|
+
const c = children[i];
|
|
150
|
+
const cr = c.getBoundingClientRect();
|
|
151
|
+
if (cr.width > 20 && cr.width < 80 && cr.height > 20 && cr.height < 80
|
|
152
|
+
&& cr.x > rect.x + rect.width - 100) {
|
|
153
|
+
c.dispatchEvent(new MouseEvent('click', {bubbles: true}));
|
|
154
|
+
return 'clicked_small_element';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return 'not_found';
|
|
160
|
+
}''')
|
|
161
|
+
log({'step': 'qr_switch', 'result': result})
|
|
162
|
+
time.sleep(2)
|
|
163
|
+
return result != 'not_found'
|
|
164
|
+
|
|
165
|
+
def verify_qr_mode():
|
|
166
|
+
"""Verify QR mode is active: APP扫一扫 + QR visible + no phone input."""
|
|
167
|
+
global page
|
|
168
|
+
result = page.evaluate('''() => {
|
|
169
|
+
const body = document.body.innerText;
|
|
170
|
+
const hasQRText = body.includes('APP扫一扫登录') || body.includes('扫码登录');
|
|
171
|
+
const hasPhoneInput = document.querySelector('input[placeholder*="手机号"]');
|
|
172
|
+
const hasPhoneVisible = hasPhoneInput && hasPhoneInput.offsetParent !== null;
|
|
173
|
+
// Check for QR image
|
|
174
|
+
let hasQRImage = false;
|
|
175
|
+
for (const img of document.querySelectorAll('img')) {
|
|
176
|
+
const rect = img.getBoundingClientRect();
|
|
177
|
+
if (rect.width >= 100 && rect.width <= 400 && rect.height >= 100 && rect.height <= 400
|
|
178
|
+
&& img.offsetParent !== null) {
|
|
179
|
+
hasQRImage = true;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
qr_text: hasQRText,
|
|
185
|
+
qr_image: hasQRImage,
|
|
186
|
+
phone_input: hasPhoneVisible,
|
|
187
|
+
success: hasQRText && hasQRImage && !hasPhoneVisible
|
|
188
|
+
};
|
|
189
|
+
}''')
|
|
190
|
+
result['success'] = (
|
|
191
|
+
result.get('qr_text')
|
|
192
|
+
and result.get('qr_image')
|
|
193
|
+
and not result.get('phone_input')
|
|
194
|
+
)
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
def capture_qr():
|
|
198
|
+
"""Capture QR code screenshot."""
|
|
199
|
+
global page
|
|
200
|
+
qr_path = str(SCREENSHOT_DIR / f'{ALIAS}_qr.png')
|
|
201
|
+
page.screenshot(path=qr_path)
|
|
202
|
+
return qr_path
|
|
203
|
+
|
|
204
|
+
def remove_qr_capture():
|
|
205
|
+
try:
|
|
206
|
+
(SCREENSHOT_DIR / f'{ALIAS}_qr.png').unlink(missing_ok=True)
|
|
207
|
+
except OSError:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
def write_response(result):
|
|
211
|
+
resp_file = BASE / 'signals' / f'{ALIAS}_response.json'
|
|
212
|
+
resp_file.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
tmp_file = resp_file.with_suffix('.json.tmp')
|
|
214
|
+
tmp_file.write_text(json.dumps(result, ensure_ascii=False))
|
|
215
|
+
os.replace(tmp_file, resp_file)
|
|
216
|
+
|
|
217
|
+
def handle_command(cmd, data=None):
|
|
218
|
+
global controller_state, browser, context, page, task_id, request_id
|
|
219
|
+
|
|
220
|
+
if cmd == 'login-status':
|
|
221
|
+
if not page:
|
|
222
|
+
return {'state': controller_state, 'error': 'NO_PAGE'}
|
|
223
|
+
url = page.url
|
|
224
|
+
alive = True
|
|
225
|
+
try:
|
|
226
|
+
page.title()
|
|
227
|
+
except Exception:
|
|
228
|
+
alive = False
|
|
229
|
+
status = check_logged_in() if alive else 'unknown'
|
|
230
|
+
if status == 'logged_in':
|
|
231
|
+
controller_state = 'LOGGED_IN'
|
|
232
|
+
return {
|
|
233
|
+
'state': controller_state,
|
|
234
|
+
'controller_pid': os.getpid(),
|
|
235
|
+
'browser_alive': alive,
|
|
236
|
+
'page_count': len(context.pages) if context else 0,
|
|
237
|
+
'current_url': url[:120],
|
|
238
|
+
'lock_owner': os.getpid(),
|
|
239
|
+
'task_id': task_id,
|
|
240
|
+
'request_id': request_id,
|
|
241
|
+
'login_status': status
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
elif cmd == 'login-scan-done':
|
|
245
|
+
if controller_state != 'WAIT_QR_SCAN':
|
|
246
|
+
return {'state': controller_state, 'error': 'NOT_WAITING_QR'}
|
|
247
|
+
# Wait 10-15s for platform to process, then poll up to 60s.
|
|
248
|
+
initial_wait, poll_interval, poll_count = scan_poll_schedule()
|
|
249
|
+
time.sleep(initial_wait)
|
|
250
|
+
for i in range(poll_count):
|
|
251
|
+
time.sleep(poll_interval)
|
|
252
|
+
try:
|
|
253
|
+
url = page.url
|
|
254
|
+
body = page.evaluate('() => document.body.innerText')
|
|
255
|
+
# SPA: URL may still be /login but dashboard already rendered
|
|
256
|
+
dashboard = [kw for kw in DASHBOARD_KEYWORDS if kw in body]
|
|
257
|
+
has_login = any(kw in body for kw in LOGIN_KEYWORDS)
|
|
258
|
+
status = classify_login_evidence(
|
|
259
|
+
url=url,
|
|
260
|
+
dashboard_evidence=dashboard,
|
|
261
|
+
login_form_detected=has_login,
|
|
262
|
+
qr_detected='APP扫一扫登录' in body or '扫码登录' in body,
|
|
263
|
+
)
|
|
264
|
+
if status == 'logged_in':
|
|
265
|
+
controller_state = 'LOGGED_IN'
|
|
266
|
+
remove_qr_capture()
|
|
267
|
+
return {
|
|
268
|
+
'state': 'LOGGED_IN',
|
|
269
|
+
'url': url[:120],
|
|
270
|
+
'elapsed': initial_wait + (i + 1) * poll_interval,
|
|
271
|
+
'spa': '/login' in url,
|
|
272
|
+
'dashboard_evidence': dashboard,
|
|
273
|
+
}
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
log({'step': 'scan_poll_error', 'error': str(exc)[:100]})
|
|
276
|
+
controller_state = post_scan_timeout_state()
|
|
277
|
+
return {
|
|
278
|
+
'state': controller_state,
|
|
279
|
+
'error': 'TIMEOUT_NO_BACKEND_EVIDENCE',
|
|
280
|
+
'rescan_requested': False,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
elif cmd == 'login-refresh-qr':
|
|
284
|
+
if controller_state != 'WAIT_QR_SCAN':
|
|
285
|
+
return {'state': controller_state, 'error': 'NOT_WAITING_QR'}
|
|
286
|
+
# Find and click refresh button on QR area
|
|
287
|
+
page.evaluate('''() => {
|
|
288
|
+
for (const el of document.querySelectorAll('div, span, a, button')) {
|
|
289
|
+
const t = el.textContent.trim();
|
|
290
|
+
if (t === '刷新' || t === '重新获取' || t.includes('点击刷新')) {
|
|
291
|
+
el.click(); return 'clicked';
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return 'not_found';
|
|
295
|
+
}''')
|
|
296
|
+
time.sleep(2)
|
|
297
|
+
qr_path = capture_qr()
|
|
298
|
+
return {'state': 'WAIT_QR_SCAN', 'qr_path': qr_path}
|
|
299
|
+
|
|
300
|
+
elif cmd == 'login-close':
|
|
301
|
+
controller_state = 'CLOSING'
|
|
302
|
+
try:
|
|
303
|
+
if context:
|
|
304
|
+
context.close()
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
try:
|
|
308
|
+
if browser:
|
|
309
|
+
browser.close()
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
browser = None
|
|
313
|
+
context = None
|
|
314
|
+
page = None
|
|
315
|
+
remove_qr_capture()
|
|
316
|
+
release_lock()
|
|
317
|
+
controller_state = 'COMPLETE'
|
|
318
|
+
return {'state': 'COMPLETE'}
|
|
319
|
+
|
|
320
|
+
return {'error': 'UNKNOWN_COMMAND'}
|
|
321
|
+
|
|
322
|
+
def server_thread():
|
|
323
|
+
"""Unix Socket IPC server."""
|
|
324
|
+
if SOCK_FILE.exists():
|
|
325
|
+
SOCK_FILE.unlink()
|
|
326
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
327
|
+
sock.bind(str(SOCK_FILE))
|
|
328
|
+
os.chmod(str(SOCK_FILE), 0o600)
|
|
329
|
+
sock.listen(1)
|
|
330
|
+
sock.settimeout(1.0)
|
|
331
|
+
|
|
332
|
+
while controller_state != 'CLOSING' and controller_state != 'COMPLETE':
|
|
333
|
+
try:
|
|
334
|
+
conn, _ = sock.accept()
|
|
335
|
+
data = b''
|
|
336
|
+
while True:
|
|
337
|
+
chunk = conn.recv(4096)
|
|
338
|
+
if not chunk:
|
|
339
|
+
break
|
|
340
|
+
data += chunk
|
|
341
|
+
if len(data) > 65536:
|
|
342
|
+
break
|
|
343
|
+
conn.close()
|
|
344
|
+
if data:
|
|
345
|
+
msg = json.loads(data.decode())
|
|
346
|
+
cmd = msg.get('command', '')
|
|
347
|
+
cmd_data = msg.get('data', {})
|
|
348
|
+
cmd_data['request_id'] = msg.get('request_id', '')
|
|
349
|
+
result = handle_command(cmd, cmd_data)
|
|
350
|
+
result['request_id'] = msg.get('request_id', '')
|
|
351
|
+
write_response(result)
|
|
352
|
+
log({'step': 'cmd_handled', 'command': cmd, 'result': list(result.keys())})
|
|
353
|
+
except socket.timeout:
|
|
354
|
+
continue
|
|
355
|
+
except Exception as e:
|
|
356
|
+
log({'step': 'socket_error', 'error': str(e)[:100]})
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
sock.close()
|
|
360
|
+
SOCK_FILE.unlink(missing_ok=True)
|
|
361
|
+
except OSError:
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
def main():
|
|
365
|
+
global controller_state, task_id
|
|
366
|
+
if len(sys.argv) < 2:
|
|
367
|
+
raise SystemExit('profile_id required')
|
|
368
|
+
configure(sys.argv[1])
|
|
369
|
+
# Initial startup
|
|
370
|
+
task_id = f'xhs_login_{ALIAS}_{datetime.now(tz).strftime("%Y%m%d_%H%M%S")}'
|
|
371
|
+
if not acquire_lock():
|
|
372
|
+
log({'error': 'PROFILE_LOCKED'})
|
|
373
|
+
sys.exit(1)
|
|
374
|
+
|
|
375
|
+
browser_obj = launch_persistent_context(
|
|
376
|
+
user_data_dir=str(PROFILE_DIR / 'browser_data'),
|
|
377
|
+
headless=False, stealth_args=True,
|
|
378
|
+
viewport=cfg['fingerprint']['viewport'],
|
|
379
|
+
locale=cfg['fingerprint']['locale'],
|
|
380
|
+
timezone=cfg['fingerprint']['timezone'],
|
|
381
|
+
humanize=False, geoip=False
|
|
382
|
+
)
|
|
383
|
+
global browser, context, page
|
|
384
|
+
browser = browser_obj
|
|
385
|
+
context = browser_obj
|
|
386
|
+
page = context.pages[0] if context.pages else context.new_page()
|
|
387
|
+
|
|
388
|
+
result = navigate_to_qr()
|
|
389
|
+
if result == 'logged_in':
|
|
390
|
+
controller_state = 'LOGGED_IN'
|
|
391
|
+
startup = {'event': 'controller_started', 'state': 'LOGGED_IN', 'controller_pid': os.getpid(), 'task_id': task_id}
|
|
392
|
+
write_response(startup)
|
|
393
|
+
log(startup)
|
|
394
|
+
elif result in ('sms_login_found', 'qr_already'):
|
|
395
|
+
switched = result == 'qr_already' or switch_to_qr()
|
|
396
|
+
if switched:
|
|
397
|
+
qr_verify = verify_qr_mode()
|
|
398
|
+
if qr_verify.get('success'):
|
|
399
|
+
controller_state = 'WAIT_QR_SCAN'
|
|
400
|
+
qr_path = capture_qr()
|
|
401
|
+
startup = {'event': 'controller_started', 'state': 'WAIT_QR_SCAN', 'qr_path': qr_path, 'task_id': task_id, 'pid': os.getpid(), 'qr_verified': qr_verify}
|
|
402
|
+
write_response(startup)
|
|
403
|
+
log(startup)
|
|
404
|
+
else:
|
|
405
|
+
controller_state = 'WAIT_QR_MODE'
|
|
406
|
+
startup = {'event': 'controller_started', 'state': 'WAIT_QR_MODE', 'error': 'QR_SWITCH_FAILED', 'task_id': task_id, 'qr_verify': qr_verify}
|
|
407
|
+
write_response(startup)
|
|
408
|
+
log(startup)
|
|
409
|
+
else:
|
|
410
|
+
controller_state = 'WAIT_QR_MODE'
|
|
411
|
+
startup = {'event': 'controller_started', 'state': 'WAIT_QR_MODE', 'error': 'QR_ICON_NOT_FOUND', 'task_id': task_id}
|
|
412
|
+
write_response(startup)
|
|
413
|
+
log(startup)
|
|
414
|
+
else:
|
|
415
|
+
controller_state = 'WAIT_QR_MODE'
|
|
416
|
+
startup = {'event': 'controller_started', 'state': 'WAIT_QR_MODE', 'error': 'UNKNOWN_FORM', 'task_id': task_id}
|
|
417
|
+
write_response(startup)
|
|
418
|
+
log(startup)
|
|
419
|
+
|
|
420
|
+
# Start IPC server in background
|
|
421
|
+
t = threading.Thread(target=server_thread, daemon=True)
|
|
422
|
+
t.start()
|
|
423
|
+
|
|
424
|
+
# Keep alive
|
|
425
|
+
try:
|
|
426
|
+
while controller_state not in ('CLOSING', 'COMPLETE'):
|
|
427
|
+
time.sleep(1)
|
|
428
|
+
except KeyboardInterrupt:
|
|
429
|
+
pass
|
|
430
|
+
|
|
431
|
+
# Cleanup
|
|
432
|
+
try:
|
|
433
|
+
if context:
|
|
434
|
+
context.close()
|
|
435
|
+
except:
|
|
436
|
+
pass
|
|
437
|
+
try:
|
|
438
|
+
if browser:
|
|
439
|
+
browser.close()
|
|
440
|
+
except:
|
|
441
|
+
pass
|
|
442
|
+
release_lock()
|
|
443
|
+
try:
|
|
444
|
+
SOCK_FILE.unlink(missing_ok=True)
|
|
445
|
+
except:
|
|
446
|
+
pass
|
|
447
|
+
|
|
448
|
+
if __name__ == '__main__':
|
|
449
|
+
main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pure evidence and state helpers for Xiaohongshu Creator login."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DASHBOARD_KEYWORDS = (
|
|
10
|
+
"创作服务平台",
|
|
11
|
+
"首页",
|
|
12
|
+
"笔记管理",
|
|
13
|
+
"数据看板",
|
|
14
|
+
"创作中心",
|
|
15
|
+
"发布笔记",
|
|
16
|
+
"我的笔记",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
LOGIN_KEYWORDS = ("短信登录", "APP扫一扫登录", "扫码登录", "手机号")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def classify_login_evidence(
|
|
23
|
+
*,
|
|
24
|
+
url: str,
|
|
25
|
+
dashboard_evidence: Iterable[str],
|
|
26
|
+
login_form_detected: bool,
|
|
27
|
+
qr_detected: bool,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Classify login without trusting URL alone.
|
|
30
|
+
|
|
31
|
+
Xiaohongshu's SPA may retain /login after rendering the authenticated
|
|
32
|
+
dashboard, so strong dashboard DOM plus absence of login UI wins.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
dashboard = {item for item in dashboard_evidence if item}
|
|
36
|
+
backend_route = "/new/" in url or "/creator" in url
|
|
37
|
+
strong_dashboard = len(dashboard) >= 2 or (
|
|
38
|
+
backend_route and len(dashboard) >= 1
|
|
39
|
+
)
|
|
40
|
+
login_ui = login_form_detected or qr_detected
|
|
41
|
+
|
|
42
|
+
if strong_dashboard and not login_ui:
|
|
43
|
+
return "logged_in"
|
|
44
|
+
if login_ui and not strong_dashboard:
|
|
45
|
+
return "login_required"
|
|
46
|
+
return "indeterminate"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def qr_mode_ready(*, qr_text: bool, qr_image: bool, phone_input: bool) -> bool:
|
|
50
|
+
"""Return true only when the QR UI is visible and SMS inputs are gone."""
|
|
51
|
+
|
|
52
|
+
return qr_text and qr_image and not phone_input
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def scan_poll_schedule() -> tuple[int, int, int]:
|
|
56
|
+
"""Initial wait, poll interval, and maximum poll count."""
|
|
57
|
+
|
|
58
|
+
return 12, 2, 30
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def post_scan_timeout_state() -> str:
|
|
62
|
+
"""Never ask for another scan merely because redirect polling timed out."""
|
|
63
|
+
|
|
64
|
+
return "INDETERMINATE"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Profile-scoped IPC client for the Xiaohongshu login controller."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
from media_agent.runtime.paths import runtime_home
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
BASE = runtime_home()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def response_path(profile_id: str) -> Path:
|
|
21
|
+
return BASE / "signals" / f"{profile_id}_response.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def wait_for_response(path: Path, request_id: str, timeout: float) -> dict:
|
|
25
|
+
deadline = time.monotonic() + timeout
|
|
26
|
+
while time.monotonic() < deadline:
|
|
27
|
+
if path.exists():
|
|
28
|
+
try:
|
|
29
|
+
payload = json.loads(path.read_text())
|
|
30
|
+
except (OSError, json.JSONDecodeError):
|
|
31
|
+
payload = {}
|
|
32
|
+
if payload.get("request_id") == request_id:
|
|
33
|
+
return payload
|
|
34
|
+
time.sleep(0.2)
|
|
35
|
+
return {
|
|
36
|
+
"state": "IPC_TIMEOUT",
|
|
37
|
+
"error": "response timeout",
|
|
38
|
+
"request_id": request_id,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def send(profile_id: str, command: str, timeout: float) -> dict:
|
|
43
|
+
sock_path = BASE / "controllers" / f"{profile_id}.sock"
|
|
44
|
+
if not sock_path.exists():
|
|
45
|
+
return {"state": "CONTROLLER_NOT_FOUND", "error": "no socket"}
|
|
46
|
+
|
|
47
|
+
request_id = uuid.uuid4().hex
|
|
48
|
+
payload = {"command": command, "request_id": request_id, "data": {}}
|
|
49
|
+
try:
|
|
50
|
+
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
51
|
+
client.settimeout(5)
|
|
52
|
+
client.connect(str(sock_path))
|
|
53
|
+
client.sendall(json.dumps(payload).encode())
|
|
54
|
+
try:
|
|
55
|
+
client.shutdown(socket.SHUT_WR)
|
|
56
|
+
except OSError:
|
|
57
|
+
# 半关闭只是 EOF 提示;请求已完整发送,对端先关闭时(如 macOS 偶发
|
|
58
|
+
# ENOTCONN)不得误判 CONTROLLER_DEAD,响应经信号文件交付。
|
|
59
|
+
pass
|
|
60
|
+
client.close()
|
|
61
|
+
except OSError as exc:
|
|
62
|
+
return {
|
|
63
|
+
"state": "CONTROLLER_DEAD",
|
|
64
|
+
"error": str(exc)[:120],
|
|
65
|
+
"request_id": request_id,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return wait_for_response(response_path(profile_id), request_id, timeout)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def wait_start(profile_id: str, timeout: float) -> dict:
|
|
72
|
+
path = response_path(profile_id)
|
|
73
|
+
deadline = time.monotonic() + timeout
|
|
74
|
+
while time.monotonic() < deadline:
|
|
75
|
+
if path.exists():
|
|
76
|
+
try:
|
|
77
|
+
payload = json.loads(path.read_text())
|
|
78
|
+
except (OSError, json.JSONDecodeError):
|
|
79
|
+
payload = {}
|
|
80
|
+
if payload.get("event") == "controller_started":
|
|
81
|
+
return payload
|
|
82
|
+
time.sleep(0.2)
|
|
83
|
+
return {"state": "START_TIMEOUT", "error": "controller startup timeout"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> int:
|
|
87
|
+
parser = argparse.ArgumentParser()
|
|
88
|
+
parser.add_argument("profile_id")
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"command",
|
|
91
|
+
choices=("status", "scan-done", "refresh-qr", "close", "wait-start"),
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument("--timeout", type=float)
|
|
94
|
+
args = parser.parse_args()
|
|
95
|
+
|
|
96
|
+
defaults = {
|
|
97
|
+
"status": 10,
|
|
98
|
+
"scan-done": 80,
|
|
99
|
+
"refresh-qr": 15,
|
|
100
|
+
"close": 15,
|
|
101
|
+
"wait-start": 45,
|
|
102
|
+
}
|
|
103
|
+
timeout = args.timeout or defaults[args.command]
|
|
104
|
+
if args.command == "wait-start":
|
|
105
|
+
result = wait_start(args.profile_id, timeout)
|
|
106
|
+
else:
|
|
107
|
+
wire_command = {
|
|
108
|
+
"status": "login-status",
|
|
109
|
+
"scan-done": "login-scan-done",
|
|
110
|
+
"refresh-qr": "login-refresh-qr",
|
|
111
|
+
"close": "login-close",
|
|
112
|
+
}[args.command]
|
|
113
|
+
result = send(args.profile_id, wire_command, timeout)
|
|
114
|
+
|
|
115
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
116
|
+
return 0 if "error" not in result else 1
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sys.exit(main())
|