refactor(login): 移除脚本内扫码重登,登录统一交给账号 vault 与 cookie-gate
删除 relogin_*/friday_relogin_parallel 子进程及其测试;批量补采、评论采集、 星图/蒲公英/B站/抖音自营与抖店竞品脚本不再内嵌扫码等待,登录态失效统一由 cookie-gate 阻断并走 gyxx accounts login 恢复;runtime-bindings 与模块注册 同步移除 content.relogin.weekly。
This commit is contained in:
1 parent
01f3d7aaf7
commit
78ea4641ce
28 files changed
+120
-4254
No files matched your search
+13
-100
@@ -312,8 +312,6 @@
|
||||
},
|
||||
"members": [
|
||||
"content.comments.collect_xiaohongshu",
|
||||
"content_marketing:data/tools/relogin_pgy.py",
|
||||
"content_marketing:data/tools/relogin_xiaohongshu.py",
|
||||
"content_marketing:pgy_xhs_scraper_v2.py"
|
||||
]
|
||||
},
|
||||
@@ -340,7 +338,6 @@
|
||||
},
|
||||
"members": [
|
||||
"content.comments.collect_douyin",
|
||||
"content_marketing:data/tools/relogin_douyin.py",
|
||||
"content_marketing:self_douyin_scraper.py"
|
||||
]
|
||||
},
|
||||
@@ -368,12 +365,15 @@
|
||||
"real_chrome": true
|
||||
},
|
||||
"members": [
|
||||
"content_marketing:data/tools/relogin_xingtu.py"
|
||||
]
|
||||
},
|
||||
"chanmama": {
|
||||
"cdp_port": 22207,
|
||||
"login_mode": "D",
|
||||
"credential_env_names": [
|
||||
"CHANMAMA_ACCOUNT",
|
||||
"CHANMAMA_PASSWORD"
|
||||
],
|
||||
"required_cookie_domains": [
|
||||
"chanmama.com"
|
||||
],
|
||||
@@ -411,8 +411,7 @@
|
||||
},
|
||||
"members": [
|
||||
"content.comments.collect_bilibili",
|
||||
"content.metrics.collect_self_bilibili",
|
||||
"content_marketing:data/tools/relogin_bilibili.py"
|
||||
"content.metrics.collect_self_bilibili"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -582,23 +581,6 @@
|
||||
],
|
||||
"cdp_port": 22016
|
||||
},
|
||||
"content_marketing:data/tools/friday_relogin.bat": {
|
||||
"script_id": "content_marketing:data/tools/friday_relogin.bat",
|
||||
"state_key": "content_marketing:data/tools/friday_relogin.bat",
|
||||
"aliases": [
|
||||
"content_marketing:data/tools/friday_relogin.bat"
|
||||
],
|
||||
"cdp_port": 22017
|
||||
},
|
||||
"content.login.refresh": {
|
||||
"script_id": "content_marketing:data/tools/friday_relogin_parallel.py",
|
||||
"state_key": "content_marketing:data/tools/friday_relogin_parallel.py",
|
||||
"aliases": [
|
||||
"content_marketing:data/tools/friday_relogin_parallel.py"
|
||||
],
|
||||
"cdp_port": 22018,
|
||||
"login_mode": "D"
|
||||
},
|
||||
"content.creator_report.generate": {
|
||||
"script_id": "content_marketing:data/tools/generate_creator_report.py",
|
||||
"state_key": "content_marketing:data/tools/generate_creator_report.py",
|
||||
@@ -679,83 +661,6 @@
|
||||
],
|
||||
"cdp_port": 22135
|
||||
},
|
||||
"content_marketing:data/tools/relogin_bilibili.py": {
|
||||
"script_id": "content_marketing:data/tools/relogin_bilibili.py",
|
||||
"state_key": "content_marketing:data/tools/relogin_bilibili.py",
|
||||
"aliases": [
|
||||
"content_marketing:data/tools/relogin_bilibili.py"
|
||||
],
|
||||
"cdp_port": 22028,
|
||||
"login_mode": "D",
|
||||
"required_cookie_domains": [
|
||||
"bilibili.com"
|
||||
],
|
||||
"required_cookie_names": [
|
||||
"SESSDATA"
|
||||
]
|
||||
},
|
||||
"content_marketing:data/tools/relogin_douyin.py": {
|
||||
"script_id": "content_marketing:data/tools/relogin_douyin.py",
|
||||
"state_key": "content_marketing:data/tools/relogin_douyin.py",
|
||||
"aliases": [
|
||||
"content_marketing:data/tools/relogin_douyin.py"
|
||||
],
|
||||
"cdp_port": 22029,
|
||||
"login_mode": "D",
|
||||
"required_cookie_domains": [
|
||||
"douyin.com"
|
||||
],
|
||||
"required_cookie_names": [
|
||||
"sessionid"
|
||||
]
|
||||
},
|
||||
"content_marketing:data/tools/relogin_pgy.py": {
|
||||
"script_id": "content_marketing:data/tools/relogin_pgy.py",
|
||||
"state_key": "content_marketing:data/tools/relogin_pgy.py",
|
||||
"aliases": [
|
||||
"content_marketing:data/tools/relogin_pgy.py"
|
||||
],
|
||||
"cdp_port": 22030,
|
||||
"login_mode": "D",
|
||||
"required_cookie_domains": [
|
||||
"xiaohongshu.com"
|
||||
]
|
||||
},
|
||||
"content_marketing:data/tools/relogin_xiaohongshu.py": {
|
||||
"script_id": "content_marketing:data/tools/relogin_xiaohongshu.py",
|
||||
"state_key": "content_marketing:data/tools/relogin_xiaohongshu.py",
|
||||
"aliases": [
|
||||
"content_marketing:data/tools/relogin_xiaohongshu.py"
|
||||
],
|
||||
"cdp_port": 22031,
|
||||
"login_mode": "D",
|
||||
"required_cookie_domains": [
|
||||
"xiaohongshu.com"
|
||||
],
|
||||
"required_cookie_names": [
|
||||
"web_session"
|
||||
]
|
||||
},
|
||||
"content_marketing:data/tools/relogin_xingtu.py": {
|
||||
"script_id": "content_marketing:data/tools/relogin_xingtu.py",
|
||||
"state_key": "content_marketing:data/tools/relogin_xingtu.py",
|
||||
"aliases": [
|
||||
"content_marketing:data/tools/relogin_xingtu.py"
|
||||
],
|
||||
"cdp_port": 22032,
|
||||
"login_mode": "D",
|
||||
"required_cookie_domains": [
|
||||
"douyin.com",
|
||||
"oceanengine.com",
|
||||
"xingtu.cn"
|
||||
],
|
||||
"required_cookie_names": [
|
||||
"sessionid",
|
||||
"sessionid_ss",
|
||||
"sid_tt",
|
||||
"sid_guard"
|
||||
]
|
||||
},
|
||||
"content.failed.retry": {
|
||||
"script_id": "content_marketing:data/tools/retry_failed.py",
|
||||
"state_key": "content_marketing:data/tools/retry_failed.py",
|
||||
@@ -1441,6 +1346,14 @@
|
||||
"erp321.com"
|
||||
]
|
||||
},
|
||||
"product.hk_anpei_public_inventory.refresh": {
|
||||
"script_id": "product_commerce:public_sku_inventory_sheet.py",
|
||||
"state_key": "product_commerce:public_sku_inventory_sheet.py",
|
||||
"aliases": [
|
||||
"product_commerce:public_sku_inventory_sheet.py"
|
||||
],
|
||||
"cdp_port": 22166
|
||||
},
|
||||
"product_commerce:scripts/insert_jd_main_image_records.py": {
|
||||
"script_id": "product_commerce:scripts/insert_jd_main_image_records.py",
|
||||
"state_key": "product_commerce:scripts/insert_jd_main_image_records.py",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Content metrics, creator, comment, report, and login workflows."""
|
||||
"""Content metrics, creator, comment, and report workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,7 +14,6 @@ CONTENT_SCHEDULED_WORKFLOW_IDS = (
|
||||
"content.metrics.self_operated.daily",
|
||||
"content.metrics.backfill",
|
||||
"content.marketing_report.daily",
|
||||
"content.relogin.weekly",
|
||||
"content.creator_report.monthly",
|
||||
"content.summary.monthly",
|
||||
"content.notes_master.daily",
|
||||
@@ -28,7 +27,6 @@ CONTENT_COMMENT_TIMEOUT_SECONDS = 6 * 60 * 60
|
||||
# weakening the module-wide write lock.
|
||||
CONTENT_NOTES_MASTER_LOCK_TIMEOUT_SECONDS = 6 * 60 * 60
|
||||
CONTENT_RESOURCE = "module:content_marketing"
|
||||
_RELOGIN_WORKFLOW_ID = "content.relogin.weekly"
|
||||
_PARALLEL_COMMENT_WORKFLOW_ID = "content.comments.weekly"
|
||||
# Keep both long-running daily metrics graphs on workflow-scoped resources so
|
||||
# their collection steps do not block unrelated content workflows.
|
||||
@@ -94,7 +92,6 @@ class ContentMarketingModule:
|
||||
),
|
||||
resource=workflow_resource,
|
||||
command_factory=command_factory,
|
||||
official_notification=workflow_id == _RELOGIN_WORKFLOW_ID,
|
||||
independent_step_resources=(
|
||||
workflow_id in {
|
||||
_PARALLEL_COMMENT_WORKFLOW_ID,
|
||||
|
||||
@@ -77,23 +77,15 @@ def is_logged_in(page: Page) -> bool:
|
||||
|
||||
|
||||
def maybe_wait_for_login(page: Page, login_timeout: int) -> None:
|
||||
"""检测登录态,未登录就等用户扫码。B 站登录页 URL 含 passport.bilibili.com/login。"""
|
||||
"""检测登录态;未登录立即降级为游客态,不再等待人工扫码。
|
||||
|
||||
登录统一由账号 vault 管理(gyxx accounts login bilibili-content),
|
||||
由工作流执行前的 cookie 网关保证登录态。
|
||||
"""
|
||||
if is_logged_in(page):
|
||||
return
|
||||
log("B 站未登录 (无 SESSDATA cookie)。请在弹出的浏览器窗口扫码登录。")
|
||||
# 跳到登录页,确保用户能看到二维码
|
||||
try:
|
||||
page.goto("https://passport.bilibili.com/login", wait_until="domcontentloaded", timeout=30000)
|
||||
except Exception as exc:
|
||||
log(f" 跳登录页失败 (继续等当前页扫码): {exc}")
|
||||
deadline = time.time() + login_timeout
|
||||
while time.time() < deadline:
|
||||
if is_logged_in(page):
|
||||
log("登录成功,SESSDATA 已写入")
|
||||
save_state(page)
|
||||
return
|
||||
page.wait_for_timeout(2000)
|
||||
log(f" [WARN] 等待 {login_timeout}s 仍未登录,继续以游客态采集 (评论只会拿到热门 3-5 条)")
|
||||
log(" [WARN] B 站未登录 (无 SESSDATA cookie),降级为游客态采集 (评论只会拿到热门 3-5 条)。"
|
||||
"如需全量评论请执行 gyxx accounts login bilibili-content")
|
||||
|
||||
|
||||
def safe_filename(value: str) -> str:
|
||||
@@ -437,48 +429,17 @@ def write_outputs(url: str, comments: list[dict[str, Any]], result: dict[str, An
|
||||
return json_path, csv_path
|
||||
|
||||
|
||||
def login_only(login_timeout: int) -> int:
|
||||
"""只触发登录(headed),登录完成立刻退出。给 relogin_bilibili.py 用。"""
|
||||
def action(page: Page) -> None:
|
||||
page.goto("https://passport.bilibili.com/login", wait_until="domcontentloaded", timeout=30000)
|
||||
page.wait_for_timeout(1500)
|
||||
maybe_wait_for_login(page, login_timeout)
|
||||
|
||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with DynamicSession(
|
||||
headless=False,
|
||||
real_chrome=True,
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
locale="zh-CN",
|
||||
timezone_id="Asia/Shanghai",
|
||||
timeout=90000,
|
||||
network_idle=False,
|
||||
disable_resources=False,
|
||||
google_search=False,
|
||||
page_setup=restore_cookies,
|
||||
max_pages=1,
|
||||
) as session:
|
||||
session.fetch("https://passport.bilibili.com/login", page_action=action, wait=1000)
|
||||
log("[login-only] 完成")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Scrape Bilibili video comments with a real browser and API.")
|
||||
parser.add_argument("url", help="Bilibili video URL (传 dummy 'login' 配合 --login-only 也可)")
|
||||
parser.add_argument("url", help="Bilibili video URL")
|
||||
parser.add_argument("--max-reply-pages", type=int, default=200, help="max sub-reply pages per top-level comment")
|
||||
parser.add_argument("--max-pages", type=int, default=200, help="最多抓取一级评论页数")
|
||||
parser.add_argument("--delay", type=float, default=0.25, help="接口请求间隔秒数")
|
||||
parser.add_argument("--headless", action="store_true", help="无头模式")
|
||||
parser.add_argument("--login-timeout", type=int, default=0,
|
||||
help="未登录时等待扫码秒数(>0 启用登录检测;默认 0 沿用游客态)")
|
||||
parser.add_argument("--login-only", action="store_true",
|
||||
help="只触发登录(headed)然后退出,给 relogin_bilibili 用")
|
||||
help="保留参数(登录等待已移除;未登录将直接以游客态采集)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.login_only:
|
||||
return login_only(args.login_timeout if args.login_timeout > 0 else 300)
|
||||
|
||||
comments, result = scrape_comments(
|
||||
args.url, args.max_pages, args.delay, args.headless,
|
||||
args.max_reply_pages, args.login_timeout,
|
||||
|
||||
@@ -18,7 +18,6 @@ from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
_TOOLS_DIR = PATHS.tools_root
|
||||
_PROJECT_ROOT = PATHS.module_root
|
||||
|
||||
RELOGIN_SCRIPT = _TOOLS_DIR / "relogin_bilibili.py"
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||
@@ -81,39 +80,6 @@ def comment_replace_policy(comments: list[dict], stats: dict, metrics: dict) ->
|
||||
return False, False, "空评论且无法确认平台为 0 评论"
|
||||
|
||||
|
||||
def do_relogin() -> bool:
|
||||
"""Run relogin_bilibili.py (headed, waits for QR scan). Returns True if successful."""
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled:
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.batch_rescrape_bilibili.relogin",
|
||||
details={
|
||||
"status": "SKIPPED_COOKIE",
|
||||
"reason": "cookie is invalid and QR relogin is disabled for acceptance",
|
||||
},
|
||||
)
|
||||
log(
|
||||
" [SKIPPED_COOKIE] Cookie invalid; acceptance mode will not "
|
||||
"launch relogin or wait for a QR scan"
|
||||
)
|
||||
return False
|
||||
log(" Cookie 可能过期,触发自动重登 (请扫码)...")
|
||||
try:
|
||||
rc = subprocess.call(
|
||||
[sys.executable, str(RELOGIN_SCRIPT)],
|
||||
cwd=str(_PROJECT_ROOT),
|
||||
)
|
||||
if rc == 0:
|
||||
log(" 重登成功,继续采集")
|
||||
return True
|
||||
else:
|
||||
log(f" 重登失败 (exit={rc})")
|
||||
return False
|
||||
except Exception as exc:
|
||||
log(f" 重登脚本执行异常: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
|
||||
rows = collect_urls()
|
||||
@@ -126,8 +92,6 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
|
||||
total_comments = 0
|
||||
consecutive_fail = 0
|
||||
MAX_CONSECUTIVE_FAIL = 3
|
||||
relogin_used = 0
|
||||
MAX_RELOGINS = 1
|
||||
t0 = time.time()
|
||||
for i, row in enumerate(rows, 1):
|
||||
note_id = row["id"]
|
||||
@@ -152,11 +116,12 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
|
||||
break
|
||||
if comments is None:
|
||||
consecutive_fail += 1
|
||||
if consecutive_fail >= MAX_CONSECUTIVE_FAIL and relogin_used < MAX_RELOGINS:
|
||||
if do_relogin():
|
||||
relogin_used += 1
|
||||
consecutive_fail = 0
|
||||
continue
|
||||
if consecutive_fail >= MAX_CONSECUTIVE_FAIL:
|
||||
# 登录统一由账号 vault 管理;连续失败只提示,不再触发扫码重登。
|
||||
log(
|
||||
" 连续失败达到上限;若为登录态失效请执行 "
|
||||
"uv run gyxx accounts login bilibili-content"
|
||||
)
|
||||
if current_acceptance_policy().enabled:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
continue
|
||||
|
||||
@@ -26,7 +26,6 @@ from gyxx_flow.modules.content_marketing.serialized_page_action import (
|
||||
_TOOLS_DIR = PATHS.tools_root
|
||||
_PROJECT_ROOT = PATHS.module_root
|
||||
|
||||
RELOGIN_SCRIPT = _TOOLS_DIR / "relogin_douyin.py"
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||
@@ -104,38 +103,6 @@ def result_logged_in(result: dict | None) -> bool:
|
||||
return bool(nested_stats.get("logged_in")) if isinstance(nested_stats, dict) else False
|
||||
|
||||
|
||||
def do_relogin() -> bool:
|
||||
"""Run relogin_douyin.py (headed, waits for QR scan). Returns True if successful."""
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled:
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.batch_rescrape_douyin.relogin",
|
||||
details={
|
||||
"status": "SKIPPED_COOKIE",
|
||||
"reason": "cookie is invalid and QR relogin is disabled for acceptance",
|
||||
},
|
||||
)
|
||||
log(
|
||||
" [SKIPPED_COOKIE] Cookie invalid; acceptance mode will not "
|
||||
"launch relogin or wait for a QR scan"
|
||||
)
|
||||
return False
|
||||
log(" Cookie 可能过期,触发自动重登 (请扫码)...")
|
||||
try:
|
||||
rc = subprocess.call(
|
||||
[sys.executable, str(RELOGIN_SCRIPT)],
|
||||
cwd=str(_PROJECT_ROOT),
|
||||
)
|
||||
if rc == 0:
|
||||
log(" 重登成功,继续采集")
|
||||
return True
|
||||
log(f" 重登失败 (exit={rc})")
|
||||
return False
|
||||
except Exception as exc:
|
||||
log(f" 重登脚本执行异常: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def scrape_with_fresh_browser(
|
||||
url: str,
|
||||
@@ -203,8 +170,6 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
|
||||
COOLDOWN_SECONDS = 300
|
||||
cooldown_used = 0
|
||||
MAX_COOLDOWNS = 3
|
||||
relogin_used = 0
|
||||
MAX_RELOGINS = 1 # 每轮 batch 最多自动重登1次
|
||||
t0 = time.time()
|
||||
for i, row in enumerate(rows, 1):
|
||||
note_id = row["id"]
|
||||
@@ -214,14 +179,8 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
|
||||
|
||||
if comments is None:
|
||||
consecutive_fail += 1
|
||||
# 连续失败 + 可能 cookie 过期 → 尝试自动重登
|
||||
if consecutive_fail >= MAX_CONSECUTIVE_FAIL and relogin_used < MAX_RELOGINS:
|
||||
if do_relogin():
|
||||
relogin_used += 1
|
||||
consecutive_fail = 0
|
||||
comments, stats, scrape_error = scrape_with_fresh_browser(url)
|
||||
else:
|
||||
# 重登失败,走冷却逻辑;验收模式必须直接跳过,不能等待。
|
||||
# 登录统一由账号 vault 管理;不再触发扫码重登,验收模式直接跳过。
|
||||
if consecutive_fail >= MAX_CONSECUTIVE_FAIL:
|
||||
if current_acceptance_policy().enabled:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ from gyxx_flow.modules.content_marketing.serialized_page_action import (
|
||||
_TOOLS_DIR = PATHS.tools_root
|
||||
_PROJECT_ROOT = PATHS.module_root
|
||||
|
||||
RELOGIN_SCRIPT = _TOOLS_DIR / "relogin_xiaohongshu.py"
|
||||
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||
@@ -87,38 +86,6 @@ def comment_replace_policy(comments: list[dict], stats: dict, metrics: dict) ->
|
||||
return False, False, "空评论且无法确认平台为 0 评论"
|
||||
|
||||
|
||||
def do_relogin() -> bool:
|
||||
"""Run relogin_xiaohongshu.py (headed, waits for QR scan). Returns True if successful."""
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled:
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.batch_rescrape_xiaohongshu.relogin",
|
||||
details={
|
||||
"status": "SKIPPED_COOKIE",
|
||||
"reason": "cookie is invalid and QR relogin is disabled for acceptance",
|
||||
},
|
||||
)
|
||||
log(
|
||||
" [SKIPPED_COOKIE] Cookie invalid; acceptance mode will not "
|
||||
"launch relogin or wait for a QR scan"
|
||||
)
|
||||
return False
|
||||
log(" Cookie 可能过期,触发自动重登 (请扫码)...")
|
||||
try:
|
||||
rc = subprocess.call(
|
||||
[sys.executable, str(RELOGIN_SCRIPT)],
|
||||
cwd=str(_PROJECT_ROOT),
|
||||
)
|
||||
if rc == 0:
|
||||
log(" 重登成功,继续采集")
|
||||
return True
|
||||
log(f" 重登失败 (exit={rc})")
|
||||
return False
|
||||
except Exception as exc:
|
||||
log(f" 重登脚本执行异常: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def scrape_with_fresh_browser(
|
||||
url: str,
|
||||
@@ -173,8 +140,6 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
|
||||
total_comments = 0
|
||||
consecutive_fail = 0
|
||||
MAX_CONSECUTIVE_FAIL = 3
|
||||
relogin_used = 0
|
||||
MAX_RELOGINS = 1
|
||||
t0 = time.time()
|
||||
for i, row in enumerate(rows, 1):
|
||||
note_id = row["id"]
|
||||
@@ -183,13 +148,13 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
|
||||
comments, result, scrape_error = scrape_with_fresh_browser(url)
|
||||
if comments is None:
|
||||
consecutive_fail += 1
|
||||
if consecutive_fail >= MAX_CONSECUTIVE_FAIL and relogin_used < MAX_RELOGINS:
|
||||
if do_relogin():
|
||||
relogin_used += 1
|
||||
consecutive_fail = 0
|
||||
comments, result, scrape_error = scrape_with_fresh_browser(url)
|
||||
if consecutive_fail >= MAX_CONSECUTIVE_FAIL:
|
||||
if current_acceptance_policy().enabled:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
log(
|
||||
" 连续失败达到上限;若为登录态失效请执行 "
|
||||
"uv run gyxx accounts login xiaohongshu-content"
|
||||
)
|
||||
if comments is None:
|
||||
log(f" ERROR: {scrape_error}")
|
||||
fail += 1
|
||||
|
||||
@@ -1,666 +0,0 @@
|
||||
"""Launch five QR relogins in parallel, notify via analyzer Lark, retry 3 times."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import datetime as dt
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from ctypes import wintypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import psutil
|
||||
|
||||
from gyxx_flow.adapters import (
|
||||
ANALYZER_NOTIFICATION_APP_PROFILE,
|
||||
COOKIE_SKIP_EXIT_CODE,
|
||||
ResolvedNotificationRoute,
|
||||
binding_from_environment,
|
||||
current_acceptance_policy,
|
||||
environment_for_child_script,
|
||||
resolve_notification_route,
|
||||
send_lark_bot_message,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
ANALYZER_DIR = PATHS.tmp_root / "relogin"
|
||||
WORKFLOW_ID = "content.relogin.weekly"
|
||||
MARKETING_RECIPIENT = "ou_24cc944d6e43c69c59d6560ad4e2ae6e"
|
||||
TECHNICAL_RECIPIENT = "ou_7ad5fc8012e2f741afc5346e05ffd447"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Platform:
|
||||
label: str
|
||||
command: tuple[str, ...]
|
||||
required_cookie: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NotificationDeliveryFailure:
|
||||
"""One failed recipient delivery that must affect the workflow outcome."""
|
||||
|
||||
stage: str
|
||||
platform: str
|
||||
recipient: str
|
||||
detail: str
|
||||
|
||||
|
||||
PLATFORMS = {
|
||||
"douyin": Platform(
|
||||
"\u6296\u97f3", ("data/tools/relogin_douyin.py",),
|
||||
"sessionid",
|
||||
),
|
||||
"bilibili": Platform(
|
||||
"B\u7ad9", ("data/tools/relogin_bilibili.py",),
|
||||
"SESSDATA",
|
||||
),
|
||||
"pgy": Platform(
|
||||
"\u84b2\u516c\u82f1", ("data/tools/relogin_pgy.py",),
|
||||
"",
|
||||
),
|
||||
"xingtu": Platform(
|
||||
"\u661f\u56fe", ("data/tools/relogin_xingtu.py", "--force"),
|
||||
"sessionid",
|
||||
),
|
||||
"xiaohongshu": Platform(
|
||||
"\u5c0f\u7ea2\u4e66", ("data/tools/relogin_xiaohongshu.py",),
|
||||
"web_session",
|
||||
),
|
||||
}
|
||||
|
||||
DEFAULT_RECIPIENTS = {
|
||||
"pgy": MARKETING_RECIPIENT,
|
||||
"xingtu": MARKETING_RECIPIENT,
|
||||
"douyin": TECHNICAL_RECIPIENT,
|
||||
"bilibili": TECHNICAL_RECIPIENT,
|
||||
"xiaohongshu": TECHNICAL_RECIPIENT,
|
||||
}
|
||||
|
||||
|
||||
def resolve_platform_names(requested: list[str] | None) -> list[str]:
|
||||
"""Resolve an optional manual platform subset while preserving order."""
|
||||
if not requested:
|
||||
return list(PLATFORMS)
|
||||
names: list[str] = []
|
||||
for name in requested:
|
||||
if name not in PLATFORMS:
|
||||
raise ValueError(f"unknown platform: {name}")
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def recipient_for_platform(platform_name: str, override: str | None = None) -> str:
|
||||
"""Return the responsible recipient, unless a manual all-platform override is set."""
|
||||
from gyxx_flow.adapters.acceptance_policy import resolve_notification_recipients
|
||||
|
||||
if override:
|
||||
recipient = override
|
||||
else:
|
||||
try:
|
||||
recipient = DEFAULT_RECIPIENTS[platform_name]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"no Lark recipient configured for platform: {platform_name}"
|
||||
) from exc
|
||||
return resolve_notification_recipients(
|
||||
(recipient,),
|
||||
app_profile=ANALYZER_NOTIFICATION_APP_PROFILE,
|
||||
)[0]
|
||||
|
||||
|
||||
def resolve_weekly_notification_route(
|
||||
override: str | None = None,
|
||||
) -> ResolvedNotificationRoute:
|
||||
"""Resolve the scheduled workflow route while retaining legacy defaults."""
|
||||
defaults = (
|
||||
(override,)
|
||||
if override
|
||||
else tuple(dict.fromkeys(DEFAULT_RECIPIENTS.values()))
|
||||
)
|
||||
return resolve_notification_route(WORKFLOW_ID, defaults)
|
||||
|
||||
|
||||
def recipients_for_platform(
|
||||
platform_name: str,
|
||||
route: ResolvedNotificationRoute,
|
||||
override: str | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return dynamic recipients, or the original per-platform recipient."""
|
||||
if not route.enabled:
|
||||
return ()
|
||||
if route.configured:
|
||||
return route.open_ids
|
||||
return (recipient_for_platform(platform_name, override),)
|
||||
|
||||
|
||||
def group_platforms_by_recipient(
|
||||
platform_names: list[str], override: str | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
grouped: dict[str, list[str]] = {}
|
||||
for name in platform_names:
|
||||
grouped.setdefault(recipient_for_platform(name, override), []).append(name)
|
||||
return grouped
|
||||
|
||||
|
||||
def group_platforms_by_notification_recipient(
|
||||
platform_names: list[str],
|
||||
route: ResolvedNotificationRoute,
|
||||
override: str | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Group failed platforms for legacy or configured multi-recipient delivery."""
|
||||
if not route.enabled:
|
||||
return {}
|
||||
if route.configured:
|
||||
return {recipient: list(platform_names) for recipient in route.open_ids}
|
||||
return group_platforms_by_recipient(platform_names, override)
|
||||
|
||||
|
||||
def run_with_retries(
|
||||
platform_names: list[str],
|
||||
*,
|
||||
max_attempts: int,
|
||||
run_round: Callable[[list[str], int], dict[str, bool]],
|
||||
) -> dict[str, bool]:
|
||||
status = {name: False for name in platform_names}
|
||||
pending = list(platform_names)
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
if not pending:
|
||||
break
|
||||
round_result = run_round(pending, attempt)
|
||||
for name in pending:
|
||||
status[name] = bool(round_result.get(name))
|
||||
pending = [name for name in pending if not status[name]]
|
||||
return status
|
||||
|
||||
|
||||
def _valid_cookie(platform: Platform, cookie_file: Path) -> bool:
|
||||
if not cookie_file.is_file():
|
||||
return False
|
||||
if not platform.required_cookie:
|
||||
return cookie_file.stat().st_size > 10
|
||||
try:
|
||||
import json
|
||||
values = json.loads(cookie_file.read_text(encoding="utf-8"))
|
||||
return any(
|
||||
item.get("name") == platform.required_cookie and item.get("value")
|
||||
for item in values if isinstance(item, dict)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _descendant_pids(pid: int) -> set[int]:
|
||||
try:
|
||||
return {child.pid for child in psutil.Process(pid).children(recursive=True)}
|
||||
except (psutil.Error, OSError):
|
||||
return set()
|
||||
|
||||
|
||||
def _terminate_process_tree(process: subprocess.Popen) -> None:
|
||||
"""Terminate the whole Windows subprocess tree, including Chrome/Scrapling."""
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
except OSError:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
|
||||
|
||||
def _window_for_process(pid: int) -> int | None:
|
||||
pids = _descendant_pids(pid) | {pid}
|
||||
handles: list[int] = []
|
||||
user32 = ctypes.windll.user32
|
||||
callback_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
||||
|
||||
def callback(hwnd, _lparam):
|
||||
process_id = ctypes.c_ulong()
|
||||
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(process_id))
|
||||
if process_id.value in pids and user32.IsWindowVisible(hwnd):
|
||||
handles.append(int(hwnd))
|
||||
return True
|
||||
|
||||
user32.EnumWindows(callback_type(callback), 0)
|
||||
return handles[0] if handles else None
|
||||
|
||||
|
||||
def _platform_from_window_title(title: str) -> str | None:
|
||||
"""Identify a login window without relying on Chrome's process tree."""
|
||||
if "\u84b2\u516c\u82f1" in title:
|
||||
return "pgy"
|
||||
if "\u6296\u97f3\u6388\u6743" in title or "\u5de8\u91cf\u661f\u56fe" in title:
|
||||
return "xingtu"
|
||||
if "\u6296\u97f3" in title:
|
||||
return "douyin"
|
||||
if "bilibili" in title.lower() or "\u8d26\u53f7\u767b\u5f55" in title:
|
||||
return "bilibili"
|
||||
if "\u5c0f\u7ea2\u4e66" in title:
|
||||
return "xiaohongshu"
|
||||
return None
|
||||
|
||||
|
||||
def _login_windows() -> dict[str, int]:
|
||||
"""Map visible Chrome login windows to platforms by their page titles."""
|
||||
user32 = ctypes.windll.user32
|
||||
found: dict[str, int] = {}
|
||||
callback_type = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p)
|
||||
|
||||
def callback(hwnd, _lparam):
|
||||
if not user32.IsWindowVisible(hwnd):
|
||||
return True
|
||||
length = user32.GetWindowTextLengthW(hwnd)
|
||||
if length <= 0:
|
||||
return True
|
||||
buffer = ctypes.create_unicode_buffer(length + 1)
|
||||
user32.GetWindowTextW(hwnd, buffer, length + 1)
|
||||
platform = _platform_from_window_title(buffer.value)
|
||||
if platform:
|
||||
found[platform] = int(hwnd)
|
||||
return True
|
||||
|
||||
user32.EnumWindows(callback_type(callback), 0)
|
||||
return found
|
||||
|
||||
|
||||
def _capture_window(hwnd: int, output: Path) -> None:
|
||||
"""Maximize and render one window directly, even when it is not foreground."""
|
||||
from PIL import Image
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
gdi32 = ctypes.windll.gdi32
|
||||
user32.ShowWindow(hwnd, 3) # SW_MAXIMIZE
|
||||
time.sleep(1)
|
||||
rect = wintypes.RECT()
|
||||
user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
||||
width, height = rect.right - rect.left, rect.bottom - rect.top
|
||||
if width <= 0 or height <= 0:
|
||||
raise RuntimeError(f"invalid window size: {width}x{height}")
|
||||
|
||||
window_dc = user32.GetWindowDC(hwnd)
|
||||
memory_dc = gdi32.CreateCompatibleDC(window_dc)
|
||||
bitmap = gdi32.CreateCompatibleBitmap(window_dc, width, height)
|
||||
previous = gdi32.SelectObject(memory_dc, bitmap)
|
||||
try:
|
||||
if not user32.PrintWindow(hwnd, memory_dc, 2): # PW_RENDERFULLCONTENT
|
||||
raise RuntimeError("PrintWindow failed")
|
||||
|
||||
class BitmapInfoHeader(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("biSize", wintypes.DWORD), ("biWidth", wintypes.LONG),
|
||||
("biHeight", wintypes.LONG), ("biPlanes", wintypes.WORD),
|
||||
("biBitCount", wintypes.WORD), ("biCompression", wintypes.DWORD),
|
||||
("biSizeImage", wintypes.DWORD),
|
||||
("biXPelsPerMeter", wintypes.LONG),
|
||||
("biYPelsPerMeter", wintypes.LONG),
|
||||
("biClrUsed", wintypes.DWORD),
|
||||
("biClrImportant", wintypes.DWORD),
|
||||
]
|
||||
|
||||
byte_count = width * height * 4
|
||||
pixels = ctypes.create_string_buffer(byte_count)
|
||||
header = BitmapInfoHeader(
|
||||
ctypes.sizeof(BitmapInfoHeader), width, -height, 1, 32, 0,
|
||||
byte_count, 0, 0, 0, 0,
|
||||
)
|
||||
rows = gdi32.GetDIBits(
|
||||
memory_dc, bitmap, 0, height, pixels, ctypes.byref(header), 0
|
||||
)
|
||||
if rows != height:
|
||||
raise RuntimeError(f"GetDIBits returned {rows}/{height} rows")
|
||||
image = Image.frombuffer(
|
||||
"RGB", (width, height), pixels, "raw", "BGRX", 0, 1
|
||||
).copy()
|
||||
finally:
|
||||
gdi32.SelectObject(memory_dc, previous)
|
||||
gdi32.DeleteObject(bitmap)
|
||||
gdi32.DeleteDC(memory_dc)
|
||||
user32.ReleaseDC(hwnd, window_dc)
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(output, "PNG")
|
||||
|
||||
|
||||
def _tile_windows(handles: list[int]) -> None:
|
||||
"""Place four login windows in a 2x2 grid so screenshots cannot overlap."""
|
||||
user32 = ctypes.windll.user32
|
||||
screen_w = user32.GetSystemMetrics(0)
|
||||
screen_h = user32.GetSystemMetrics(1)
|
||||
cell_w = max(640, screen_w // 2)
|
||||
cell_h = max(450, screen_h // 2)
|
||||
for index, hwnd in enumerate(handles):
|
||||
x = (index % 2) * cell_w
|
||||
y = (index // 2) * cell_h
|
||||
user32.ShowWindow(hwnd, 9) # SW_RESTORE
|
||||
user32.MoveWindow(hwnd, x, y, cell_w, cell_h, True)
|
||||
|
||||
|
||||
def _send_lark(
|
||||
recipient: str,
|
||||
text: str | None = None,
|
||||
image: Path | None = None,
|
||||
*,
|
||||
app_profile: str = ANALYZER_NOTIFICATION_APP_PROFILE,
|
||||
) -> tuple[dict[str, object], ...]:
|
||||
"""Send text and/or image through the analyzer Hermes bot."""
|
||||
if app_profile != ANALYZER_NOTIFICATION_APP_PROFILE:
|
||||
raise ValueError(
|
||||
"relogin notifications must use the hermes-analyzer lark-cli profile"
|
||||
)
|
||||
lark_env = {
|
||||
key: value for key, value in os.environ.items()
|
||||
if not key.upper().startswith("HERMES_")
|
||||
}
|
||||
receipts: list[dict[str, object]] = []
|
||||
if text:
|
||||
ANALYZER_DIR.mkdir(parents=True, exist_ok=True)
|
||||
receipts.append(send_lark_bot_message(
|
||||
user_id=recipient,
|
||||
text=text,
|
||||
profile=ANALYZER_NOTIFICATION_APP_PROFILE,
|
||||
cwd=ANALYZER_DIR,
|
||||
env=lark_env,
|
||||
))
|
||||
if image:
|
||||
receipts.append(send_lark_bot_message(
|
||||
user_id=recipient,
|
||||
image=image,
|
||||
profile=ANALYZER_NOTIFICATION_APP_PROFILE,
|
||||
cwd=image.parent,
|
||||
env=lark_env,
|
||||
))
|
||||
return tuple(receipts)
|
||||
|
||||
|
||||
def _send_qr_notification(
|
||||
route: ResolvedNotificationRoute,
|
||||
platform_name: str,
|
||||
*,
|
||||
override: str | None,
|
||||
text: str,
|
||||
image: Path,
|
||||
) -> tuple[NotificationDeliveryFailure, ...]:
|
||||
"""Attempt every QR delivery and return all recipient-specific failures."""
|
||||
profile = route.app_profile or ANALYZER_NOTIFICATION_APP_PROFILE
|
||||
failures: list[NotificationDeliveryFailure] = []
|
||||
for recipient in recipients_for_platform(platform_name, route, override):
|
||||
for stage, kwargs in (
|
||||
("qr_text", {"text": text}),
|
||||
("qr_image", {"image": image}),
|
||||
):
|
||||
try:
|
||||
_send_lark(recipient, app_profile=profile, **kwargs)
|
||||
except Exception as exc:
|
||||
failures.append(
|
||||
NotificationDeliveryFailure(
|
||||
stage=stage,
|
||||
platform=platform_name,
|
||||
recipient=recipient,
|
||||
detail=str(exc) or type(exc).__name__,
|
||||
)
|
||||
)
|
||||
return tuple(failures)
|
||||
|
||||
|
||||
def _unavailable_qr_delivery_failures(
|
||||
route: ResolvedNotificationRoute,
|
||||
platform_name: str,
|
||||
*,
|
||||
override: str | None,
|
||||
detail: str,
|
||||
) -> tuple[NotificationDeliveryFailure, ...]:
|
||||
"""Record both intended QR deliveries when no screenshot can be sent."""
|
||||
return tuple(
|
||||
NotificationDeliveryFailure(
|
||||
stage=stage,
|
||||
platform=platform_name,
|
||||
recipient=recipient,
|
||||
detail=detail,
|
||||
)
|
||||
for recipient in recipients_for_platform(platform_name, route, override)
|
||||
for stage in ("qr_text", "qr_image")
|
||||
)
|
||||
|
||||
|
||||
def _send_failure_summaries(
|
||||
route: ResolvedNotificationRoute,
|
||||
failed_platforms: list[str],
|
||||
*,
|
||||
override: str | None,
|
||||
max_attempts: int,
|
||||
) -> tuple[NotificationDeliveryFailure, ...]:
|
||||
"""Attempt every final failure summary and return all delivery failures."""
|
||||
groups = group_platforms_by_notification_recipient(
|
||||
failed_platforms,
|
||||
route,
|
||||
override,
|
||||
)
|
||||
profile = route.app_profile or ANALYZER_NOTIFICATION_APP_PROFILE
|
||||
failures: list[NotificationDeliveryFailure] = []
|
||||
for recipient, names in groups.items():
|
||||
labels = "\u3001".join(PLATFORMS[name].label for name in names)
|
||||
try:
|
||||
_send_lark(
|
||||
recipient,
|
||||
text=(
|
||||
f"\u4ee5\u4e0b\u5e73\u53f0\u8fde\u7eed {max_attempts} \u6b21\u672a\u626b\u7801\uff0c"
|
||||
"\u5df2\u6062\u590d\u5e76\u7ee7\u4f7f\u7528\u65e7 Cookie\uff1a"
|
||||
f"{labels}"
|
||||
),
|
||||
app_profile=profile,
|
||||
)
|
||||
except Exception as exc:
|
||||
failures.append(
|
||||
NotificationDeliveryFailure(
|
||||
stage="failure_summary",
|
||||
platform=",".join(names),
|
||||
recipient=recipient,
|
||||
detail=str(exc) or type(exc).__name__,
|
||||
)
|
||||
)
|
||||
return tuple(failures)
|
||||
|
||||
|
||||
def _run_round_factory(
|
||||
args,
|
||||
notification_route: ResolvedNotificationRoute | None,
|
||||
notification_failures: list[NotificationDeliveryFailure],
|
||||
):
|
||||
python = sys.executable
|
||||
screenshot_root = PATHS.tmp_root / "qrcode/friday_relogin"
|
||||
|
||||
def run_round(names: list[str], attempt: int) -> dict[str, bool]:
|
||||
started: dict[str, subprocess.Popen] = {}
|
||||
cookie_files: dict[str, Path] = {}
|
||||
missing_qr_windows: set[str] = set()
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
for name in names:
|
||||
platform = PLATFORMS[name]
|
||||
cmd = [python, *platform.command, "--login-timeout", str(args.round_timeout)]
|
||||
target = PROJECT_DIR / platform.command[0]
|
||||
environment = environment_for_child_script(target, os.environ)
|
||||
cookie_files[name] = binding_from_environment(environment).cookie_file
|
||||
started[name] = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=PROJECT_DIR,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
time.sleep(args.screenshot_delay)
|
||||
title_windows = _login_windows()
|
||||
windows = {
|
||||
name: title_windows.get(name) or _window_for_process(process.pid)
|
||||
for name, process in started.items()
|
||||
}
|
||||
_tile_windows([hwnd for hwnd in windows.values() if hwnd])
|
||||
time.sleep(2)
|
||||
for name, process in started.items():
|
||||
platform = PLATFORMS[name]
|
||||
hwnd = windows[name]
|
||||
if not hwnd:
|
||||
print(f"[{name}] no visible browser window for QR screenshot", flush=True)
|
||||
missing_qr_windows.add(name)
|
||||
continue
|
||||
output = screenshot_root / f"{stamp}_{name}_round{attempt}.png"
|
||||
try:
|
||||
_capture_window(hwnd, output)
|
||||
print(f"[{name}] QR screenshot: {output}", flush=True)
|
||||
except Exception as exc:
|
||||
print(f"[{name}] screenshot failed: {exc}", flush=True)
|
||||
if not args.no_send and notification_route is not None:
|
||||
notification_failures.extend(
|
||||
_unavailable_qr_delivery_failures(
|
||||
notification_route,
|
||||
name,
|
||||
override=args.recipient,
|
||||
detail=(
|
||||
"QR screenshot capture failed: "
|
||||
f"{str(exc) or type(exc).__name__}"
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not args.no_send and notification_route is not None:
|
||||
notification_failures.extend(
|
||||
_send_qr_notification(
|
||||
notification_route,
|
||||
name,
|
||||
override=args.recipient,
|
||||
text=(
|
||||
f"{platform.label} \u767b\u5f55\u4e8c\u7ef4\u7801"
|
||||
f"\uff08\u7b2c {attempt}/{args.max_attempts} \u6b21\uff09\uff0c"
|
||||
"\u8bf7\u5728 3 \u5206\u949f\u5185\u626b\u7801\u3002"
|
||||
),
|
||||
image=output,
|
||||
)
|
||||
)
|
||||
|
||||
# Put the still-open login windows back into a compact layout after
|
||||
# taking full-size screenshots one at a time.
|
||||
_tile_windows([hwnd for hwnd in windows.values() if hwnd])
|
||||
|
||||
results: dict[str, bool] = {}
|
||||
for name, process in started.items():
|
||||
try:
|
||||
rc = process.wait(timeout=args.round_timeout + 90)
|
||||
except subprocess.TimeoutExpired:
|
||||
_terminate_process_tree(process)
|
||||
rc = 1
|
||||
results[name] = rc == 0 and _valid_cookie(
|
||||
PLATFORMS[name],
|
||||
cookie_files[name],
|
||||
)
|
||||
if (
|
||||
not results[name]
|
||||
and name in missing_qr_windows
|
||||
and not args.no_send
|
||||
and notification_route is not None
|
||||
):
|
||||
notification_failures.extend(
|
||||
_unavailable_qr_delivery_failures(
|
||||
notification_route,
|
||||
name,
|
||||
override=args.recipient,
|
||||
detail=(
|
||||
"no visible QR window and relogin did not succeed"
|
||||
),
|
||||
)
|
||||
)
|
||||
print(f"[{name}] round={attempt} exit={rc} success={results[name]}", flush=True)
|
||||
return results
|
||||
|
||||
return run_round
|
||||
|
||||
|
||||
def main() -> int:
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled:
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.relogin.weekly",
|
||||
details={
|
||||
"status": "SKIPPED_COOKIE",
|
||||
"reason": "acceptance runs never launch QR relogin workflows",
|
||||
},
|
||||
)
|
||||
print(
|
||||
"[SKIPPED_COOKIE] Acceptance mode does not launch login windows, "
|
||||
"reset cookies/profiles, or wait for QR scans.",
|
||||
flush=True,
|
||||
)
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
|
||||
parser = argparse.ArgumentParser(description="Friday five-platform parallel QR relogin")
|
||||
parser.add_argument(
|
||||
"--recipient", default=None,
|
||||
help="Override the configured recipients and send every platform to one open_id",
|
||||
)
|
||||
parser.add_argument("--max-attempts", type=int, default=3)
|
||||
parser.add_argument("--round-timeout", type=int, default=300)
|
||||
parser.add_argument("--screenshot-delay", type=int, default=60)
|
||||
parser.add_argument("--no-send", action="store_true", help="Do not send Lark messages")
|
||||
parser.add_argument(
|
||||
"--platform", action="append", choices=sorted(PLATFORMS),
|
||||
help="Only relogin the selected platform; may be repeated",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
platform_names = resolve_platform_names(args.platform)
|
||||
notification_route = (
|
||||
None if args.no_send else resolve_weekly_notification_route(args.recipient)
|
||||
)
|
||||
notification_failures: list[NotificationDeliveryFailure] = []
|
||||
status = run_with_retries(
|
||||
platform_names, max_attempts=args.max_attempts,
|
||||
run_round=_run_round_factory(
|
||||
args,
|
||||
notification_route,
|
||||
notification_failures,
|
||||
),
|
||||
)
|
||||
failed = [name for name, ok in status.items() if not ok]
|
||||
if failed and not args.no_send and notification_route is not None:
|
||||
notification_failures.extend(
|
||||
_send_failure_summaries(
|
||||
notification_route,
|
||||
failed,
|
||||
override=args.recipient,
|
||||
max_attempts=args.max_attempts,
|
||||
)
|
||||
)
|
||||
print(f"Final relogin status: {status}", flush=True)
|
||||
if notification_failures:
|
||||
print(
|
||||
f"Final notification status: failed ({len(notification_failures)} deliveries)",
|
||||
flush=True,
|
||||
)
|
||||
for failure in notification_failures:
|
||||
print(
|
||||
"[NOTIFICATION_ERROR] "
|
||||
f"stage={failure.stage} platform={failure.platform} "
|
||||
f"recipient={failure.recipient} error={failure.detail}",
|
||||
flush=True,
|
||||
)
|
||||
return 2
|
||||
if notification_route is not None and notification_route.enabled:
|
||||
print("Final notification status: delivered", flush=True)
|
||||
else:
|
||||
print("Final notification status: skipped", flush=True)
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,113 +0,0 @@
|
||||
"""每周一重登 B 站。
|
||||
|
||||
强制清掉 cookie JSON + 浏览器 profile,确保下次启动时必须扫码。
|
||||
任务计划: 每周一 09:00
|
||||
|
||||
跑法:
|
||||
python data/tools/relogin_bilibili.py # 实际跑(会弹二维码)
|
||||
python data/tools/relogin_bilibili.py --dry-run # 只看会做什么
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_runtime_paths import (
|
||||
child_browser_environment,
|
||||
resolve_relogin_runtime_paths,
|
||||
sync_relogin_state_to_consumers,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_transaction import (
|
||||
begin,
|
||||
commit,
|
||||
rollback,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
RUNTIME_PATHS = resolve_relogin_runtime_paths("bilibili")
|
||||
COOKIE_FILE = RUNTIME_PATHS.cookie_file
|
||||
STATE_FILE = RUNTIME_PATHS.storage_state_file
|
||||
PROFILE_DIR = RUNTIME_PATHS.profile_dir
|
||||
SCRAPER = PROJECT_DIR / "bilibili_comment_scraper.py"
|
||||
|
||||
|
||||
def reset_state():
|
||||
"""删 cookie JSON + storage_state + 清浏览器 profile。"""
|
||||
state = begin((COOKIE_FILE, STATE_FILE), PROFILE_DIR)
|
||||
print(" [OK] 旧 Cookie/Profile 已暂存,失败时将自动恢复")
|
||||
return state
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="每周一重登 B 站")
|
||||
ap.add_argument("--login-timeout", type=int, default=300, help="扫码超时(秒)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="只打印计划,不删不跑")
|
||||
args = ap.parse_args()
|
||||
|
||||
print("== Relogin: B 站 ==")
|
||||
print(f" 今天: {dt.date.today()} ({dt.date.today().strftime('%A')})")
|
||||
print(f" cookie: {COOKIE_FILE}")
|
||||
print(f" profile: {PROFILE_DIR}")
|
||||
print()
|
||||
|
||||
if args.dry_run:
|
||||
print("[DRY-RUN] 实际跑会: 删 cookie + 清 profile + 弹二维码")
|
||||
return 0
|
||||
|
||||
old_state = reset_state()
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRAPER),
|
||||
"login", # dummy url (login-only 模式不需要真实 url)
|
||||
"--login-only",
|
||||
"--login-timeout",
|
||||
str(args.login_timeout),
|
||||
]
|
||||
print(f"\n [RUN] {' '.join(cmd)}")
|
||||
try:
|
||||
rc = subprocess.call(
|
||||
cmd,
|
||||
cwd=str(PROJECT_DIR),
|
||||
env=child_browser_environment(
|
||||
cookie_file=COOKIE_FILE,
|
||||
storage_state_file=STATE_FILE,
|
||||
profile_dir=PROFILE_DIR,
|
||||
),
|
||||
)
|
||||
except BaseException:
|
||||
rollback(old_state)
|
||||
raise
|
||||
print(f"\n exit={rc}")
|
||||
login_ok = False
|
||||
if rc == 0:
|
||||
# 验证 SESSDATA 是否真写入了
|
||||
try:
|
||||
import json
|
||||
cookies = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
|
||||
sessdata = any(c.get("name") == "SESSDATA" and c.get("value") for c in cookies)
|
||||
login_ok = bool(sessdata)
|
||||
print(f" SESSDATA 写入: {'OK' if sessdata else 'FAIL — 没有看到 SESSDATA,登录可能没成功'}")
|
||||
except Exception as exc:
|
||||
print(f" [WARN] 验证 cookie 时出错: {exc}")
|
||||
if rc != 0 or not login_ok:
|
||||
rollback(old_state)
|
||||
print(" [ROLLBACK] 未完成扫码,已恢复旧登录状态")
|
||||
return rc or 1
|
||||
try:
|
||||
sync_relogin_state_to_consumers(
|
||||
"bilibili",
|
||||
cookie_file=COOKIE_FILE,
|
||||
storage_state_file=STATE_FILE,
|
||||
)
|
||||
except Exception as exc:
|
||||
rollback(old_state)
|
||||
print(f" [ROLLBACK] 同步采集器登录态失败: {exc}")
|
||||
return 1
|
||||
commit(old_state)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Force a fresh Douyin login while preserving the previous usable session."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_runtime_paths import (
|
||||
child_browser_environment,
|
||||
resolve_relogin_runtime_paths,
|
||||
sync_relogin_state_to_consumers,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
try:
|
||||
from .relogin_transaction import begin, commit, rollback
|
||||
except ImportError: # Direct script execution from data/tools.
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_transaction import (
|
||||
begin,
|
||||
commit,
|
||||
rollback,
|
||||
)
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
RUNTIME_PATHS = resolve_relogin_runtime_paths("douyin")
|
||||
COOKIE_FILE = RUNTIME_PATHS.cookie_file
|
||||
STATE_FILE = RUNTIME_PATHS.storage_state_file
|
||||
PROFILE_DIR = RUNTIME_PATHS.profile_dir
|
||||
SCRAPER = PROJECT_DIR / "douyin_comment_scraper.py"
|
||||
|
||||
LOGIN_COOKIE = "sessionid"
|
||||
|
||||
|
||||
def reset_state():
|
||||
state = begin((COOKIE_FILE, STATE_FILE), PROFILE_DIR)
|
||||
print(" [OK] Old Cookie/Profile staged; failures will roll back automatically")
|
||||
return state
|
||||
|
||||
|
||||
def is_cookie_valid() -> bool:
|
||||
if not COOKIE_FILE.exists():
|
||||
return False
|
||||
try:
|
||||
cookies = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
|
||||
return any(
|
||||
isinstance(cookie, dict)
|
||||
and cookie.get("name") == LOGIN_COOKIE
|
||||
and cookie.get("value")
|
||||
for cookie in cookies
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Relogin Douyin")
|
||||
parser.add_argument("--login-timeout", type=int, default=300)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("== Relogin: Douyin ==")
|
||||
print(f" date: {dt.date.today()}")
|
||||
print(f" cookie: {COOKIE_FILE}")
|
||||
print(f" profile: {PROFILE_DIR}")
|
||||
print(f" current cookie: {'present' if is_cookie_valid() else 'missing/invalid'}")
|
||||
|
||||
if args.dry_run:
|
||||
print("[DRY-RUN] Would stage Cookie/Profile and open the QR login window")
|
||||
return 0
|
||||
|
||||
old_state = reset_state()
|
||||
command = [
|
||||
sys.executable,
|
||||
str(SCRAPER),
|
||||
"login",
|
||||
"--login-only",
|
||||
"--login-timeout",
|
||||
str(args.login_timeout),
|
||||
]
|
||||
print(f"\n [RUN] {' '.join(command)}")
|
||||
try:
|
||||
return_code = subprocess.call(
|
||||
command,
|
||||
cwd=str(PROJECT_DIR),
|
||||
env=child_browser_environment(
|
||||
cookie_file=COOKIE_FILE,
|
||||
storage_state_file=STATE_FILE,
|
||||
profile_dir=PROFILE_DIR,
|
||||
),
|
||||
)
|
||||
except BaseException:
|
||||
rollback(old_state)
|
||||
raise
|
||||
|
||||
login_ok = return_code == 0 and is_cookie_valid()
|
||||
print(f"\n exit={return_code}")
|
||||
print(f" {LOGIN_COOKIE} written: {'OK' if login_ok else 'FAIL'}")
|
||||
if not login_ok:
|
||||
rollback(old_state)
|
||||
print(" [ROLLBACK] Login incomplete; restored previous Cookie/Profile")
|
||||
return return_code or 1
|
||||
try:
|
||||
sync_relogin_state_to_consumers(
|
||||
"douyin",
|
||||
cookie_file=COOKIE_FILE,
|
||||
storage_state_file=STATE_FILE,
|
||||
)
|
||||
except Exception as exc:
|
||||
rollback(old_state)
|
||||
print(f" [ROLLBACK] Failed to sync collector login state: {exc}")
|
||||
return 1
|
||||
|
||||
commit(old_state)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,148 +0,0 @@
|
||||
"""每周一重登小红书蒲公英。
|
||||
|
||||
强制清掉 cookie JSON + 浏览器 profile,确保下次启动时必须扫码。
|
||||
任务计划: 每周一 09:00
|
||||
|
||||
跑法:
|
||||
python data/tools/relogin_pgy.py # 实际跑(会弹二维码)
|
||||
python data/tools/relogin_pgy.py --dry-run # 只看会做什么
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_runtime_paths import (
|
||||
child_browser_environment,
|
||||
resolve_relogin_runtime_paths,
|
||||
sync_relogin_state_to_consumers,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
RUNTIME_PATHS = resolve_relogin_runtime_paths("pgy")
|
||||
COOKIE_FILE = RUNTIME_PATHS.cookie_file
|
||||
STATE_FILE = RUNTIME_PATHS.storage_state_file
|
||||
PROFILE_DIR = RUNTIME_PATHS.profile_dir
|
||||
SCRAPER = PROJECT_DIR / "pgy_xhs_scraper_v2.py"
|
||||
|
||||
|
||||
def reset_state() -> tuple[Path | None, Path | None, Path | None]:
|
||||
"""Temporarily move the old login state aside so it can be restored."""
|
||||
ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
cookie_backup = None
|
||||
state_backup = None
|
||||
profile_backup = None
|
||||
if COOKIE_FILE.exists():
|
||||
backup = COOKIE_FILE.with_suffix(f".json.bak.{ts}")
|
||||
COOKIE_FILE.rename(backup)
|
||||
cookie_backup = backup
|
||||
print(f" [OK] 备份 cookie -> {backup.name}")
|
||||
else:
|
||||
print(f" [INFO] {COOKIE_FILE.name} 不存在,跳过")
|
||||
if STATE_FILE.exists():
|
||||
state_backup = STATE_FILE.with_suffix(f".json.bak.{ts}")
|
||||
STATE_FILE.rename(state_backup)
|
||||
print(f" [OK] 备份 storage state -> {state_backup.name}")
|
||||
else:
|
||||
print(f" [INFO] {STATE_FILE.name} 不存在,跳过")
|
||||
if PROFILE_DIR.exists():
|
||||
profile_backup = PROFILE_DIR.with_name(f"{PROFILE_DIR.name}.bak.{ts}")
|
||||
PROFILE_DIR.rename(profile_backup)
|
||||
print(f" [OK] 暂存 {PROFILE_DIR.name}/ -> {profile_backup.name}/")
|
||||
else:
|
||||
print(f" [INFO] {PROFILE_DIR.name}/ 不存在,跳过")
|
||||
return cookie_backup, profile_backup, state_backup
|
||||
|
||||
|
||||
def restore_previous_state(
|
||||
cookie_backup: Path | None,
|
||||
profile_backup: Path | None,
|
||||
state_backup: Path | None = None,
|
||||
) -> None:
|
||||
"""Discard an incomplete login and restore the last known-good state."""
|
||||
if COOKIE_FILE.exists():
|
||||
COOKIE_FILE.unlink()
|
||||
if cookie_backup and cookie_backup.exists():
|
||||
cookie_backup.rename(COOKIE_FILE)
|
||||
print(f" [ROLLBACK] 已恢复旧 cookie: {COOKIE_FILE.name}")
|
||||
if STATE_FILE.exists():
|
||||
STATE_FILE.unlink()
|
||||
if state_backup and state_backup.exists():
|
||||
state_backup.rename(STATE_FILE)
|
||||
print(f" [ROLLBACK] 已恢复旧 storage state: {STATE_FILE.name}")
|
||||
if PROFILE_DIR.exists():
|
||||
shutil.rmtree(PROFILE_DIR)
|
||||
if profile_backup and profile_backup.exists():
|
||||
profile_backup.rename(PROFILE_DIR)
|
||||
print(f" [ROLLBACK] 已恢复旧浏览器状态: {PROFILE_DIR.name}/")
|
||||
|
||||
|
||||
def finish_successful_relogin(profile_backup: Path | None) -> None:
|
||||
"""Keep the new state and remove the bulky old profile backup."""
|
||||
if profile_backup and profile_backup.exists():
|
||||
shutil.rmtree(profile_backup)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="每周一重登小红书蒲公英")
|
||||
ap.add_argument("--login-timeout", type=int, default=300, help="扫码超时(秒)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="只打印计划,不删不跑")
|
||||
args = ap.parse_args()
|
||||
|
||||
print("== Relogin: 小红书蒲公英 ==")
|
||||
print(f" 今天: {dt.date.today()} ({dt.date.today().strftime('%A')})")
|
||||
print(f" cookie: {COOKIE_FILE}")
|
||||
print(f" profile: {PROFILE_DIR}")
|
||||
print()
|
||||
|
||||
if args.dry_run:
|
||||
print("[DRY-RUN] 实际跑会: 删 cookie + 清 profile + 弹二维码")
|
||||
return 0
|
||||
|
||||
cookie_backup, profile_backup, state_backup = reset_state()
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRAPER),
|
||||
"--login-only",
|
||||
"--login-timeout",
|
||||
str(args.login_timeout),
|
||||
]
|
||||
print(f"\n [RUN] {' '.join(cmd)}")
|
||||
try:
|
||||
rc = subprocess.call(
|
||||
cmd,
|
||||
cwd=str(PROJECT_DIR),
|
||||
env=child_browser_environment(
|
||||
cookie_file=COOKIE_FILE,
|
||||
storage_state_file=STATE_FILE,
|
||||
profile_dir=PROFILE_DIR,
|
||||
),
|
||||
)
|
||||
except BaseException:
|
||||
restore_previous_state(cookie_backup, profile_backup, state_backup)
|
||||
raise
|
||||
print(f"\n exit={rc}")
|
||||
if rc != 0 or not COOKIE_FILE.exists():
|
||||
print(" [WARN] 新登录未完成,恢复旧登录状态")
|
||||
restore_previous_state(cookie_backup, profile_backup, state_backup)
|
||||
return rc or 1
|
||||
try:
|
||||
sync_relogin_state_to_consumers(
|
||||
"pgy",
|
||||
cookie_file=COOKIE_FILE,
|
||||
storage_state_file=STATE_FILE,
|
||||
)
|
||||
except Exception as exc:
|
||||
restore_previous_state(cookie_backup, profile_backup, state_backup)
|
||||
print(f" [ROLLBACK] 同步采集器登录态失败: {exc}")
|
||||
return 1
|
||||
finish_successful_relogin(profile_backup)
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,279 +0,0 @@
|
||||
"""Resolve one relogin wrapper's browser state contract.
|
||||
|
||||
The workflow runtime injects a unique browser binding into each relogin
|
||||
wrapper. Standalone legacy launches do not have that environment, so they
|
||||
retain the original per-platform files below the content-marketing state root.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from gyxx_flow.adapters.integration import RuntimeIntegrationCatalog
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
_PLATFORM = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
|
||||
|
||||
CONSUMER_BINDING_IDS: Mapping[str, tuple[str, ...]] = {
|
||||
"bilibili": ("content.comments.collect_bilibili",),
|
||||
"douyin": ("content.comments.collect_douyin",),
|
||||
"pgy": ("content_marketing:pgy_xhs_scraper_v2.py",),
|
||||
"xiaohongshu": ("content.comments.collect_xiaohongshu",),
|
||||
# v2 now reads the Buyin/Doudian site and is owned by the douyin-shop
|
||||
# account. Keep the legacy Xingtu relogin isolated from that vault.
|
||||
"buyin": ("content_marketing:xingtu_scraper_v2.py",),
|
||||
"xingtu": ("content_marketing:xingtu_scraper.py",),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReloginRuntimePaths:
|
||||
cookie_file: Path
|
||||
storage_state_file: Path
|
||||
profile_dir: Path
|
||||
|
||||
|
||||
def resolve_relogin_runtime_paths(
|
||||
platform: str,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> ReloginRuntimePaths:
|
||||
"""Prefer the injected binding and fall back to original platform paths."""
|
||||
|
||||
if not _PLATFORM.fullmatch(platform):
|
||||
raise ValueError("invalid relogin platform")
|
||||
values = os.environ if environment is None else environment
|
||||
legacy_cookie = PATHS.state_root / "cookies" / f"{platform}_cookies.json"
|
||||
legacy_storage = (
|
||||
PATHS.state_root / "cookies" / f"{platform}_storage_state.json"
|
||||
)
|
||||
legacy_profile = PATHS.state_root / "browser-profiles" / platform
|
||||
return ReloginRuntimePaths(
|
||||
cookie_file=_bound_path(
|
||||
values,
|
||||
"GYXX_BROWSER_COOKIE_FILE",
|
||||
legacy_cookie,
|
||||
),
|
||||
storage_state_file=_bound_path(
|
||||
values,
|
||||
"GYXX_BROWSER_STORAGE_STATE_FILE",
|
||||
legacy_storage,
|
||||
),
|
||||
profile_dir=_bound_path(
|
||||
values,
|
||||
"GYXX_BROWSER_PROFILE_DIR",
|
||||
legacy_profile,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def child_browser_environment(
|
||||
*,
|
||||
cookie_file: Path,
|
||||
storage_state_file: Path,
|
||||
profile_dir: Path,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Keep the nested login scraper on the wrapper's injected binding."""
|
||||
|
||||
result = dict(os.environ if environment is None else environment)
|
||||
result.update(
|
||||
{
|
||||
"GYXX_BROWSER_COOKIE_FILE": str(cookie_file.resolve()),
|
||||
"GYXX_BROWSER_STORAGE_STATE_FILE": str(
|
||||
storage_state_file.resolve()
|
||||
),
|
||||
"GYXX_BROWSER_PROFILE_DIR": str(profile_dir.resolve()),
|
||||
}
|
||||
)
|
||||
# content_marketing/sitecustomize.py normally rebinds a nested Python
|
||||
# script from argv[0]. A relogin scraper is an implementation detail of
|
||||
# this wrapper and must write the wrapper binding that the parent monitors.
|
||||
result.pop("GYXX_MODULE_ID", None)
|
||||
return result
|
||||
|
||||
|
||||
def sync_relogin_state_to_consumers(
|
||||
platform: str,
|
||||
*,
|
||||
cookie_file: Path,
|
||||
storage_state_file: Path,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> tuple[Path, ...]:
|
||||
"""Atomically publish a verified relogin state to unique consumer paths."""
|
||||
|
||||
try:
|
||||
consumer_ids = CONSUMER_BINDING_IDS[platform]
|
||||
except KeyError as exc:
|
||||
raise ValueError("invalid relogin platform") from exc
|
||||
values = os.environ if environment is None else environment
|
||||
project_root = values.get("GYXX_PROJECT_ROOT", "").strip()
|
||||
data_root = values.get("GYXX_DATA_ROOT", "").strip()
|
||||
if not project_root and not data_root:
|
||||
# Original standalone launches use the legacy per-platform state files
|
||||
# directly, so there is no separate managed consumer to publish to.
|
||||
return ()
|
||||
if not project_root or not data_root:
|
||||
raise RuntimeError("managed relogin state sync requires project and data roots")
|
||||
|
||||
catalog = RuntimeIntegrationCatalog.load_default(
|
||||
project_root=project_root,
|
||||
data_root=data_root,
|
||||
)
|
||||
source_cookie = cookie_file.expanduser().resolve()
|
||||
source_storage = storage_state_file.expanduser().resolve()
|
||||
cookie_payload = _read_cookie_payload(source_cookie)
|
||||
storage_available = source_storage.is_file()
|
||||
if storage_available:
|
||||
_read_storage_state_payload(source_storage)
|
||||
|
||||
source_paths = {source_cookie, source_storage}
|
||||
destination_paths: set[Path] = set()
|
||||
destinations: list[Path] = []
|
||||
updates: list[tuple[Path, Path]] = []
|
||||
for consumer_id in consumer_ids:
|
||||
binding = catalog.binding_for(consumer_id)
|
||||
target_cookie = binding.cookie_file.resolve()
|
||||
target_storage = binding.storage_state_file.resolve()
|
||||
targets = (target_cookie, target_storage)
|
||||
if any(target in source_paths for target in targets) or any(
|
||||
target in destination_paths for target in targets
|
||||
):
|
||||
raise RuntimeError(
|
||||
"relogin and consumer browser state paths must be unique"
|
||||
)
|
||||
destination_paths.update(targets)
|
||||
_validate_cookie_requirements(
|
||||
cookie_payload,
|
||||
required_domains=binding.required_cookie_domains,
|
||||
required_names=binding.required_cookie_names,
|
||||
)
|
||||
updates.append((source_cookie, target_cookie))
|
||||
if storage_available:
|
||||
updates.append((source_storage, target_storage))
|
||||
destinations.append(target_cookie)
|
||||
_atomic_replace_files(updates)
|
||||
return tuple(destinations)
|
||||
|
||||
|
||||
def _bound_path(
|
||||
environment: Mapping[str, str],
|
||||
name: str,
|
||||
fallback: Path,
|
||||
) -> Path:
|
||||
configured = environment.get(name, "").strip()
|
||||
return (
|
||||
Path(configured).expanduser().resolve()
|
||||
if configured
|
||||
else fallback.resolve()
|
||||
)
|
||||
|
||||
|
||||
def _read_cookie_payload(path: Path) -> list[dict[str, object]]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, ValueError) as exc:
|
||||
raise RuntimeError("relogin cookie state is missing or invalid") from exc
|
||||
if not isinstance(payload, list) or not payload or not all(
|
||||
isinstance(cookie, dict) for cookie in payload
|
||||
):
|
||||
raise RuntimeError("relogin cookie state is missing or invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _read_storage_state_payload(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, ValueError) as exc:
|
||||
raise RuntimeError("relogin storage state is invalid") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("relogin storage state is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _validate_cookie_requirements(
|
||||
cookies: list[dict[str, object]],
|
||||
*,
|
||||
required_domains: tuple[str, ...],
|
||||
required_names: tuple[str, ...],
|
||||
) -> None:
|
||||
usable = [cookie for cookie in cookies if cookie.get("value")]
|
||||
if not usable:
|
||||
raise RuntimeError("relogin cookie state has no usable cookies")
|
||||
if required_domains and not any(
|
||||
any(
|
||||
str(cookie.get("domain", "")).lstrip(".").casefold()
|
||||
.endswith(domain.casefold().lstrip("."))
|
||||
for domain in required_domains
|
||||
)
|
||||
for cookie in usable
|
||||
):
|
||||
raise RuntimeError("relogin cookie state does not match consumer domain")
|
||||
if required_names and not any(
|
||||
str(cookie.get("name", "")) in required_names for cookie in usable
|
||||
):
|
||||
raise RuntimeError("relogin cookie state misses the consumer login cookie")
|
||||
|
||||
|
||||
def _atomic_replace_files(updates: list[tuple[Path, Path]]) -> None:
|
||||
"""Replace a small related file set and restore every old target on error."""
|
||||
|
||||
transaction = uuid.uuid4().hex
|
||||
staged: list[tuple[Path, Path]] = []
|
||||
backups: dict[Path, Path | None] = {}
|
||||
replaced: list[Path] = []
|
||||
try:
|
||||
for source, target in updates:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle, raw_temp = tempfile.mkstemp(
|
||||
prefix=f".{target.name}.{transaction}.",
|
||||
suffix=".tmp",
|
||||
dir=target.parent,
|
||||
)
|
||||
temp = Path(raw_temp)
|
||||
with os.fdopen(handle, "wb") as stream:
|
||||
stream.write(source.read_bytes())
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
staged.append((temp, target))
|
||||
backup = target.with_name(f".{target.name}.{transaction}.bak")
|
||||
if target.is_file():
|
||||
shutil.copy2(target, backup)
|
||||
backups[target] = backup
|
||||
else:
|
||||
backups[target] = None
|
||||
|
||||
for temp, target in staged:
|
||||
os.replace(temp, target)
|
||||
replaced.append(target)
|
||||
except BaseException:
|
||||
for target in reversed(replaced):
|
||||
backup = backups.get(target)
|
||||
if backup is not None and backup.exists():
|
||||
os.replace(backup, target)
|
||||
elif target.exists():
|
||||
target.unlink()
|
||||
raise
|
||||
finally:
|
||||
for temp, _target in staged:
|
||||
if temp.exists():
|
||||
temp.unlink()
|
||||
for backup in backups.values():
|
||||
if backup is not None and backup.exists():
|
||||
backup.unlink()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CONSUMER_BINDING_IDS",
|
||||
"ReloginRuntimePaths",
|
||||
"child_browser_environment",
|
||||
"resolve_relogin_runtime_paths",
|
||||
"sync_relogin_state_to_consumers",
|
||||
]
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Transactional backup/rollback helpers for interactive relogin scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginStateBackup:
|
||||
files: dict[Path, Path]
|
||||
profile: Path
|
||||
profile_backup: Path | None
|
||||
|
||||
|
||||
def begin(files: tuple[Path, ...], profile: Path) -> LoginStateBackup:
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
backups: dict[Path, Path] = {}
|
||||
for source in files:
|
||||
if source.exists():
|
||||
backup = source.with_name(f"{source.name}.bak.{stamp}")
|
||||
source.rename(backup)
|
||||
backups[source] = backup
|
||||
profile_backup = None
|
||||
if profile.exists():
|
||||
profile_backup = profile.with_name(f"{profile.name}.bak.{stamp}")
|
||||
profile.rename(profile_backup)
|
||||
return LoginStateBackup(backups, profile, profile_backup)
|
||||
|
||||
|
||||
def rollback(state: LoginStateBackup) -> None:
|
||||
for source, backup in state.files.items():
|
||||
if source.exists():
|
||||
source.unlink()
|
||||
if backup.exists():
|
||||
backup.rename(source)
|
||||
if state.profile.exists():
|
||||
shutil.rmtree(state.profile)
|
||||
if state.profile_backup and state.profile_backup.exists():
|
||||
state.profile_backup.rename(state.profile)
|
||||
|
||||
|
||||
def commit(state: LoginStateBackup) -> None:
|
||||
if state.profile_backup and state.profile_backup.exists():
|
||||
shutil.rmtree(state.profile_backup)
|
||||
@@ -1,116 +0,0 @@
|
||||
"""每周重登小红书。
|
||||
|
||||
强制清掉 cookie JSON + 浏览器 profile,确保下次启动时必须扫码。
|
||||
跑法:
|
||||
python data/tools/relogin_xiaohongshu.py # 实际跑(会弹二维码)
|
||||
python data/tools/relogin_xiaohongshu.py --dry-run # 只看会做什么
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_runtime_paths import (
|
||||
child_browser_environment,
|
||||
resolve_relogin_runtime_paths,
|
||||
sync_relogin_state_to_consumers,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_transaction import (
|
||||
begin,
|
||||
commit,
|
||||
rollback,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
RUNTIME_PATHS = resolve_relogin_runtime_paths("xiaohongshu")
|
||||
COOKIE_FILE = RUNTIME_PATHS.cookie_file
|
||||
STATE_FILE = RUNTIME_PATHS.storage_state_file
|
||||
PROFILE_DIR = RUNTIME_PATHS.profile_dir
|
||||
SCRAPER = PROJECT_DIR / "xiaohongshu_comment_scraper.py"
|
||||
|
||||
LOGIN_COOKIE = "web_session"
|
||||
|
||||
|
||||
def reset_state():
|
||||
state = begin((COOKIE_FILE, STATE_FILE), PROFILE_DIR)
|
||||
print(" [OK] 旧 Cookie/Profile 已暂存,失败时将自动恢复")
|
||||
return state
|
||||
|
||||
|
||||
def is_cookie_valid() -> bool:
|
||||
if not COOKIE_FILE.exists():
|
||||
return False
|
||||
try:
|
||||
cookies = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
|
||||
return any(c.get("name") == LOGIN_COOKIE and c.get("value") for c in cookies)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="重登小红书")
|
||||
ap.add_argument("--login-timeout", type=int, default=300, help="扫码超时(秒)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="只打印计划,不删不跑")
|
||||
args = ap.parse_args()
|
||||
|
||||
print("== Relogin: 小红书 ==")
|
||||
print(f" 今天: {dt.date.today()}")
|
||||
print(f" cookie: {COOKIE_FILE}")
|
||||
print(f" profile: {PROFILE_DIR}")
|
||||
print(f" 当前 cookie 状态: {'有效' if is_cookie_valid() else '无效/不存在'}")
|
||||
print()
|
||||
|
||||
if args.dry_run:
|
||||
print("[DRY-RUN] 实际跑会: 删 cookie + 清 profile + 弹二维码")
|
||||
return 0
|
||||
|
||||
old_state = reset_state()
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRAPER),
|
||||
"login",
|
||||
"--login-only",
|
||||
"--login-timeout",
|
||||
str(args.login_timeout),
|
||||
]
|
||||
print(f"\n [RUN] {' '.join(cmd)}")
|
||||
try:
|
||||
rc = subprocess.call(
|
||||
cmd,
|
||||
cwd=str(PROJECT_DIR),
|
||||
env=child_browser_environment(
|
||||
cookie_file=COOKIE_FILE,
|
||||
storage_state_file=STATE_FILE,
|
||||
profile_dir=PROFILE_DIR,
|
||||
),
|
||||
)
|
||||
except BaseException:
|
||||
rollback(old_state)
|
||||
raise
|
||||
print(f"\n exit={rc}")
|
||||
login_ok = rc == 0 and is_cookie_valid()
|
||||
if rc == 0:
|
||||
print(f" {LOGIN_COOKIE} 写入: {'OK' if login_ok else 'FAIL — 没有看到 web_session,登录可能没成功'}")
|
||||
if not login_ok:
|
||||
rollback(old_state)
|
||||
print(" [ROLLBACK] 未完成扫码,已恢复旧登录状态")
|
||||
return rc or 1
|
||||
try:
|
||||
sync_relogin_state_to_consumers(
|
||||
"xiaohongshu",
|
||||
cookie_file=COOKIE_FILE,
|
||||
storage_state_file=STATE_FILE,
|
||||
)
|
||||
except Exception as exc:
|
||||
rollback(old_state)
|
||||
print(f" [ROLLBACK] 同步采集器登录态失败: {exc}")
|
||||
return 1
|
||||
commit(old_state)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,350 +0,0 @@
|
||||
"""每 3 个周一重登一次抖音/星图。
|
||||
|
||||
强制清掉 cookie JSON + 浏览器 profile,确保下次启动时必须扫码。
|
||||
星图登录完成后,自动把登录态 cookie 同步到抖音评论 scraper,避免再扫一次码。
|
||||
|
||||
任务计划: 每周一 09:00 (脚本内部判断是否到了该重登的周一,不到就退出)
|
||||
|
||||
3 周循环基准: 任意一个周一,后续 (今天 - BASE_DATE) // 7 % 3 == 0 才是星图周。
|
||||
改 BASE_DATE 就改了重登周期。
|
||||
|
||||
跑法:
|
||||
python data/tools/relogin_xingtu.py # 实际跑
|
||||
python data/tools/relogin_xingtu.py --dry-run # 看今天是不是星图周
|
||||
python data/tools/relogin_xingtu.py --force # 忽略 3 周判断,强制重登
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_runtime_paths import (
|
||||
child_browser_environment,
|
||||
resolve_relogin_runtime_paths,
|
||||
sync_relogin_state_to_consumers,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
RUNTIME_PATHS = resolve_relogin_runtime_paths("xingtu")
|
||||
COOKIE_FILE = RUNTIME_PATHS.cookie_file
|
||||
STATE_FILE = RUNTIME_PATHS.storage_state_file
|
||||
PROFILE_DIR = RUNTIME_PATHS.profile_dir
|
||||
SCRAPER = PROJECT_DIR / "xingtu_scraper_v2.py"
|
||||
|
||||
# 抖音评论 scraper 的 cookie 文件 (登录态共享目标)
|
||||
DY_COOKIE_FILE = PATHS.state_root / "cookies/douyin_cookies.json"
|
||||
DY_STATE_FILE = PATHS.state_root / "cookies/douyin_storage_state.json"
|
||||
DY_PROFILE_DIR = PATHS.state_root / "browser-profiles/douyin"
|
||||
|
||||
# 需要同步的抖音登录 cookie 名
|
||||
DY_LOGIN_COOKIES = {"sessionid", "sessionid_ss", "sid_guard", "sid_tt", "passport_csrf_token"}
|
||||
|
||||
# 3 周循环起点(必须是周一)。星图 cookie ~22 天,每 3 周(21 天)重登正好。
|
||||
BASE_DATE = dt.date(2026, 6, 22)
|
||||
LOGIN_COOKIE_NAMES = {"sessionid", "sessionid_ss", "sid_tt", "sid_guard"}
|
||||
|
||||
|
||||
def transaction_file() -> Path:
|
||||
return COOKIE_FILE.with_name(".xingtu_relogin_transaction.json")
|
||||
|
||||
|
||||
def _write_transaction(
|
||||
cookie_backup: Path | None,
|
||||
profile_backup: Path | None,
|
||||
state_backup: Path | None,
|
||||
) -> None:
|
||||
marker = transaction_file()
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = marker.with_name(f"{marker.name}.tmp")
|
||||
temp.write_text(json.dumps({
|
||||
"cookie_backup": str(cookie_backup) if cookie_backup else "",
|
||||
"state_backup": str(state_backup) if state_backup else "",
|
||||
"profile_backup": str(profile_backup) if profile_backup else "",
|
||||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(temp, marker)
|
||||
|
||||
|
||||
def _clear_transaction() -> None:
|
||||
marker = transaction_file()
|
||||
if marker.exists():
|
||||
marker.unlink()
|
||||
|
||||
|
||||
def _remove_path(path: Path) -> None:
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
elif path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def recover_interrupted_state() -> bool:
|
||||
"""Restore a relogin transaction that was killed after moving old state."""
|
||||
marker = transaction_file()
|
||||
if not marker.exists():
|
||||
return False
|
||||
try:
|
||||
state = json.loads(marker.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
cookie_backup = Path(state["cookie_backup"]) if state.get("cookie_backup") else None
|
||||
state_backup = Path(state["state_backup"]) if state.get("state_backup") else None
|
||||
profile_backup = Path(state["profile_backup"]) if state.get("profile_backup") else None
|
||||
terminate_profile_processes()
|
||||
if cookie_backup and cookie_backup.exists():
|
||||
_remove_path(COOKIE_FILE)
|
||||
cookie_backup.rename(COOKIE_FILE)
|
||||
if state_backup and state_backup.exists():
|
||||
_remove_path(STATE_FILE)
|
||||
state_backup.rename(STATE_FILE)
|
||||
if profile_backup and profile_backup.exists():
|
||||
_remove_path(PROFILE_DIR)
|
||||
profile_backup.rename(PROFILE_DIR)
|
||||
_clear_transaction()
|
||||
print(" [RECOVER] 已恢复上次被中断的星图登录状态")
|
||||
return True
|
||||
|
||||
|
||||
def has_valid_login_cookie_file(path: Path | None = None) -> bool:
|
||||
path = path or COOKIE_FILE
|
||||
try:
|
||||
cookies = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
return any(
|
||||
isinstance(cookie, dict)
|
||||
and cookie.get("name") in LOGIN_COOKIE_NAMES
|
||||
and cookie.get("value")
|
||||
for cookie in cookies
|
||||
)
|
||||
|
||||
|
||||
def terminate_profile_processes() -> None:
|
||||
"""Stop only browser processes using Xingtu's dedicated profile."""
|
||||
profile_text = str(PROFILE_DIR.resolve()).casefold()
|
||||
matches = []
|
||||
for process in psutil.process_iter(["pid", "cmdline"]):
|
||||
try:
|
||||
command = " ".join(process.info.get("cmdline") or []).casefold()
|
||||
if process.pid != os.getpid() and profile_text in command:
|
||||
matches.append(process)
|
||||
except (psutil.Error, OSError):
|
||||
continue
|
||||
for process in matches:
|
||||
try:
|
||||
process.terminate()
|
||||
except (psutil.Error, OSError):
|
||||
pass
|
||||
_gone, alive = psutil.wait_procs(matches, timeout=5)
|
||||
for process in alive:
|
||||
try:
|
||||
process.kill()
|
||||
except (psutil.Error, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def is_xingtu_week(today: dt.date, base: dt.date) -> bool:
|
||||
days = (today - base).days
|
||||
if days < 0:
|
||||
return False
|
||||
return (days // 7) % 3 == 0
|
||||
|
||||
|
||||
def reset_state() -> tuple[Path | None, Path | None, Path | None]:
|
||||
"""Temporarily move the old login state aside so it can be restored."""
|
||||
ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
cookie_backup = None
|
||||
state_backup = None
|
||||
profile_backup = None
|
||||
if COOKIE_FILE.exists():
|
||||
cookie_backup = COOKIE_FILE.with_suffix(f".json.bak.{ts}")
|
||||
if STATE_FILE.exists():
|
||||
state_backup = STATE_FILE.with_suffix(f".json.bak.{ts}")
|
||||
if PROFILE_DIR.exists():
|
||||
profile_backup = PROFILE_DIR.with_name(f"{PROFILE_DIR.name}.bak.{ts}")
|
||||
_write_transaction(cookie_backup, profile_backup, state_backup)
|
||||
|
||||
if cookie_backup:
|
||||
COOKIE_FILE.rename(cookie_backup)
|
||||
print(f" [OK] 备份 cookie -> {cookie_backup.name}")
|
||||
else:
|
||||
print(f" [INFO] {COOKIE_FILE.name} 不存在,跳过")
|
||||
if state_backup:
|
||||
STATE_FILE.rename(state_backup)
|
||||
print(f" [OK] 备份 storage state -> {state_backup.name}")
|
||||
else:
|
||||
print(f" [INFO] {STATE_FILE.name} 不存在,跳过")
|
||||
if profile_backup:
|
||||
PROFILE_DIR.rename(profile_backup)
|
||||
print(f" [OK] 暂存 {PROFILE_DIR.name}/ -> {profile_backup.name}/")
|
||||
else:
|
||||
print(f" [INFO] {PROFILE_DIR.name}/ 不存在,跳过")
|
||||
return cookie_backup, profile_backup, state_backup
|
||||
|
||||
|
||||
def restore_previous_state(
|
||||
cookie_backup: Path | None,
|
||||
profile_backup: Path | None,
|
||||
state_backup: Path | None = None,
|
||||
) -> None:
|
||||
"""Discard an incomplete login and restore the last known-good state."""
|
||||
terminate_profile_processes()
|
||||
if COOKIE_FILE.exists():
|
||||
COOKIE_FILE.unlink()
|
||||
if cookie_backup and cookie_backup.exists():
|
||||
cookie_backup.rename(COOKIE_FILE)
|
||||
print(f" [ROLLBACK] 已恢复旧 cookie: {COOKIE_FILE.name}")
|
||||
_remove_path(STATE_FILE)
|
||||
if state_backup and state_backup.exists():
|
||||
state_backup.rename(STATE_FILE)
|
||||
print(f" [ROLLBACK] 已恢复旧 storage state: {STATE_FILE.name}")
|
||||
if PROFILE_DIR.exists():
|
||||
shutil.rmtree(PROFILE_DIR)
|
||||
if profile_backup and profile_backup.exists():
|
||||
profile_backup.rename(PROFILE_DIR)
|
||||
print(f" [ROLLBACK] 已恢复旧浏览器状态: {PROFILE_DIR.name}/")
|
||||
_clear_transaction()
|
||||
|
||||
|
||||
def finish_successful_relogin(profile_backup: Path | None) -> None:
|
||||
"""Keep the new state and remove the bulky old profile backup."""
|
||||
if profile_backup and profile_backup.exists():
|
||||
shutil.rmtree(profile_backup)
|
||||
_clear_transaction()
|
||||
|
||||
|
||||
def sync_to_douyin_scraper() -> None:
|
||||
"""把星图的登录态 cookie 同步到抖音评论 scraper,避免再扫一次码。"""
|
||||
if not COOKIE_FILE.exists():
|
||||
print(" [SKIP] 星图 cookie 不存在,无法同步")
|
||||
return
|
||||
|
||||
try:
|
||||
xingtu_cookies = json.loads(COOKIE_FILE.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
print(f" [SKIP] 读取星图 cookie 失败: {exc}")
|
||||
return
|
||||
|
||||
# 提取登录态 cookie
|
||||
login_cookies = [c for c in xingtu_cookies if isinstance(c, dict) and c.get("name") in DY_LOGIN_COOKIES]
|
||||
if not login_cookies:
|
||||
print(" [SKIP] 星图 cookie 中没有找到登录态,跳过同步")
|
||||
return
|
||||
|
||||
# 清除抖音评论 scraper 的旧 profile (强制刷新)
|
||||
if DY_PROFILE_DIR.exists():
|
||||
shutil.rmtree(DY_PROFILE_DIR)
|
||||
print(f" [OK] 清空 {DY_PROFILE_DIR.name}/")
|
||||
|
||||
# 读取或创建抖音评论 cookie 文件
|
||||
dy_cookies = []
|
||||
if DY_COOKIE_FILE.exists():
|
||||
try:
|
||||
dy_cookies = json.loads(DY_COOKIE_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
dy_cookies = []
|
||||
|
||||
# 合并: 同名 cookie 覆盖,新 cookie 追加
|
||||
dy_by_name = {c.get("name"): c for c in dy_cookies if isinstance(c, dict)}
|
||||
for c in login_cookies:
|
||||
# 修正 domain: 星图的 cookie domain 可能是 .douyin.com 子域,评论采集需要 .douyin.com
|
||||
domain = c.get("domain", "")
|
||||
if "xingtu" in domain or "oceanengine" in domain:
|
||||
c["domain"] = ".douyin.com"
|
||||
dy_by_name[c["name"]] = c
|
||||
|
||||
DY_COOKIE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
DY_COOKIE_FILE.write_text(
|
||||
json.dumps(list(dy_by_name.values()), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f" [OK] 同步 {len(login_cookies)} 个登录 cookie 到 {DY_COOKIE_FILE.name}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="每 3 周周一重登星图")
|
||||
ap.add_argument("--base-date", default=BASE_DATE.isoformat(),
|
||||
help="3 周循环起点(必须是周一),格式 YYYY-MM-DD")
|
||||
ap.add_argument("--login-timeout", type=int, default=300)
|
||||
ap.add_argument("--force", action="store_true", help="忽略 3 周判断,强制重登")
|
||||
ap.add_argument("--dry-run", action="store_true", help="只打印计划")
|
||||
args = ap.parse_args()
|
||||
|
||||
today = dt.date.today()
|
||||
base = dt.date.fromisoformat(args.base_date)
|
||||
is_xw = is_xingtu_week(today, base)
|
||||
|
||||
print("== Relogin: 抖音/星图 ==")
|
||||
print(f" 今天: {today} ({today.strftime('%A')})")
|
||||
print(f" 3 周基准: {base}")
|
||||
print(f" 星图周? {'是' if is_xw else '否'}")
|
||||
print()
|
||||
|
||||
if not is_xw and not args.force:
|
||||
print("今天不是星图周,跳过 (传 --force 强制重登)")
|
||||
return 0
|
||||
|
||||
if args.dry_run:
|
||||
print("[DRY-RUN] 实际跑会: 删 cookie + 清 profile + 弹二维码")
|
||||
return 0
|
||||
|
||||
recover_interrupted_state()
|
||||
terminate_profile_processes()
|
||||
cookie_backup, profile_backup, state_backup = reset_state()
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(SCRAPER),
|
||||
"--login-only",
|
||||
"--login-timeout",
|
||||
str(args.login_timeout),
|
||||
]
|
||||
print(f"\n [RUN] {' '.join(cmd)}")
|
||||
try:
|
||||
rc = subprocess.call(
|
||||
cmd,
|
||||
cwd=str(PROJECT_DIR),
|
||||
env=child_browser_environment(
|
||||
cookie_file=COOKIE_FILE,
|
||||
storage_state_file=STATE_FILE,
|
||||
profile_dir=PROFILE_DIR,
|
||||
),
|
||||
)
|
||||
except BaseException:
|
||||
restore_previous_state(cookie_backup, profile_backup, state_backup)
|
||||
raise
|
||||
finally:
|
||||
terminate_profile_processes()
|
||||
print(f"\n exit={rc}")
|
||||
|
||||
if rc != 0 or not has_valid_login_cookie_file():
|
||||
print(" [WARN] 新登录未完成,恢复旧登录状态")
|
||||
restore_previous_state(cookie_backup, profile_backup, state_backup)
|
||||
return rc or 1
|
||||
try:
|
||||
sync_relogin_state_to_consumers(
|
||||
"xingtu",
|
||||
cookie_file=COOKIE_FILE,
|
||||
storage_state_file=STATE_FILE,
|
||||
)
|
||||
except Exception as exc:
|
||||
restore_previous_state(cookie_backup, profile_backup, state_backup)
|
||||
print(f" [ROLLBACK] 同步采集器登录态失败: {exc}")
|
||||
return 1
|
||||
|
||||
finish_successful_relogin(profile_backup)
|
||||
if rc == 0:
|
||||
sync_to_douyin_scraper()
|
||||
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -273,18 +273,15 @@ def maybe_wait_for_login(
|
||||
*,
|
||||
deadline: float | None = None,
|
||||
) -> None:
|
||||
login_deadline = time.monotonic() + login_timeout
|
||||
while time.monotonic() < login_deadline:
|
||||
_check_note_deadline(deadline, "login wait")
|
||||
# 登录统一由账号 vault 管理(gyxx accounts login douyin-content),
|
||||
# 由工作流执行前的 cookie 网关保证;运行中失效立即降级,不再等待人工扫码。
|
||||
try:
|
||||
body = page.locator("body").inner_text(timeout=5000)
|
||||
except Exception:
|
||||
body = ""
|
||||
if "扫码登录" not in body and "登录后" not in body:
|
||||
return
|
||||
log("Douyin may require login. Please finish login in the opened Chrome window.")
|
||||
page.wait_for_timeout(3000)
|
||||
_check_note_deadline(deadline, "login wait")
|
||||
if "扫码登录" in body or "登录后" in body:
|
||||
log("Douyin login state missing; collecting degraded samples. "
|
||||
"Run `gyxx accounts login douyin-content` to restore full comments.")
|
||||
|
||||
|
||||
def is_logged_in(page: Page) -> bool:
|
||||
@@ -833,55 +830,16 @@ def write_outputs(url: str, comments: list[dict[str, Any]], result: dict[str, An
|
||||
return json_path, csv_path
|
||||
|
||||
|
||||
def login_only(login_timeout: int) -> int:
|
||||
"""只触发登录(headed),登录完成立刻退出。给 relogin_douyin.py 用。"""
|
||||
def action(page: Page) -> None:
|
||||
page.goto("https://www.douyin.com/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(2500)
|
||||
# 主动触发登录弹窗(点登录按钮)
|
||||
try:
|
||||
login_btn = page.get_by_text("登录", exact=True).first
|
||||
if login_btn.count():
|
||||
login_btn.click(timeout=3000)
|
||||
page.wait_for_timeout(1500)
|
||||
except Exception:
|
||||
pass
|
||||
maybe_wait_for_login(page, login_timeout)
|
||||
save_state(page)
|
||||
|
||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with ScraplingBrowser(
|
||||
headless=False,
|
||||
real_chrome=True,
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
locale="zh-CN",
|
||||
timezone_id="Asia/Shanghai",
|
||||
timeout=90000,
|
||||
network_idle=False,
|
||||
disable_resources=False,
|
||||
google_search=False,
|
||||
page_setup=restore_cookies,
|
||||
max_pages=1,
|
||||
) as browser:
|
||||
browser.run_fetch_action("https://www.douyin.com/", action, wait=1000)
|
||||
log("[login-only] 完成")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Scrape Douyin video comments via Scrapling browser and XHR interception.")
|
||||
parser.add_argument("url", help="Douyin video/user URL (传 'login' 配合 --login-only)")
|
||||
parser.add_argument("--login-timeout", type=int, default=300, help="等待扫码/登录秒数")
|
||||
parser.add_argument("url", help="Douyin video/user URL")
|
||||
parser.add_argument("--login-timeout", type=int, default=300,
|
||||
help="保留参数(登录等待已移除;未登录将直接降级采集)")
|
||||
parser.add_argument("--max-scrolls", type=int, default=120, help="最多滚动加载次数")
|
||||
parser.add_argument("--idle-rounds", type=int, default=8, help="连续几轮无新评论后停止")
|
||||
parser.add_argument("--headless", action="store_true", help="无头模式;首次登录不要使用")
|
||||
parser.add_argument("--login-only", action="store_true",
|
||||
help="只触发登录(headed)然后退出,给 relogin_douyin 用")
|
||||
parser.add_argument("--headless", action="store_true", help="无头模式")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.login_only:
|
||||
return login_only(args.login_timeout)
|
||||
|
||||
comments, result = scrape_comments(args.url, args.login_timeout, args.max_scrolls, args.idle_rounds, args.headless)
|
||||
json_path, csv_path = write_outputs(args.url, comments, result)
|
||||
log(f"Final URL: {result.get('final_url', '')}")
|
||||
|
||||
@@ -38,6 +38,7 @@ from gyxx_flow.adapters.scrapling import (
|
||||
BrowserTimeoutError, # noqa: F401
|
||||
is_browser_timeout_error,
|
||||
)
|
||||
from gyxx_flow.adapters.browser import CookieExpiredError, cookie_expired_message
|
||||
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
|
||||
from gyxx_flow.modules.content_marketing.collection_completeness import (
|
||||
BLOCKED_INPUT,
|
||||
@@ -333,56 +334,6 @@ def is_logged_in(page: Page) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def open_scan_login(page: Page) -> None:
|
||||
"""Open the login dialog and prefer its QR-code login tab."""
|
||||
for pattern in (r"^登录$|账号登录",):
|
||||
try:
|
||||
button = page.get_by_text(re.compile(pattern)).first
|
||||
if button.count() and button.is_visible():
|
||||
button.click(timeout=2000)
|
||||
page.wait_for_timeout(500)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# The login dialog uses an image-only folded-corner switch. Its bundled
|
||||
# asset name is stable even though the generated CSS class is not.
|
||||
try:
|
||||
qr_switch = page.locator('img[src*="qr_code"]').first
|
||||
if qr_switch.count() and qr_switch.is_visible():
|
||||
# The browser engine's actionability check is blocked by the folded-corner
|
||||
# overlay. A native DOM click triggers the image's real handler.
|
||||
qr_switch.evaluate("e => e.click()")
|
||||
page.wait_for_timeout(1000)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for pattern in (r"扫码登录|二维码登录|扫码",):
|
||||
try:
|
||||
button = page.get_by_text(re.compile(pattern)).first
|
||||
if button.count() and button.is_visible():
|
||||
button.click(timeout=2000)
|
||||
page.wait_for_timeout(500)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def wait_for_scan_login(page: Page, timeout_seconds: int) -> None:
|
||||
log("Not logged in. 请在打开的 Chrome 中扫码登录。")
|
||||
open_scan_login(page)
|
||||
deadline = time.time() + timeout_seconds
|
||||
next_login_click = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
if is_logged_in(page):
|
||||
save_state(page)
|
||||
return
|
||||
if time.time() >= next_login_click:
|
||||
open_scan_login(page)
|
||||
next_login_click = time.time() + 5
|
||||
page.wait_for_timeout(2000)
|
||||
raise TimeoutError(f"Login timed out after {timeout_seconds} seconds.")
|
||||
|
||||
|
||||
def ensure_login(page: Page, login_timeout: int) -> None:
|
||||
page.goto(HOME_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(2500)
|
||||
@@ -402,20 +353,11 @@ def ensure_login(page: Page, login_timeout: int) -> None:
|
||||
)
|
||||
log(f"[SKIPPED_COOKIE] {reason}")
|
||||
raise AcceptanceCookieSkip(reason)
|
||||
page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
wait_for_scan_login(page, login_timeout)
|
||||
page.goto(HOME_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
# 关闭弹窗
|
||||
for text in ("跳过", "不再提示", "我知道了"):
|
||||
try:
|
||||
b = page.get_by_text(text, exact=True).first
|
||||
if b.count():
|
||||
b.click(timeout=1500)
|
||||
page.wait_for_timeout(800)
|
||||
except Exception:
|
||||
pass
|
||||
# 登录统一由账号 vault 管理(gyxx accounts login xiaohongshu-content),
|
||||
# 采集脚本不再内嵌扫码等待;运行中失效直接快速失败。
|
||||
reason = cookie_expired_message("xiaohongshu-content", "小红书蒲公英")
|
||||
log(f"[COOKIE_EXPIRED] {reason}")
|
||||
raise CookieExpiredError(reason)
|
||||
|
||||
|
||||
def _is_login_timeout(exc: BaseException) -> bool:
|
||||
@@ -1933,7 +1875,7 @@ def scrape_styles(styles: list[dict], login_timeout: int, headless: bool,
|
||||
action,
|
||||
page_setup=restore_cookies,
|
||||
)
|
||||
except AcceptanceCookieSkip:
|
||||
except (AcceptanceCookieSkip, CookieExpiredError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
session_lost = is_browser_session_lost(exc)
|
||||
@@ -2044,39 +1986,6 @@ def parse_int(s) -> int | None:
|
||||
return int(num)
|
||||
|
||||
|
||||
def login_only(login_timeout: int) -> int:
|
||||
"""只触发登录(headed),登录完成立刻退出。给 weekly_relogin.py 用。"""
|
||||
def action(page):
|
||||
ensure_login(page, login_timeout)
|
||||
return page
|
||||
|
||||
session = None
|
||||
try:
|
||||
with DynamicSession(
|
||||
headless=False,
|
||||
real_chrome=True,
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
locale="zh-CN",
|
||||
timezone_id="Asia/Shanghai",
|
||||
timeout=60000,
|
||||
network_idle=False,
|
||||
disable_resources=False,
|
||||
google_search=False,
|
||||
page_setup=restore_cookies,
|
||||
max_pages=2,
|
||||
) as session:
|
||||
session.fetch(HOME_URL, page_action=action, wait=1000)
|
||||
except Exception as exc:
|
||||
if "TargetClosedError" in type(exc).__name__ or "has been closed" in str(exc):
|
||||
log(f" [WARN] 浏览器清理时报错: {type(exc).__name__}")
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
_force_cleanup_session(session)
|
||||
log("[login-only] 登录完成,退出")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="小红书蒲公英 V2: 多维表格驱动精准采集+回填")
|
||||
add_repeatable_style_argument(parser, "只跑指定款式编号(可多次)")
|
||||
@@ -2090,8 +1999,6 @@ def main() -> int:
|
||||
parser.add_argument("--skip-done", action="store_true", help="跳过已完成的款式(从进度文件读)")
|
||||
parser.add_argument("--inter-style-pause", type=int, default=5,
|
||||
help="兼容旧参数;全局按达人采集后不再按款式暂停")
|
||||
parser.add_argument("--login-only", action="store_true",
|
||||
help="只触发登录(headed),登录完成立刻退出,给 weekly_relogin 用")
|
||||
parser.add_argument("--self-operated", action="store_true",
|
||||
help="抓取自营达人表格(而非合作达人)")
|
||||
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE,
|
||||
@@ -2108,12 +2015,6 @@ def main() -> int:
|
||||
help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.login_only:
|
||||
try:
|
||||
return login_only(args.login_timeout)
|
||||
except AcceptanceCookieSkip:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
|
||||
mapping = load_mapping(self_operated=args.self_operated)
|
||||
styles = mapping["tables"]
|
||||
if args.style:
|
||||
@@ -2213,7 +2114,7 @@ def main() -> int:
|
||||
published_from=args.published_from,
|
||||
max_age_days=args.max_age_days, no_retry=args.no_retry,
|
||||
)
|
||||
except AcceptanceCookieSkip:
|
||||
except (AcceptanceCookieSkip, CookieExpiredError):
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
except Exception as exc:
|
||||
if not _is_login_timeout(exc):
|
||||
|
||||
@@ -363,8 +363,8 @@ def log(msg: str) -> None:
|
||||
|
||||
|
||||
# ============================================================
|
||||
# B站登录态: Cookie 持久化 + 扫码登录引导
|
||||
# 空间投稿列表需要登录态,首次用 --login-only 扫码,之后调度运行复用。
|
||||
# B站登录态: Cookie 持久化;登录统一由账号 vault 管理
|
||||
# (gyxx accounts login bilibili-content),脚本内不再提供扫码登录。
|
||||
# ============================================================
|
||||
BILI_COOKIE_FILE = PATHS.browser_cookie_file
|
||||
|
||||
@@ -389,56 +389,6 @@ def save_state(page) -> None:
|
||||
log(f"已保存 Cookie ({len(cookies)} 条): {BILI_COOKIE_FILE}")
|
||||
|
||||
|
||||
def _has_login_cookies(page) -> bool:
|
||||
names = {cookie.get("name") for cookie in page.context.cookies()}
|
||||
return "SESSDATA" in names and "DedeUserID" in names
|
||||
|
||||
|
||||
def login_only(login_timeout: int = 300) -> int:
|
||||
"""打开 B站扫码登录页,等待人工扫码后保存 Cookie。"""
|
||||
from scrapling.fetchers import StealthySession
|
||||
|
||||
with StealthySession(
|
||||
headless=False,
|
||||
real_chrome=True,
|
||||
user_data_dir=str(PATHS.browser_profile_dir),
|
||||
locale="zh-CN",
|
||||
timezone_id="Asia/Shanghai",
|
||||
timeout=60_000,
|
||||
network_idle=False,
|
||||
disable_resources=False,
|
||||
google_search=False,
|
||||
max_pages=2,
|
||||
page_setup=restore_cookies,
|
||||
) as session:
|
||||
page = (
|
||||
session.context.pages[0]
|
||||
if session.context.pages
|
||||
else session.context.new_page()
|
||||
)
|
||||
page.goto("https://passport.bilibili.com/login",
|
||||
wait_until="domcontentloaded", timeout=60_000)
|
||||
page.wait_for_timeout(2000)
|
||||
if _has_login_cookies(page):
|
||||
log("✅ 已有 B站登录态,无需扫码")
|
||||
save_state(page)
|
||||
return 0
|
||||
print("\n" + "=" * 60)
|
||||
print("🔐 请在打开的浏览器中用哔哩哔哩 App 扫码登录")
|
||||
print(" 登录成功后脚本会自动继续并保存 Cookie")
|
||||
print("=" * 60, flush=True)
|
||||
deadline = time.time() + login_timeout
|
||||
while time.time() < deadline:
|
||||
if _has_login_cookies(page):
|
||||
log("✅ 检测到登录成功")
|
||||
page.wait_for_timeout(2000)
|
||||
save_state(page)
|
||||
return 0
|
||||
page.wait_for_timeout(2000)
|
||||
log(f"❌ 等待扫码超时({login_timeout}s),未保存登录态")
|
||||
return 1
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 飞书多维表格
|
||||
# ============================================================
|
||||
@@ -846,17 +796,10 @@ def main() -> int:
|
||||
description="自营 B 站采集: 公开 API 全量视频指标 + UP 主主页全量投稿 + 飞书曝光回填")
|
||||
parser.add_argument("--style", type=int, help="只跑指定款式编号")
|
||||
parser.add_argument("--dry-run", action="store_true", help="不写回飞书")
|
||||
parser.add_argument("--login-only", action="store_true",
|
||||
help="只打开 B站扫码登录并保存 Cookie(首次部署引导)")
|
||||
parser.add_argument("--login-timeout", type=int, default=300,
|
||||
help="等待人工扫码的秒数")
|
||||
parser.add_argument("--delay", type=float, default=0.3,
|
||||
help="每条请求间隔秒数(防风控)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.login_only:
|
||||
return login_only(args.login_timeout)
|
||||
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
try:
|
||||
mapping = feishu_mapping.load_mapping(self_operated=True)
|
||||
|
||||
@@ -27,6 +27,8 @@ from typing import Any
|
||||
from scrapling.fetchers import DynamicSession
|
||||
|
||||
from gyxx_flow.adapters.scrapling import BrowserPage as Page
|
||||
from gyxx_flow.adapters.browser import CookieExpiredError, cookie_expired_message
|
||||
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
|
||||
|
||||
from gyxx_flow.modules.content_marketing.collection_completeness import (
|
||||
BLOCKED_INPUT,
|
||||
@@ -261,17 +263,16 @@ def save_state(page: Page) -> None:
|
||||
|
||||
|
||||
def maybe_wait_for_login(page: Page, login_timeout: int) -> None:
|
||||
deadline = time.time() + login_timeout
|
||||
while time.time() < deadline:
|
||||
# 登录统一由账号 vault 管理(gyxx accounts login douyin-content),
|
||||
# 采集过程遇到登录态失效立即快速失败,不再等待人工扫码。
|
||||
try:
|
||||
body = page.locator("body").inner_text(timeout=5000)
|
||||
except Exception:
|
||||
body = ""
|
||||
if "扫码登录" not in body and "登录后" not in body:
|
||||
return
|
||||
log("抖音需要登录,请在打开的 Chrome 窗口中完成登录")
|
||||
page.wait_for_timeout(3000)
|
||||
log("[WARN] 登录等待超时")
|
||||
if "扫码登录" in body or "登录后" in body:
|
||||
reason = cookie_expired_message("douyin-content", "自营抖音")
|
||||
log(f"[COOKIE_EXPIRED] {reason}")
|
||||
raise CookieExpiredError(reason)
|
||||
|
||||
|
||||
def dismiss_popups(page: Page) -> None:
|
||||
@@ -605,33 +606,6 @@ def scrape_one_style(style: dict, login_timeout: int, headless: bool,
|
||||
return summary
|
||||
|
||||
|
||||
def login_only(login_timeout: int) -> int:
|
||||
"""只触发登录"""
|
||||
login_done = False
|
||||
|
||||
def page_action(page: Page):
|
||||
restore_cookies(page)
|
||||
page.goto("https://www.douyin.com/", wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(2000)
|
||||
maybe_wait_for_login(page, login_timeout)
|
||||
save_state(page)
|
||||
log("登录完成")
|
||||
|
||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with DynamicSession(
|
||||
headless=False,
|
||||
real_chrome=True,
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
locale="zh-CN",
|
||||
timezone_id="Asia/Shanghai",
|
||||
timeout=60000,
|
||||
page_setup=restore_cookies,
|
||||
) as session:
|
||||
session.fetch("https://www.douyin.com/", page_action=page_action, wait=1000)
|
||||
|
||||
return 0 if login_done else 1
|
||||
|
||||
|
||||
# ============================================================
|
||||
# main
|
||||
# ============================================================
|
||||
@@ -642,12 +616,8 @@ def main() -> int:
|
||||
parser.add_argument("--headless", action="store_true")
|
||||
parser.add_argument("--login-timeout", type=int, default=300)
|
||||
parser.add_argument("--dry-run", action="store_true", help="不写回多维表格")
|
||||
parser.add_argument("--login-only", action="store_true", help="只触发登录")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.login_only:
|
||||
return login_only(args.login_timeout)
|
||||
|
||||
mapping = load_mapping()
|
||||
styles = mapping["tables"]
|
||||
if args.style:
|
||||
@@ -703,6 +673,9 @@ def main() -> int:
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except CookieExpiredError as exc:
|
||||
print(f"[COOKIE_EXPIRED] {exc}", file=sys.stderr)
|
||||
raise SystemExit(COOKIE_SKIP_EXIT_CODE)
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.", file=sys.stderr)
|
||||
raise SystemExit(130)
|
||||
@@ -223,18 +223,15 @@ def maybe_wait_for_login(
|
||||
*,
|
||||
deadline: float | None = None,
|
||||
) -> None:
|
||||
login_deadline = time.monotonic() + max(0, login_timeout)
|
||||
while time.monotonic() < login_deadline:
|
||||
_check_note_deadline(deadline, "login wait")
|
||||
# 登录统一由账号 vault 管理(gyxx accounts login xiaohongshu-content),
|
||||
# 由工作流执行前的 cookie 网关保证;运行中失效立即降级,不再等待人工扫码。
|
||||
try:
|
||||
body = page.locator("body").inner_text(timeout=5000)
|
||||
except Exception:
|
||||
body = ""
|
||||
if "扫码登录" not in body and "登录后" not in body and "验证码" not in body:
|
||||
return
|
||||
log("Xiaohongshu may require login. Please finish login in the opened Chrome window.")
|
||||
page.wait_for_timeout(3000)
|
||||
_check_note_deadline(deadline, "login wait")
|
||||
if "扫码登录" in body or "登录后" in body or "验证码" in body:
|
||||
log("Xiaohongshu login state missing; collecting degraded samples. "
|
||||
"Run `gyxx accounts login xiaohongshu-content` to restore full comments.")
|
||||
|
||||
|
||||
def is_logged_in(page: Page) -> bool:
|
||||
@@ -751,55 +748,16 @@ def write_outputs(url: str, comments: list[dict[str, Any]], result: dict[str, An
|
||||
return json_path, csv_path
|
||||
|
||||
|
||||
def login_only(login_timeout: int) -> int:
|
||||
"""只触发登录(headed),登录完成立刻退出。给 relogin_xiaohongshu.py 用。"""
|
||||
def action(page: Page) -> None:
|
||||
page.goto("https://www.xiaohongshu.com/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(2500)
|
||||
# 主动点登录按钮触发二维码
|
||||
try:
|
||||
login_btn = page.get_by_text("登录", exact=True).first
|
||||
if login_btn.count():
|
||||
login_btn.click(timeout=3000)
|
||||
page.wait_for_timeout(1500)
|
||||
except Exception:
|
||||
pass
|
||||
maybe_wait_for_login(page, login_timeout)
|
||||
save_state(page)
|
||||
|
||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with ScraplingBrowser(
|
||||
headless=False,
|
||||
real_chrome=True,
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
locale="zh-CN",
|
||||
timezone_id="Asia/Shanghai",
|
||||
timeout=90000,
|
||||
network_idle=False,
|
||||
disable_resources=False,
|
||||
google_search=False,
|
||||
page_setup=restore_cookies,
|
||||
max_pages=1,
|
||||
) as browser:
|
||||
browser.run_fetch_action("https://www.xiaohongshu.com/", action, wait=1000)
|
||||
log("[login-only] 完成")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Scrape Xiaohongshu note comments with a real browser.")
|
||||
parser.add_argument("url", help="Xiaohongshu explore/note URL (传 'login' 配合 --login-only)")
|
||||
parser.add_argument("--login-timeout", type=int, default=300, help="等待扫码/登录秒数")
|
||||
parser.add_argument("url", help="Xiaohongshu explore/note URL")
|
||||
parser.add_argument("--login-timeout", type=int, default=300,
|
||||
help="保留参数(登录等待已移除;未登录将直接降级采集)")
|
||||
parser.add_argument("--max-scrolls", type=int, default=120, help="最多滚动加载次数")
|
||||
parser.add_argument("--idle-rounds", type=int, default=8, help="连续几轮无新评论后停止")
|
||||
parser.add_argument("--headless", action="store_true", help="无头模式;首次登录不要使用")
|
||||
parser.add_argument("--login-only", action="store_true",
|
||||
help="只触发登录(headed)然后退出,给 relogin_xiaohongshu 用")
|
||||
parser.add_argument("--headless", action="store_true", help="无头模式")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.login_only:
|
||||
return login_only(args.login_timeout)
|
||||
|
||||
comments, result = scrape_comments(args.url, args.login_timeout, args.max_scrolls, args.idle_rounds, args.headless)
|
||||
json_path, csv_path = write_outputs(args.url, comments, result)
|
||||
log(f"Final URL: {result.get('final_url', '')}")
|
||||
|
||||
@@ -33,6 +33,7 @@ from gyxx_flow.adapters.scrapling import (
|
||||
BrowserTimeoutError,
|
||||
is_browser_timeout_error,
|
||||
)
|
||||
from gyxx_flow.adapters.browser import CookieExpiredError, cookie_expired_message
|
||||
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
|
||||
from gyxx_flow.modules.content_marketing.collection_completeness import (
|
||||
BLOCKED_INPUT,
|
||||
@@ -537,183 +538,6 @@ def is_logged_in(page: Page) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def open_scan_login(page: Page) -> None:
|
||||
"""Open the selected platform's login dialog without handling passwords."""
|
||||
if CONTENT_SOURCE == "buyin":
|
||||
if not is_business_page(page) or "/dashboard/servicehall/daren-square" not in page.url:
|
||||
try:
|
||||
page.goto(CUSTOMER_LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
except Exception:
|
||||
pass
|
||||
for pattern in (r"^登录$", r"扫码登录|二维码登录|抖音登录|使用抖音登录"):
|
||||
try:
|
||||
button = page.get_by_text(re.compile(pattern)).first
|
||||
if button.count() and button.is_visible():
|
||||
button.click(timeout=3000)
|
||||
page.wait_for_timeout(800)
|
||||
return
|
||||
except Exception as exc:
|
||||
reraise_if_browser_session_lost(exc)
|
||||
return
|
||||
|
||||
if "sso.oceanengine.com/xingtu/login" not in page.url:
|
||||
try:
|
||||
page.goto(CUSTOMER_LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# The Douyin login icon on Xingtu's customer SSO page has a stable
|
||||
# aweme.png source. Clicking it opens the QR authorization page directly.
|
||||
try:
|
||||
douyin = page.locator('img[src*="aweme.png"]').first
|
||||
if douyin.count() and douyin.is_visible():
|
||||
douyin.click(timeout=3000)
|
||||
page.wait_for_timeout(1500)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for pattern in (r"^登录$|立即登录", r"抖音扫码登录|扫码登录|二维码登录|扫码"):
|
||||
try:
|
||||
button = page.get_by_text(re.compile(pattern)).first
|
||||
if button.count() and button.is_visible():
|
||||
button.click(timeout=2000)
|
||||
page.wait_for_timeout(500)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def is_scan_qr_page(page: Page) -> bool:
|
||||
"""Return True once Douyin's QR authorization page is visible.
|
||||
|
||||
The QR page lives outside xingtu.cn. Treating that URL as a page that still
|
||||
needs opening causes the login helper to navigate back and click again every
|
||||
few seconds, continuously refreshing the QR code.
|
||||
"""
|
||||
url = page.url.lower()
|
||||
if any(host in url for host in ("open.douyin.com", "sso.douyin.com")):
|
||||
return True
|
||||
for selector in (
|
||||
'img[src*="qrcode"]',
|
||||
'img[src*="qr_code"]',
|
||||
'canvas',
|
||||
):
|
||||
try:
|
||||
locator = page.locator(selector).first
|
||||
if locator.count() and locator.is_visible():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def resolve_live_page(page: Page, *, create_if_missing: bool = False) -> Page | None:
|
||||
"""Return a usable page after OAuth closes or replaces its QR tab."""
|
||||
try:
|
||||
if not page.is_closed():
|
||||
return page
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
context = page.context
|
||||
for candidate in reversed(list(context.pages)):
|
||||
try:
|
||||
if not candidate.is_closed():
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
if create_if_missing:
|
||||
return context.new_page()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def find_logged_in_page(page: Page) -> Page | None:
|
||||
"""Find an authenticated business tab even while the OAuth tab stays open."""
|
||||
candidates: list[Page] = []
|
||||
try:
|
||||
candidates.extend(reversed(list(page.context.pages)))
|
||||
except Exception:
|
||||
pass
|
||||
candidates.append(page)
|
||||
|
||||
seen: set[int] = set()
|
||||
for candidate in candidates:
|
||||
marker = id(candidate)
|
||||
if marker in seen:
|
||||
continue
|
||||
seen.add(marker)
|
||||
try:
|
||||
if candidate.is_closed() or not is_business_page(candidate):
|
||||
continue
|
||||
if is_logged_in(candidate):
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def wait_for_scan_login(page: Page, timeout_seconds: int) -> Page:
|
||||
log("Not logged in. 请在打开的 Chrome 中扫码登录。")
|
||||
current_page = page
|
||||
open_scan_login(current_page)
|
||||
deadline = time.time() + timeout_seconds
|
||||
next_login_click = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
authenticated = find_logged_in_page(current_page)
|
||||
if authenticated is not None:
|
||||
save_state(authenticated)
|
||||
return authenticated
|
||||
|
||||
replacement = resolve_live_page(current_page, create_if_missing=True)
|
||||
if replacement is None:
|
||||
raise RuntimeError("Xingtu browser context closed during OAuth login")
|
||||
if replacement is not current_page:
|
||||
current_page = replacement
|
||||
try:
|
||||
current_page.goto(
|
||||
MARKET_URL, wait_until="domcontentloaded", timeout=60000
|
||||
)
|
||||
current_page.wait_for_timeout(2000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if (
|
||||
CONTENT_SOURCE == "xingtu"
|
||||
and "redirect_uri=/ad/creator/market" in current_page.url
|
||||
and not is_scan_qr_page(current_page)
|
||||
):
|
||||
try:
|
||||
current_page.goto(
|
||||
MARKET_URL, wait_until="domcontentloaded", timeout=60000
|
||||
)
|
||||
current_page.wait_for_timeout(2000)
|
||||
except Exception:
|
||||
pass
|
||||
authenticated = find_logged_in_page(current_page)
|
||||
if authenticated is not None:
|
||||
save_state(authenticated)
|
||||
return authenticated
|
||||
if (
|
||||
time.time() >= next_login_click
|
||||
and not is_scan_qr_page(current_page)
|
||||
and not is_business_page(current_page)
|
||||
):
|
||||
open_scan_login(current_page)
|
||||
next_login_click = time.time() + 5
|
||||
try:
|
||||
current_page.wait_for_timeout(2000)
|
||||
except Exception:
|
||||
# OAuth may close the QR tab after authorization. The next loop
|
||||
# follows the surviving tab in the same browser context.
|
||||
continue
|
||||
raise TimeoutError(f"Login timed out after {timeout_seconds} seconds.")
|
||||
|
||||
|
||||
def ensure_login(page: Page, login_timeout: int) -> Page:
|
||||
page.goto(MARKET_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(3000)
|
||||
@@ -735,12 +559,11 @@ def ensure_login(page: Page, login_timeout: int) -> Page:
|
||||
)
|
||||
log(f"[SKIPPED_COOKIE] {reason}")
|
||||
raise AcceptanceCookieSkip(reason)
|
||||
page = wait_for_scan_login(page, login_timeout)
|
||||
page.goto(MARKET_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(3000)
|
||||
if not is_logged_in(page):
|
||||
page = wait_for_scan_login(page, login_timeout)
|
||||
return page
|
||||
# 登录统一由账号 vault 管理(gyxx accounts login douyin-shop),
|
||||
# 采集脚本不再内嵌扫码等待;运行中失效直接快速失败。
|
||||
reason = cookie_expired_message("douyin-shop", f"{CONTENT_SOURCE}达人广场")
|
||||
log(f"[COOKIE_EXPIRED] {reason}")
|
||||
raise CookieExpiredError(reason)
|
||||
|
||||
|
||||
def dismiss_popups(page: Page) -> None:
|
||||
@@ -3736,7 +3559,7 @@ def scrape_styles_with_fallback(
|
||||
summary_override=summaries,
|
||||
replace_existing=True,
|
||||
)
|
||||
except AcceptanceCookieSkip as exc:
|
||||
except (AcceptanceCookieSkip, CookieExpiredError) as exc:
|
||||
log(f" [WARN] 抖店补采登录态不可用,保留星图结果: {exc}")
|
||||
_mark_fallback_not_processed(
|
||||
fallback_tasks, summaries, "buyin_cookie_skipped"
|
||||
@@ -3805,96 +3628,6 @@ def _force_cleanup_session(session) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def login_only(login_timeout: int) -> int:
|
||||
"""只触发登录(headed),登录完成立刻退出。给 weekly_relogin.py 用。"""
|
||||
login_completed = False
|
||||
|
||||
def action(page):
|
||||
nonlocal login_completed
|
||||
page = ensure_login(page, login_timeout)
|
||||
if not is_logged_in(page):
|
||||
raise RuntimeError(f"{CONTENT_SOURCE} 登录状态验证失败")
|
||||
save_state(page)
|
||||
login_completed = True
|
||||
return page
|
||||
|
||||
session = None
|
||||
try:
|
||||
with DynamicSession(
|
||||
headless=False,
|
||||
real_chrome=True,
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
locale="zh-CN",
|
||||
timezone_id="Asia/Shanghai",
|
||||
timeout=60000,
|
||||
network_idle=False,
|
||||
disable_resources=False,
|
||||
google_search=False,
|
||||
page_setup=restore_cookies,
|
||||
max_pages=2,
|
||||
) as session:
|
||||
session.fetch(MARKET_URL, page_action=action, wait=1000)
|
||||
except Exception as exc:
|
||||
err_msg = str(exc).lower()
|
||||
if "closed" in err_msg or "context" in err_msg or "target" in err_msg:
|
||||
log(f"[login-only] 浏览器会话关闭异常: {exc}")
|
||||
else:
|
||||
log(f"[login-only] 登录执行失败: {exc}")
|
||||
finally:
|
||||
_force_cleanup_session(session)
|
||||
|
||||
# Douyin OAuth may close the only QR tab (and therefore the temporary
|
||||
# browser context) after a successful mobile confirmation. The durable
|
||||
# Chromium profile can already contain the new session even though the
|
||||
# page_action never got a chance to export cookies. Reopen the same profile
|
||||
# once, verify the selected business page, then export its cookies.
|
||||
if not login_completed or not has_valid_login_cookie_file():
|
||||
log(f"[login-only] 扫码窗口已结束,重新打开{CONTENT_SOURCE}验证持久会话")
|
||||
login_completed = verify_persisted_profile_login()
|
||||
if not login_completed or not has_valid_login_cookie_file():
|
||||
log("[login-only] 登录未完成或Cookie缺少有效会话")
|
||||
return 1
|
||||
log("[login-only] 登录完成,退出")
|
||||
return 0
|
||||
|
||||
|
||||
def verify_persisted_profile_login() -> bool:
|
||||
"""Reopen the persistent profile after OAuth closes its only tab."""
|
||||
verified = False
|
||||
|
||||
def action(page: Page):
|
||||
nonlocal verified
|
||||
page.goto(MARKET_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(3000)
|
||||
if not is_logged_in(page):
|
||||
raise RuntimeError(f"persisted {CONTENT_SOURCE} profile is not authenticated")
|
||||
save_state(page)
|
||||
verified = True
|
||||
return page
|
||||
|
||||
session = None
|
||||
try:
|
||||
with DynamicSession(
|
||||
headless=False,
|
||||
real_chrome=True,
|
||||
user_data_dir=str(PROFILE_DIR),
|
||||
locale="zh-CN",
|
||||
timezone_id="Asia/Shanghai",
|
||||
timeout=60000,
|
||||
network_idle=False,
|
||||
disable_resources=False,
|
||||
google_search=False,
|
||||
page_setup=restore_cookies,
|
||||
max_pages=1,
|
||||
) as session:
|
||||
session.fetch(MARKET_URL, page_action=action, wait=1000)
|
||||
except Exception as exc:
|
||||
log(f"[login-only] 持久会话验证失败: {exc}")
|
||||
finally:
|
||||
_force_cleanup_session(session)
|
||||
return verified and has_valid_login_cookie_file()
|
||||
|
||||
|
||||
def load_retry_scope(summary_path: Path, reason: str) -> dict[int, set[str]]:
|
||||
"""Load exact style/record pairs for a reason-limited retry."""
|
||||
try:
|
||||
@@ -3926,8 +3659,6 @@ def main() -> int:
|
||||
parser.add_argument("--login-timeout", type=int, default=300)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--data-dir", default=str(PATHS.normalized_root))
|
||||
parser.add_argument("--login-only", action="store_true",
|
||||
help="只触发登录(headed),登录完成立刻退出,给 weekly_relogin 用")
|
||||
parser.add_argument(
|
||||
"--buyin",
|
||||
action="store_true",
|
||||
@@ -3952,12 +3683,6 @@ def main() -> int:
|
||||
"buyin" if args.buyin else os.environ.get("GYXX_CONTENT_SOURCE", "xingtu")
|
||||
)
|
||||
|
||||
if args.login_only:
|
||||
try:
|
||||
return login_only(args.login_timeout)
|
||||
except AcceptanceCookieSkip:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
|
||||
mapping = load_mapping(self_operated=args.self_operated)
|
||||
styles = mapping["tables"]
|
||||
if args.style:
|
||||
@@ -4038,7 +3763,7 @@ def main() -> int:
|
||||
max_age_days=args.max_age_days,
|
||||
no_retry=args.no_retry,
|
||||
)
|
||||
except AcceptanceCookieSkip:
|
||||
except (AcceptanceCookieSkip, CookieExpiredError):
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
persist_summaries(all_summaries)
|
||||
for s, summary in zip(styles, all_summaries):
|
||||
|
||||
Vendored
+12
-23
@@ -223,23 +223,16 @@ def _check_login_status(page) -> bool:
|
||||
|
||||
|
||||
def _ensure_login(page):
|
||||
print("\n" + "=" * 60)
|
||||
print("【请登录】请在弹出的浏览器窗口中完成登录")
|
||||
print("提示:扫码/短信/密码均可,登录成功后页面会自动跳转")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
for i in range(LOGIN_TIMEOUT):
|
||||
page = _active_page(page)
|
||||
if _check_login_status(page):
|
||||
print(f"[OK] Login success! URL: {page.url}")
|
||||
page.wait_for_timeout(3000)
|
||||
return page
|
||||
if i % 5 == 0:
|
||||
print(f"[WAIT] Waiting for login... {i}s / {LOGIN_TIMEOUT}s")
|
||||
time.sleep(1)
|
||||
|
||||
print(f"[ERR] Login timeout ({LOGIN_TIMEOUT}s)")
|
||||
return None
|
||||
# 登录统一由账号 vault 管理(gyxx accounts login douyin-shop),
|
||||
# 采集脚本不再弹出浏览器等待人工扫码/短信登录。
|
||||
print(
|
||||
"[COOKIE_EXPIRED] 抖音罗盘登录态已失效:"
|
||||
"请执行 uv run gyxx accounts login douyin-shop 刷新账号登录态"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"抖音罗盘登录态已失效;请执行 uv run gyxx accounts login douyin-shop "
|
||||
"刷新账号登录态后重跑"
|
||||
)
|
||||
|
||||
|
||||
def _select_menu_and_date(page) -> None:
|
||||
@@ -1927,12 +1920,8 @@ def _page_automation(page) -> None:
|
||||
|
||||
# 1. Check and wait for login
|
||||
if not _check_login_status(page):
|
||||
logged_page = _ensure_login(page)
|
||||
if not logged_page:
|
||||
print("[ERR] Login failed, will try save cookies anyway...")
|
||||
else:
|
||||
page = logged_page
|
||||
_maximize_page(page)
|
||||
# 登录态失效时 _ensure_login 会直接抛 RuntimeError
|
||||
_ensure_login(page)
|
||||
|
||||
# 2. Save state after login (best effort)
|
||||
_save_browser_state(page, "after login")
|
||||
|
||||
@@ -226,23 +226,16 @@ def ensure_login(page: Page, *, headless: bool, login_timeout: int) -> None:
|
||||
print("[SKIPPED_COOKIE] 抖店会话跳转到了登录页")
|
||||
raise DouyinSessionExpiredError("抖店会话跳转到了登录页")
|
||||
|
||||
if headless:
|
||||
raise RuntimeError(
|
||||
"抖音 Cookie 已失效且当前为无头模式,无法扫码登录;"
|
||||
"请先执行 uv run gyxx accounts login douyin-shop 刷新账号登录态"
|
||||
# 登录统一由账号 vault 管理(gyxx accounts login douyin-shop),
|
||||
# 采集脚本不再弹出浏览器等待人工扫码/验证码。
|
||||
print(
|
||||
"[COOKIE_EXPIRED] 抖店 Cookie 已失效:"
|
||||
"请执行 uv run gyxx accounts login douyin-shop 刷新账号登录态"
|
||||
)
|
||||
raise DouyinSessionExpiredError(
|
||||
"抖音 Cookie 已失效;请执行 uv run gyxx accounts login douyin-shop "
|
||||
"刷新账号登录态后重跑"
|
||||
)
|
||||
|
||||
print("=" * 60)
|
||||
print("【请登录】请在弹出的浏览器窗口中完成抖店登录")
|
||||
print("=" * 60)
|
||||
deadline = time.monotonic() + login_timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not douyin_common.is_login_page(page):
|
||||
print(f" [OK] 登录成功: {page.url}")
|
||||
page.wait_for_timeout(3_000)
|
||||
return
|
||||
page.wait_for_timeout(1_000)
|
||||
raise RuntimeError(f"登录在 {login_timeout} 秒内未完成,当前页面: {page.url}")
|
||||
|
||||
|
||||
class DouyinSessionExpiredError(RuntimeError):
|
||||
|
||||
+12
-23
@@ -262,23 +262,16 @@ def _check_login_status(page) -> bool:
|
||||
|
||||
|
||||
def _ensure_login(page):
|
||||
print("\n" + "=" * 60)
|
||||
print("【请登录】请在弹出的浏览器窗口中完成登录")
|
||||
print("提示:扫码/短信/密码均可,登录成功后页面会自动跳转")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
for i in range(LOGIN_TIMEOUT):
|
||||
page = _active_page(page)
|
||||
if _check_login_status(page):
|
||||
print(f"[OK] Login success! URL: {page.url}")
|
||||
page.wait_for_timeout(3000)
|
||||
return page
|
||||
if i % 5 == 0:
|
||||
print(f"[WAIT] Waiting for login... {i}s / {LOGIN_TIMEOUT}s")
|
||||
time.sleep(1)
|
||||
|
||||
print(f"[ERR] Login timeout ({LOGIN_TIMEOUT}s)")
|
||||
return None
|
||||
# 登录统一由账号 vault 管理(gyxx accounts login douyin-shop),
|
||||
# 采集脚本不再弹出浏览器等待人工扫码/短信登录。
|
||||
print(
|
||||
"[COOKIE_EXPIRED] 抖音罗盘登录态已失效:"
|
||||
"请执行 uv run gyxx accounts login douyin-shop 刷新账号登录态"
|
||||
)
|
||||
raise BrowserSessionExpiredError(
|
||||
"抖音罗盘登录态已失效;请执行 uv run gyxx accounts login douyin-shop "
|
||||
"刷新账号登录态后重跑"
|
||||
)
|
||||
|
||||
|
||||
def _select_menu_and_date(page) -> None:
|
||||
@@ -2325,12 +2318,8 @@ def _page_automation_impl(page) -> None:
|
||||
|
||||
# 1. Check and wait for login
|
||||
if not _check_login_status(page):
|
||||
logged_page = _ensure_login(page)
|
||||
if not logged_page:
|
||||
print("[ERR] Login failed, will try save cookies anyway...")
|
||||
else:
|
||||
page = logged_page
|
||||
_maximize_page(page)
|
||||
# If the login state is expired, _ensure_login will directly raise BrowserSessionExpiredError
|
||||
_ensure_login(page)
|
||||
|
||||
# 2. Save state after login (best effort)
|
||||
_save_browser_state(page, "after login")
|
||||
|
||||
@@ -52,7 +52,6 @@ MAX_NOTIFICATION_BINDINGS_PER_PERSON = 16
|
||||
NOTIFICATION_CAPABILITIES: Mapping[str, str] = MappingProxyType(
|
||||
{
|
||||
"content.marketing_report.daily": "正式执行且启用发送时投递最终营销日报。",
|
||||
"content.relogin.weekly": "刷新期间投递各平台扫码二维码,并在平台失败时投递失败汇总。",
|
||||
"product.alert.daily": "命中销量下滑规则且尚未重复通知时投递告警。",
|
||||
"product.market_rank": "存在本次新生成的平台报告链接且尚未重复通知时投递。",
|
||||
"product.online_stock_alert.daily": "线上商品当前库存低于 3 时向配置接收人发送库存预警。",
|
||||
|
||||
@@ -21,20 +21,11 @@ def test_page_crash_requires_a_fresh_browser():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch", [xhs_batch, douyin_batch])
|
||||
def test_non_acceptance_relogin_runs_platform_script(monkeypatch, batch):
|
||||
class Policy:
|
||||
enabled = False
|
||||
def test_batch_tools_no_longer_embed_relogin(batch):
|
||||
"""扫码重登已统一到账号 vault(gyxx accounts login),工具内不再提供。"""
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(batch, "current_acceptance_policy", lambda: Policy())
|
||||
monkeypatch.setattr(
|
||||
batch.subprocess,
|
||||
"call",
|
||||
lambda command, **kwargs: calls.append((command, kwargs)) or 0,
|
||||
)
|
||||
|
||||
assert batch.do_relogin() is True
|
||||
assert calls[0][0][1] == str(batch.RELOGIN_SCRIPT)
|
||||
assert not hasattr(batch, "do_relogin")
|
||||
assert not hasattr(batch, "RELOGIN_SCRIPT")
|
||||
|
||||
|
||||
def test_comment_checkpoint_matches_both_note_id_and_url(tmp_path):
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.content_marketing.data.tools import (
|
||||
friday_relogin_parallel as relogin,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.data.tools import relogin_transaction
|
||||
|
||||
|
||||
def test_five_expected_platforms_are_configured():
|
||||
assert set(relogin.PLATFORMS) == {
|
||||
"bilibili", "douyin", "pgy", "xingtu", "xiaohongshu"
|
||||
}
|
||||
assert relogin.PLATFORMS["xingtu"].required_cookie == "sessionid"
|
||||
assert relogin.PLATFORMS["douyin"].required_cookie == "sessionid"
|
||||
|
||||
|
||||
def test_manual_relogin_can_select_only_xingtu():
|
||||
assert relogin.resolve_platform_names(["xingtu"]) == ["xingtu"]
|
||||
assert relogin.resolve_platform_names(None) == list(relogin.PLATFORMS)
|
||||
|
||||
|
||||
def test_default_screenshot_recipients_are_routed_by_platform():
|
||||
assert relogin.DEFAULT_RECIPIENTS == {
|
||||
"pgy": "ou_24cc944d6e43c69c59d6560ad4e2ae6e",
|
||||
"xingtu": "ou_24cc944d6e43c69c59d6560ad4e2ae6e",
|
||||
"douyin": "ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
"bilibili": "ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
"xiaohongshu": "ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
}
|
||||
assert relogin.recipient_for_platform("pgy") == "ou_24cc944d6e43c69c59d6560ad4e2ae6e"
|
||||
assert relogin.recipient_for_platform("douyin") == "ou_7ad5fc8012e2f741afc5346e05ffd447"
|
||||
assert relogin.recipient_for_platform("bilibili") == "ou_7ad5fc8012e2f741afc5346e05ffd447"
|
||||
assert relogin.recipient_for_platform("pgy", "ou_override") == "ou_override"
|
||||
|
||||
|
||||
def test_failed_platforms_are_grouped_by_their_responsible_recipient():
|
||||
grouped = relogin.group_platforms_by_recipient(
|
||||
["pgy", "douyin", "bilibili", "xingtu", "xiaohongshu"]
|
||||
)
|
||||
assert grouped == {
|
||||
"ou_24cc944d6e43c69c59d6560ad4e2ae6e": ["pgy", "xingtu"],
|
||||
"ou_7ad5fc8012e2f741afc5346e05ffd447": [
|
||||
"douyin", "bilibili", "xiaohongshu"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_legacy_route_keeps_the_existing_per_platform_recipients(monkeypatch):
|
||||
observed = {}
|
||||
|
||||
def fake_resolver(workflow_id, defaults):
|
||||
observed["workflow_id"] = workflow_id
|
||||
observed["defaults"] = defaults
|
||||
return relogin.ResolvedNotificationRoute(
|
||||
configured=False,
|
||||
enabled=True,
|
||||
app_profile=None,
|
||||
open_ids=tuple(defaults),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(relogin, "resolve_notification_route", fake_resolver)
|
||||
|
||||
route = relogin.resolve_weekly_notification_route()
|
||||
|
||||
assert observed == {
|
||||
"workflow_id": "content.relogin.weekly",
|
||||
"defaults": (
|
||||
"ou_24cc944d6e43c69c59d6560ad4e2ae6e",
|
||||
"ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
),
|
||||
}
|
||||
assert relogin.recipients_for_platform("pgy", route) == (
|
||||
"ou_24cc944d6e43c69c59d6560ad4e2ae6e",
|
||||
)
|
||||
assert relogin.recipients_for_platform("douyin", route) == (
|
||||
"ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_route_still_runs_relogin_without_sending(monkeypatch):
|
||||
route = relogin.ResolvedNotificationRoute(
|
||||
configured=True,
|
||||
enabled=False,
|
||||
app_profile="hermes-analyzer",
|
||||
open_ids=(),
|
||||
)
|
||||
rounds = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
relogin,
|
||||
"current_acceptance_policy",
|
||||
lambda: SimpleNamespace(enabled=False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
relogin,
|
||||
"resolve_weekly_notification_route",
|
||||
lambda _override=None: route,
|
||||
)
|
||||
|
||||
def fake_round_factory(_args, resolved_route, notification_failures):
|
||||
assert resolved_route is route
|
||||
assert notification_failures == []
|
||||
|
||||
def run_round(names, attempt):
|
||||
rounds.append((tuple(names), attempt))
|
||||
return {name: True for name in names}
|
||||
|
||||
return run_round
|
||||
|
||||
monkeypatch.setattr(relogin, "_run_round_factory", fake_round_factory)
|
||||
monkeypatch.setattr(
|
||||
relogin,
|
||||
"_send_lark",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("disabled route must not send Lark messages")
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
relogin.sys,
|
||||
"argv",
|
||||
["friday_relogin_parallel.py", "--platform", "pgy"],
|
||||
)
|
||||
|
||||
assert relogin.main() == 0
|
||||
assert rounds == [(("pgy",), 1)]
|
||||
|
||||
|
||||
def test_enabled_route_sends_qr_text_and_image_to_every_recipient(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
route = relogin.ResolvedNotificationRoute(
|
||||
configured=True,
|
||||
enabled=True,
|
||||
app_profile="hermes-analyzer",
|
||||
open_ids=("ou_first123", "ou_second456"),
|
||||
)
|
||||
sent = []
|
||||
image = tmp_path / "qrcode.png"
|
||||
|
||||
monkeypatch.setattr(
|
||||
relogin,
|
||||
"_send_lark",
|
||||
lambda recipient, text=None, image=None, app_profile=None: sent.append(
|
||||
(recipient, text, image, app_profile)
|
||||
),
|
||||
)
|
||||
|
||||
failures = relogin._send_qr_notification(
|
||||
route,
|
||||
"pgy",
|
||||
override=None,
|
||||
text="请扫码",
|
||||
image=image,
|
||||
)
|
||||
|
||||
assert failures == ()
|
||||
assert sent == [
|
||||
("ou_first123", "请扫码", None, "hermes-analyzer"),
|
||||
("ou_first123", None, image, "hermes-analyzer"),
|
||||
("ou_second456", "请扫码", None, "hermes-analyzer"),
|
||||
("ou_second456", None, image, "hermes-analyzer"),
|
||||
]
|
||||
assert relogin.group_platforms_by_notification_recipient(
|
||||
["pgy", "douyin"], route
|
||||
) == {
|
||||
"ou_first123": ["pgy", "douyin"],
|
||||
"ou_second456": ["pgy", "douyin"],
|
||||
}
|
||||
|
||||
|
||||
def test_qr_delivery_aggregates_errors_and_attempts_every_recipient(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
route = relogin.ResolvedNotificationRoute(
|
||||
configured=True,
|
||||
enabled=True,
|
||||
app_profile="hermes-analyzer",
|
||||
open_ids=("ou_first123", "ou_second456"),
|
||||
)
|
||||
attempts = []
|
||||
image = tmp_path / "qrcode.png"
|
||||
|
||||
def fake_send(recipient, text=None, image=None, app_profile=None):
|
||||
stage = "qr_text" if text is not None else "qr_image"
|
||||
attempts.append((recipient, stage, app_profile))
|
||||
if (recipient, stage) == ("ou_first123", "qr_text"):
|
||||
raise RuntimeError("first text rejected")
|
||||
if (recipient, stage) == ("ou_second456", "qr_image"):
|
||||
raise RuntimeError("second image rejected")
|
||||
|
||||
monkeypatch.setattr(relogin, "_send_lark", fake_send)
|
||||
|
||||
failures = relogin._send_qr_notification(
|
||||
route,
|
||||
"pgy",
|
||||
override=None,
|
||||
text="请扫码",
|
||||
image=image,
|
||||
)
|
||||
|
||||
assert attempts == [
|
||||
("ou_first123", "qr_text", "hermes-analyzer"),
|
||||
("ou_first123", "qr_image", "hermes-analyzer"),
|
||||
("ou_second456", "qr_text", "hermes-analyzer"),
|
||||
("ou_second456", "qr_image", "hermes-analyzer"),
|
||||
]
|
||||
assert failures == (
|
||||
relogin.NotificationDeliveryFailure(
|
||||
stage="qr_text",
|
||||
platform="pgy",
|
||||
recipient="ou_first123",
|
||||
detail="first text rejected",
|
||||
),
|
||||
relogin.NotificationDeliveryFailure(
|
||||
stage="qr_image",
|
||||
platform="pgy",
|
||||
recipient="ou_second456",
|
||||
detail="second image rejected",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_failure_summary_aggregates_every_recipient_error(monkeypatch):
|
||||
route = relogin.ResolvedNotificationRoute(
|
||||
configured=True,
|
||||
enabled=True,
|
||||
app_profile="hermes-analyzer",
|
||||
open_ids=("ou_first123", "ou_second456"),
|
||||
)
|
||||
attempts = []
|
||||
|
||||
def fail_send(recipient, **_kwargs):
|
||||
attempts.append(recipient)
|
||||
raise RuntimeError(f"rejected {recipient}")
|
||||
|
||||
monkeypatch.setattr(relogin, "_send_lark", fail_send)
|
||||
|
||||
failures = relogin._send_failure_summaries(
|
||||
route,
|
||||
["pgy", "douyin"],
|
||||
override=None,
|
||||
max_attempts=3,
|
||||
)
|
||||
|
||||
assert attempts == ["ou_first123", "ou_second456"]
|
||||
assert [failure.stage for failure in failures] == [
|
||||
"failure_summary",
|
||||
"failure_summary",
|
||||
]
|
||||
assert [failure.recipient for failure in failures] == attempts
|
||||
assert [failure.platform for failure in failures] == [
|
||||
"pgy,douyin",
|
||||
"pgy,douyin",
|
||||
]
|
||||
|
||||
|
||||
def test_main_returns_failure_and_reports_all_notification_errors(
|
||||
monkeypatch,
|
||||
capsys,
|
||||
):
|
||||
route = relogin.ResolvedNotificationRoute(
|
||||
configured=True,
|
||||
enabled=True,
|
||||
app_profile="hermes-analyzer",
|
||||
open_ids=("ou_first123", "ou_second456"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
relogin,
|
||||
"current_acceptance_policy",
|
||||
lambda: SimpleNamespace(enabled=False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
relogin,
|
||||
"resolve_weekly_notification_route",
|
||||
lambda _override=None: route,
|
||||
)
|
||||
|
||||
def fake_round_factory(_args, _route, notification_failures):
|
||||
notification_failures.extend(
|
||||
(
|
||||
relogin.NotificationDeliveryFailure(
|
||||
"qr_text", "pgy", "ou_first123", "text rejected"
|
||||
),
|
||||
relogin.NotificationDeliveryFailure(
|
||||
"qr_image", "pgy", "ou_second456", "image rejected"
|
||||
),
|
||||
)
|
||||
)
|
||||
return lambda names, _attempt: {name: True for name in names}
|
||||
|
||||
monkeypatch.setattr(relogin, "_run_round_factory", fake_round_factory)
|
||||
monkeypatch.setattr(
|
||||
relogin.sys,
|
||||
"argv",
|
||||
["friday_relogin_parallel.py", "--platform", "pgy"],
|
||||
)
|
||||
|
||||
assert relogin.main() == 2
|
||||
output = capsys.readouterr().out
|
||||
assert "Final relogin status: {'pgy': True}" in output
|
||||
assert "Final notification status: failed (2 deliveries)" in output
|
||||
assert "recipient=ou_first123 error=text rejected" in output
|
||||
assert "recipient=ou_second456 error=image rejected" in output
|
||||
|
||||
|
||||
def test_round_completes_relogin_after_notification_failure(monkeypatch, tmp_path):
|
||||
route = relogin.ResolvedNotificationRoute(
|
||||
configured=True,
|
||||
enabled=True,
|
||||
app_profile="hermes-analyzer",
|
||||
open_ids=("ou_first123", "ou_second456"),
|
||||
)
|
||||
args = SimpleNamespace(
|
||||
round_timeout=10,
|
||||
screenshot_delay=0,
|
||||
no_send=False,
|
||||
recipient=None,
|
||||
max_attempts=1,
|
||||
)
|
||||
failures = []
|
||||
attempts = []
|
||||
waits = []
|
||||
|
||||
class Process:
|
||||
pid = 1234
|
||||
|
||||
def wait(self, timeout):
|
||||
waits.append(timeout)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(relogin.subprocess, "Popen", lambda *_args, **_kwargs: Process())
|
||||
monkeypatch.setattr(relogin, "environment_for_child_script", lambda *_args: {})
|
||||
monkeypatch.setattr(
|
||||
relogin,
|
||||
"binding_from_environment",
|
||||
lambda _environment: SimpleNamespace(cookie_file=tmp_path / "cookies.json"),
|
||||
)
|
||||
monkeypatch.setattr(relogin.time, "sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr(relogin, "_login_windows", lambda: {"pgy": 101})
|
||||
monkeypatch.setattr(relogin, "_tile_windows", lambda _handles: None)
|
||||
monkeypatch.setattr(relogin, "_capture_window", lambda _hwnd, _output: None)
|
||||
monkeypatch.setattr(relogin, "_valid_cookie", lambda *_args: True)
|
||||
|
||||
def fake_send(recipient, text=None, image=None, **_kwargs):
|
||||
stage = "qr_text" if text is not None else "qr_image"
|
||||
attempts.append((recipient, stage))
|
||||
if (recipient, stage) == ("ou_first123", "qr_text"):
|
||||
raise RuntimeError("temporary send failure")
|
||||
|
||||
monkeypatch.setattr(relogin, "_send_lark", fake_send)
|
||||
|
||||
run_round = relogin._run_round_factory(args, route, failures)
|
||||
|
||||
assert run_round(["pgy"], 1) == {"pgy": True}
|
||||
assert waits == [100]
|
||||
assert attempts == [
|
||||
("ou_first123", "qr_text"),
|
||||
("ou_first123", "qr_image"),
|
||||
("ou_second456", "qr_text"),
|
||||
("ou_second456", "qr_image"),
|
||||
]
|
||||
assert failures == [
|
||||
relogin.NotificationDeliveryFailure(
|
||||
stage="qr_text",
|
||||
platform="pgy",
|
||||
recipient="ou_first123",
|
||||
detail="temporary send failure",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("returncode", "cookie_valid", "expected_status", "expected_failure_count"),
|
||||
(
|
||||
(0, True, True, 0),
|
||||
(1, False, False, 4),
|
||||
),
|
||||
)
|
||||
def test_missing_qr_window_is_classified_after_relogin_result(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
returncode,
|
||||
cookie_valid,
|
||||
expected_status,
|
||||
expected_failure_count,
|
||||
):
|
||||
route = relogin.ResolvedNotificationRoute(
|
||||
configured=True,
|
||||
enabled=True,
|
||||
app_profile="hermes-analyzer",
|
||||
open_ids=("ou_first123", "ou_second456"),
|
||||
)
|
||||
args = SimpleNamespace(
|
||||
round_timeout=10,
|
||||
screenshot_delay=0,
|
||||
no_send=False,
|
||||
recipient=None,
|
||||
max_attempts=1,
|
||||
)
|
||||
failures = []
|
||||
|
||||
class Process:
|
||||
pid = 1234
|
||||
|
||||
def wait(self, timeout):
|
||||
assert timeout == 100
|
||||
return returncode
|
||||
|
||||
monkeypatch.setattr(relogin.subprocess, "Popen", lambda *_args, **_kwargs: Process())
|
||||
monkeypatch.setattr(relogin, "environment_for_child_script", lambda *_args: {})
|
||||
monkeypatch.setattr(
|
||||
relogin,
|
||||
"binding_from_environment",
|
||||
lambda _environment: SimpleNamespace(cookie_file=tmp_path / "cookies.json"),
|
||||
)
|
||||
monkeypatch.setattr(relogin.time, "sleep", lambda _seconds: None)
|
||||
monkeypatch.setattr(relogin, "_login_windows", lambda: {})
|
||||
monkeypatch.setattr(relogin, "_window_for_process", lambda _pid: None)
|
||||
monkeypatch.setattr(relogin, "_tile_windows", lambda _handles: None)
|
||||
monkeypatch.setattr(relogin, "_valid_cookie", lambda *_args: cookie_valid)
|
||||
monkeypatch.setattr(
|
||||
relogin,
|
||||
"_send_lark",
|
||||
lambda *_args, **_kwargs: pytest.fail(
|
||||
"no QR window must not start a send before child classification"
|
||||
),
|
||||
)
|
||||
|
||||
run_round = relogin._run_round_factory(args, route, failures)
|
||||
|
||||
assert run_round(["pgy"], 1) == {"pgy": expected_status}
|
||||
assert len(failures) == expected_failure_count
|
||||
assert {failure.stage for failure in failures} <= {"qr_text", "qr_image"}
|
||||
assert all(
|
||||
failure.detail == "no visible QR window and relogin did not succeed"
|
||||
for failure in failures
|
||||
)
|
||||
|
||||
|
||||
def test_lark_send_uses_shared_analyzer_user_sender_for_text_and_image(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
calls = []
|
||||
image = tmp_path / "qrcode.png"
|
||||
image.write_bytes(b"png")
|
||||
|
||||
monkeypatch.setattr(relogin, "ANALYZER_DIR", tmp_path)
|
||||
monkeypatch.setenv("HERMES_ANALYZER_TOKEN", "must-not-leak")
|
||||
|
||||
def fake_send(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return {"message_id": f"om_{len(calls)}"}
|
||||
|
||||
monkeypatch.setattr(relogin, "send_lark_bot_message", fake_send)
|
||||
|
||||
receipts = relogin._send_lark(
|
||||
"ou_recipient123",
|
||||
text="登录提醒",
|
||||
image=image,
|
||||
)
|
||||
|
||||
assert [receipt["message_id"] for receipt in receipts] == ["om_1", "om_2"]
|
||||
assert calls[0]["user_id"] == "ou_recipient123"
|
||||
assert calls[0]["text"] == "登录提醒"
|
||||
assert "image" not in calls[0]
|
||||
assert calls[1]["user_id"] == "ou_recipient123"
|
||||
assert calls[1]["image"] == image
|
||||
assert "text" not in calls[1]
|
||||
assert all(call["profile"] == "hermes-analyzer" for call in calls)
|
||||
assert all("HERMES_ANALYZER_TOKEN" not in call["env"] for call in calls)
|
||||
|
||||
|
||||
def test_login_windows_are_classified_by_title():
|
||||
assert relogin._platform_from_window_title("账号登录 - Google Chrome") == "bilibili"
|
||||
assert relogin._platform_from_window_title("小红书蒲公英 - Google Chrome") == "pgy"
|
||||
assert relogin._platform_from_window_title("抖音授权 - Google Chrome") == "xingtu"
|
||||
assert relogin._platform_from_window_title("抖音 - 记录美好生活") == "douyin"
|
||||
assert relogin._platform_from_window_title("小红书 - 你的生活兴趣社区") == "xiaohongshu"
|
||||
assert relogin._platform_from_window_title("Codex") is None
|
||||
|
||||
|
||||
def test_retry_only_failed_platforms_for_at_most_three_rounds():
|
||||
calls = []
|
||||
|
||||
def fake_round(names, attempt):
|
||||
calls.append((tuple(names), attempt))
|
||||
if attempt == 1:
|
||||
return {name: name != "xingtu" for name in names}
|
||||
if attempt == 2:
|
||||
return {name: False for name in names}
|
||||
return {name: True for name in names}
|
||||
|
||||
result = relogin.run_with_retries(
|
||||
["bilibili", "pgy", "xingtu", "xiaohongshu"],
|
||||
max_attempts=3,
|
||||
run_round=fake_round,
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
(("bilibili", "pgy", "xingtu", "xiaohongshu"), 1),
|
||||
(("xingtu",), 2),
|
||||
(("xingtu",), 3),
|
||||
]
|
||||
assert all(result.values())
|
||||
|
||||
|
||||
def test_stops_after_third_failed_round():
|
||||
calls = []
|
||||
|
||||
def always_fail(names, attempt):
|
||||
calls.append(attempt)
|
||||
return {name: False for name in names}
|
||||
|
||||
result = relogin.run_with_retries(
|
||||
["pgy"], max_attempts=3, run_round=always_fail
|
||||
)
|
||||
|
||||
assert calls == [1, 2, 3]
|
||||
assert result == {"pgy": False}
|
||||
|
||||
|
||||
def test_timeout_terminates_the_complete_process_tree(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Process:
|
||||
pid = 1234
|
||||
|
||||
def terminate(self):
|
||||
calls.append("fallback-terminate")
|
||||
|
||||
def wait(self, timeout):
|
||||
calls.append(("wait", timeout))
|
||||
|
||||
monkeypatch.setattr(
|
||||
relogin.subprocess,
|
||||
"run",
|
||||
lambda command, **kwargs: calls.append((command, kwargs)),
|
||||
)
|
||||
|
||||
relogin._terminate_process_tree(Process())
|
||||
|
||||
assert calls[0][0] == ["taskkill", "/PID", "1234", "/T", "/F"]
|
||||
assert calls[1] == ("wait", 10)
|
||||
|
||||
|
||||
def test_transaction_rolls_back_files_and_profile(tmp_path):
|
||||
cookie = tmp_path / "cookies.json"
|
||||
storage = tmp_path / "storage.json"
|
||||
profile = tmp_path / "profile"
|
||||
cookie.write_text("old-cookie", encoding="utf-8")
|
||||
storage.write_text("old-storage", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
|
||||
|
||||
state = relogin_transaction.begin((cookie, storage), profile)
|
||||
cookie.write_text("partial", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "partial.txt").write_text("partial", encoding="utf-8")
|
||||
relogin_transaction.rollback(state)
|
||||
|
||||
assert cookie.read_text(encoding="utf-8") == "old-cookie"
|
||||
assert storage.read_text(encoding="utf-8") == "old-storage"
|
||||
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
|
||||
assert not (profile / "partial.txt").exists()
|
||||
@@ -1,494 +0,0 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.content_marketing import (
|
||||
pgy_xhs_scraper_v2,
|
||||
xingtu_scraper_v2,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.data.tools import (
|
||||
relogin_douyin,
|
||||
relogin_pgy,
|
||||
relogin_xingtu,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", [relogin_pgy, relogin_xingtu])
|
||||
def test_failed_relogin_restores_cookie_and_profile(tmp_path, monkeypatch, module):
|
||||
cookie = tmp_path / "cookies.json"
|
||||
storage = tmp_path / "storage_state.json"
|
||||
profile = tmp_path / "profile"
|
||||
cookie.write_text("old-cookie", encoding="utf-8")
|
||||
storage.write_text("old-storage", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "state.txt").write_text("old-profile", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(module, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(module, "STATE_FILE", storage)
|
||||
monkeypatch.setattr(module, "PROFILE_DIR", profile)
|
||||
|
||||
cookie_backup, profile_backup, state_backup = module.reset_state()
|
||||
assert not cookie.exists()
|
||||
assert not storage.exists()
|
||||
assert not profile.exists()
|
||||
|
||||
cookie.write_text("partial-new-cookie", encoding="utf-8")
|
||||
storage.write_text("partial-new-storage", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "partial.txt").write_text("partial", encoding="utf-8")
|
||||
|
||||
module.restore_previous_state(cookie_backup, profile_backup, state_backup)
|
||||
|
||||
assert cookie.read_text(encoding="utf-8") == "old-cookie"
|
||||
assert storage.read_text(encoding="utf-8") == "old-storage"
|
||||
assert (profile / "state.txt").read_text(encoding="utf-8") == "old-profile"
|
||||
assert not (profile / "partial.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", [relogin_pgy, relogin_xingtu])
|
||||
def test_successful_relogin_keeps_new_cookie(tmp_path, monkeypatch, module):
|
||||
cookie = tmp_path / "cookies.json"
|
||||
storage = tmp_path / "storage_state.json"
|
||||
profile = tmp_path / "profile"
|
||||
cookie.write_text("old-cookie", encoding="utf-8")
|
||||
storage.write_text("old-storage", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
|
||||
monkeypatch.setattr(module, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(module, "STATE_FILE", storage)
|
||||
monkeypatch.setattr(module, "PROFILE_DIR", profile)
|
||||
|
||||
cookie_backup, profile_backup, state_backup = module.reset_state()
|
||||
cookie.write_text("new-cookie", encoding="utf-8")
|
||||
storage.write_text("new-storage", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
|
||||
module.finish_successful_relogin(profile_backup)
|
||||
|
||||
assert cookie.read_text(encoding="utf-8") == "new-cookie"
|
||||
assert storage.read_text(encoding="utf-8") == "new-storage"
|
||||
assert cookie_backup.exists()
|
||||
assert state_backup.exists()
|
||||
assert not profile_backup.exists()
|
||||
|
||||
|
||||
def test_xingtu_scan_login_opens_customer_sso_and_clicks_douyin():
|
||||
class FakeLocator:
|
||||
def __init__(self):
|
||||
self.first = self
|
||||
self.clicked = False
|
||||
self.evaluated = False
|
||||
|
||||
def count(self):
|
||||
return 1
|
||||
|
||||
def is_visible(self):
|
||||
return True
|
||||
|
||||
def click(self, timeout):
|
||||
self.clicked = True
|
||||
|
||||
def evaluate(self, script):
|
||||
assert script == "e => e.click()"
|
||||
self.evaluated = True
|
||||
|
||||
class FakePage:
|
||||
url = "https://www.xingtu.cn/?redirect_uri=/ad/creator/market"
|
||||
|
||||
def __init__(self):
|
||||
self.goto_url = None
|
||||
self.selectors = []
|
||||
self.douyin = FakeLocator()
|
||||
|
||||
def goto(self, url, **kwargs):
|
||||
self.goto_url = url
|
||||
self.url = url
|
||||
|
||||
def wait_for_timeout(self, _milliseconds):
|
||||
pass
|
||||
|
||||
def locator(self, selector):
|
||||
self.selectors.append(selector)
|
||||
return self.douyin
|
||||
|
||||
def get_by_text(self, _pattern):
|
||||
return FakeLocator()
|
||||
|
||||
page = FakePage()
|
||||
xingtu_scraper_v2.open_scan_login(page)
|
||||
|
||||
assert page.goto_url == xingtu_scraper_v2.CUSTOMER_LOGIN_URL
|
||||
assert 'img[src*="aweme.png"]' in page.selectors
|
||||
assert page.douyin.clicked
|
||||
|
||||
|
||||
def test_xingtu_index_page_is_recognized_as_logged_in():
|
||||
class Locator:
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
def wait_for(self, timeout):
|
||||
assert timeout == 1200
|
||||
|
||||
class Page:
|
||||
url = "https://www.xingtu.cn/ad/creator/index"
|
||||
|
||||
def get_by_text(self, _text, exact=False):
|
||||
return Locator()
|
||||
|
||||
assert xingtu_scraper_v2.is_logged_in(Page()) is True
|
||||
|
||||
|
||||
def test_xingtu_index_page_with_xingtu_session_is_logged_in_without_market_ui():
|
||||
class Locator:
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
def count(self):
|
||||
return 0
|
||||
|
||||
def wait_for(self, timeout):
|
||||
raise xingtu_scraper_v2.BrowserTimeoutError("not on market page")
|
||||
|
||||
def is_visible(self):
|
||||
return False
|
||||
|
||||
class Context:
|
||||
def cookies(self):
|
||||
return [
|
||||
{
|
||||
"name": "sessionid",
|
||||
"value": "fresh",
|
||||
"domain": ".xingtu.cn",
|
||||
}
|
||||
]
|
||||
|
||||
class Page:
|
||||
url = "https://www.xingtu.cn/ad/creator/index"
|
||||
context = Context()
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
return Locator()
|
||||
|
||||
def locator(self, *_args, **_kwargs):
|
||||
return Locator()
|
||||
|
||||
assert xingtu_scraper_v2.is_logged_in(Page()) is True
|
||||
|
||||
|
||||
def test_xingtu_index_page_without_xingtu_session_is_not_logged_in():
|
||||
class Locator:
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
def count(self):
|
||||
return 0
|
||||
|
||||
def wait_for(self, timeout):
|
||||
raise xingtu_scraper_v2.BrowserTimeoutError("missing")
|
||||
|
||||
def is_visible(self):
|
||||
return False
|
||||
|
||||
class Context:
|
||||
def cookies(self):
|
||||
return [
|
||||
{
|
||||
"name": "sessionid",
|
||||
"value": "douyin-only",
|
||||
"domain": ".douyin.com",
|
||||
}
|
||||
]
|
||||
|
||||
class Page:
|
||||
url = "https://www.xingtu.cn/ad/creator/index"
|
||||
context = Context()
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
return Locator()
|
||||
|
||||
def locator(self, *_args, **_kwargs):
|
||||
return Locator()
|
||||
|
||||
assert xingtu_scraper_v2.is_logged_in(Page()) is False
|
||||
|
||||
|
||||
def test_xingtu_public_homepage_is_not_logged_in_even_with_stale_cookie():
|
||||
class Context:
|
||||
def cookies(self):
|
||||
return [{"name": "sessionid", "value": "stale"}]
|
||||
|
||||
class Page:
|
||||
url = "https://www.xingtu.cn/?redirect_uri=/ad/creator/market"
|
||||
context = Context()
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
raise AssertionError("public homepage must be rejected before marker checks")
|
||||
|
||||
assert xingtu_scraper_v2.is_logged_in(Page()) is False
|
||||
|
||||
|
||||
def test_xingtu_market_page_without_business_ui_rejects_stale_cookie():
|
||||
class Locator:
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
def wait_for(self, **_kwargs):
|
||||
raise xingtu_scraper_v2.BrowserTimeoutError("missing")
|
||||
|
||||
def count(self):
|
||||
return 0
|
||||
|
||||
class Context:
|
||||
def cookies(self):
|
||||
return [{"name": "sessionid", "value": "stale"}]
|
||||
|
||||
class Page:
|
||||
url = "https://www.xingtu.cn/ad/creator/market"
|
||||
context = Context()
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
return Locator()
|
||||
|
||||
def locator(self, *_args, **_kwargs):
|
||||
return Locator()
|
||||
|
||||
assert xingtu_scraper_v2.is_logged_in(Page()) is False
|
||||
|
||||
|
||||
def test_xingtu_cookie_validation_requires_real_session_cookie(tmp_path):
|
||||
cookie = tmp_path / "xingtu.json"
|
||||
cookie.write_text('[{"name":"other","value":"1"}]', encoding="utf-8")
|
||||
assert xingtu_scraper_v2.has_valid_login_cookie_file(cookie) is False
|
||||
|
||||
cookie.write_text('[{"name":"sessionid","value":"fresh"}]', encoding="utf-8")
|
||||
assert xingtu_scraper_v2.has_valid_login_cookie_file(cookie) is True
|
||||
|
||||
|
||||
def test_xingtu_login_only_does_not_report_success_when_page_action_was_swallowed(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
cookie = tmp_path / "xingtu.json"
|
||||
cookie.write_text('[{"name":"sessionid","value":"old"}]', encoding="utf-8")
|
||||
|
||||
class Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def fetch(self, *_args, **_kwargs):
|
||||
# Scrapling can log a page_action error and still return a response.
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "DynamicSession", lambda **_kwargs: Session())
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "_force_cleanup_session", lambda _session: None)
|
||||
|
||||
assert xingtu_scraper_v2.login_only(1) == 1
|
||||
|
||||
|
||||
def test_xingtu_login_only_verifies_persisted_profile_after_oauth_context_closes(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
cookie = tmp_path / "xingtu.json"
|
||||
|
||||
class Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def fetch(self, *_args, **_kwargs):
|
||||
raise RuntimeError("Target page, context or browser has been closed")
|
||||
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "DynamicSession", lambda **_kwargs: Session())
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "_force_cleanup_session", lambda _session: None)
|
||||
|
||||
def verify():
|
||||
cookie.write_text('[{"name":"sessionid","value":"fresh"}]', encoding="utf-8")
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "verify_persisted_profile_login", verify)
|
||||
|
||||
assert xingtu_scraper_v2.login_only(1) == 0
|
||||
|
||||
|
||||
def test_xingtu_scan_login_follows_replacement_page_after_oauth_tab_closes(monkeypatch):
|
||||
class Context:
|
||||
def __init__(self):
|
||||
self.pages = []
|
||||
|
||||
def new_page(self):
|
||||
raise AssertionError("a surviving replacement page should be reused")
|
||||
|
||||
class Page:
|
||||
def __init__(self, context, url, *, closes_on_wait=False):
|
||||
self.context = context
|
||||
self.url = url
|
||||
self.closed = False
|
||||
self.closes_on_wait = closes_on_wait
|
||||
|
||||
def is_closed(self):
|
||||
return self.closed
|
||||
|
||||
def wait_for_timeout(self, _milliseconds):
|
||||
if self.closes_on_wait:
|
||||
self.closed = True
|
||||
|
||||
def goto(self, url, **_kwargs):
|
||||
self.url = url
|
||||
|
||||
context = Context()
|
||||
qr_page = Page(
|
||||
context,
|
||||
"https://open.douyin.com/platform/oauth/pc/auth",
|
||||
closes_on_wait=True,
|
||||
)
|
||||
market_page = Page(context, xingtu_scraper_v2.MARKET_URL)
|
||||
context.pages = [qr_page, market_page]
|
||||
saved = []
|
||||
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "open_scan_login", lambda _page: None)
|
||||
monkeypatch.setattr(
|
||||
xingtu_scraper_v2, "is_scan_qr_page", lambda page: page is qr_page
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
xingtu_scraper_v2, "is_logged_in", lambda page: page is market_page
|
||||
)
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "save_state", lambda page: saved.append(page))
|
||||
|
||||
result = xingtu_scraper_v2.wait_for_scan_login(qr_page, 2)
|
||||
|
||||
assert result is market_page
|
||||
assert saved == [market_page]
|
||||
|
||||
|
||||
def test_xingtu_scan_login_detects_business_tab_while_qr_tab_stays_open(monkeypatch):
|
||||
class Context:
|
||||
def __init__(self):
|
||||
self.pages = []
|
||||
|
||||
class Page:
|
||||
def __init__(self, context, url):
|
||||
self.context = context
|
||||
self.url = url
|
||||
|
||||
def is_closed(self):
|
||||
return False
|
||||
|
||||
def wait_for_timeout(self, _milliseconds):
|
||||
raise AssertionError("authenticated tab should be detected immediately")
|
||||
|
||||
context = Context()
|
||||
qr_page = Page(context, "https://open.douyin.com/platform/oauth/pc/auth")
|
||||
index_page = Page(context, "https://www.xingtu.cn/ad/creator/index")
|
||||
context.pages = [qr_page, index_page]
|
||||
saved = []
|
||||
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "open_scan_login", lambda _page: None)
|
||||
monkeypatch.setattr(
|
||||
xingtu_scraper_v2, "is_logged_in", lambda page: page is index_page
|
||||
)
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "save_state", lambda page: saved.append(page))
|
||||
|
||||
result = xingtu_scraper_v2.wait_for_scan_login(qr_page, 2)
|
||||
|
||||
assert result is index_page
|
||||
assert saved == [index_page]
|
||||
|
||||
|
||||
def test_xingtu_interrupted_transaction_is_recovered(tmp_path, monkeypatch):
|
||||
cookie = tmp_path / "xingtu_cookies.json"
|
||||
storage = tmp_path / "xingtu_storage_state.json"
|
||||
profile = tmp_path / "profile"
|
||||
cookie.write_text("old-cookie", encoding="utf-8")
|
||||
storage.write_text("old-storage", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(relogin_xingtu, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(relogin_xingtu, "STATE_FILE", storage)
|
||||
monkeypatch.setattr(relogin_xingtu, "PROFILE_DIR", profile)
|
||||
|
||||
cookie_backup, profile_backup, state_backup = relogin_xingtu.reset_state()
|
||||
profile.mkdir()
|
||||
(profile / "partial.txt").write_text("partial", encoding="utf-8")
|
||||
|
||||
assert relogin_xingtu.recover_interrupted_state() is True
|
||||
assert cookie.read_text(encoding="utf-8") == "old-cookie"
|
||||
assert storage.read_text(encoding="utf-8") == "old-storage"
|
||||
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
|
||||
assert not (profile / "partial.txt").exists()
|
||||
assert cookie_backup is not None
|
||||
assert state_backup is not None
|
||||
assert profile_backup is not None
|
||||
|
||||
|
||||
def test_pgy_scan_login_clicks_qr_switch_image():
|
||||
class FakeLocator:
|
||||
def __init__(self):
|
||||
self.first = self
|
||||
self.clicked = False
|
||||
self.evaluated = False
|
||||
|
||||
def count(self):
|
||||
return 1
|
||||
|
||||
def is_visible(self):
|
||||
return True
|
||||
|
||||
def click(self, timeout):
|
||||
self.clicked = True
|
||||
|
||||
def evaluate(self, script):
|
||||
assert script == "e => e.click()"
|
||||
self.evaluated = True
|
||||
|
||||
class FakePage:
|
||||
def __init__(self):
|
||||
self.selectors = []
|
||||
self.qr_switch = FakeLocator()
|
||||
|
||||
def wait_for_timeout(self, _milliseconds):
|
||||
pass
|
||||
|
||||
def get_by_text(self, _pattern):
|
||||
return FakeLocator()
|
||||
|
||||
def locator(self, selector):
|
||||
self.selectors.append(selector)
|
||||
return self.qr_switch
|
||||
|
||||
page = FakePage()
|
||||
pgy_xhs_scraper_v2.open_scan_login(page)
|
||||
|
||||
assert 'img[src*="qr_code"]' in page.selectors
|
||||
assert page.qr_switch.evaluated
|
||||
|
||||
|
||||
def test_failed_douyin_relogin_restores_cookie_storage_and_profile(tmp_path, monkeypatch):
|
||||
cookie = tmp_path / "douyin_cookies.json"
|
||||
storage = tmp_path / "douyin_storage_state.json"
|
||||
profile = tmp_path / "profile"
|
||||
cookie.write_text('[{"name":"sessionid","value":"old"}]', encoding="utf-8")
|
||||
storage.write_text("old-storage", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(relogin_douyin, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(relogin_douyin, "STATE_FILE", storage)
|
||||
monkeypatch.setattr(relogin_douyin, "PROFILE_DIR", profile)
|
||||
monkeypatch.setattr(relogin_douyin.subprocess, "call", lambda *_args, **_kwargs: 1)
|
||||
monkeypatch.setattr(relogin_douyin.sys, "argv", ["relogin_douyin.py"])
|
||||
|
||||
assert relogin_douyin.main() == 1
|
||||
assert cookie.read_text(encoding="utf-8") == '[{"name":"sessionid","value":"old"}]'
|
||||
assert storage.read_text(encoding="utf-8") == "old-storage"
|
||||
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
|
||||
@@ -1,410 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.adapters.integration import RuntimeIntegrationCatalog
|
||||
from gyxx_flow.modules.content_marketing.data.tools import (
|
||||
relogin_bilibili,
|
||||
relogin_douyin,
|
||||
relogin_pgy,
|
||||
relogin_runtime_paths,
|
||||
relogin_xiaohongshu,
|
||||
relogin_xingtu,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_runtime_paths import (
|
||||
CONSUMER_BINDING_IDS,
|
||||
child_browser_environment,
|
||||
resolve_relogin_runtime_paths,
|
||||
sync_relogin_state_to_consumers,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def test_relogin_paths_prefer_injected_runtime_binding(tmp_path: Path) -> None:
|
||||
cookie = tmp_path / "binding" / "cookies.json"
|
||||
storage = tmp_path / "binding" / "storage_state.json"
|
||||
profile = tmp_path / "binding" / "profile"
|
||||
|
||||
resolved = resolve_relogin_runtime_paths(
|
||||
"pgy",
|
||||
{
|
||||
"GYXX_BROWSER_COOKIE_FILE": str(cookie),
|
||||
"GYXX_BROWSER_STORAGE_STATE_FILE": str(storage),
|
||||
"GYXX_BROWSER_PROFILE_DIR": str(profile),
|
||||
},
|
||||
)
|
||||
|
||||
assert resolved.cookie_file == cookie.resolve()
|
||||
assert resolved.storage_state_file == storage.resolve()
|
||||
assert resolved.profile_dir == profile.resolve()
|
||||
|
||||
|
||||
def test_relogin_paths_keep_legacy_standalone_layout() -> None:
|
||||
resolved = resolve_relogin_runtime_paths("xiaohongshu", {})
|
||||
|
||||
assert resolved.cookie_file == (
|
||||
PATHS.state_root / "cookies/xiaohongshu_cookies.json"
|
||||
).resolve()
|
||||
assert resolved.storage_state_file == (
|
||||
PATHS.state_root / "cookies/xiaohongshu_storage_state.json"
|
||||
).resolve()
|
||||
assert resolved.profile_dir == (
|
||||
PATHS.state_root / "browser-profiles/xiaohongshu"
|
||||
).resolve()
|
||||
|
||||
|
||||
def test_child_login_environment_preserves_wrapper_binding(tmp_path: Path) -> None:
|
||||
cookie = tmp_path / "cookies.json"
|
||||
storage = tmp_path / "storage_state.json"
|
||||
profile = tmp_path / "profile"
|
||||
|
||||
environment = child_browser_environment(
|
||||
cookie_file=cookie,
|
||||
storage_state_file=storage,
|
||||
profile_dir=profile,
|
||||
environment={"GYXX_MODULE_ID": "content_marketing", "KEEP": "1"},
|
||||
)
|
||||
|
||||
assert environment["GYXX_BROWSER_COOKIE_FILE"] == str(cookie.resolve())
|
||||
assert environment["GYXX_BROWSER_STORAGE_STATE_FILE"] == str(
|
||||
storage.resolve()
|
||||
)
|
||||
assert environment["GYXX_BROWSER_PROFILE_DIR"] == str(profile.resolve())
|
||||
assert "GYXX_MODULE_ID" not in environment
|
||||
assert environment["KEEP"] == "1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module", "platform", "login_cookie", "argv"),
|
||||
[
|
||||
(
|
||||
relogin_bilibili,
|
||||
"bilibili",
|
||||
"SESSDATA",
|
||||
["relogin_bilibili.py"],
|
||||
),
|
||||
(relogin_douyin, "douyin", "sessionid", ["relogin_douyin.py"]),
|
||||
(relogin_pgy, "pgy", "web_session", ["relogin_pgy.py"]),
|
||||
(
|
||||
relogin_xiaohongshu,
|
||||
"xiaohongshu",
|
||||
"web_session",
|
||||
["relogin_xiaohongshu.py"],
|
||||
),
|
||||
(
|
||||
relogin_xingtu,
|
||||
"xingtu",
|
||||
"sessionid",
|
||||
["relogin_xingtu.py", "--force"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_relogin_wrapper_validates_the_same_binding_written_by_child(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
module: ModuleType,
|
||||
platform: str,
|
||||
login_cookie: str,
|
||||
argv: list[str],
|
||||
) -> None:
|
||||
cookie = tmp_path / "cookies.json"
|
||||
storage = tmp_path / "storage_state.json"
|
||||
profile = tmp_path / "profile"
|
||||
monkeypatch.setattr(module, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(module, "STATE_FILE", storage)
|
||||
monkeypatch.setattr(module, "PROFILE_DIR", profile)
|
||||
monkeypatch.setattr(module.sys, "argv", argv)
|
||||
monkeypatch.setenv("GYXX_MODULE_ID", "content_marketing")
|
||||
if module is relogin_xingtu:
|
||||
monkeypatch.setattr(module, "terminate_profile_processes", lambda: None)
|
||||
monkeypatch.setattr(module, "sync_to_douyin_scraper", lambda: None)
|
||||
synced: list[tuple[str, Path, Path]] = []
|
||||
|
||||
def fake_sync(
|
||||
synced_platform: str,
|
||||
*,
|
||||
cookie_file: Path,
|
||||
storage_state_file: Path,
|
||||
) -> tuple[Path, ...]:
|
||||
synced.append(
|
||||
(synced_platform, cookie_file.resolve(), storage_state_file.resolve())
|
||||
)
|
||||
return ()
|
||||
|
||||
monkeypatch.setattr(module, "sync_relogin_state_to_consumers", fake_sync)
|
||||
|
||||
def fake_call(
|
||||
_command: list[str],
|
||||
*,
|
||||
cwd: str,
|
||||
env: dict[str, str],
|
||||
) -> int:
|
||||
assert cwd == str(module.PROJECT_DIR)
|
||||
assert env["GYXX_BROWSER_COOKIE_FILE"] == str(cookie.resolve())
|
||||
assert env["GYXX_BROWSER_STORAGE_STATE_FILE"] == str(storage.resolve())
|
||||
assert env["GYXX_BROWSER_PROFILE_DIR"] == str(profile.resolve())
|
||||
assert "GYXX_MODULE_ID" not in env
|
||||
cookie.write_text(
|
||||
json.dumps([{"name": login_cookie, "value": "fresh"}]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
storage.write_text("{}", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "call", fake_call)
|
||||
|
||||
assert module.main() == 0
|
||||
assert cookie.is_file()
|
||||
assert storage.is_file()
|
||||
assert profile.is_dir()
|
||||
assert synced == [(platform, cookie.resolve(), storage.resolve())]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module", "platform", "login_cookie", "cookie_domain", "argv"),
|
||||
[
|
||||
(
|
||||
relogin_bilibili,
|
||||
"bilibili",
|
||||
"SESSDATA",
|
||||
".bilibili.com",
|
||||
["relogin_bilibili.py"],
|
||||
),
|
||||
(
|
||||
relogin_douyin,
|
||||
"douyin",
|
||||
"sessionid",
|
||||
".douyin.com",
|
||||
["relogin_douyin.py"],
|
||||
),
|
||||
(
|
||||
relogin_pgy,
|
||||
"pgy",
|
||||
"web_session",
|
||||
".xiaohongshu.com",
|
||||
["relogin_pgy.py"],
|
||||
),
|
||||
(
|
||||
relogin_xiaohongshu,
|
||||
"xiaohongshu",
|
||||
"web_session",
|
||||
".xiaohongshu.com",
|
||||
["relogin_xiaohongshu.py"],
|
||||
),
|
||||
(
|
||||
relogin_xingtu,
|
||||
"xingtu",
|
||||
"sessionid",
|
||||
".xingtu.cn",
|
||||
["relogin_xingtu.py", "--force"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_relogin_wrapper_restores_previous_state_when_publish_fails(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
module: ModuleType,
|
||||
platform: str,
|
||||
login_cookie: str,
|
||||
cookie_domain: str,
|
||||
argv: list[str],
|
||||
) -> None:
|
||||
cookie = tmp_path / "cookies.json"
|
||||
storage = tmp_path / "storage_state.json"
|
||||
profile = tmp_path / "profile"
|
||||
old_cookie = '[{"name":"old","value":"old-session"}]'
|
||||
old_storage = '{"cookies":[{"name":"old"}]}'
|
||||
cookie.write_text(old_cookie, encoding="utf-8")
|
||||
storage.write_text(old_storage, encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "old-state.txt").write_text("old-profile", encoding="utf-8")
|
||||
monkeypatch.setattr(module, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(module, "STATE_FILE", storage)
|
||||
monkeypatch.setattr(module, "PROFILE_DIR", profile)
|
||||
monkeypatch.setattr(module.sys, "argv", argv)
|
||||
if module is relogin_xingtu:
|
||||
monkeypatch.setattr(module, "terminate_profile_processes", lambda: None)
|
||||
monkeypatch.setattr(module, "sync_to_douyin_scraper", lambda: None)
|
||||
|
||||
def fake_call(
|
||||
_command: list[str],
|
||||
*,
|
||||
cwd: str,
|
||||
env: dict[str, str],
|
||||
) -> int:
|
||||
assert cwd == str(module.PROJECT_DIR)
|
||||
assert env["GYXX_BROWSER_COOKIE_FILE"] == str(cookie.resolve())
|
||||
cookie.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"name": login_cookie,
|
||||
"value": "fresh-session",
|
||||
"domain": cookie_domain,
|
||||
}
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
storage.write_text('{"cookies":[],"origins":[]}', encoding="utf-8")
|
||||
profile.mkdir()
|
||||
return 0
|
||||
|
||||
def fail_publish(
|
||||
synced_platform: str,
|
||||
*,
|
||||
cookie_file: Path,
|
||||
storage_state_file: Path,
|
||||
) -> tuple[Path, ...]:
|
||||
assert synced_platform == platform
|
||||
assert cookie_file == cookie
|
||||
assert storage_state_file == storage
|
||||
raise OSError("simulated consumer publish failure")
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "call", fake_call)
|
||||
monkeypatch.setattr(module, "sync_relogin_state_to_consumers", fail_publish)
|
||||
|
||||
assert module.main() == 1
|
||||
assert cookie.read_text(encoding="utf-8") == old_cookie
|
||||
assert storage.read_text(encoding="utf-8") == old_storage
|
||||
assert (profile / "old-state.txt").read_text(encoding="utf-8") == (
|
||||
"old-profile"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("platform", "cookie_name", "cookie_domain"),
|
||||
[
|
||||
("bilibili", "SESSDATA", ".bilibili.com"),
|
||||
("douyin", "sessionid", ".douyin.com"),
|
||||
("pgy", "web_session", ".xiaohongshu.com"),
|
||||
("xiaohongshu", "web_session", ".xiaohongshu.com"),
|
||||
("xingtu", "sessionid", ".xingtu.cn"),
|
||||
],
|
||||
)
|
||||
def test_successful_relogin_atomically_updates_unique_consumer_state(
|
||||
tmp_path: Path,
|
||||
platform: str,
|
||||
cookie_name: str,
|
||||
cookie_domain: str,
|
||||
) -> None:
|
||||
data_root = tmp_path / "data"
|
||||
source_cookie = tmp_path / "relogin" / "cookies.json"
|
||||
source_storage = tmp_path / "relogin" / "storage_state.json"
|
||||
source_cookie.parent.mkdir(parents=True)
|
||||
fresh_cookie = [
|
||||
{
|
||||
"name": cookie_name,
|
||||
"value": "fresh-session",
|
||||
"domain": cookie_domain,
|
||||
}
|
||||
]
|
||||
fresh_storage = {"cookies": fresh_cookie, "origins": []}
|
||||
source_cookie.write_text(json.dumps(fresh_cookie), encoding="utf-8")
|
||||
source_storage.write_text(json.dumps(fresh_storage), encoding="utf-8")
|
||||
|
||||
catalog = RuntimeIntegrationCatalog.load_default(
|
||||
project_root=PROJECT_ROOT,
|
||||
data_root=data_root,
|
||||
)
|
||||
consumer_id = CONSUMER_BINDING_IDS[platform][0]
|
||||
consumer = catalog.binding_for(consumer_id)
|
||||
consumer.cookie_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
consumer.cookie_file.write_text('[{"name":"old"}]', encoding="utf-8")
|
||||
consumer.storage_state_file.write_text(
|
||||
'{"cookies":[{"name":"old"}]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
destinations = sync_relogin_state_to_consumers(
|
||||
platform,
|
||||
cookie_file=source_cookie,
|
||||
storage_state_file=source_storage,
|
||||
environment={
|
||||
"GYXX_PROJECT_ROOT": str(PROJECT_ROOT),
|
||||
"GYXX_DATA_ROOT": str(data_root),
|
||||
},
|
||||
)
|
||||
|
||||
assert source_cookie.resolve() != consumer.cookie_file.resolve()
|
||||
assert source_storage.resolve() != consumer.storage_state_file.resolve()
|
||||
assert destinations == (consumer.cookie_file.resolve(),)
|
||||
assert json.loads(consumer.cookie_file.read_text(encoding="utf-8")) == (
|
||||
fresh_cookie
|
||||
)
|
||||
assert json.loads(
|
||||
consumer.storage_state_file.read_text(encoding="utf-8")
|
||||
) == fresh_storage
|
||||
|
||||
|
||||
def test_consumer_state_is_rolled_back_if_atomic_publish_fails(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
data_root = tmp_path / "data"
|
||||
source_cookie = tmp_path / "relogin" / "cookies.json"
|
||||
source_storage = tmp_path / "relogin" / "storage_state.json"
|
||||
source_cookie.parent.mkdir(parents=True)
|
||||
source_cookie.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "SESSDATA",
|
||||
"value": "fresh-session",
|
||||
"domain": ".bilibili.com",
|
||||
}
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
source_storage.write_text('{"cookies":[],"origins":[]}', encoding="utf-8")
|
||||
|
||||
catalog = RuntimeIntegrationCatalog.load_default(
|
||||
project_root=PROJECT_ROOT,
|
||||
data_root=data_root,
|
||||
)
|
||||
consumer = catalog.binding_for(CONSUMER_BINDING_IDS["bilibili"][0])
|
||||
consumer.cookie_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
old_cookie = '[{"name":"SESSDATA","value":"old"}]'
|
||||
old_storage = '{"cookies":[{"name":"old"}]}'
|
||||
consumer.cookie_file.write_text(old_cookie, encoding="utf-8")
|
||||
consumer.storage_state_file.write_text(old_storage, encoding="utf-8")
|
||||
|
||||
original_replace = relogin_runtime_paths.os.replace
|
||||
failed = False
|
||||
|
||||
def fail_second_replace(source: Path | str, target: Path | str) -> None:
|
||||
nonlocal failed
|
||||
source_path = Path(source)
|
||||
target_path = Path(target)
|
||||
if (
|
||||
not failed
|
||||
and source_path.suffix == ".tmp"
|
||||
and target_path == consumer.storage_state_file
|
||||
):
|
||||
failed = True
|
||||
raise OSError("simulated storage publish failure")
|
||||
original_replace(source, target)
|
||||
|
||||
monkeypatch.setattr(relogin_runtime_paths.os, "replace", fail_second_replace)
|
||||
|
||||
with pytest.raises(OSError, match="simulated storage publish failure"):
|
||||
sync_relogin_state_to_consumers(
|
||||
"bilibili",
|
||||
cookie_file=source_cookie,
|
||||
storage_state_file=source_storage,
|
||||
environment={
|
||||
"GYXX_PROJECT_ROOT": str(PROJECT_ROOT),
|
||||
"GYXX_DATA_ROOT": str(data_root),
|
||||
},
|
||||
)
|
||||
|
||||
assert consumer.cookie_file.read_text(encoding="utf-8") == old_cookie
|
||||
assert consumer.storage_state_file.read_text(encoding="utf-8") == old_storage
|
||||
Reference in new issue
Block a user