import re from asyncio import sleep from datetime import datetime, timedelta from nonebot import get_driver, on_message, on_startswith, require from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, PrivateMessageEvent from nonebot.exception import ActionFailed from nonebot.log import logger from nonebot.plugin import PluginMetadata from nonebot.rule import is_type, to_me require("nonebot_plugin_alconna") from nonebot_plugin_alconna import UniMessage from .config import ( CHAIN_REPLY, CHECK, DAILY_LIMIT, IGNORE_STAMP, SAUCENAO_KEY, SEARCH_TIMEOUT, threshold, ) from .image import check_screenshot, get_image_data_ascii, get_image_data_sauce # 统一配置标准:把「插件内部常量」也暴露给 Web 管理台(来源无关)。 # 说明:Web 读取/修改会写入 plugin_config.json 并热更新 config 模块属性; # 要在运行期立即生效,插件各使用点应改用 get_effective_value 读取(见 docs/plugin-config-standard.md §5b)。 from hexi.config_standard import register_config_items # noqa: E402 from . import config as _cfgmod # noqa: E402 register_config_items( __name__, [ {"key": "SAUCENAO_KEY", "label": "SauceNAO API Key", "type": "password", "secret": True}, {"key": "DAILY_LIMIT", "label": "每日搜图限额", "type": "int", "default": 50}, {"key": "SEARCH_TIMEOUT", "label": "批量搜索超时(秒)", "type": "int", "default": 60}, {"key": "CHECK", "label": "开启截屏判定", "type": "bool", "default": True}, {"key": "THUMB_ON", "label": "启用缩略图", "type": "bool", "default": True}, {"key": "CHAIN_REPLY", "label": "合并转发回复", "type": "bool", "default": True}, {"key": "IGNORE_STAMP", "label": "批量忽略表情包", "type": "bool", "default": True}, {"key": "threshold", "label": "相似度阈值", "type": "float", "default": 70}, ], store=_cfgmod, ) __plugin_meta__ = PluginMetadata( name="搜图", description="SauceNAO + ascii2d 二次元搜图", usage=( "(xx为bot名称)\n" "[@bot+图片] 单张/多张搜图\n" "[xx搜图] 进入批量搜图模式\n" "[谢谢xx] 退出批量搜图模式" ), type="application", ) driver_config = get_driver().config SUPERUSERS = driver_config.superusers NICKNAMES = list(driver_config.nickname) if driver_config.nickname else ["竹竹"] NICKNAME = NICKNAMES[0] class DailyNumberLimiter: """每日次数限制器(按年积日计数)""" def __init__(self, max_num): self.max = max_num self.today = -1 self.count = {} def check(self, key) -> bool: day = datetime.now().timetuple().tm_yday if day != self.today: self.today = day self.count.clear() return self.count.get(key, 0) < self.max def get_num(self, key): return self.count.get(key, 0) def increase(self, key, num=1): self.count[key] = self.count.get(key, 0) + num lmtd = DailyNumberLimiter(DAILY_LIMIT) class PicListener: def __init__(self): self.on = {} self.count = {} self.limit = {} self.timeout = {} def get_on_off_status(self, gid): return self.on[gid] if self.on.get(gid) is not None else False def turn_on(self, gid, uid): self.on[gid] = uid self.timeout[gid] = datetime.now() + timedelta(seconds=SEARCH_TIMEOUT) self.count[gid] = 0 self.limit[gid] = DAILY_LIMIT - lmtd.get_num(uid) def turn_off(self, gid): self.on.pop(gid) self.count.pop(gid) self.timeout.pop(gid) self.limit.pop(gid) def count_plus(self, gid): self.count[gid] += 1 pls = PicListener() start_finder = on_startswith(("识图", "搜图", "查图", "找图"), rule=to_me()) picmessage = on_message(rule=is_type(GroupMessageEvent), block=False) replymessage = on_message(rule=is_type(GroupMessageEvent), block=False) thanks = on_startswith("谢谢") pic_private = on_message(rule=is_type(PrivateMessageEvent), block=False) def parse_report(text: str) -> UniMessage: """将含 [CQ:image,file=...] 的搜索报告字符串转为 UniMessage""" msg = UniMessage() parts = re.split(r"(\[CQ:image,[^\]]*\])", text) for part in parts: if part.startswith("[CQ:image,"): m = re.search(r"file=([^,\]]+)", part) msg += UniMessage.image(url=m.group(1) if m else "") elif part: msg += UniMessage.text(part) return msg def _extract_image(ev) -> tuple[str, str, str | None] | None: """从消息中提取第一张图片的 (file, url, subType),无图片返回 None""" for m in ev.message: if m.type == "image": return m.data["file"], m.data["url"], m.data.get("subType") return None @start_finder.handle() async def start_finder_handle(bot: Bot, ev: GroupMessageEvent): uid = ev.user_id gid = ev.group_id mid = ev.message_id if str(uid) not in SUPERUSERS: await UniMessage.text("暂不支持在群聊中使用!").send() return ret = _extract_image(ev) if not ret: if pls.get_on_off_status(gid): if uid == pls.on[gid]: pls.timeout[gid] = datetime.now() + timedelta(seconds=30) await UniMessage.text( "您已经在搜图模式下啦!\n如想退出搜图模式请发送“谢谢竹竹”~" ).send() await start_finder.finish() else: await UniMessage.at(user_id=str(pls.on[gid])).text( "正在搜图,请耐心等待~" ).send() await start_finder.finish() pls.turn_on(gid, uid) await UniMessage.text( f"了解~请发送图片吧!支持批量噢!\n如想退出搜索模式请发送“谢谢{NICKNAME}”" ).send() await sleep(30) ct = 0 while pls.get_on_off_status(gid): if datetime.now() < pls.timeout[gid]: if ct != pls.count[gid]: ct = pls.count[gid] pls.timeout[gid] = datetime.now() + timedelta(seconds=60) else: temp = pls.on[gid] if not pls.count[gid]: await UniMessage.at(user_id=str(temp)).text( " 由于超时,已为您自动退出搜图模式~\n您本次搜索期间未发送任何图片,请检查是否被吞图~" ).send() else: await UniMessage.at(user_id=str(temp)).text( f" 由于超时,已为您自动退出搜图模式,以后要记得说“谢谢{NICKNAME}”来退出搜图模式噢~\n您本次搜索共搜索了{pls.count[gid]}张图片~" ).send() pls.turn_off(ev.group_id) break await sleep(30) return file, url, _ = ret if str(uid) not in SUPERUSERS: if not lmtd.check(uid): await UniMessage.text( f"您今天已经搜过{DAILY_LIMIT}次图了,休息一下明天再来吧~" ).send(at_sender=True) return if CHECK: result = await check_screenshot(bot, file, url) if result: if result == 1: await UniMessage.reply(id=str(mid)).text( "该图似乎是手机截屏,请进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~" ).send() if result == 2: await UniMessage.reply(id=str(mid)).text( "该图似乎是长图拼接,请进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~" ).send() return await UniMessage.text("正在搜索,请稍候~").send() await picfinder(bot, ev, url) @picmessage.handle() async def picmessage_handle(bot: Bot, ev: GroupMessageEvent): mid = ev.message_id atcheck = False batchcheck = False for m in ev.message: if m.type == "at" and int(m.data["qq"]) == ev.self_id: atcheck = True if pls.get_on_off_status(ev.group_id): if int(pls.on[ev.group_id]) == int(ev.user_id): batchcheck = True if not (batchcheck or atcheck): return uid = ev.user_id ret = _extract_image(ev) if not ret: return file, url, sbtype = ret if str(uid) not in SUPERUSERS: if not lmtd.check(uid): await UniMessage.text( f"您今天已经搜过{DAILY_LIMIT}次图了,休息一下明天再来吧~" ).send(at_sender=True) if pls.get_on_off_status(ev.group_id): pls.turn_off(ev.group_id) return if pls.get_on_off_status(ev.group_id): pls.count_plus(ev.group_id) if pls.count[ev.group_id] > pls.limit[ev.group_id]: await UniMessage.text( f"您今天已经搜过{DAILY_LIMIT}次图了,休息一下明天再来吧~" ).send(at_sender=True) pls.turn_off(ev.group_id) return if sbtype and IGNORE_STAMP: if sbtype != "0": await UniMessage.reply(id=str(mid)).text( "该图为表情,已忽略~如确需搜索请尝试单发搜索或回复搜索~" ).send() return if CHECK: result = await check_screenshot(bot, file, url) if result: if result == 1: await UniMessage.reply(id=str(mid)).text( "该图似乎是手机截屏,请手动进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~" ).send() if result == 2: await UniMessage.reply(id=str(mid)).text( "该图似乎是长图拼接,请手动进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~" ).send() return if "c2cpicdw.qpic.cn/offpic_new/" in url: md5 = file[:-6].upper() url = f"http://gchat.qpic.cn/gchatpic_new/0/0-0-{md5}/0?term=2" await UniMessage.text("正在搜索,请稍候~").send() await picfinder(bot, ev, url) @replymessage.handle() async def replymessage_handle(bot: Bot, ev: GroupMessageEvent): mid = ev.message_id uid = ev.user_id seg = ev.message[0] if seg.type != "reply": return tmid = seg.data["id"] cmd = ev.message.extract_plain_text() flag1 = 0 flag2 = 0 for m in ev.message[2:]: if m.type == "at" and int(m.data["qq"]) == ev.self_id: flag1 = 1 for name in NICKNAMES: if name in cmd: flag1 = 1 break for pfcmd in ["识图", "搜图", "查图", "找图"]: if pfcmd in cmd: flag2 = 1 if not (flag1 and flag2): return if str(uid) not in SUPERUSERS: if not lmtd.check(uid): await UniMessage.text( f"您今天已经搜过{DAILY_LIMIT}次图了,休息一下明天再来吧~" ).send(at_sender=True) try: tmsg = await bot.get_msg(message_id=int(tmid)) except ActionFailed: await UniMessage.text("该消息已过期,请重新转发~").send() await replymessage.finish() ret = re.search(r"\[CQ:image,file=(.*)?,url=(.*)\]", str(tmsg["message"])) if not ret: await UniMessage.text("未找到图片~").send() return file = ret.group(1) url = ret.group(2) if ",subType=" in url: url = url.split(",")[0] elif ",subType=" in file: file = file.split(",")[0] if CHECK: result = await check_screenshot(bot, file, url) if result: if result == 1: await UniMessage.reply(id=str(mid)).text( "该图似乎是手机截屏,请手动进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~" ).send() if result == 2: await UniMessage.reply(id=str(mid)).text( "该图似乎是长图拼接,请手动进行适当裁剪后再尝试搜图~\n*请注意搜索漫画时务必截取一个完整单页进行搜图~" ).send() return if "c2cpicdw.qpic.cn/offpic_new/" in url: md5 = file[:-6].upper() url = f"http://gchat.qpic.cn/gchatpic_new/0/0-0-{md5}/0?term=2" await UniMessage.text("正在搜索,请稍候~").send() await picfinder(bot, ev, url) @thanks.handle() async def thanks_handle(bot: Bot, ev: GroupMessageEvent): gid = ev.group_id name = ev.message.extract_plain_text().removeprefix("谢谢").strip() if name not in NICKNAMES: return if pls.get_on_off_status(gid): if pls.on[gid] != ev.user_id: await UniMessage.text("不能替别人结束搜图哦~").send() return if not pls.count[gid]: await UniMessage.text("不用谢~\n您本次搜索期间未发送任何图片,请检查是否被吞图~").send() else: await UniMessage.text(f"不用谢~\n您本次搜索共搜索了{pls.count[gid]}张图片~").send() pls.turn_off(gid) return await UniMessage.text("にゃ~").send() @pic_private.handle() async def pic_private_handle(bot: Bot, ev: PrivateMessageEvent): uid = ev.user_id ret = _extract_image(ev) if not ret: flag1 = flag2 = 0 for name in NICKNAMES: if name in str(ev.message): flag1 = 1 break for pfcmd in ["识图", "搜图", "查图", "找图"]: if pfcmd in str(ev.message): flag2 = 1 if flag1 and flag2: await UniMessage.text("私聊搜图请直接发送图片~").send() return file, url, _ = ret if not lmtd.check(uid): await UniMessage.text( f"您今天已经搜过{DAILY_LIMIT}次图了,休息一下明天再来吧~" ).send() return if "c2cpicdw.qpic.cn/offpic_new/" in url: md5 = file.upper() logger.info(f"图片URL{url}") url = f"https://gchat.qpic.cn/gchatpic_new/0/0-0-{md5}/0?term=2" await UniMessage.text("正在搜索,请稍候~").send() result = await get_image_data_sauce(url, SAUCENAO_KEY) image_data_report = result[0] simimax = result[1] if "Index #" in image_data_report: su = next(iter(SUPERUSERS)) await bot.send_private_msg(user_id=int(su), message="发生index解析错误") await bot.send_private_msg(user_id=int(su), message=url) await bot.send_private_msg(user_id=int(su), message=image_data_report) await parse_report(image_data_report).send() if float(simimax) > float(threshold): lmtd.increase(uid) else: if simimax != 0: await UniMessage.text("相似度过低,换用ascii2d检索中…").send() else: logger.error("SauceNao not found imageInfo") await UniMessage.text("SauceNao检索失败,换用ascii2d检索中…").send() image_data_report = await get_image_data_ascii(url) if image_data_report[0]: await parse_report(image_data_report[0]).send() lmtd.increase(uid) if image_data_report[1]: await parse_report(image_data_report[1]).send() if not (image_data_report[0] or image_data_report[1]): logger.error("ascii2d not found imageInfo") await UniMessage.text("ascii2d检索失败…").send() async def chain_reply(bot: Bot, ev: GroupMessageEvent, chain, msg): if not CHAIN_REPLY: await parse_report(msg).send() return chain data = { "type": "node", "data": { "name": str(NICKNAME) if str(NICKNAME) else "竹竹", "uin": str(ev.self_id), "content": str(msg), }, } chain.append(data) return chain async def picfinder(bot: Bot, ev: GroupMessageEvent, image_data): uid = ev.user_id chain = [] result = await get_image_data_sauce(image_data, SAUCENAO_KEY) image_data_report = result[0] simimax = result[1] if "Index #" in image_data_report: su = next(iter(SUPERUSERS)) await bot.send_private_msg(user_id=int(su), message="发生index解析错误") await bot.send_private_msg(user_id=int(su), message=image_data) await bot.send_private_msg(user_id=int(su), message=image_data_report) chain = await chain_reply(bot, ev, chain, image_data_report) if float(simimax) > float(threshold): lmtd.increase(uid) else: if simimax != 0: chain = await chain_reply(bot, ev, chain, "相似度过低,换用ascii2d检索中…") else: logger.error("SauceNao not found imageInfo") chain = await chain_reply(bot, ev, chain, "SauceNao检索失败,换用ascii2d检索中…") image_data_report = await get_image_data_ascii(image_data) if image_data_report[0]: chain = await chain_reply(bot, ev, chain, image_data_report[0]) lmtd.increase(uid) if image_data_report[1]: chain = await chain_reply(bot, ev, chain, image_data_report[1]) if not (image_data_report[0] or image_data_report[1]): logger.error("ascii2d not found imageInfo") chain = await chain_reply(bot, ev, chain, "ascii2d检索失败…") if CHAIN_REPLY: await bot.send_group_forward_msg(group_id=ev.group_id, messages=chain)