commit 00f8678d758f33518ce008f9d33db581a2a1c7ee Author: RRRosmontis Date: Thu Aug 27 21:31:10 2026 +0800 openapi 源码 + token 模板 + nginx 统一入口配置 diff --git a/README.md b/README.md new file mode 100644 index 0000000..04c2c98 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# OpenAPI + +统一接口服务。线上入口:`https://openapi.rrrosmontis.icu` + +## 结构 + +``` +source/ +├── openapi/main.py # 聚合层 (127.0.0.1:9888) /pixiv/get_image /bilibili/get_video +├── pixiv/ +│ ├── pixiv.py # Pixiv 图片服务 (9088) +│ └── tokens.template.json # Pixiv token 模板 +└── bilibili/ + ├── bilibili.py # bilibili 视频服务 (19990) + ├── refresh_cookie.py # cookie 定时刷新守护 + ├── qr_login.py # 扫码登录获取 cookie + └── qr_login_fast.py +nginx/ +└── openapi.rrrosmontis.icu.conf # nginx 统一入口(/pixiv/ /bilibili/ /img-master/ 图片反代) +``` + +## 部署 + +1. token:复制 `source/pixiv/tokens.template.json` 为 `source/pixiv/tokens.json`,填入实际 pixiv token +2. bilibili cookie:放置 `source/bilibili/cookies.txt`(`name=value` 格式),或运行 `qr_login.py` 扫码生成 +3. 服务:openapi / pixiv / bilibili / bilibili-refresh 四个 systemd 服务 diff --git a/nginx/openapi.rrrosmontis.icu.conf b/nginx/openapi.rrrosmontis.icu.conf new file mode 100644 index 0000000..321a60d --- /dev/null +++ b/nginx/openapi.rrrosmontis.icu.conf @@ -0,0 +1,119 @@ +# openapi.rrrosmontis.icu 统一入口(原 api/pixiv/apixiv.bu4.ren 合并) +# /pixiv/ /bilibili/ -> openapi 聚合层 127.0.0.1:9888 (main.py) +# /img-master/ -> i.pximg.net 图片反代(原 apixiv.bu4.ren 逻辑,方案二) + +proxy_cache_path /var/cache/nginx/openapi_img_cache levels=1:2 keys_zone=openapi_img_cache:20m inactive=1d max_size=5g; + +server { + listen 80; + listen [::]:80; + server_name openapi.rrrosmontis.icu; + location /.well-known/acme-challenge/ { + root /var/www/acme; + } + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name openapi.rrrosmontis.icu; + + ssl_certificate /home/openapi/cert/openapi.rrrosmontis.icu/fullchain.pem; + ssl_certificate_key /home/openapi/cert/openapi.rrrosmontis.icu/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + add_header Strict-Transport-Security "max-age=31536000"; + error_page 497 https://$host$request_uri; + + resolver 8.8.8.8 1.1.1.1 ipv6=off valid=300s; + + # ---- 图片反代 i.pximg.net(原 apixiv.bu4.ren 逻辑,路径前缀 /img-master/)---- + location ^~ /img-master/ { + set $backend i.pximg.net; + proxy_pass https://$backend$request_uri; + + proxy_set_header Host i.pximg.net; + proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + proxy_set_header Referer "https://www.pixiv.net/"; + + proxy_ssl_server_name on; + proxy_ssl_name i.pximg.net; + + proxy_connect_timeout 10s; + proxy_read_timeout 300s; + + proxy_cache openapi_img_cache; + proxy_cache_valid 200 7d; + proxy_cache_valid 404 5m; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + proxy_cache_lock on; + proxy_cache_key "$scheme$proxy_host$request_uri"; + + add_header X-Cache-Status $upstream_cache_status; + } + + # ---- pixiv(openapi 聚合层 /pixiv/get_image)---- + location ^~ /pixiv/ { + proxy_pass http://127.0.0.1:9888; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-Port $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header REMOTE-HOST $remote_addr; + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + + # ---- bilibili(openapi 聚合层 /bilibili/get_video)---- + location ^~ /bilibili/ { + proxy_pass http://127.0.0.1:9888; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-Port $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header REMOTE-HOST $remote_addr; + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + + # ---- 其余(根路径 / )→ 9888(main.py:跳转文章页)---- + location / { + proxy_pass http://127.0.0.1:9888; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-Port $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header REMOTE-HOST $remote_addr; + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + + access_log /var/log/nginx/openapi.rrrosmontis.icu.log; + error_log /var/log/nginx/openapi.rrrosmontis.icu.error.log; +} diff --git a/source/bilibili/bilibili.py b/source/bilibili/bilibili.py new file mode 100644 index 0000000..698d0eb --- /dev/null +++ b/source/bilibili/bilibili.py @@ -0,0 +1,175 @@ +import asyncio +import sys +import base64 +import os +import re +import tempfile +from pathlib import Path + +import httpx +from aiohttp import web + +COOKIE_FILE = str(Path(__file__).parent / "cookies.txt") +UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" + + +def _prepare_cookie_file(): + """Convert simple name=value cookies.txt to Netscape format for yt-dlp.""" + try: + with open(COOKIE_FILE) as f: + cookies = [line.strip() for line in f if line.strip() and not line.startswith("#")] + except FileNotFoundError: + return None + if not cookies: + return None + tmp = tempfile.NamedTemporaryFile( + mode="w", suffix=".txt", prefix="bilibili_cookie_", delete=False + ) + tmp.write("# Netscape HTTP Cookie File\n") + for c in cookies: + if "=" not in c: + continue + name, value = c.split("=", 1) + # Netscape format: domain flag path secure expires name value + tmp.write(f".bilibili.com\tTRUE\t/\tFALSE\t0\t{name}\t{value}\n") + tmp.close() + return tmp.name + + +async def resolve_bvid(link): + # 直接是 BV 号 + m = re.search(r"(BV[a-zA-Z0-9]{10})", link) + if m: + return m.group(1) + + # b23.tv 短链接,跟随跳转 + if "b23.tv" in link: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + link.strip(), + follow_redirects=False, + headers={"User-Agent": UA}, + ) + location = resp.headers.get("Location", "") + if m := re.search(r"(BV[a-zA-Z0-9]{10})", location): + return m.group(1) + if m := re.search(r"(BV[a-zA-Z0-9]{10})", resp.text): + return m.group(1) + + # 普通 bilibili.com URL + if m := re.search(r"(BV[a-zA-Z0-9]{10})", link): + return m.group(1) + + raise ValueError(f"unable to resolve BV from: {link}") + + +def decode_url(url_b64): + try: + return base64.urlsafe_b64decode(url_b64.encode()).decode() + except Exception: + return base64.b64decode(url_b64.encode()).decode() + + +async def get_video(url_b64=None, bv=None): + if bv: + bvid = bv + else: + try: + url = decode_url(url_b64) + except Exception: + return web.json_response({"error": "invalid base64 encoding"}, status=400) + + if not url: + return web.json_response({"error": "empty url"}, status=400) + + if "bilibili.com" not in url and "b23.tv" not in url and not url.startswith("BV"): + return web.json_response({"error": "not a bilibili url"}, status=400) + + try: + bvid = await resolve_bvid(url) + except ValueError as e: + return web.json_response({"error": str(e)}, status=400) + + cookie_tmp = _prepare_cookie_file() + if not cookie_tmp: + return web.json_response({"error": "missing bilibili cookie"}, status=500) + + tmpdir = tempfile.mkdtemp(prefix="bilibili_") + output_template = os.path.join(tmpdir, "video.%(ext)s") + cmd = [ + os.path.join(os.path.dirname(sys.executable), "yt-dlp"), + "--cookies", cookie_tmp, + "--add-header", f"User-Agent:{UA}", + "--add-header", "Referer:https://www.bilibili.com/", + "-f", "bestvideo*+bestaudio/best", + "--merge-output-format", "mp4", + "-o", output_template, + f"https://www.bilibili.com/video/{bvid}", + ] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=300 + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return web.json_response({"error": "download timeout"}, status=504) + finally: + if cookie_tmp and os.path.exists(cookie_tmp): + os.unlink(cookie_tmp) + + video_files = [p for p in Path(tmpdir).glob("*") if p.is_file()] + if proc.returncode != 0 and not video_files: + err_msg = stderr.decode()[-1000:] if stderr else "unknown error" + return web.json_response( + {"error": "download failed", "detail": err_msg}, + status=502, + ) + + if not video_files: + return web.json_response({"error": "no output file"}, status=502) + + video_path = max(video_files, key=lambda p: p.stat().st_size) + + ext = video_path.suffix.lower() + content_types = { + ".mp4": "video/mp4", + ".flv": "video/x-flv", + ".mkv": "video/x-matroska", + ".webm": "video/webm", + } + content_type = content_types.get(ext, "application/octet-stream") + + return web.FileResponse( + path=video_path, + headers={ + "Content-Type": content_type, + "Content-Disposition": f'attachment; filename="{video_path.name}"', + }, + ) + except Exception as e: + print(f"[bilibili] Error: {e}") + return web.json_response({"error": str(e)}, status=500) + + +async def handle_bilibili(request): + bv = request.query.get("bv", "") + if bv: + return await get_video(bv=bv) + url_b64 = request.query.get("url", "") + if not url_b64: + return web.json_response({"error": "missing url or bv parameter"}, status=400) + return await get_video(url_b64=url_b64) + + +app = web.Application() +app.router.add_get("/bilibili/get_video", handle_bilibili) + +if __name__ == "__main__": + web.run_app(app, host="127.0.0.1", port=19990) diff --git a/source/bilibili/qr_login.py b/source/bilibili/qr_login.py new file mode 100644 index 0000000..d738042 --- /dev/null +++ b/source/bilibili/qr_login.py @@ -0,0 +1,58 @@ +import asyncio +import sys +import time +from pathlib import Path +from bilibili_api.login_v2 import QrCodeLogin, QrCodeLoginEvents + +QR_PATH = Path("/home/bilibili/qrcode.png") +COOKIE_FILE = Path("/home/bilibili/cookies.txt") +CF_QR_PATH = "/mnt/E/temp/bilibili_qrcode.png" + +async def main(): + login = QrCodeLogin() + await login.generate_qrcode() + pic = login.get_qrcode_picture() + QR_PATH.write_bytes(pic.content) + print(f"QR saved to {QR_PATH}") + print("Please scan the QR code with Bilibili App") + + # copy to cf + import subprocess + try: + subprocess.run([ + "sshpass", "-p", "ftA406u@all#2309", + "scp", "-P", "10526", str(QR_PATH), "root@rrrosmontis.icu:/mnt/E/temp/bilibili_qrcode.png" + ], check=True, timeout=30) + print(f"QR copied to cf:{CF_QR_PATH}") + except Exception as e: + print(f"Failed to copy QR to cf: {e}") + + # wait up to 5 minutes + for i in range(60): + await asyncio.sleep(5) + state = await login.check_state() + print(f"[{i*5}s] state: {state}") + if state == QrCodeLoginEvents.DONE: + cred = login.get_credential() + lines = [] + if cred.sessdata: + lines.append(f"SESSDATA={cred.sessdata}") + if cred.bili_jct: + lines.append(f"bili_jct={cred.bili_jct}") + if cred.buvid3: + lines.append(f"buvid3={cred.buvid3}") + if cred.dedeuserid: + lines.append(f"dedeuserid={cred.dedeuserid}") + if cred.ac_time_value: + lines.append(f"ac_time_value={cred.ac_time_value}") + COOKIE_FILE.write_text("\n".join(lines) + "\n") + print(f"Login success. Cookie saved to {COOKIE_FILE}") + print(f"ac_time_value present: {bool(cred.ac_time_value)}") + return + elif state == QrCodeLoginEvents.TIMEOUT: + print("QR code timeout") + sys.exit(1) + print("QR login timeout after 5 minutes") + sys.exit(1) + +asyncio.run(main()) diff --git a/source/bilibili/qr_login_fast.py b/source/bilibili/qr_login_fast.py new file mode 100644 index 0000000..cb4e8ba --- /dev/null +++ b/source/bilibili/qr_login_fast.py @@ -0,0 +1,52 @@ +import asyncio +import sys +import subprocess +from pathlib import Path +from bilibili_api.login_v2 import QrCodeLogin, QrCodeLoginEvents, QrCodeLoginChannel + +QR_PATH = Path("/home/bilibili/qrcode.png") +COOKIE_FILE = Path("/home/bilibili/cookies.txt") +CF_QR_PATH = "root@rrrosmontis.icu:/mnt/E/temp/bilibili_qrcode.png" + +async def main(): + # try TV channel which may have longer expiration + login = QrCodeLogin(platform=QrCodeLoginChannel.TV) + await login.generate_qrcode() + pic = login.get_qrcode_picture() + QR_PATH.write_bytes(pic.content) + print(f"QR regenerated: {QR_PATH}") + + # sync to cf immediately + try: + subprocess.run([ + "sshpass", "-p", "ftA406u@all#2309", + "scp", "-P", "10526", str(QR_PATH), CF_QR_PATH + ], check=True, timeout=10) + print("QR synced to cf") + except Exception as e: + print(f"Sync failed: {e}") + + print("Scan now! Polling every 3s for 3 minutes...") + for i in range(60): + await asyncio.sleep(3) + state = await login.check_state() + print(f"[{i*3}s] {state}") + if state == QrCodeLoginEvents.DONE: + cred = login.get_credential() + lines = [] + if cred.sessdata: lines.append(f"SESSDATA={cred.sessdata}") + if cred.bili_jct: lines.append(f"bili_jct={cred.bili_jct}") + if cred.buvid3: lines.append(f"buvid3={cred.buvid3}") + if cred.dedeuserid: lines.append(f"dedeuserid={cred.dedeuserid}") + if cred.ac_time_value: lines.append(f"ac_time_value={cred.ac_time_value}") + COOKIE_FILE.write_text("\n".join(lines) + "\n") + print("LOGIN SUCCESS") + print(f"ac_time_value: {bool(cred.ac_time_value)}") + return + elif state == QrCodeLoginEvents.TIMEOUT: + print("QR TIMEOUT") + sys.exit(1) + print("POLLING TIMEOUT") + sys.exit(1) + +asyncio.run(main()) diff --git a/source/bilibili/refresh_cookie.py b/source/bilibili/refresh_cookie.py new file mode 100644 index 0000000..1293dd9 --- /dev/null +++ b/source/bilibili/refresh_cookie.py @@ -0,0 +1,113 @@ +import asyncio +import json +import logging +import random +import sys +from pathlib import Path +from datetime import datetime + +from bilibili_api import Credential, sync + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) + +COOKIE_FILE = Path("cookies.txt") + +def load_credential() -> Credential: + """从 KEY=VALUE 格式的 txt 文件加载 Credential 对象""" + if not COOKIE_FILE.exists(): + logger.error("Cookie file not found: %s", COOKIE_FILE) + sys.exit(1) + + try: + data = {} + with open(COOKIE_FILE, "r") as f: + for line in f: + line = line.strip() + if not line: + continue + if "=" in line: + key, _, value = line.partition("=") + data[key.strip()] = value.strip() + # 构建 Credential 对象(key 名大小写不敏感) + cred = Credential( + sessdata=data.get("SESSDATA") or data.get("sessdata"), + bili_jct=data.get("bili_jct") or data.get("BILI_JCT"), + buvid3=data.get("buvid3") or data.get("BUVID3"), + dedeuserid=data.get("dedeuserid") or data.get("DEDEUSERID"), + ac_time_value=data.get("ac_time_value") or data.get("AC_TIME_VALUE"), + ) + logger.info("Credential loaded from %s", COOKIE_FILE) + return cred + except Exception as e: + logger.error("Failed to load credential: %s", e) + sys.exit(1) + +async def check_and_refresh(cred: Credential) -> bool: + """检查有效性,如果有效则刷新 token。返回是否成功刷新(或仍有效)""" + try: + valid = await cred.check_valid() + if not valid: + logger.warning("Credential is invalid (maybe expired). Refresh may fail.") + else: + logger.info("Credential is valid, proceeding to refresh...") + + # 无论 valid 状态如何,都尝试 refresh(refresh 接口本身会处理) + await cred.refresh() + logger.info("Token refresh successful. New SESSDATA: %s...", cred.sessdata[:20]) + # 保存更新后的凭证到文件 + save_credential(cred) + return True + except Exception as e: + logger.error("Refresh failed: %s", e) + return False + +def save_credential(cred: Credential): + """将 credential 的关键字段保存回 txt 文件(KEY=VALUE 格式)""" + lines = [] + if cred.sessdata: + lines.append(f"SESSDATA={cred.sessdata}") + if cred.bili_jct: + lines.append(f"bili_jct={cred.bili_jct}") + if cred.buvid3: + lines.append(f"buvid3={cred.buvid3}") + if cred.dedeuserid: + lines.append(f"dedeuserid={cred.dedeuserid}") + if cred.ac_time_value: + lines.append(f"ac_time_value={cred.ac_time_value}") + + with open(COOKIE_FILE, "w") as f: + f.write("\n".join(lines) + "\n") + logger.info("Credential saved to %s", COOKIE_FILE) + +async def main_loop(): + logger.info("Starting token refresh daemon...") + cred = load_credential() + + while True: + # 随机间隔 50~70 分钟(单位:秒) + interval = random.randint(50 * 60, 70 * 60) + next_refresh = datetime.now().timestamp() + interval + logger.info("Next refresh in %d minutes (at %s)", interval // 60, datetime.fromtimestamp(next_refresh).strftime("%Y-%m-%d %H:%M:%S")) + + await asyncio.sleep(interval) + + logger.info("Refreshing token...") + success = await check_and_refresh(cred) + if not success: + logger.error("Refresh failed, will retry in next cycle.") + else: + logger.info("Refresh cycle completed.") + +if __name__ == "__main__": + try: + asyncio.run(main_loop()) + except KeyboardInterrupt: + logger.info("Daemon stopped by user.") + except Exception as e: + logger.exception("Unexpected error: %s", e) + sys.exit(1) diff --git a/source/openapi/main.py b/source/openapi/main.py new file mode 100644 index 0000000..298343e --- /dev/null +++ b/source/openapi/main.py @@ -0,0 +1,55 @@ +from aiohttp import web, ClientSession, ClientTimeout + + +PIXIV_BACKEND = "http://127.0.0.1:9088" +BILIBILI_BACKEND = "http://127.0.0.1:19990" + + +async def handle_pixiv(request): + pid = request.query.get("id", "") + if not pid: + return web.json_response({"error": "missing id parameter"}, status=400) + target = f"{PIXIV_BACKEND}/{pid}" + return await proxy(request, target) + + +async def handle_bilibili(request): + qs = request.query_string + target = f"{BILIBILI_BACKEND}/bilibili/get_video" + if qs: + target += "?" + qs + return await proxy(request, target) + + +async def proxy(request, target): + timeout = ClientTimeout(total=300) + async with ClientSession(timeout=timeout) as session: + try: + async with session.get(target) as resp: + body = await resp.read() + headers = {} + for key in ("Content-Type", "Content-Disposition", "Cache-Control"): + if key in resp.headers: + headers[key] = resp.headers[key] + return web.Response( + body=body, + status=resp.status, + headers=headers, + ) + except Exception as e: + return web.json_response( + {"error": "backend unreachable", "detail": str(e)}, status=502 + ) + + +async def handle_root(request): + raise web.HTTPFound("https://rrrosmontis.icu/articles/index.html?id=00003") + + +app = web.Application() +app.router.add_get("/", handle_root) +app.router.add_get("/pixiv/get_image", handle_pixiv) +app.router.add_get("/bilibili/get_video", handle_bilibili) + +if __name__ == "__main__": + web.run_app(app, host="127.0.0.1", port=9888) diff --git a/source/pixiv/pixiv.py b/source/pixiv/pixiv.py new file mode 100644 index 0000000..f0f572b --- /dev/null +++ b/source/pixiv/pixiv.py @@ -0,0 +1,199 @@ +from flask import Flask, jsonify, send_file +import requests +import os +import json +import urllib3 +from pathlib import Path + +IMAGE_PROXY_DOMAIN = "openapi.rrrosmontis.icu" +TOKEN_FILE = "tokens.json" +API_HOST = "app-api.pixiv.net" +USER_AGENT = "PixivIOSApp/7.13.0 (iOS 14.6; iPhone)" + +urllib3.disable_warnings() + +IMAGE_DIR = Path("/home/pixiv/images") +IMAGE_DIR.mkdir(exist_ok=True) + +app = Flask(__name__) + + +def get_access_token(): + token_path = Path(__file__).parent / TOKEN_FILE + if not token_path.exists(): + return None + try: + with open(token_path, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get("access_token") + except Exception: + return None + + +def fetch_illust_url(pid, page): + access_token = get_access_token() + if not access_token: + return None + try: + resp = requests.get( + f"https://{API_HOST}/v1/illust/detail", + params={"illust_id": pid}, + headers={ + "Host": API_HOST, + "User-Agent": USER_AGENT, + "Authorization": f"Bearer {access_token}" + }, + timeout=20, + verify=False + ) + if resp.status_code != 200: + return None + data = resp.json() + if data.get("error"): + return None + illust = data.get("illust") + if not illust: + return None + page_count = illust.get("page_count", 1) + if page >= page_count: + return None + original_url = None + if page_count > 1: + meta_pages = illust.get("meta_pages") + if meta_pages and page < len(meta_pages): + original_url = meta_pages[page]["image_urls"]["original"] + else: + meta_single = illust.get("meta_single_page") + if meta_single: + original_url = meta_single.get("original_image_url") + if not original_url: + original_url = illust.get("image_urls", {}).get("large") + if not original_url: + return None + return original_url.replace("i.pximg.net", IMAGE_PROXY_DOMAIN) + except Exception: + return None + + +def make_re_url(pid, page): + if page == 0: + return f"https://pixiv.re/{pid}.jpg" + else: + return f"https://pixiv.re/{pid}_p{page}.jpg" + + +def make_cat_url(pid, page): + if page == 0: + return f"https://pixiv.cat/{pid}.jpg" + else: + return f"https://pixiv.cat/{pid}-{page+1}.jpg" + + +def check_cache(pid, page): + pattern = f"{pid}.*" if page == 0 else f"{pid}-p{page+1}.*" + for f in IMAGE_DIR.glob(pattern): + ext = f.suffix.lstrip('.') + mime = 'image/jpeg' + if ext == 'png': + mime = 'image/png' + elif ext == 'gif': + mime = 'image/gif' + return f, mime + return None, None + + +def download_image(url, pid, page): + cached_path, mime = check_cache(pid, page) + if cached_path: + return cached_path, mime + try: + resp = requests.get( + url, + headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}, + timeout=30, + verify=False + ) + if resp.status_code != 200: + return None, None + ct = resp.headers.get('Content-Type', 'image/jpeg') + ext = 'jpg' + if 'png' in ct: + ext = 'png' + elif 'gif' in ct: + ext = 'gif' + filename = f"{pid}.{ext}" if page == 0 else f"{pid}-p{page+1}.{ext}" + tmp = IMAGE_DIR / (filename + '.tmp') + final = IMAGE_DIR / filename + with open(tmp, 'wb') as f: + f.write(resp.content) + os.replace(tmp, final) + return final, ct + except Exception: + return None, None + + +@app.route('/') +def serve_image(path): + import re + m = re.match(r'^(\d+)(?:-(\d+))?(?:\.\w+)?$', path) + if not m: + return jsonify(error="Invalid path format, expected /{pid} or /{pid}-{page}"), 400 + pid = int(m.group(1)) + page = int(m.group(2)) - 1 if m.group(2) else 0 + + cached_file, mime = check_cache(pid, page) + if cached_file: + return send_file(cached_file, mimetype=mime, max_age=3600) + + urls_to_try = [] + official_url = fetch_illust_url(pid, page) + if official_url: + urls_to_try.append(official_url) + urls_to_try.append(make_re_url(pid, page)) + urls_to_try.append(make_cat_url(pid, page)) + + for url in urls_to_try: + img_path, mime = download_image(url, pid, page) + if img_path: + return send_file(img_path, mimetype=mime, max_age=3600) + + return jsonify(error="Failed to fetch image from all sources"), 500 + + +@app.route('/health') +def health(): + access_token = get_access_token() + if not access_token: + return jsonify(status="error", detail="tokens.json missing or invalid") + try: + resp = requests.get( + f"https://{API_HOST}/v1/illust/detail", + params={"illust_id": 105383871}, + headers={ + "Host": API_HOST, + "User-Agent": USER_AGENT, + "Authorization": f"Bearer {access_token}" + }, + timeout=15, + verify=False + ) + if resp.status_code == 200: + return jsonify(status="ok", token="valid") + else: + return jsonify(status="error", detail=f"API returned {resp.status_code}") + except Exception as e: + return jsonify(status="error", detail=str(e)) + + +if __name__ == '__main__': + token_path = Path(__file__).parent / TOKEN_FILE + if not token_path.exists(): + print(f"Warning: {TOKEN_FILE} not found, access_token may be missing") + else: + token = get_access_token() + if token: + print("Access token loaded") + else: + print("Warning: access_token missing in tokens.json") + print(f"Cache directory: {IMAGE_DIR}") + app.run(host='0.0.0.0', port=9088, debug=False) \ No newline at end of file diff --git a/source/pixiv/tokens.template.json b/source/pixiv/tokens.template.json new file mode 100644 index 0000000..ec843f4 --- /dev/null +++ b/source/pixiv/tokens.template.json @@ -0,0 +1,4 @@ +{ + "access_token": "", + "refresh_token": "" +}