"""PikPak 云下载:假传输上的登录、刷新轮换、人机验证、离线提交与对账。 断言落在请求形状与凭据去向上:refresh token 每次刷新都写回,密码只在勾选时保存, 要人机验证时把验证页交出来而不是吞掉。 """ from __future__ import annotations import json import unittest from urllib.parse import parse_qs, urlsplit import httpx from peach import downloads as dl from peach import downloads_pikpak as pk HASH = "c9e15763f722f23e98a29decdfae341b98d53056" MAGNET = dl.parse_magnet(f"magnet:?xt=urn:btih:{HASH}&dn=ABC-123") TARGET = "/云下载" class FakePikPak: def __init__(self): self.requests: list[httpx.Request] = [] self.captcha_url = "" self.refresh_count = 0 self.expired_once = False self.tasks: list[dict] = [] self.signin_error = None def __call__(self, request: httpx.Request) -> httpx.Response: self.requests.append(request) url = urlsplit(str(request.url)) body = json.loads(request.content) if request.content else {} route = (request.method, url.netloc, url.path) if route[2] == "/v1/shield/captcha/init": if self.captcha_url: return httpx.Response(200, json={"url": self.captcha_url}) return httpx.Response(200, json={"captcha_token": f"ct-{body['action']}"}) if route[2] == "/v1/auth/signin": if self.signin_error: return httpx.Response(400, json=self.signin_error) return httpx.Response(200, json=self._tokens()) if route[2] == "/v1/auth/token": if body.get("refresh_token") != f"rt{self.refresh_count}": return httpx.Response(400, json={"error": "invalid_grant", "error_code": 4126}) return httpx.Response(200, json=self._tokens()) if self.expired_once: self.expired_once = False return httpx.Response(401, json={"error": "unauthenticated", "error_code": 16}) if route == ("GET", "api-drive.mypikpak.com", "/drive/v1/files"): parent = parse_qs(url.query).get("parent_id", [""])[0] files = {"": [{"id": "f1", "name": "云下载", "kind": "drive#folder"}], "f1": [{"id": "x", "name": "ABC-123", "kind": "drive#folder"}]} return httpx.Response(200, json={"files": files.get(parent, []), "next_page_token": ""}) if route == ("POST", "api-drive.mypikpak.com", "/drive/v1/files"): task = {"id": "t1", "file_name": "ABC-123", "phase": "PHASE_TYPE_RUNNING", "progress": 0, "params": {"url": body["url"]["url"]}} self.tasks.append(task) return httpx.Response(200, json={"task": task}) if route == ("GET", "api-drive.mypikpak.com", "/drive/v1/tasks"): return httpx.Response(200, json={"tasks": self.tasks, "next_page_token": ""}) if route == ("DELETE", "api-drive.mypikpak.com", "/drive/v1/tasks"): ids = parse_qs(url.query)["task_ids"] self.tasks = [task for task in self.tasks if task["id"] not in ids] return httpx.Response(200, json={}) return httpx.Response(404, json={"error": "not_found"}) def _tokens(self): self.refresh_count += 1 return {"access_token": f"at{self.refresh_count}", "refresh_token": f"rt{self.refresh_count}", "expires_in": 7200, "sub": "user-1"} def paths(self) -> list[str]: return [f"{request.method} {request.url.path}" for request in self.requests] class _Case(unittest.TestCase): def setUp(self): self.fake = FakePikPak() self.transport = httpx.MockTransport(self.fake) self.stored: dict = {} def persist(self, values): self.stored = dict(values) def login(self, remember=False): return pk.login(lambda: self.stored, "me@example.com", "pw", remember=remember, persist=self.persist, transport=self.transport) def provider(self): return pk.PikPakProvider(lambda: dict(self.stored), persist=self.persist, transport=self.transport) class LoginTests(_Case): def test_signing_in_keeps_tokens_and_drops_the_password_unless_asked(self): self.assertEqual(self.login(), {"username": "me@example.com", "remember": False}) self.assertEqual(self.stored["refresh_token"], "rt1") self.assertNotIn("password", self.stored) self.assertEqual(len(self.stored["device_id"]), 32) signin = json.loads(self.fake.requests[1].content) self.assertEqual((signin["username"], signin["captcha_token"]), ("me@example.com", "ct-POST:https://user.mypikpak.com/v1/auth/signin")) self.login(remember=True) self.assertEqual(self.stored["password"], "pw") self.assertEqual(pk.account(self.stored), {"logged_in": True, "username": "me@example.com", "remember": True, "method": "password"}) def test_a_captcha_hands_back_the_page_and_keeps_the_device(self): self.fake.captcha_url = "https://user.mypikpak.com/captcha?x=1" with self.assertRaises(pk.CaptchaRequired) as caught: self.login() self.assertEqual(caught.exception.url, self.fake.captcha_url) device = self.stored["device_id"] self.assertNotIn("refresh_token", self.stored) self.fake.captcha_url = "" self.login() self.assertEqual(self.stored["device_id"], device) def test_a_wrong_password_is_a_configuration_failure(self): self.fake.signin_error = {"error": "invalid_account_or_password", "error_code": 4022, "error_description": "账号或密码错误"} with self.assertRaises(dl.DownloadError) as caught: self.login() self.assertEqual(caught.exception.failure, "config") self.assertIn("登录失败:账号或密码错误", caught.exception.detail) def test_the_captcha_signature_matches_the_reference_algorithm(self): self.assertEqual(pk.captcha_sign("abc", "123"), "1.3b6c3d5e08b41cc77d291c082ba26e7d") class DriveTests(_Case): def setUp(self): super().setUp() self.login() def task(self, **overrides) -> dl.DownloadTask: return dl.DownloadTask(1, HASH, "pikpak", MAGNET.uri, "ABC-123", TARGET, dl.SUBMITTED, **overrides) def test_a_submission_resolves_the_folder_and_carries_a_captcha_token(self): status = self.provider().submit(MAGNET, TARGET) self.assertEqual((status.state, status.remote_id, status.adopted), (dl.RUNNING, "t1", False)) post = [r for r in self.fake.requests if r.method == "POST" and r.url.path == "/drive/v1/files"][0] self.assertEqual(post.headers["X-Captcha-Token"], "ct-POST:/drive/v1/files") body = json.loads(post.content) self.assertEqual((body["parent_id"], body["url"]["url"], body["upload_type"]), ("f1", MAGNET.uri, "UPLOAD_TYPE_URL")) def test_the_same_magnet_is_adopted_from_the_task_list(self): self.provider().submit(MAGNET, TARGET) status = self.provider().submit(MAGNET, TARGET) self.assertTrue(status.adopted) self.assertEqual(len(self.fake.tasks), 1) def test_a_missing_folder_is_reported(self): with self.assertRaises(dl.DownloadError) as caught: self.provider().submit(MAGNET, "/没有") self.assertEqual(caught.exception.failure, "config") def test_an_expired_access_token_is_refreshed_and_the_new_refresh_token_saved(self): self.fake.expired_once = True self.assertEqual(self.provider().status(self.task()).state, dl.MISSING) self.assertEqual(self.stored["refresh_token"], "rt2") self.assertIn("POST /v1/auth/token", self.fake.paths()) def test_a_dead_refresh_token_without_a_saved_password_asks_to_sign_in_again(self): self.stored["refresh_token"] = "stale" self.stored["expires_at"] = "0" with self.assertRaises(dl.DownloadError) as caught: self.provider().status(self.task()) self.assertEqual(caught.exception.failure, "config") self.assertIn("重新登录", caught.exception.detail) def test_a_dead_refresh_token_with_a_saved_password_signs_in_again(self): self.login(remember=True) self.stored["refresh_token"] = "stale" self.stored["expires_at"] = "0" self.provider().status(self.task()) self.assertTrue(self.stored["refresh_token"].startswith("rt")) self.assertEqual(self.stored["password"], "pw") def test_phases_map_onto_remote_states(self): self.provider().submit(MAGNET, TARGET) self.fake.tasks[0].update(phase="PHASE_TYPE_ERROR", message="no seeds") status = self.provider().status(self.task(remote_id="t1")) self.assertEqual((status.state, status.message), (dl.ERROR, "no seeds")) self.fake.tasks[0].update(phase="PHASE_TYPE_COMPLETE") self.assertEqual(self.provider().status(self.task()).state, dl.DONE) self.fake.tasks[0].update(phase="SOMETHING_NEW") with self.assertRaises(dl.DownloadError) as caught: self.provider().status(self.task()) self.assertIn("接口可能已经变了", caught.exception.detail) def test_landed_lists_the_target_folder_and_cancel_keeps_files(self): self.assertTrue(self.provider().landed(self.task())) self.provider().submit(MAGNET, TARGET) self.provider().cancel(self.task(remote_id="t1")) delete = [r for r in self.fake.requests if r.method == "DELETE"][0] self.assertEqual(parse_qs(delete.url.query.decode())["delete_files"], ["false"]) self.assertEqual(self.fake.tasks, []) def test_without_a_login_nothing_is_sent(self): self.stored = {} sent = len(self.fake.requests) with self.assertRaises(dl.DownloadError): self.provider().submit(MAGNET, TARGET) self.assertEqual(len(self.fake.requests), sent) class WebClientTests(_Case): """浏览器登录读到的 token 由网页端 client 签发:刷新、captcha/init 与签名都按网页端常量走。""" def setUp(self): super().setUp() self.fake.refresh_count = 1 self.stored = {"refresh_token": "rt1", "access_token": "at1", "expires_at": "0", "user_id": "user-1", "device_id": "webdevice", "client_id": pk.WEB.client_id} def task(self) -> dl.DownloadTask: return dl.DownloadTask(1, HASH, "pikpak", MAGNET.uri, "ABC-123", TARGET, dl.SUBMITTED) def bodies(self, path: str) -> list[dict]: return [json.loads(r.content) for r in self.fake.requests if r.url.path == path] def test_the_web_signature_matches_the_page_script(self): """期望值用网页脚本里那段 `reduce` 的写法在 Node 里算出:md5 链、固定时间戳、网页端 15 条盐。""" self.assertEqual(pk.captcha_sign("abc", pk.WEB.sign_timestamp, pk.WEB), "1.e07351bd60bb92ce14b49dcf7273008f") def test_web_tokens_refresh_with_the_web_client_and_no_secret(self): self.assertEqual(self.provider().status(self.task()).state, dl.MISSING) refresh = [r for r in self.fake.requests if r.url.path == "/v1/auth/token"][0] body = json.loads(refresh.content) self.assertEqual(body["client_id"], "YUMx5nI8ZU8Ap8pm") self.assertNotIn("client_secret", body) self.assertEqual((refresh.headers["x-client-id"], refresh.headers["x-device-id"]), ("YUMx5nI8ZU8Ap8pm", "webdevice")) self.assertEqual((self.stored["refresh_token"], self.stored["client_id"]), ("rt2", pk.WEB.client_id)) def test_a_web_submission_signs_the_captcha_with_web_constants(self): self.stored["expires_at"] = "9999999999" self.provider().submit(MAGNET, TARGET) captcha = self.bodies("/v1/shield/captcha/init")[0] self.assertEqual(captcha["client_id"], pk.WEB.client_id) self.assertEqual(captcha["meta"]["timestamp"], "1790733736477") self.assertEqual((captcha["meta"]["client_version"], captcha["meta"]["package_name"]), ("2.0.0", "mypikpak.com")) self.assertEqual(captcha["meta"]["captcha_sign"], pk.captcha_sign("webdevice", "1790733736477", pk.WEB)) def test_a_rejected_web_refresh_names_the_reason_and_asks_for_the_browser(self): self.stored["refresh_token"] = "stale" with self.assertRaises(dl.DownloadError) as caught: self.provider().status(self.task()) self.assertEqual(caught.exception.failure, "config") self.assertIn("用浏览器登录", caught.exception.detail) self.assertIn("invalid_grant", caught.exception.detail) self.assertNotIn("stale", caught.exception.detail) def test_a_rejected_web_signature_says_the_constants_may_have_changed(self): self.stored["expires_at"] = "9999999999" def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/v1/shield/captcha/init": return httpx.Response(400, json={"error": "invalid_argument", "error_description": "captcha_sign"}) return self.fake(request) provider = pk.PikPakProvider(lambda: dict(self.stored), persist=self.persist, transport=httpx.MockTransport(handler)) with self.assertRaises(dl.DownloadError) as caught: provider.submit(MAGNET, TARGET) self.assertIn("网页端的签名常量可能已经更新", caught.exception.detail) def test_an_unknown_client_refreshes_but_refuses_to_sign(self): self.stored.update(client_id="SomeNewClient", expires_at="9999999999") with self.assertRaises(dl.DownloadError) as caught: self.provider().submit(MAGNET, TARGET) self.assertIn("SomeNewClient", caught.exception.detail) self.assertNotIn("/v1/shield/captcha/init", [r.url.path for r in self.fake.requests]) self.stored["expires_at"] = "0" self.provider().status(self.task()) self.assertEqual(self.bodies("/v1/auth/token")[0]["client_id"], "SomeNewClient") def test_the_account_says_how_it_was_signed_in(self): self.assertEqual(pk.account(self.stored)["method"], "browser") self.assertEqual(pk.account({"refresh_token": "x"})["method"], "password") self.login() self.assertEqual(self.stored["client_id"], pk.ANDROID.client_id) self.assertEqual(pk.account(self.stored)["method"], "password") if __name__ == "__main__": unittest.main()