@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,254 @@
1
+ #!/usr/bin/env python3
2
+ """Offline regression validator for douyin video ranking output files.
3
+ Usage: python3 validate_rankings.py <output_dir>
4
+ Exits 0 on all passes, non-zero on any failure."""
5
+ import json, sys, os
6
+ from pathlib import Path
7
+
8
+ RT = ['线索榜', '引流榜', '热门榜']
9
+ EXPECTED_HEADERS = {
10
+ '线索榜': ['线索指数', '线索工具覆盖率', '线索工具点击率'],
11
+ '引流榜': ['直播引流指数', '视频热度'],
12
+ '热门榜': ['视频热度', '流量指数', '互动指数'],
13
+ }
14
+ REQUIRED_FIELDS = ['title', 'author', 'douyin_id', 'publish_date', 'cover_url']
15
+
16
+ def validate(output_dir):
17
+ base = Path(output_dir)
18
+ errors = []
19
+ warnings = []
20
+
21
+ json_files = sorted(base.glob('douyin_video_rankings_*.json'))
22
+ xlsx_files = sorted(base.glob('douyin_video_rankings_*.xlsx'))
23
+ meta_files = sorted(base.glob('douyin_video_rankings_*.meta.json'))
24
+
25
+ if not json_files: errors.append('NO_JSON_FILE'); return errors, 1
26
+ if not meta_files: errors.append('NO_META_FILE'); return errors, 1
27
+
28
+ jf, mf = json_files[0], meta_files[0]
29
+
30
+ # Load JSON
31
+ try: data = json.loads(jf.read_text())
32
+ except Exception as e: errors.append(f'JSON_PARSE: {e}'); return errors, 1
33
+
34
+ try: meta = json.loads(mf.read_text())
35
+ except Exception as e: errors.append(f'META_PARSE: {e}'); return errors, 1
36
+
37
+ print(f'JSON: {jf.name}', flush=True)
38
+ print(f'META: {mf.name}', flush=True)
39
+
40
+ # === 1. Counts ===
41
+ total = 0
42
+ for rt in RT:
43
+ items = data.get('rankings', {}).get(rt, [])
44
+ total += len(items)
45
+ if len(items) != 20:
46
+ errors.append(f'COUNT_{rt}: expected 20, got {len(items)}')
47
+ else:
48
+ print(f' {rt}: 20 items ✓', flush=True)
49
+
50
+ if total != 60:
51
+ errors.append(f'TOTAL: expected 60, got {total}')
52
+ else:
53
+ print(f' Total: 60 ✓', flush=True)
54
+
55
+ if meta.get('total_count') != total:
56
+ errors.append(f'META_TOTAL: {meta.get("total_count")} != {total}')
57
+
58
+ # === 2. Ranks ===
59
+ all_ranks = {}
60
+ for rt in RT:
61
+ items = data.get('rankings', {}).get(rt, [])
62
+ ranks = sorted([i['global_rank'] for i in items])
63
+ missing = [r for r in range(1, 21) if r not in ranks]
64
+ all_ranks[rt] = {'ranks': ranks, 'missing': missing}
65
+ if missing:
66
+ errors.append(f'RANK_{rt}: missing {missing}')
67
+ else:
68
+ print(f' {rt}: ranks 1-20 continuous ✓', flush=True)
69
+
70
+ if not all(len(v['missing']) == 0 for v in all_ranks.values()):
71
+ errors.append('RANK_CONTINUITY_FAILED')
72
+
73
+ # === 3. Industry ===
74
+ industry = data.get('industry_display', '')
75
+ if '汽车' not in industry or '汽车厂商' not in industry:
76
+ errors.append(f'INDUSTRY: expected 汽车/汽车厂商, got {industry}')
77
+ else:
78
+ print(f' Industry: {industry} ✓', flush=True)
79
+
80
+ if meta.get('industry_verified') != True:
81
+ errors.append('INDUSTRY_NOT_VERIFIED')
82
+ else:
83
+ print(f' industry_verified: true ✓', flush=True)
84
+
85
+ if meta.get('page2_dom_verified') != True:
86
+ errors.append('PAGE2_NOT_VERIFIED')
87
+ else:
88
+ print(f' page2_dom_verified: true ✓', flush=True)
89
+
90
+ # === 4. Field validation ===
91
+ douyin_suffix_err = 0
92
+ title_eq_author = 0
93
+ empty_fields = {f: 0 for f in REQUIRED_FIELDS}
94
+ col_temp = 0
95
+
96
+ for rt in RT:
97
+ items = data.get('rankings', {}).get(rt, [])
98
+ for item in items:
99
+ r = item['global_rank']
100
+ for field in REQUIRED_FIELDS:
101
+ if not item.get(field):
102
+ empty_fields[field] += 1
103
+ errors.append(f'EMPTY_{field}: {rt} rank {r}')
104
+ if '发布时间' in item.get('douyin_id', ''):
105
+ douyin_suffix_err += 1
106
+ if item.get('title') == item.get('author'):
107
+ title_eq_author += 1
108
+ # Check for temp columns
109
+ for k in item.keys():
110
+ if k.startswith('col_') and k[4:].isdigit():
111
+ col_temp += 1
112
+
113
+ for field in REQUIRED_FIELDS:
114
+ if empty_fields[field] > 0:
115
+ errors.append(f'EMPTY_{field}_COUNT: {empty_fields[field]}')
116
+ else:
117
+ print(f' {field}: all non-empty ✓', flush=True)
118
+
119
+ if douyin_suffix_err > 0:
120
+ errors.append(f'DOUYIN_SUFFIX: {douyin_suffix_err} items')
121
+ else:
122
+ print(f' douyin_id: no suffix errors ✓', flush=True)
123
+
124
+ if title_eq_author > 0:
125
+ errors.append(f'TITLE_EQ_AUTHOR: {title_eq_author} items')
126
+ else:
127
+ print(f' title != author: all ✓', flush=True)
128
+
129
+ if col_temp > 0:
130
+ errors.append(f'COL_TEMP: {col_temp} temp fields')
131
+ else:
132
+ print(f' no col_N temp fields ✓', flush=True)
133
+
134
+ # === 5. Metric headers ===
135
+ for rt in RT:
136
+ items = data.get('rankings', {}).get(rt, [])
137
+ if not items: continue
138
+ expected = EXPECTED_HEADERS[rt]
139
+ for h in expected:
140
+ if h not in items[0]:
141
+ errors.append(f'HEADER_MISSING_{rt}: {h}')
142
+ else:
143
+ print(f' {rt}: {h} ✓', flush=True)
144
+
145
+ # === 6. Numeric fields ===
146
+ for rt in RT:
147
+ items = data.get('rankings', {}).get(rt, [])
148
+ if not items: continue
149
+ first = items[0]
150
+ expected = EXPECTED_HEADERS[rt]
151
+ for h in expected:
152
+ if h + '_numeric' not in first:
153
+ errors.append(f'NUMERIC_MISSING_{rt}: {h}_numeric')
154
+ else:
155
+ print(f' {rt}: {h}_numeric ✓', flush=True)
156
+
157
+ # === 7. XLSX check ===
158
+ if xlsx_files:
159
+ try:
160
+ import openpyxl
161
+ wb = openpyxl.load_workbook(str(xlsx_files[0]), read_only=True)
162
+ sheets = wb.sheetnames
163
+ expected_sheets = ['汇总', '线索榜', '引流榜', '热门榜']
164
+ for es in expected_sheets:
165
+ if es not in sheets:
166
+ errors.append(f'XLSX_SHEET_MISSING: {es}')
167
+ if 'summary' in sheets:
168
+ errors.append('XLSX_WRONG_SHEET: summary should be 汇总')
169
+ print(f' XLSX sheets: {sheets} ✓', flush=True)
170
+
171
+ # Check numeric columns in XLSX
172
+ for rt in RT:
173
+ if rt not in sheets: continue
174
+ ws = wb[rt]
175
+ header_row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True))
176
+ header_row = [h for h in header_row if h]
177
+ for h in EXPECTED_HEADERS[rt]:
178
+ if h + '_numeric' not in header_row:
179
+ errors.append(f'XLSX_NUMERIC_MISSING_{rt}: {h}_numeric')
180
+ else:
181
+ print(f' XLSX {rt}: {h}_numeric ✓', flush=True)
182
+ except Exception as e:
183
+ errors.append(f'XLSX_OPEN: {e}')
184
+
185
+ # === 8. video_url ===
186
+ video_url_empty = 0
187
+ for rt in RT:
188
+ items = data.get('rankings', {}).get(rt, [])
189
+ for item in items:
190
+ if not item.get('video_url'):
191
+ video_url_empty += 1
192
+
193
+ if video_url_empty != total:
194
+ errors.append(f'VIDEO_URL_EMPTY: {video_url_empty} != {total}')
195
+ else:
196
+ print(f' video_url: all {video_url_empty} empty ✓', flush=True)
197
+
198
+ # === 9. missing_fields ===
199
+ mf = data.get('missing_fields', [])
200
+ mfc = data.get('missing_field_counts', {})
201
+ video_url_mf = sum(1 for m in mf if 'video_url' in m)
202
+ if video_url_mf != total:
203
+ errors.append(f'MISSING_FIELDS_VIDEO_URL: {video_url_mf} != {total}')
204
+ else:
205
+ print(f' missing_fields video_url: {video_url_mf} ✓', flush=True)
206
+
207
+ if mfc.get('video_url') != total:
208
+ errors.append(f'MISSING_FIELD_COUNTS_video_url: {mfc.get("video_url")} != {total}')
209
+ else:
210
+ print(f' missing_field_counts video_url: {total} ✓', flush=True)
211
+
212
+ # === 10. completeness ===
213
+ completeness = meta.get('completeness', '')
214
+ if completeness != 'PARTIAL':
215
+ errors.append(f'COMPLETENESS: expected PARTIAL, got {completeness}')
216
+ else:
217
+ print(f' completeness: PARTIAL ✓', flush=True)
218
+
219
+ reason = meta.get('completeness_reason', '') or meta.get('video_url_note', '')
220
+ if '视频' not in reason and '链接' not in reason:
221
+ warnings.append('COMPLETENESS_REASON: missing video_url explanation')
222
+ print(f' completeness_reason: {reason} ⚠', flush=True)
223
+ else:
224
+ print(f' completeness_reason: {reason} ✓', flush=True)
225
+
226
+ # === 11. Cross-consistency ===
227
+ ml = meta.get('lead_count', 0); mt = meta.get('traffic_count', 0); mp = meta.get('popular_count', 0)
228
+ jl = len(data.get('rankings', {}).get('线索榜', []))
229
+ jt = len(data.get('rankings', {}).get('引流榜', []))
230
+ jp = len(data.get('rankings', {}).get('热门榜', []))
231
+ if ml != jl or mt != jt or mp != jp:
232
+ errors.append(f'CROSS_COUNT: meta({ml},{mt},{mp}) != json({jl},{jt},{jp})')
233
+ else:
234
+ print(f' cross-consistency: OK ✓', flush=True)
235
+
236
+ # === Summary ===
237
+ if errors:
238
+ print(f'\n❌ {len(errors)} ERRORS:', flush=True)
239
+ for e in errors: print(f' - {e}', flush=True)
240
+ if warnings:
241
+ print(f'\n⚠ {len(warnings)} WARNINGS:', flush=True)
242
+ for w in warnings: print(f' - {w}', flush=True)
243
+
244
+ exit_code = 0 if not errors else 1
245
+ if not errors:
246
+ print('\n✅ ALL CHECKS PASSED', flush=True)
247
+ f''
248
+ return errors, exit_code
249
+
250
+ if __name__ == '__main__':
251
+ target = sys.argv[1] if len(sys.argv) > 1 else '.'
252
+ _, code = validate(target)
253
+ print(f'\nEXIT CODE: {code}', flush=True)
254
+ sys.exit(code)
@@ -0,0 +1 @@
1
+ """Media Agent capabilities."""
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env python3
2
+ """Standalone read-only Toutiao login checker."""
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 time
11
+ import uuid
12
+ from pathlib import Path
13
+
14
+ try:
15
+ from media_agent.runtime.browser import launch_persistent_context
16
+ except ImportError: # Offline tests inject a launcher.
17
+ launch_persistent_context = None
18
+ from media_agent.platforms.toutiao.toutiao_login_controller import ToutiaoLoginController
19
+ from media_agent.platforms.toutiao.toutiao_login_evidence import classify_login_evidence, verify_account
20
+
21
+
22
+ def check_login(base: Path, profile_id: str, *, launcher=launch_persistent_context) -> dict:
23
+ probe = ToutiaoLoginController(base, profile_id, launcher=launcher)
24
+ try: config = probe.load_config()
25
+ except ValueError as exc: return {"state": str(exc), "profile_id": profile_id}
26
+ probe.task_id = f"toutiao_check_{profile_id}_{uuid.uuid4().hex[:12]}"
27
+ if not probe.acquire_lock(): return {"state": "PROFILE_LOCKED", "profile_id": profile_id}
28
+ try:
29
+ probe.launch(config)
30
+ evidence = probe.page_evidence()
31
+ status = classify_login_evidence(**evidence)
32
+ expected = config.get("account", {}).get("expected_account_name") or config.get("account", {}).get("name", "")
33
+ account_state = verify_account(evidence.get("account_name"), expected) if status == "LOGGED_IN" else "not_run"
34
+ return {"state": status, "profile_id": profile_id,
35
+ "login_status": "logged_in" if status == "LOGGED_IN" else "login_required" if status == "LOGIN_REQUIRED" else "indeterminate",
36
+ "account_display_name": evidence.get("account_name") or "unknown",
37
+ "account_verified": account_state == "ACCOUNT_CONFIRMED",
38
+ "qr_visible": evidence.get("qr_visible", False), "persistence_verified": "not_run"}
39
+ except Exception as exc:
40
+ return {"state": "CHECK_FAILED", "profile_id": profile_id, "error": str(exc)[:120]}
41
+ finally:
42
+ if probe.context is not None:
43
+ try: probe.context.close()
44
+ except Exception: pass
45
+ probe.release_owned_lock()
46
+
47
+
48
+ def main() -> int:
49
+ parser = argparse.ArgumentParser(); parser.add_argument("profile_id"); parser.add_argument("--base", type=Path, default=runtime_home())
50
+ args = parser.parse_args(); result = check_login(args.base, args.profile_id); print(json.dumps(result, ensure_ascii=False, indent=2))
51
+ return 0 if result.get("state") == "LOGGED_IN" else 2 if result.get("state") == "LOGIN_REQUIRED" else 1
52
+
53
+
54
+ if __name__ == "__main__": raise SystemExit(main())
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ """Persistent Toutiao login controller with serialized browser operations."""
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 queue
11
+ import socket
12
+ import threading
13
+ import time
14
+ import uuid
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ try:
19
+ from media_agent.runtime.browser import launch_persistent_context
20
+ except ImportError: # Offline tests inject a launcher.
21
+ launch_persistent_context = None
22
+ from media_agent.platforms.toutiao.toutiao_login_evidence import allowed_toutiao_site, classify_login_evidence, scan_poll_plan, verify_account
23
+
24
+
25
+ class ToutiaoLoginController:
26
+ def __init__(self, base: Path, profile_id: str, *, launcher=launch_persistent_context):
27
+ self.base = Path(base)
28
+ self.profile_id = profile_id
29
+ self.profile_dir = self.base / "profiles" / profile_id
30
+ self.lock_path = self.base / "locks" / f"{profile_id}.lock"
31
+ self.socket_path = self.base / "controllers" / f"{profile_id}.sock"
32
+ self.launcher = launcher
33
+ self.pid = os.getpid()
34
+ self.task_id = f"toutiao_login_{profile_id}_{uuid.uuid4().hex[:12]}"
35
+ self.state = "INIT"
36
+ self.state_data: dict = {}
37
+ self.context = None
38
+ self.page = None
39
+ self.running = True
40
+ self.operations: queue.Queue[tuple[str, dict]] = queue.Queue()
41
+ self.worker: threading.Thread | None = None
42
+
43
+ def load_config(self) -> dict:
44
+ if not self.profile_dir.is_dir():
45
+ raise ValueError("PROFILE_NOT_FOUND")
46
+ path = self.profile_dir / "config.json"
47
+ if not path.is_file():
48
+ raise ValueError("CONFIG_NOT_FOUND")
49
+ config = json.loads(path.read_text())
50
+ if config.get("account", {}).get("platform", "").lower() != "toutiao":
51
+ raise ValueError("WRONG_PLATFORM")
52
+ sites = config.get("allowed_sites") or config.get("account", {}).get("allowed_sites", [])
53
+ if not allowed_toutiao_site(sites):
54
+ raise ValueError("MISSING_ALLOWED_SITE")
55
+ return config
56
+
57
+ @staticmethod
58
+ def _alive(pid) -> bool:
59
+ try:
60
+ if not isinstance(pid, int) or pid <= 1:
61
+ return False
62
+ os.kill(pid, 0)
63
+ return True
64
+ except OSError:
65
+ return False
66
+
67
+ def acquire_lock(self) -> bool:
68
+ self.lock_path.parent.mkdir(parents=True, exist_ok=True)
69
+ if self.lock_path.exists():
70
+ try:
71
+ current = json.loads(self.lock_path.read_text())
72
+ except (OSError, json.JSONDecodeError):
73
+ return False
74
+ if self._alive(current.get("pid")):
75
+ return False
76
+ self.lock_path.unlink()
77
+ self.lock_path.write_text(json.dumps({"pid": self.pid, "task_id": self.task_id,
78
+ "created_at": datetime.now(timezone.utc).isoformat()}))
79
+ return True
80
+
81
+ def release_owned_lock(self) -> bool:
82
+ if not self.lock_path.exists():
83
+ return True
84
+ try:
85
+ current = json.loads(self.lock_path.read_text())
86
+ except (OSError, json.JSONDecodeError):
87
+ return False
88
+ if current.get("pid") != self.pid or current.get("task_id") != self.task_id:
89
+ return False
90
+ self.lock_path.unlink()
91
+ return True
92
+
93
+ def launch(self, config: dict) -> None:
94
+ if self.launcher is None:
95
+ raise RuntimeError("cloakbrowser is required for live browser operations")
96
+ fp = config["fingerprint"]
97
+ self.context = self.launcher(
98
+ user_data_dir=str(self.profile_dir / "browser_data"), headless=False,
99
+ stealth_args=True, viewport=fp["viewport"], locale=fp["locale"],
100
+ timezone=fp["timezone"], humanize=False, geoip=False)
101
+ self.page = self.context.pages[0] if self.context.pages else self.context.new_page()
102
+ self.page.goto("https://www.toutiao.com/", wait_until="load", timeout=60000)
103
+
104
+ def page_evidence(self) -> dict:
105
+ return self.page.evaluate("""() => {
106
+ const visible = el => !!el && el.offsetParent !== null;
107
+ const text = el => (el?.textContent || '').trim();
108
+ const headers = [...document.querySelectorAll('header,[class*="header"],[class*="topbar"],[class*="top-bar"]')].filter(visible);
109
+ const candidates = [];
110
+ for (const root of headers) {
111
+ for (const el of root.querySelectorAll('[class*="user"],[class*="name"],[class*="nick"],a,span')) {
112
+ const t = text(el); const r = el.getBoundingClientRect();
113
+ if (visible(el) && t.length >= 2 && t.length <= 40 && r.top < 100 && r.left > innerWidth * .55 && t !== '登录') candidates.push(t);
114
+ }
115
+ }
116
+ const login = [...document.querySelectorAll('button,a,span,div')].some(el => visible(el) && text(el) === '登录' && el.getBoundingClientRect().top < 120);
117
+ const qrText = [...document.querySelectorAll('div,span')].some(el => visible(el) && ['扫码登录','二维码登录'].includes(text(el)));
118
+ const qrImage = [...document.querySelectorAll('img,canvas')].some(el => { const r=el.getBoundingClientRect(); return visible(el) && r.width>=140 && r.width<=400 && r.height>=140 && r.height<=400; });
119
+ return {login_visible: login, qr_visible: qrText && qrImage, account_name: candidates[0] || ''};
120
+ }""")
121
+
122
+ def capture_qr(self) -> str | None:
123
+ path = self.base / "screenshots" / f"{self.profile_id}_qr.png"
124
+ path.parent.mkdir(parents=True, exist_ok=True)
125
+ locator = self.page.get_by_text("扫码登录", exact=True).first
126
+ if locator.count() == 0:
127
+ return None
128
+ container = locator.locator("xpath=ancestor::*[.//img or .//canvas][1]")
129
+ qr = container.locator("img,canvas").first
130
+ if qr.count() == 0:
131
+ return None
132
+ qr.screenshot(path=str(path))
133
+ return str(path) if path.exists() and path.stat().st_size > 0 else None
134
+
135
+ def open_login(self) -> None:
136
+ self.page.get_by_text("登录", exact=True).first.click()
137
+ self.page.wait_for_timeout(1000)
138
+
139
+ def refresh_qr(self) -> str | None:
140
+ qr_text = self.page.get_by_text("扫码登录", exact=True).first
141
+ container = qr_text.locator("xpath=ancestor::*[.//*[contains(text(),'刷新') or contains(text(),'重新获取')]][1]")
142
+ button = container.get_by_text("刷新", exact=False).or_(container.get_by_text("重新获取", exact=False)).first
143
+ if button.count() == 0:
144
+ return None
145
+ button.click()
146
+ self.page.wait_for_timeout(1000)
147
+ return self.capture_qr()
148
+
149
+ def expected_name(self, supplied=None) -> str:
150
+ if supplied:
151
+ return supplied
152
+ return self.load_config().get("account", {}).get("expected_account_name") or self.load_config().get("account", {}).get("name", "")
153
+
154
+ def verify_current_account(self, expected=None) -> dict:
155
+ evidence = self.page_evidence()
156
+ result = verify_account(evidence.get("account_name"), self.expected_name(expected))
157
+ self.state = result
158
+ self.state_data = {"account_display_name": evidence.get("account_name") or "unknown",
159
+ "expected_account_name": self.expected_name(expected),
160
+ "account_verified": result == "ACCOUNT_CONFIRMED"}
161
+ return {"state": self.state, **self.state_data}
162
+
163
+ def scan(self, expected=None) -> None:
164
+ interval, polls = scan_poll_plan()
165
+ for _ in range(polls):
166
+ evidence = self.page_evidence()
167
+ status = classify_login_evidence(**evidence)
168
+ if status == "LOGGED_IN":
169
+ self.verify_current_account(expected)
170
+ return
171
+ time.sleep(interval)
172
+ self.state = "INDETERMINATE"
173
+ self.state_data = {"error": "TIMEOUT_NO_STABLE_LOGIN_EVIDENCE"}
174
+
175
+ def worker_loop(self) -> None:
176
+ while self.running:
177
+ operation, data = self.operations.get()
178
+ try:
179
+ if operation == "scan": self.scan(data.get("expected_account_name"))
180
+ elif operation == "verify": self.verify_current_account(data.get("expected_account_name"))
181
+ elif operation == "refresh":
182
+ qr = self.refresh_qr(); self.state = "WAIT_QR_SCAN" if qr else "QR_EXPIRED"; self.state_data = {"qr_path": qr}
183
+ elif operation == "close": self.running = False
184
+ finally:
185
+ self.operations.task_done()
186
+
187
+ def command(self, request: dict) -> dict:
188
+ request_id = request.get("request_id")
189
+ if not request_id:
190
+ return {"state": "INVALID_REQUEST", "error": "request_id required"}
191
+ if request.get("profile_id") != self.profile_id:
192
+ return {"state": "PROFILE_MISMATCH", "request_id": request_id}
193
+ action = request.get("action")
194
+ response = {"state": self.state, "request_id": request_id, "profile_id": self.profile_id,
195
+ "task_id": self.task_id, **self.state_data}
196
+ if action == "status": return response
197
+ mapping = {"scan_done": "scan", "verify_account": "verify", "refresh_qr": "refresh", "close": "close"}
198
+ if action not in mapping:
199
+ return {**response, "state": "INVALID_REQUEST", "error": "unknown action"}
200
+ if action == "scan_done": self.state = "VERIFYING_LOGIN"
201
+ self.operations.put((mapping[action], dict(request)))
202
+ return {**response, "state": self.state, "accepted": True}
203
+
204
+ def serve(self) -> None:
205
+ self.socket_path.parent.mkdir(parents=True, exist_ok=True)
206
+ self.socket_path.unlink(missing_ok=True)
207
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
208
+ server.bind(str(self.socket_path)); os.chmod(self.socket_path, 0o600); server.listen(4); server.settimeout(1)
209
+ try:
210
+ while self.running:
211
+ try: conn, _ = server.accept()
212
+ except socket.timeout: continue
213
+ with conn:
214
+ conn.settimeout(3); data = conn.recv(65536)
215
+ try: response = self.command(json.loads(data.decode()))
216
+ except Exception as exc: response = {"state": "IPC_ERROR", "error": str(exc)[:120]}
217
+ conn.sendall(json.dumps(response, ensure_ascii=False).encode())
218
+ finally:
219
+ server.close(); self.socket_path.unlink(missing_ok=True)
220
+
221
+ def run(self) -> int:
222
+ try: config = self.load_config()
223
+ except ValueError as exc:
224
+ print(json.dumps({"state": str(exc)})); return 5
225
+ if not self.acquire_lock():
226
+ print(json.dumps({"state": "PROFILE_LOCKED"})); return 3
227
+ try:
228
+ self.launch(config)
229
+ evidence = self.page_evidence()
230
+ status = classify_login_evidence(**evidence)
231
+ if status == "LOGGED_IN": self.verify_current_account()
232
+ elif status == "LOGIN_REQUIRED":
233
+ self.open_login(); qr = self.capture_qr(); self.state = "WAIT_QR_SCAN" if qr else "INDETERMINATE"; self.state_data = {"qr_path": qr}
234
+ else: self.state = "INDETERMINATE"
235
+ print(json.dumps({"state": self.state, "task_id": self.task_id, **self.state_data}, ensure_ascii=False), flush=True)
236
+ self.worker = threading.Thread(target=self.worker_loop, daemon=False); self.worker.start(); self.serve()
237
+ self.worker.join(timeout=65)
238
+ if self.worker.is_alive(): return 1
239
+ return 0
240
+ finally:
241
+ if self.context is not None: self.context.close()
242
+ self.release_owned_lock()
243
+
244
+
245
+ def main() -> int:
246
+ parser = argparse.ArgumentParser(); parser.add_argument("profile_id"); parser.add_argument("--base", type=Path, default=runtime_home())
247
+ args = parser.parse_args(); return ToutiaoLoginController(args.base, args.profile_id).run()
248
+
249
+
250
+ if __name__ == "__main__": raise SystemExit(main())
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env python3
2
+ """Pure evidence and state helpers for Toutiao login."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import Iterable
8
+
9
+
10
+ TOUTIAO_URL = "https://www.toutiao.com/"
11
+
12
+
13
+ def normalize_nickname(value: str | None) -> str:
14
+ return re.sub(r"\s+", " ", (value or "").strip())
15
+
16
+
17
+ def verify_account(actual: str | None, expected: str | None) -> str:
18
+ if not normalize_nickname(expected):
19
+ return "WAIT_ACCOUNT_CONFIRM"
20
+ if not normalize_nickname(actual):
21
+ return "INDETERMINATE"
22
+ return (
23
+ "ACCOUNT_CONFIRMED"
24
+ if normalize_nickname(actual) == normalize_nickname(expected)
25
+ else "ACCOUNT_MISMATCH"
26
+ )
27
+
28
+
29
+ def classify_login_evidence(*, login_visible: bool, qr_visible: bool, account_name: str | None) -> str:
30
+ account_visible = bool(normalize_nickname(account_name))
31
+ if account_visible and not login_visible and not qr_visible:
32
+ return "LOGGED_IN"
33
+ if (login_visible or qr_visible) and not account_visible:
34
+ return "LOGIN_REQUIRED"
35
+ return "INDETERMINATE"
36
+
37
+
38
+ def scan_poll_plan(interval: float = 2.0, max_seconds: float = 60.0) -> tuple[float, int]:
39
+ bounded_interval = min(max(float(interval), 0.1), max_seconds)
40
+ return bounded_interval, max(1, int(max_seconds // bounded_interval))
41
+
42
+
43
+ def persistence_verified(*, closed: bool, waited_seconds: float, same_profile: bool,
44
+ qr_visible: bool, account_state: str) -> bool:
45
+ return all((closed, waited_seconds >= 10, same_profile, not qr_visible,
46
+ account_state == "ACCOUNT_CONFIRMED"))
47
+
48
+
49
+ def allowed_toutiao_site(sites: Iterable[str]) -> bool:
50
+ return any("toutiao.com" in site for site in sites)