feat(adapters): add 115 cookie adapter with downloads and web uploads

This commit is contained in:
shiyu committed 2026-10-03 17:56:40 +08:00
1 parent d7f9a0b304
commit 62def8bb76
8 files changed
+367 -2

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ Utilizes an extensible adapter pattern to easily integrate various storage types
| Category | Adapters |
|---|---|
| **Standard Protocols** | Local, S3-compatible, WebDAV, SFTP, FTP |
| **Cloud Drives** | Google Drive, OneDrive, Dropbox, Quark, UC, 123Pan |
| **Cloud Drives** | Google Drive, OneDrive, Dropbox, Quark, UC, 123Pan, 115 |
| **Special** | Telegram, AList, Foxel-to-Foxel |
### 🔍 AI-Powered Semantic Search
+1 -1
View File
@@ -40,7 +40,7 @@
| 分类 | 适配器 |
|---|---|
| **标准协议** | 本地存储、S3 兼容存储、WebDAV、SFTP、FTP |
| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘 |
| **网盘服务** | Google Drive、OneDrive、Dropbox、夸克网盘、UC 网盘、123 网盘、115 网盘 |
| **特殊类型** | Telegram、AList、Foxel 互联 |
### 🔍 AI 语义搜索
+28
View File
@@ -0,0 +1,28 @@
# Third-Party Notices
## 115 Download Wire Format
`domain/adapters/providers/_115_cipher.py` adapts the m115 protocol implementation
from https://github.com/SheltonZhu/115driver/tree/main/pkg/crypto/m115.
MIT License
Copyright (c) 2022-2024 SheltonZhu
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+59
View File
@@ -0,0 +1,59 @@
"""115 download wire format, adapted from SheltonZhu/115driver (MIT).
Copyright (c) 2022-2024 SheltonZhu. See THIRD_PARTY_NOTICES.md.
"""
import base64
import secrets
_MODULUS = int(
"8686980c0f5a24c4b9d43020cd2c22703ff3f450756529058b1cf88f09b86021"
"36477198a6e2683149659bd122c33592fdb5ad47944ad1ea4d36c6b172aad633"
"8c3bb6ac6227502d010993ac967d1aef00f0c8e038de2e4d3bc2ec368af2e9f1"
"0a6f1eda4f7262f136420c07c331b871bf139f74f3010e3c4fe57df3afb71683", 16)
_SEED = bytes.fromhex(
"f0e569aebfdcbf8a1a45e8be7da673b8de8fe7c445da86c49b648b146ab4f1aa"
"3801359e26692c86006b4fa5363462a62a966818f24afdbd6b978f4d8f8913b7"
"6c8e93ed0e0d483ed72f88d8fefe7e8650954fd1eb832634db667b9c7e9d7a81"
"32eab633de3aa95934663baaba816048b9d5819cf86c8477ff5478265fbee81e"
"369f34805c452c9b76d51b8fccc3b8f5")
_CLIENT_KEY = bytes.fromhex("7806ad4c33865d184c013f46")
def _derive(seed, length):
return bytes(((seed[i] + _SEED[length * i]) & 255) ^ _SEED[length * (length - i - 1)]
for i in range(length))
def _xor(data, key):
prefix = len(data) % 4
return bytes(value ^ key[(i if i < prefix else i - prefix) % len(key)]
for i, value in enumerate(data))
def encode(data, key):
transformed = key + _xor(_xor(data, _derive(key, 4))[::-1], _CLIENT_KEY)
encrypted = bytearray()
for offset in range(0, len(transformed), 117):
chunk = transformed[offset:offset + 117]
padding = bytes(secrets.randbelow(255) + 1 for _ in range(125 - len(chunk)))
block = b"\0\2" + padding + b"\0" + chunk
encrypted.extend(pow(int.from_bytes(block), 65537, _MODULUS).to_bytes(128))
return base64.b64encode(encrypted).decode("ascii")
def decode(data, key):
encrypted = base64.b64decode(data, validate=True)
if not encrypted or len(encrypted) % 128:
raise ValueError("Invalid 115 encrypted response")
decrypted = bytearray()
for offset in range(0, len(encrypted), 128):
block = pow(int.from_bytes(encrypted[offset:offset + 128]), 65537, _MODULUS).to_bytes(128)
separator = block.find(b"\0", 2)
if block[:2] != b"\0\1" or separator < 10:
raise ValueError("Invalid 115 RSA response padding")
decrypted.extend(block[separator + 1:])
if len(decrypted) < 16:
raise ValueError("Invalid 115 response key")
return _xor(_xor(decrypted[16:], _derive(decrypted[:16], 12))[::-1], _derive(key, 4))
+188
View File
@@ -0,0 +1,188 @@
import asyncio
import json
import secrets
import time
from urllib.parse import urljoin
from fastapi import HTTPException
from ._remote import IDFileAdapter, clean_path, entry
from ._115_cipher import encode, decode
class Pan115Adapter(IDFileAdapter):
"""115 Cloud authenticated with a browser Cookie or an authorized QR token."""
api_base = "https://webapi.115.com"
def __init__(self, record):
self.record = record
cfg = record.config or {}
self.cookie = str(cfg.get("cookie") or "").strip()
self.qrcode_token = str(cfg.get("qrcode_token") or "").strip()
self.root_id = str(cfg.get("root_id", "0"))
self.timeout = float(cfg.get("timeout", 60))
self.page_size = max(1, min(1000, int(cfg.get("page_size", 1000))))
if not self.cookie and not self.qrcode_token:
raise ValueError("115 requires cookie or qrcode_token")
self._session_cookie = self.cookie
self._download_cookies = {}
self._auth_lock = asyncio.Lock()
async def _ensure_cookie(self):
if self._session_cookie:
return
async with self._auth_lock:
if self._session_cookie:
return
async with self._client() as client:
response = await client.post("https://passportapi.115.com/app/1.0/web/1.0/login/qrcode",
data={"app": "web", "account": self.qrcode_token})
response.raise_for_status()
payload = response.json()
cookies = (payload.get("data") or {}).get("cookie") or {}
if not payload.get("state") or not cookies:
raise HTTPException(401, "115 QR token is not authorized or has expired")
self._session_cookie = "; ".join(f"{k}={v}" for k, v in cookies.items())
def _headers(self):
return {"Cookie": self._session_cookie, "Referer": "https://115.com/", "Origin": "https://115.com",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131 Safari/537.36"}
async def _api(self, method, path, *, params=None, data=None, retry=True):
await self._ensure_cookie()
async with self._client() as client:
response = await client.request(method, urljoin(self.api_base, path), headers=self._headers(),
params=params, data=data)
if response.url.host == "proapi.115.com" and response.url.path.endswith("downurl"):
self._download_cookies = dict(response.cookies.items())
if response.status_code in (401, 403) or response.url.path.endswith("/login"):
raise HTTPException(401, "115 authentication expired; update the Cookie")
response.raise_for_status()
try:
payload = response.json()
except ValueError as exc:
raise HTTPException(502, "115 returned an invalid JSON response") from exc
if isinstance(payload, dict) and payload.get("state") is False:
raise HTTPException(502, payload.get("error_msg") or payload.get("error") or "115 API request failed")
return payload
async def _children(self, parent_id):
result = []
offset = 0
while True:
payload = await self._api("GET", "/files", params={"cid": parent_id, "offset": offset, "limit": self.page_size,
"show_dir": 1, "aid": 1, "o": "file_name", "asc": 1, "record_open_time": 1})
items = payload.get("data") or []
for item in items:
is_dir = not bool(item.get("fid"))
fid = item.get("cid") if is_dir else item["fid"]
result.append(entry(item["n"], is_dir, item.get("s"), item.get("te") or item.get("t"),
id=str(fid), pick_code=item.get("pc"), raw=item))
offset += len(items)
if not items or offset >= int(payload.get("count", offset)):
return result
async def _download(self, root, rel):
item = await self._resolve(root, rel)
if item["is_dir"]:
raise IsADirectoryError(rel)
key = secrets.token_bytes(16)
payload = await self._api("POST", "https://proapi.115.com/app/chrome/downurl",
params={"t": int(time.time())}, data={"data": encode(json.dumps({"pickcode": item["pick_code"]}).encode(), key)})
try:
files = json.loads(decode(payload["data"], key))
url = next(iter(files.values()))["url"]["url"]
except (ValueError, KeyError, TypeError, StopIteration) as exc:
raise HTTPException(502, "115 returned invalid download information") from exc
if not url:
raise HTTPException(502, "115 returned no download URL")
headers = self._headers()
cookies = dict(part.strip().split("=", 1) for part in self._session_cookie.split(";") if "=" in part)
cookies.update(self._download_cookies)
headers["Cookie"] = "; ".join(f"{key}={value}" for key, value in cookies.items())
return url, headers
async def mkdir(self, root, rel):
parent, name = await self._parent(root, rel)
await self._api("POST", "/files/add", data={"pid": parent["id"], "cname": name})
async def delete(self, root, rel):
await self._parent(root, rel)
item = await self._resolve(root, rel)
await self._api("POST", "/rb/delete", data={"fid[0]": item["id"]})
async def move(self, root, src_rel, dst_rel):
src_parent, _ = await self._parent(root, src_rel)
item = await self._resolve(root, src_rel)
parent, name = await self._parent(root, dst_rel)
if clean_path(dst_rel).startswith(clean_path(src_rel) + "/"):
raise ValueError("Cannot move into source directory")
if clean_path(src_rel) == clean_path(dst_rel):
return
if await self.exists(root, dst_rel):
raise FileExistsError(dst_rel)
if src_parent["id"] != parent["id"]:
await self._api("POST", "/files/move", data={"pid": parent["id"], "fid[0]": item["id"]})
if name != item["name"]:
await self._api("POST", "/files/batch_rename", data={"fid": item["id"], "file_name": name,
f"files_new_name[{item['id']}]": name})
async def copy(self, root, src_rel, dst_rel, overwrite=False):
src_rel, dst_rel = clean_path(src_rel), clean_path(dst_rel)
if not src_rel or not dst_rel or src_rel == dst_rel or dst_rel.startswith(src_rel + "/"):
raise ValueError("Invalid copy destination")
item = await self._resolve(root, src_rel)
parent, name = await self._parent(root, dst_rel)
if name != item["name"]:
return await super().copy(root, src_rel, dst_rel, overwrite)
if await self.exists(root, dst_rel):
if not overwrite:
raise FileExistsError(dst_rel)
await self.delete(root, dst_rel)
await self._api("POST", "/files/copy", data={"pid": parent["id"], "fid[0]": item["id"]})
async def write_upload_file(self, root, rel, file_obj, filename=None, file_size=None, content_type=None):
parent, name = await self._parent(root, rel)
file_obj.seek(0, 2)
size = file_obj.tell()
file_obj.seek(0)
payload = await self._api("POST", "https://uplb.115.com/3.0/sampleinitupload.php",
data={"filename": name, "target": "U_1_" + parent["id"], "filesize": str(size)})
fields = {"name": name, "key": payload["object"], "policy": payload["policy"],
"OSSAccessKeyId": payload["accessid"], "success_action_status": "200",
"callback": payload["callback"], "signature": payload["signature"]}
async with self._client() as client:
response = await client.post(payload["host"], data=fields,
files={"file": (name, file_obj, content_type or "application/octet-stream")})
response.raise_for_status()
result = response.json()
if result.get("state") is False:
raise HTTPException(502, "115 upload callback failed")
return {"size": size}
async def get_usage(self, root):
payload = await self._api("GET", "/files/index_info")
data = payload.get("data") or payload
space = data.get("space_info") or data
def value(key):
val = space.get(key)
return val.get("size") if isinstance(val, dict) else val
used = value("used_space")
total = value("all_total")
return {"used_bytes": int(used) if used is not None else None,
"total_bytes": int(total) if total is not None else None,
"free_bytes": int(total) - int(used) if total is not None and used is not None else None,
"source": "115", "scope": "account"}
ADAPTER_TYPE = "pan115"
ADAPTER_FACTORY = Pan115Adapter
CONFIG_SCHEMA = [
{"key": "cookie", "label": "Cookie", "type": "password", "required": False,
"placeholder": "从 115.com 复制"},
{"key": "qrcode_token", "label": "QR code token", "type": "string", "required": False},
{"key": "root_id", "label": "Root ID", "type": "string", "default": "0"},
{"key": "page_size", "label": "Page size", "type": "number", "default": 1000},
{"key": "timeout", "label": "Timeout (seconds)", "type": "number", "default": 60},
]
+88
View File
@@ -0,0 +1,88 @@
import json
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from urllib.parse import parse_qs
import httpx
from fastapi import HTTPException
from domain.adapters.providers.pan115 import Pan115Adapter
from domain.adapters.providers._115_cipher import decode, encode
class Pan115AdapterTests(unittest.IsolatedAsyncioTestCase):
def make_adapter(self, handler):
adapter = Pan115Adapter(SimpleNamespace(config={"cookie": "UID=1; CID=2"}))
adapter._client = lambda **kwargs: httpx.AsyncClient(transport=httpx.MockTransport(handler), **kwargs)
return adapter
async def test_cookie_listing_and_nested_resolution(self):
calls = []
def handler(request):
calls.append(request)
self.assertEqual(request.headers["Cookie"], "UID=1; CID=2")
return httpx.Response(200, json={"state": True, "data": [
{"n": "folder", "cid": "10", "is_dir": 1},
]} if request.url.params["cid"] == "0" else {"state": True, "data": [
{"n": "file.txt", "cid": "10", "fid": "11", "s": 12, "pc": "pick"},
]})
adapter = self.make_adapter(handler)
items, total = await adapter.list_dir("0", "")
self.assertEqual((items[0]["name"], total), ("folder", 1))
item = await adapter.stat_file("0", "folder/file.txt")
self.assertEqual(item["id"], "11")
self.assertEqual(len(calls), 3)
async def test_download_handshake_and_cookie_headers(self):
def handler(request):
if request.url.path.endswith("downurl"):
self.assertTrue(parse_qs(request.content.decode())["data"][0])
return httpx.Response(200, json={"state": True, "data": "encrypted"},
headers={"Set-Cookie": "download_token=temporary; Path=/"})
return httpx.Response(200, json={"state": True, "data": [{"n": "file", "fid": "1", "pc": "pick"}]})
adapter = self.make_adapter(handler)
with patch("domain.adapters.providers.pan115.decode", return_value=b'{"1":{"url":{"url":"https://download.test/file"}}}'):
url, headers = await adapter._download("0", "file")
self.assertEqual(url, "https://download.test/file")
self.assertEqual(headers["Cookie"], "UID=1; CID=2; download_token=temporary")
async def test_web_upload_signatures_are_not_account_cookies(self):
uploaded = []
def handler(request):
if request.url.host == "upload.test":
self.assertNotIn("cookie", request.headers)
uploaded.append(request.content)
return httpx.Response(200, json={"state": True})
fields = parse_qs(request.content.decode())
self.assertEqual(fields["target"], ["U_1_0"])
return httpx.Response(200, json={"host": "https://upload.test", "object": "object",
"policy": "policy", "accessid": "temporary", "callback": "callback", "signature": "signature"})
import io
result = await self.make_adapter(handler).write_upload_file("0", "file.txt", io.BytesIO(b"exact content"))
self.assertEqual(result, {"size": 13})
self.assertIn(b"exact content", uploaded[0])
async def test_lists_all_pages_and_uses_file_id_not_parent_id(self):
offsets = []
def handler(request):
offset = int(request.url.params["offset"])
offsets.append(offset)
return httpx.Response(200, json={"state": True, "count": 2,
"data": [{"n": str(offset), "fid": str(offset + 1), "cid": "0"}]})
items, total = await self.make_adapter(handler).list_dir("0", "")
self.assertEqual(total, 2)
self.assertEqual(offsets, [0, 1])
self.assertEqual([item["id"] for item in items], ["1", "2"])
self.assertFalse(items[0]["is_dir"])
def test_cipher_rejects_truncated_responses_and_chunks_large_requests(self):
import base64
encrypted = encode(b"a" * 300, bytes(16))
self.assertEqual(len(base64.b64decode(encrypted)), 384)
with self.assertRaises(ValueError):
decode(base64.b64encode(b"short"), bytes(16))
def test_requires_cookie_or_qr_token(self):
with self.assertRaises(ValueError):
Pan115Adapter(SimpleNamespace(config={}))
+1
View File
@@ -592,6 +592,7 @@
"adapter.type.quark": "Quark Drive",
"adapter.type.uc": "UC Drive",
"adapter.type.pan123": "123Pan (Account)",
"adapter.type.pan115": "115 Cloud (Cookie)",
"adapter.type.alist": "AList",
"adapter.type.openlist": "OpenList",
"Automation Tasks": "Automation Tasks",
+1
View File
@@ -583,6 +583,7 @@
"adapter.type.quark": "夸克网盘",
"adapter.type.uc": "UC 网盘",
"adapter.type.pan123": "123 网盘(账号)",
"adapter.type.pan115": "115 网盘(Cookie)",
"adapter.type.alist": "AList",
"adapter.type.openlist": "OpenList",
"Automation Tasks": "自动化任务",