Files
RRRosmontis 34089d0042 Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
2026-11-11 00:25:30 +08:00

401 lines
19 KiB
Python

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))