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
+135
-4269
No files matched your search
@@ -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