|
| 1 | +import hmac |
| 2 | +import json |
| 3 | +import time |
| 4 | +import uuid |
| 5 | +import asyncio |
| 6 | +import aiohttp |
| 7 | +from yarl import URL |
| 8 | +from pathlib import Path |
| 9 | +from loguru import logger |
| 10 | +from hashlib import sha256 |
| 11 | +from typing import Literal |
| 12 | +from aiohttp import TCPConnector, ClientSession |
| 13 | + |
| 14 | +from creart import create |
| 15 | + |
| 16 | +from shared.models.config import GlobalConfig |
| 17 | + |
| 18 | +BASE_PATH = Path(__file__).parent |
| 19 | +CACHE_PATH = BASE_PATH / "cache" / "download" |
| 20 | +global_url = URL("https://picaapi.picacomic.com/") |
| 21 | +api_key = "C69BAF41DA5ABD1FFEDC6D2FEA56B" |
| 22 | +uuid_s = str(uuid.uuid4()).replace("-", "") |
| 23 | +header = { |
| 24 | + "api-key": "C69BAF41DA5ABD1FFEDC6D2FEA56B", |
| 25 | + "app-channel": "3", |
| 26 | + "app-version": "2.2.1.3.3.4", |
| 27 | + "app-uuid": "defaultUuid", |
| 28 | + "image-quality": "original", |
| 29 | + "app-platform": "android", |
| 30 | + "app-build-version": "45", |
| 31 | + "Content-Type": "application/json; charset=UTF-8", |
| 32 | + "User-Agent": "okhttp/3.8.1", |
| 33 | + "accept": "application/vnd.picacomic.com.v1+json", |
| 34 | + "time": 0, |
| 35 | + "nonce": "", |
| 36 | + "signature": "encrypt", |
| 37 | +} |
| 38 | +path_filter = ["\\", "/", ":", "*", "?", '"', "<", ">", "|"] |
| 39 | + |
| 40 | +config = create(GlobalConfig) |
| 41 | +loop = create(asyncio.AbstractEventLoop) |
| 42 | +proxy = config.proxy if config.proxy != "proxy" else "" |
| 43 | +pica_config = config.functions.get("pica", {}) |
| 44 | +username = pica_config.get("username", None) |
| 45 | +password = pica_config.get("password", None) |
| 46 | +compress_password = pica_config.get("compress_password", "i_luv_sagiri") |
| 47 | +DOWNLOAD_CACHE = pica_config.get("download_cache", True) |
| 48 | + |
| 49 | + |
| 50 | +class Pica: |
| 51 | + def __init__(self, account, pwd): |
| 52 | + CACHE_PATH.mkdir(parents=True, exist_ok=True) |
| 53 | + self.init = False |
| 54 | + self.account = account |
| 55 | + self.password = pwd |
| 56 | + self.header = header.copy() |
| 57 | + self.header["nonce"] = uuid_s |
| 58 | + self.__SigFromNative = ( |
| 59 | + "~d}$Q7$eIni=V)9\\RK/P.RM4;9[7|@/CA}b~OW!3?EV`:<>M7pddUBL5n|0/*Cn" |
| 60 | + ) |
| 61 | + asyncio.run_coroutine_threadsafe(self.check(), loop) |
| 62 | + |
| 63 | + @logger.catch |
| 64 | + async def check(self) -> bool | None: |
| 65 | + try: |
| 66 | + await self.login() |
| 67 | + self.init = True |
| 68 | + return True |
| 69 | + except aiohttp.ClientConnectorError: |
| 70 | + logger.error("proxy配置可能错误或失效,请检查") |
| 71 | + except KeyError: |
| 72 | + logger.error("pica 账号密码可能错误,请检查") |
| 73 | + |
| 74 | + def update_signature(self, url: str | URL, method: Literal["GET", "POST"]) -> dict: |
| 75 | + if isinstance(url, str): |
| 76 | + url = URL(url) |
| 77 | + ts = str(int(time.time())) |
| 78 | + temp_header = self.header.copy() |
| 79 | + temp_header["time"] = ts |
| 80 | + temp_header["signature"] = self.encrypt(url, ts, method) |
| 81 | + if method == "GET": |
| 82 | + temp_header.pop("Content-Type") |
| 83 | + return temp_header |
| 84 | + |
| 85 | + def encrypt(self, url: URL, ts, method): |
| 86 | + datas = [ |
| 87 | + global_url, |
| 88 | + url.path[1:], |
| 89 | + ts, |
| 90 | + uuid_s, |
| 91 | + method, |
| 92 | + "C69BAF41DA5ABD1FFEDC6D2FEA56B", |
| 93 | + "2.2.1.3.3.4", |
| 94 | + "45", |
| 95 | + ] |
| 96 | + _src = self.__ConFromNative(datas) |
| 97 | + _key = self.__SigFromNative |
| 98 | + return Pica.HashKey(_src, _key) |
| 99 | + |
| 100 | + @staticmethod |
| 101 | + def __ConFromNative(datas): |
| 102 | + return "".join(map(str, datas[1:6])) |
| 103 | + |
| 104 | + @staticmethod |
| 105 | + def HashKey(src, key): |
| 106 | + app_secret = key.encode("utf-8") # 秘钥 |
| 107 | + data = src.lower().encode("utf-8") # 数据 |
| 108 | + return hmac.new(app_secret, data, digestmod=sha256).hexdigest() |
| 109 | + |
| 110 | + async def request( |
| 111 | + self, |
| 112 | + url: str | URL, |
| 113 | + params: dict[str, str] | None = None, |
| 114 | + method: Literal["GET", "POST"] = "GET", |
| 115 | + ): |
| 116 | + temp_header = self.update_signature(url, method) |
| 117 | + # print(temp_header) |
| 118 | + data = json.dumps(params) if params else None |
| 119 | + # print(data) |
| 120 | + async with aiohttp.ClientSession(connector=TCPConnector(ssl=False)) as session: |
| 121 | + async with session.request(method, url=url, headers=temp_header, proxy=proxy, data=data) as resp: |
| 122 | + ret_data = await resp.json() |
| 123 | + if not resp.ok: |
| 124 | + logger.warning(f"报错返回json:{ret_data}") |
| 125 | + # print(await resp.json()) |
| 126 | + return await resp.json() |
| 127 | + |
| 128 | + async def login(self): |
| 129 | + """登录获取token""" |
| 130 | + url = global_url / "auth" / "sign-in" |
| 131 | + send = {"email": self.account, "password": self.password} |
| 132 | + ret = await self.request(url, send, "POST") |
| 133 | + self.header["authorization"] = ret["data"]["token"] |
| 134 | + |
| 135 | + async def categories(self): |
| 136 | + """获取所有目录""" |
| 137 | + url = global_url / "categories" |
| 138 | + return (await self.request(url))["data"]["categories"] |
| 139 | + |
| 140 | + async def search(self, keyword: str): |
| 141 | + """关键词搜索""" |
| 142 | + url = global_url / "comics" / "advanced-search" % {"page": 1} |
| 143 | + # print(url) |
| 144 | + param = {"categories": [], "keyword": keyword, "sort": "ua"} |
| 145 | + return [ |
| 146 | + {"name": comic["title"], "id": comic["_id"]} |
| 147 | + for q in range(1, 3) |
| 148 | + for comic in (await self.request(url % {"q": q}, param, "POST"))["data"]["comics"]["docs"] |
| 149 | + if comic["likesCount"] > 200 |
| 150 | + and comic["pagesCount"] / comic["epsCount"] < 60 |
| 151 | + and comic["epsCount"] < 10 |
| 152 | + ] |
| 153 | + |
| 154 | + async def random(self): |
| 155 | + """随机本子""" |
| 156 | + url = global_url / "comics" / "random" |
| 157 | + return (await self.request(url))["data"]["comics"] |
| 158 | + |
| 159 | + async def rank(self, tt: Literal["H24", "D7", "D30"] = "H24"): |
| 160 | + """排行榜""" |
| 161 | + url = global_url / "comics" / "leaderboard" % {"ct": "VC", "tt": tt} |
| 162 | + return (await self.request(url))["data"]["comics"] |
| 163 | + |
| 164 | + async def comic_info(self, book_id: str): |
| 165 | + """漫画详情""" |
| 166 | + url = global_url / "comics" / book_id |
| 167 | + return (await self.request(url))["data"]["comic"] |
| 168 | + |
| 169 | + async def download_image(self, url: str, path: str | Path | None = None) -> bytes: |
| 170 | + async with aiohttp.ClientSession(connector=TCPConnector(ssl=False)) as session: |
| 171 | + return await self.download_image_session(session, url, path) |
| 172 | + |
| 173 | + async def download_image_session( |
| 174 | + self, session: ClientSession, url: str, path: str | Path | None = None |
| 175 | + ): |
| 176 | + temp_header = self.update_signature(url, "GET") |
| 177 | + async with session.get(url=url, headers=temp_header, proxy=proxy) as resp: |
| 178 | + resp.raise_for_status() |
| 179 | + image_bytes = await resp.read() |
| 180 | + |
| 181 | + if path: |
| 182 | + Path(path).write_bytes(image_bytes) |
| 183 | + return image_bytes |
| 184 | + |
| 185 | + async def download_comic(self, book_id: str) -> tuple[Path, str]: |
| 186 | + info = await self.comic_info(book_id) |
| 187 | + episodes = info["epsCount"] |
| 188 | + comic_name = f"{info['title']} - {info['author']}" |
| 189 | + tasks = [] |
| 190 | + for char in path_filter: |
| 191 | + comic_name = comic_name.replace(char, " ") |
| 192 | + comic_path = CACHE_PATH / comic_name |
| 193 | + comic_path.mkdir(exist_ok=True) |
| 194 | + for episode in range(episodes): |
| 195 | + url = global_url / "comics" / book_id / "order" / str(episode + 1) / "pages" |
| 196 | + data = (await self.request(url))["data"] |
| 197 | + episode_title: str = data["ep"]["title"] |
| 198 | + episode_path = comic_path / episode_title |
| 199 | + episode_path.mkdir(exist_ok=True) |
| 200 | + for img in data["pages"]["docs"]: |
| 201 | + media = img["media"] |
| 202 | + img_url = f"{media['fileServer']}/static/{media['path']}" |
| 203 | + image_path: Path = episode_path / media["originalName"] |
| 204 | + if not image_path.exists(): |
| 205 | + tasks.append([img_url, image_path]) |
| 206 | + async with aiohttp.ClientSession( |
| 207 | + connector=TCPConnector(ssl=False, limit=5) |
| 208 | + ) as session: |
| 209 | + tasks = [self.download_image_session(session, *t) for t in tasks] |
| 210 | + await asyncio.gather(*tasks) |
| 211 | + return comic_path, comic_name |
| 212 | + |
| 213 | + |
| 214 | +pica = Pica(username, password) |
| 215 | +# print(loop.run_until_complete(pica.search("SAGIRI"))) |
| 216 | +# print(loop.run_until_complete(pica.categories())) |
| 217 | +# print(loop.run_until_complete(pica.random())) |
| 218 | +# print(loop.run_until_complete(pica.rank())) |
| 219 | +# print(loop.run_until_complete(pica.comic_info("5ce4d819431b5d017ddc8199"))) |
| 220 | +# loop.run_until_complete(pica.download_comic("5821a1d55f6b9a4f93ef4a6b")) |
0 commit comments