import asyncio import json import re import shutil import aiohttp import os from pathlib import Path BASE_DIR = os.path.dirname(os.path.abspath(__file__)) import pixiv OSS_DIR = os.environ.get("OSS_SAVE_DIR", "/mnt/oss/pixiv/images") # 1=启用: 下载->缓存->上传cf /mnt/A/pixiv->删缓存->直发图片; 0=禁用: 仅返回镜像URL SEND_IMAGE = 1 # 图片获取模式 (SEND_IMAGE=1 时生效): # "mirror" = 从 openapi.rrrosmontis.icu 镜像站获取 # "pximg" = 源站优先: lolicon 返回链接改写为 i.pximg.net, 经代理直取 (失败回退镜像站) IMAGE_FETCH_MODE = "pximg" # 是否把 pixiv.re 系中转 (lolicon 返回的 i.pixiv.re 原始链接) 作为候选下载源: # 2026-09-12 pixiv.re 全站 500 (hk 干净 IP 亦 500) 起关闭, 不再尝试 ALLOW_PIXIV_RE_RELAY = False PROXY_CONFIG_PATH = os.environ.get("BOT_PROXY_CONFIG", os.path.join(BASE_DIR, "proxies.json")) # 图片留存模式 (SEND_IMAGE=1 时生效): # "oss" = 保存到本机 C:/oss/pixiv, 不上传 cf # "upload" = 上传 cf /mnt/A/pixiv 存档 RETAIN_MODE = "oss" OSS_SAVE_DIR = os.environ.get("OSS_SAVE_DIR", "/mnt/oss/pixiv/images") # 运行 napcat 的机器上是否有可用的同一 oss 挂载 (仅 RETAIN_MODE="oss" 时读取): # True = NapCat 与 oss 同机/同一挂载 (如 cf 上 ossfs2 挂载 /mnt/A), 发图直接引用 # NAPCAT_SERVER_OSS_IMAGES_PATH 下 {pid}{后缀} 的本地路径, 不走 base64 内联 # False = 跨机器, 使用 base64 内联发图 IS_NAPCAT_SERVER_OSS_AVAILABLE = True NAPCAT_SERVER_OSS_IMAGES_PATH = os.environ.get("NAPCAT_OSS_IMAGES_PATH", "/mnt/oss/pixiv/images") HELP_TEXT = ( "来张涩图\n" "━━━━━━━━━━━━\n" "• .setu [标签,逗号分隔] [允许r18/不要r18] [允许ai/不要ai]\n" "• 来张涩图 [同上] (群聊需 @bot)\n" " - 标签:最多3个,逗号分隔,仅中英文/数字/下划线\n" " - r18:允许r18 / 不要r18\n" " - ai:允许ai / 不要ai\n" "示例:.setu 白丝,猫耳 允许r18 不要ai" ) def _load_proxy_ports(): """读取 proxies.json 中的本地代理端口 (与 jm 共用 xray 本地 http 端口)""" try: with open(PROXY_CONFIG_PATH, encoding="utf-8") as f: data = json.load(f) return [f"http://127.0.0.1:{p['localPort']}" for p in data.get("proxies", []) if p.get("localPort")] except Exception as e: print(f"[setu] 代理列表加载失败: {e}") return [] def count_oss_files(): try: os.makedirs(OSS_DIR, exist_ok=True) return len([f for f in os.listdir(OSS_DIR) if os.path.isfile(os.path.join(OSS_DIR, f))]) except Exception: return 0 def clear_cache(): """清空 pixiv 下载缓存目录 (与 pixiv 模块共用), 返回提示文本""" return pixiv.clear_cache() def parse_setu_command(raw_input): pattern = r'^来张涩图\s*(.*)$' match = re.match(pattern, raw_input.strip()) if not match: return None, None, None, None args_str = match.group(1).strip() if not args_str: return [], None, None, None tag_part = "" r18_flag = None ai_flag = None parts = args_str.split() for part in parts: part_lower = part.lower() if part_lower in ["允许r18", "不要r18"]: if r18_flag is not None: return None, None, None, "参数错误:r18状态重复定义" r18_flag = part_lower elif part_lower in ["允许ai", "不要ai"]: if ai_flag is not None: return None, None, None, "参数错误:ai状态重复定义" ai_flag = part_lower else: if tag_part: return None, None, None, "参数错误:标签必须放在最前面,且只能包含逗号分隔的关键词" tag_part = part if not tag_part: return [], r18_flag, ai_flag, None tags = [t.strip() for t in tag_part.split(',') if t.strip()] if len(tags) > 3: return None, None, None, "参数错误:最多支持3个标签" for tag in tags: if not re.match(r'^[\w一-鿿]+$', tag): return None, None, None, f"参数错误:标签 `{tag}` 包含非法字符,仅支持中英文、数字和下划线" return tags, r18_flag, ai_flag, None async def handle_setu(tags, r18_flag, ai_flag, message_type, target_id, websocket): """查询 lolicon API 获取 pid → 返回镜像 URL。图片由 hk pixiv 代理存入 OSS。""" from channel import send_message print(f"[DEBUG setu] handle_setu called: tags={tags}, r18={r18_flag}, ai={ai_flag}, " f"msg_type={message_type}, target={target_id}") r18_value = 0 if r18_flag == "允许r18": r18_value = 2 exclude_ai = False if ai_flag == "允许ai" else True base_url = "https://api.lolicon.app/setu/v2" params = { "size": "original", "r18": r18_value, "num": 1, "excludeAI": str(exclude_ai).lower() } if tags: for tag in tags: params.setdefault("tag", []).append(tag) print(f"[DEBUG setu] lolicon params: {params}") data = None timeout = aiohttp.ClientTimeout(total=15) async with aiohttp.ClientSession(timeout=timeout) as session: try: async with session.get(base_url, params=params) as resp: if resp.status != 200: await send_message(websocket, message_type, target_id, "图片API暂时不可用,请稍后再试") return data = await resp.json() except asyncio.TimeoutError: print("[超时] lolicon API请求超时") await send_message(websocket, message_type, target_id, "图片API请求超时,请稍后再试") return except Exception as e: print(f"[错误] 请求API失败: {type(e).__name__}: {e}") await send_message(websocket, message_type, target_id, "请求图片失败,请检查网络连接") return if not data.get("data"): if tags: tag_str = "、".join(tags) await send_message(websocket, message_type, target_id, f"没有找到关于「{tag_str}」的图片") else: await send_message(websocket, message_type, target_id, "没有找到图片") return # 主机改写 (2026-09-02): lolicon v2 当前返回的 urls 均为 i.pixiv.re 中转链接, # 该中转与 pixiv.re 同源且当前 500。将主机改写为 i.pximg.net 源站, # 配合本地代理 + Referer (pixiv.download_image 自带 Referer/浏览器UA) 直取原图, 不再依赖中转。 def _rewrite_to_pximg(u): if not u: return None return re.sub(r"^(https?://)i\.pixiv\.re(/)", r"\1i.pximg.net\2", u) # —— 抽取+下载循环 —— # lolicon 索引存在两类坏数据 (源站 404): ①受限/不可见作品 ②扩展名过期 (png↔jpg 与源站不符)。 # 单轮 404 时先换扩展名再试, 仍失败则重抽下一轮, 最多 MAX_DRAWS 轮。 MAX_DRAWS = 3 pool = list(data["data"]) pid = pool[0].get("pid") if not pid: await send_message(websocket, message_type, target_id, "获取图片信息失败,请稍后再试") return image_url = f"https://openapi.rrrosmontis.icu/pixiv/get_image?id={pid}" file_count = count_oss_files() if not SEND_IMAGE: await send_message(websocket, message_type, target_id, f"找到了这张图片喵: \n{image_url}\n\nStatTrak™计数器: {file_count}") return await send_message(websocket, message_type, target_id, f"正在获取图片 (PID {pid}), 请稍候...") # 代理列表恒定加载 (2026-09-12 修正): 原先仅 pximg 模式加载, mirror 模式会跳过"镜像绕行代理" # 直接落到 bj->hk 直连兜底 (~8KB/s), 大图必 Read timed out。 proxy_list = _load_proxy_ports() local_path = None last_err = None final_pid = pid prev_pid = None # 上一轮已失败的 PID, 用于剔除重抽失败时残留的同一坏图 pid_lock_held = None # 下载成功后持有的 PID 锁, 覆盖 发图->留存->清缓存 全段 for attempt in range(MAX_DRAWS): item = pool[attempt] if attempt < len(pool) else None if not item: print(f"[setu] 重抽 (第 {attempt + 1}/{MAX_DRAWS} 轮)...") timeout = aiohttp.ClientTimeout(total=15) try: async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(base_url, params=params) as resp: if resp.status == 200: pool = (await resp.json()).get("data") or [] except Exception as e: print(f"[setu] 重抽请求失败: {type(e).__name__}: {e}") pool = [] # 重抽失败(非200/异常)时 pool 未更新, pool[0] 仍是上一轮已失败的图, # 必须剔除, 避免 MAX_DRAWS 预算被同一张坏图耗尽 if pool and prev_pid is not None and pool[0].get("pid") == prev_pid: pool = pool[1:] item = pool[0] if pool else None if not item or not item.get("pid"): continue cur_pid = item["pid"] final_pid = cur_pid prev_pid = cur_pid if attempt > 0: await send_message(websocket, message_type, target_id, f"上一张获取失败, 换一张 (PID {cur_pid})...") source_url_original = (item.get("urls") or {}).get("original") download_url = _rewrite_to_pximg(source_url_original) if not download_url: print(f"[setu] PID {cur_pid} 无源站链接, 只能走镜像") continue # 候选下载源: 改写源站直取 → 扩展名互换 → 原始中转 (仅 ALLOW_PIXIV_RE_RELAY=True 时启用) candidates = [(download_url, "源站直取")] m = re.search(r"\.(png|jpg)$", download_url) if m: swapped = download_url[:-4] + (".jpg" if m.group(1) == "png" else ".png") candidates.append((swapped, "扩展名互换")) if ALLOW_PIXIV_RE_RELAY and source_url_original != download_url: candidates.append((source_url_original, "原始中转")) print(f"[setu] 第 {attempt + 1}/{MAX_DRAWS} 轮 PID {cur_pid}: " f"{len(candidates)} 个候选源, 代理节点 {len(proxy_list)} 个") # 同一 PID 并发任务串行 (与 pixiv 共用缓存目录, 同名文件互写/互删防护): # 下载成功则持锁到 发图->留存->清缓存 完成; 失败立即释放让给下一候选/任务 pid_lock = pixiv.get_pid_lock(cur_pid) await pid_lock.acquire() try: for cand_url, cand_label in candidates: for proxy_url in proxy_list: try: local_path = await asyncio.to_thread( pixiv.download_image, cur_pid, cand_url, proxy_url) break except Exception as e: print(f"[setu] {cand_label}失败 ({proxy_url}): {e}") last_err = e if local_path: print(f"[setu] {cand_label}成功: PID {cur_pid}") break finally: if local_path: pid_lock_held = pid_lock else: pid_lock.release() if local_path: pid = cur_pid # 重抽成功: 同步重建手动链接指向实际 PID (原 image_url 仍指向首轮失败的 PID) image_url = f"https://openapi.rrrosmontis.icu/pixiv/get_image?id={pid}" break print(f"[setu] PID {cur_pid} 全候选源失败 ({str(last_err)[:60]})") if local_path is None: pid = final_pid print(f"[setu] 源站获取失败, 回退镜像站: {last_err}") # 镜像回退同样持 PID 锁 (与下载成功路径共用同一防护段) pid_lock_held = pixiv.get_pid_lock(pid) await pid_lock_held.acquire() try: # 镜像站优先绕行本地代理下载 (2026-09-02): 实测 bj->hk 直连跨境仅 ~8KB/s, # 大图必断流 (IncompleteRead); 经机场节点中转 ~200KB/s。全部失败后再试直连兜底。 mirror_url = f"https://openapi.rrrosmontis.icu/pixiv/get_image?id={pid}" if proxy_list: for proxy_url in proxy_list: try: local_path = await asyncio.to_thread( pixiv.download_image, pid, mirror_url, proxy_url) break except Exception as e: print(f"[setu] 镜像代理下载失败 ({proxy_url}): {e}") last_err = e if local_path is None: try: local_path = await asyncio.to_thread(pixiv.download_image, pid) except Exception as e: image_url = f"https://openapi.rrrosmontis.icu/pixiv/get_image?id={pid}" print(f"[setu] 镜像下载失败 pid={pid}: {e}") await send_message(websocket, message_type, target_id, f"图片获取失败: {str(e)[:100]}\n可尝试自行访问: {image_url}") return finally: if local_path is None: # 回退也失败: 释放锁 (成功则继续持有到 发图->留存->清缓存 完成) pid_lock_held.release() pid_lock_held = None # 发图->留存->清缓存 全段持 PID 锁, 防同 PID 并发任务在 sleep 窗口内互删文件 try: # 发图: oss 模式且 NapCat 与 oss 同机挂载时, 先留存到 oss 再引用本地路径发图 (不走 base64 内联); # 超过阈值则直接返回镜像站链接(不内嵌 base64,避免超限) use_oss_local_path = (RETAIN_MODE == "oss" and IS_NAPCAT_SERVER_OSS_AVAILABLE) oss_retained = False if use_oss_local_path: # 先留存: bj 侧 C:/oss (rclone 挂载) 与 cf 侧 /mnt/A (ossfs2 挂载) 为同一 OSS, # 复制完成后 NapCat 即可从 NAPCAT_SERVER_OSS_IMAGES_PATH 读到该文件 try: os.makedirs(OSS_SAVE_DIR, exist_ok=True) shutil.copyfile(str(local_path), os.path.join(OSS_SAVE_DIR, os.path.basename(str(local_path)))) print(f"[setu] 已留存 OSS: {os.path.basename(str(local_path))}") oss_retained = True except Exception as e: print(f"[setu] OSS 留存失败 pid={pid}: {e}") # TG 上限 50MB (send_document): 20~50MB 走文件通道; QQ 侧维持 20MB 改发链接 _is_tg = getattr(websocket, "name", "") == "tg" _size_limit = pixiv.TG_DOC_LIMIT if _is_tg else pixiv.SEND_AS_FILE_THRESHOLD if os.path.getsize(local_path) > _size_limit: await send_message(websocket, message_type, target_id, f"找到了这张图片喵: \n{image_url}\n\nStatTrak™计数器: {file_count}\n(图片过大请手动下载)") elif use_oss_local_path and oss_retained: # 注意: NapCat 只认 file: 前缀的本地路径 (checkUriType 源码), 裸绝对路径会报"识别URL失败"; # 用正斜杠拼接 (Windows os.path.join 会产出反斜杠, NapCat 在 Linux 上无法读取) oss_path = (Path(NAPCAT_SERVER_OSS_IMAGES_PATH) / os.path.basename(str(local_path))).as_uri() await asyncio.sleep(10) # 等 rclone 写缓存同步到 OSS (实测 4MB 约 6.3s), 确保 NapCat 能读到新文件 await send_message(websocket, message_type, target_id, [ {"type": "text", "data": {"text": "找到了这张图片喵: \n"}}, {"type": "image", "data": {"file": oss_path}}, {"type": "text", "data": {"text": f"\n\nStatTrak™计数器: {file_count}"}}, ]) else: img_b64 = await asyncio.to_thread(pixiv.image_base64, local_path) await send_message(websocket, message_type, target_id, [ {"type": "text", "data": {"text": "找到了这张图片喵: \n"}}, {"type": "image", "data": {"file": img_b64}}, {"type": "text", "data": {"text": f"\n\nStatTrak™计数器: {file_count}"}}, ]) # 留存 (oss 同机已留存则跳过; 其余走 oss 本机目录 / upload=cf), sleep 后清缓存 if not oss_retained: if RETAIN_MODE == "oss": try: os.makedirs(OSS_SAVE_DIR, exist_ok=True) shutil.copyfile(str(local_path), os.path.join(OSS_SAVE_DIR, os.path.basename(str(local_path)))) print(f"[setu] 已留存 OSS: {os.path.basename(str(local_path))}") except Exception as e: print(f"[setu] OSS 留存失败 pid={pid}: {e}") await send_message(websocket, message_type, target_id, f"图片已发送, 但 OSS 留存失败: {str(e)[:80]}") else: try: up_ok, up_msg = await asyncio.to_thread(pixiv.upload_to_cf, local_path) except Exception as e: up_ok, up_msg = False, str(e) if not up_ok: print(f"[setu] 上传失败 pid={pid}: {up_msg}") await send_message(websocket, message_type, target_id, f"图片已发送, 但存档上传失败: {up_msg[:80]}") await asyncio.sleep(30) # 等待 NapCat 读取本地缓存文件 (asyncio.sleep 让出事件循环, 不阻塞主线程) finally: try: if local_path is not None: # Includes send errors and task cancellation during either sleep. await asyncio.to_thread(pixiv.cleanup_cache, local_path) finally: if pid_lock_held is not None: pid_lock_held.release() async def handle_del_cache(websocket, message_type, target_id, user_id): """清理下载缓存 (pixiv/cache), 仅管理员可用""" from channel import send_message if not websocket.is_admin(user_id): await send_message(websocket, message_type, target_id, "该指令仅管理员可用") return await send_message(websocket, message_type, target_id, await asyncio.to_thread(clear_cache))