866 lines
31 KiB
Python
866 lines
31 KiB
Python
import asyncio
|
||||
|
|
import base64
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
from io import BytesIO
|
|||
|
|
from random import randint
|
|||
|
|
from traceback import format_exc
|
|||
|
|
|
|||
|
|
import cloudscraper
|
|||
|
|
import httpx
|
|||
|
|
from PIL import Image
|
|||
|
|
from lxml import etree
|
|||
|
|
from nonebot.log import logger
|
|||
|
|
from playwright.async_api import async_playwright
|
|||
|
|
|
|||
|
|
from .config import (
|
|||
|
|
ASCII2D_COOKIES_FILE,
|
|||
|
|
ASCII_RESULT_NUM,
|
|||
|
|
HOST_CUSTOM,
|
|||
|
|
SAUCENAO_RESULT_NUM,
|
|||
|
|
THUMB_ON,
|
|||
|
|
proxies,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def pic2b64(pic) -> str:
|
|||
|
|
"""PIL Image 转 base64:// 字符串"""
|
|||
|
|
buf = BytesIO()
|
|||
|
|
pic.save(buf, format="PNG")
|
|||
|
|
return "base64://" + base64.b64encode(buf.getvalue()).decode()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _img_cq(base64_str: str) -> str:
|
|||
|
|
"""生成图片 CQ 码字符串(用于嵌入搜索报告文本)"""
|
|||
|
|
return f"[CQ:image,file={base64_str}]"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_client(timeout: float) -> httpx.AsyncClient:
|
|||
|
|
"""构造 httpx 客户端(httpx 0.28 移除了 AsyncClient 的 proxies 参数,转为 mounts)"""
|
|||
|
|
http_proxy = proxies.get("http")
|
|||
|
|
https_proxy = proxies.get("https")
|
|||
|
|
if http_proxy or https_proxy:
|
|||
|
|
mounts = {}
|
|||
|
|
if http_proxy:
|
|||
|
|
mounts["http://"] = httpx.HTTPTransport(proxy=http_proxy)
|
|||
|
|
if https_proxy:
|
|||
|
|
mounts["https://"] = httpx.HTTPTransport(proxy=https_proxy)
|
|||
|
|
return httpx.AsyncClient(mounts=mounts, timeout=timeout)
|
|||
|
|
return httpx.AsyncClient(timeout=timeout)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def get_pic(address):
|
|||
|
|
async with _make_client(20) as client:
|
|||
|
|
resp = await client.get(address)
|
|||
|
|
return resp.content
|
|||
|
|
|
|||
|
|
|
|||
|
|
def randcolor():
|
|||
|
|
return (randint(0, 255), randint(0, 255), randint(0, 255))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def ats_pic(img):
|
|||
|
|
if img.mode != "RGB":
|
|||
|
|
img = img.convert("RGB")
|
|||
|
|
width = img.size[0] - 1 # 长度
|
|||
|
|
height = img.size[1] - 1 # 宽度
|
|||
|
|
img.putpixel((0, 0), randcolor())
|
|||
|
|
img.putpixel((0, height), randcolor())
|
|||
|
|
img.putpixel((width, 0), randcolor())
|
|||
|
|
img.putpixel((width, height), randcolor())
|
|||
|
|
return img
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def check_screenshot(bot, file, imgurl):
|
|||
|
|
async with _make_client(20) as client:
|
|||
|
|
pichead = await client.head(imgurl)
|
|||
|
|
if pichead.headers["Content-Type"] == "image/gif":
|
|||
|
|
print("gif pic, not likely a screen shot")
|
|||
|
|
return 0
|
|||
|
|
try:
|
|||
|
|
resp = await client.get(imgurl)
|
|||
|
|
image = Image.open(BytesIO(resp.content))
|
|||
|
|
except Exception:
|
|||
|
|
print("download failed")
|
|||
|
|
return 0
|
|||
|
|
cord = image.size[0] / image.size[1]
|
|||
|
|
height = image.size[1]
|
|||
|
|
print(cord)
|
|||
|
|
if cord > 0.565:
|
|||
|
|
print("too short, not likely a screen shot")
|
|||
|
|
return 0
|
|||
|
|
if cord < 0.2:
|
|||
|
|
print("too long, might be long screen shot")
|
|||
|
|
return 2
|
|||
|
|
print("size checked, next ocr")
|
|||
|
|
try:
|
|||
|
|
ocr_result = await bot.call_api(".ocr_image", image=file)
|
|||
|
|
except Exception:
|
|||
|
|
print("ocr failed")
|
|||
|
|
return False
|
|||
|
|
flag = 0
|
|||
|
|
for result in ocr_result["texts"]:
|
|||
|
|
key1 = re.search("[0-9]{1,2}:[0-9]{2}", result["text"]) # 时间
|
|||
|
|
key2 = re.search("移动|联通|电信", result["text"])
|
|||
|
|
key3 = re.search("4G|5G", result["text"])
|
|||
|
|
key4 = re.search("[0-9]{1,2}%", result["text"]) # 电量
|
|||
|
|
key5 = re.search("[0-9]{0,3}[\\\/][0-9]{0,3}", result["text"]) # 页数
|
|||
|
|
if key2 or key3 or key4:
|
|||
|
|
print(str(result))
|
|||
|
|
loc = result["coordinates"][2]["y"]
|
|||
|
|
if int(loc) < (int(height) / 19):
|
|||
|
|
flag = 1
|
|||
|
|
if key1 or key5:
|
|||
|
|
print(str(result))
|
|||
|
|
loc = result["coordinates"][2]["y"]
|
|||
|
|
if int(loc) < (int(height) / 19) or int(loc) > (int(height) * 18 / 20):
|
|||
|
|
flag = 1
|
|||
|
|
if flag:
|
|||
|
|
break
|
|||
|
|
if flag:
|
|||
|
|
return 1
|
|||
|
|
else:
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sauces_info(sauce):
|
|||
|
|
service_name = ""
|
|||
|
|
info = ""
|
|||
|
|
try:
|
|||
|
|
if sauce["header"]["index_id"] == 0:
|
|||
|
|
service_name = "H-Magazines"
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
part = sauce["data"]["part"]
|
|||
|
|
date = sauce["data"]["date"]
|
|||
|
|
info = f"{title}-{part}/{date}"
|
|||
|
|
|
|||
|
|
# index 1 "h-anime" disabled
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 2:
|
|||
|
|
service_name = "H-Game CG"
|
|||
|
|
company = sauce["data"]["company"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"[{company}] {title}"
|
|||
|
|
|
|||
|
|
# index 3 "ddb-objects" disabled
|
|||
|
|
|
|||
|
|
# index 4 "ddb-samples" disabled
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 5 or sauce["header"]["index_id"] == 6:
|
|||
|
|
service_name = "pixiv"
|
|||
|
|
author_name = sauce["data"]["member_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
# index 6 "pixiv historical" with 5
|
|||
|
|
|
|||
|
|
# index 7 "anime" disabled
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 8:
|
|||
|
|
service_name = "nico nico seiga"
|
|||
|
|
author_name = sauce["data"]["member_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 9:
|
|||
|
|
service_name = "Danbooru"
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
material = sauce["data"]["material"]
|
|||
|
|
info = f"[{creator}]({material})"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 10:
|
|||
|
|
service_name = "drawr Images"
|
|||
|
|
author_name = sauce["data"]["member_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 11:
|
|||
|
|
service_name = "Nijie Images"
|
|||
|
|
author_name = sauce["data"]["member_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 12:
|
|||
|
|
service_name = "Yande.re"
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
material = sauce["data"]["material"]
|
|||
|
|
info = f"[{creator}]({material})"
|
|||
|
|
|
|||
|
|
# index 13 "animeop" disabled
|
|||
|
|
|
|||
|
|
# index 14 "IMDb" disabled
|
|||
|
|
|
|||
|
|
# index 15 "Shutterstock" disabled
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 16:
|
|||
|
|
service_name = "FAKKU"
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
source = sauce["data"]["source"]
|
|||
|
|
info = f"[{creator}]({source})"
|
|||
|
|
|
|||
|
|
# index 17 reserved
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 18 or sauce["header"]["index_id"] == 38:
|
|||
|
|
service_name = "H-Misc (ehentai)"
|
|||
|
|
eng_name = sauce["data"]["eng_name"]
|
|||
|
|
jp_name = sauce["data"]["jp_name"]
|
|||
|
|
info = f"{jp_name}" if jp_name else f"{eng_name}"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 19:
|
|||
|
|
service_name = "2D-Market"
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
source = sauce["data"]["source"]
|
|||
|
|
info = f"[{creator}]({source})"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 20:
|
|||
|
|
service_name = "MediBang"
|
|||
|
|
member_name = sauce["data"]["member_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{member_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 21:
|
|||
|
|
service_name = "Anime"
|
|||
|
|
title = sauce["data"]["source"]
|
|||
|
|
year = sauce["data"]["year"]
|
|||
|
|
part = sauce["data"]["part"]
|
|||
|
|
est_time = sauce["data"]["est_time"]
|
|||
|
|
time = est_time.split("/")[0]
|
|||
|
|
info = f"《{title}》/{year}\n第{part}集,{time}"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 22:
|
|||
|
|
service_name = "H-Anime"
|
|||
|
|
title = sauce["data"]["source"]
|
|||
|
|
year = sauce["data"]["year"]
|
|||
|
|
part = sauce["data"]["part"]
|
|||
|
|
est_time = sauce["data"]["est_time"]
|
|||
|
|
time = est_time.split("/")[0]
|
|||
|
|
info = f"《{title}》/{year}\n第{part}集,{time}"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 23:
|
|||
|
|
service_name = "IMDb-Movies"
|
|||
|
|
title = sauce["data"]["source"]
|
|||
|
|
year = sauce["data"]["year"]
|
|||
|
|
est_time = sauce["data"]["est_time"]
|
|||
|
|
time = est_time.split("/")[0]
|
|||
|
|
info = f"《{title}》/{year},{time}"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 24:
|
|||
|
|
service_name = "IMDb-Shows"
|
|||
|
|
title = sauce["data"]["source"]
|
|||
|
|
year = sauce["data"]["year"]
|
|||
|
|
part = sauce["data"]["part"]
|
|||
|
|
est_time = sauce["data"]["est_time"]
|
|||
|
|
time = est_time.split("/")[0]
|
|||
|
|
info = f"《{title}》/{year}\n第{part}集,{time}"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 25:
|
|||
|
|
service_name = "Gelbooru"
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
material = sauce["data"]["material"]
|
|||
|
|
info = f"[{creator}]({material})"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 26:
|
|||
|
|
service_name = "Konachan"
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
material = sauce["data"]["material"]
|
|||
|
|
info = f"[{creator}]({material})"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 27:
|
|||
|
|
service_name = "Sankaku Channel"
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
material = sauce["data"]["material"]
|
|||
|
|
info = f"[{creator}]({material})"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 28:
|
|||
|
|
service_name = "Anime-Pictures.net"
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
material = sauce["data"]["material"]
|
|||
|
|
info = f"[{creator}]({material})"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 29:
|
|||
|
|
service_name = "e621.net"
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
material = sauce["data"]["material"]
|
|||
|
|
info = f"[{creator}]({material})"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 30:
|
|||
|
|
service_name = "Idol Complex"
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
material = sauce["data"]["material"]
|
|||
|
|
info = f"[{creator}]({material})"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 31:
|
|||
|
|
service_name = "bcy.net Illust"
|
|||
|
|
author_name = sauce["data"]["member_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 32:
|
|||
|
|
service_name = "bcy.net Cosplay"
|
|||
|
|
author_name = sauce["data"]["member_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 33:
|
|||
|
|
service_name = "PortalGraphics.net"
|
|||
|
|
member_name = sauce["data"]["member_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{member_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 34:
|
|||
|
|
service_name = "deviantArt"
|
|||
|
|
author_name = sauce["data"]["author_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 35:
|
|||
|
|
service_name = "Pawoo.net"
|
|||
|
|
illust_id = sauce["data"]["pawoo_id"]
|
|||
|
|
author_name = sauce["data"]["pawoo_user_display_name"]
|
|||
|
|
info = f"「{illust_id}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 36:
|
|||
|
|
service_name = "Madokami (Manga)"
|
|||
|
|
source = sauce["data"]["source"]
|
|||
|
|
part = sauce["data"]["part"]
|
|||
|
|
info = part if source in part else f"{source}-{part}"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 37 or sauce["header"]["index_id"] == 371:
|
|||
|
|
service_name = "MangaDex"
|
|||
|
|
artist = sauce["data"]["artist"]
|
|||
|
|
author = sauce["data"]["author"]
|
|||
|
|
source = sauce["data"]["source"]
|
|||
|
|
part = sauce["data"]["part"]
|
|||
|
|
info_a = f"[{artist}]" if artist == author else f"[{artist}({author})]"
|
|||
|
|
info_b = part if source in part else f"{source}-{part}"
|
|||
|
|
info = info_a + info_b
|
|||
|
|
|
|||
|
|
# index 38 "H-Misc (ehentai)" with 18
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 39:
|
|||
|
|
service_name = "Artstation"
|
|||
|
|
author_name = sauce["data"]["author_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 40:
|
|||
|
|
service_name = "FurAffinity"
|
|||
|
|
author_name = sauce["data"]["author_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 41:
|
|||
|
|
service_name = "Twitter"
|
|||
|
|
author_name = sauce["data"]["twitter_user_handle"]
|
|||
|
|
time = sauce["data"]["created_at"]
|
|||
|
|
info = f"「{time[0:10]}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 42:
|
|||
|
|
service_name = "Furry Network"
|
|||
|
|
author_name = sauce["data"]["author_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「{author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 43:
|
|||
|
|
service_name = "Kemono"
|
|||
|
|
service = sauce["data"]["service_name"]
|
|||
|
|
author_name = sauce["data"]["user_name"]
|
|||
|
|
title = sauce["data"]["title"]
|
|||
|
|
info = f"「{title}」/「({service}){author_name}」"
|
|||
|
|
|
|||
|
|
elif sauce["header"]["index_id"] == 44:
|
|||
|
|
service_name = "Skeb"
|
|||
|
|
creator_name = sauce["data"]["creator_name"]
|
|||
|
|
creator = sauce["data"]["creator"]
|
|||
|
|
info = f"[{creator_name}]({creator})"
|
|||
|
|
|
|||
|
|
else:
|
|||
|
|
index = sauce["header"]["index_id"]
|
|||
|
|
service_name = f"Index #{index}"
|
|||
|
|
info = "no info"
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
index = sauce["header"]["index_id"]
|
|||
|
|
service_name = f"Index #{index}"
|
|||
|
|
info = "no info"
|
|||
|
|
print(format_exc())
|
|||
|
|
|
|||
|
|
return service_name, info
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SauceNAO:
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
api_key,
|
|||
|
|
output_type=2,
|
|||
|
|
testmode=0,
|
|||
|
|
dbmask=None,
|
|||
|
|
dbmaski=None,
|
|||
|
|
db=999,
|
|||
|
|
numres=3,
|
|||
|
|
shortlimit=20,
|
|||
|
|
longlimit=300,
|
|||
|
|
):
|
|||
|
|
params = dict()
|
|||
|
|
params["api_key"] = api_key
|
|||
|
|
params["output_type"] = output_type
|
|||
|
|
params["testmode"] = testmode
|
|||
|
|
params["dbmask"] = dbmask
|
|||
|
|
params["dbmaski"] = dbmaski
|
|||
|
|
params["db"] = db
|
|||
|
|
params["numres"] = numres
|
|||
|
|
self.params = params
|
|||
|
|
self.host = HOST_CUSTOM["SAUCENAO"] or "https://saucenao.com"
|
|||
|
|
self.header = "————>saucenao<————"
|
|||
|
|
|
|||
|
|
async def get_sauce(self, image_url):
|
|||
|
|
logger.debug(f"Now starting get the SauceNAO data:{image_url}")
|
|||
|
|
# 过滤 None 参数(SauceNAO 文件上传遇 dbmask=None 编码会返回 500)
|
|||
|
|
params = {k: v for k, v in self.params.items() if v is not None}
|
|||
|
|
# 优先本地下载图片后以文件形式提交,避免 SauceNAO 服务器抓取 QQ 图床失败
|
|||
|
|
try:
|
|||
|
|
async with _make_client(20) as client:
|
|||
|
|
resp = await client.get(image_url)
|
|||
|
|
resp.raise_for_status()
|
|||
|
|
image_bytes = resp.content
|
|||
|
|
submit_ok = True
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"图片本地下载失败({e}), 退回 URL 提交")
|
|||
|
|
submit_ok = False
|
|||
|
|
|
|||
|
|
if submit_ok:
|
|||
|
|
params.pop("url", None)
|
|||
|
|
async with _make_client(30) as client:
|
|||
|
|
response = await client.post(
|
|||
|
|
f"{self.host}/search.php",
|
|||
|
|
params=params,
|
|||
|
|
files={"file": ("search_image.jpg", image_bytes, "image/jpeg")},
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
params["url"] = image_url
|
|||
|
|
async with _make_client(15) as client:
|
|||
|
|
response = await client.get(f"{self.host}/search.php", params=params)
|
|||
|
|
|
|||
|
|
if response.status_code != 200:
|
|||
|
|
raise RuntimeError(
|
|||
|
|
f"SauceNAO 请求失败: HTTP {response.status_code}, 响应: {response.text[:100]!r}"
|
|||
|
|
)
|
|||
|
|
return response.json()
|
|||
|
|
|
|||
|
|
async def get_view(self, sauce) -> str:
|
|||
|
|
sauces = await self.get_sauce(sauce)
|
|||
|
|
repass = ""
|
|||
|
|
simimax = 0
|
|||
|
|
index = 1
|
|||
|
|
|
|||
|
|
for sauce in sauces["results"]:
|
|||
|
|
try:
|
|||
|
|
url = (
|
|||
|
|
sauce["data"]["ext_urls"][0].replace("\\", "").strip()
|
|||
|
|
if "ext_urls" in sauce["data"]
|
|||
|
|
else "no link"
|
|||
|
|
)
|
|||
|
|
similarity = sauce["header"]["similarity"]
|
|||
|
|
if not similarity.replace(".", "").isdigit():
|
|||
|
|
similarity = 0
|
|||
|
|
simimax = float(similarity) if float(similarity) > simimax else simimax
|
|||
|
|
thumbnail_url = sauce["header"]["thumbnail"]
|
|||
|
|
if THUMB_ON:
|
|||
|
|
try:
|
|||
|
|
thumbnail_image = _img_cq(
|
|||
|
|
pic2b64(
|
|||
|
|
ats_pic(
|
|||
|
|
Image.open(BytesIO(await get_pic(thumbnail_url)))
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(format_exc())
|
|||
|
|
thumbnail_image = "[预览图下载失败]"
|
|||
|
|
else:
|
|||
|
|
thumbnail_image = ""
|
|||
|
|
|
|||
|
|
service_name, info = sauces_info(sauce)
|
|||
|
|
|
|||
|
|
putline = (
|
|||
|
|
f"搜索结果{index}:\n\n{thumbnail_image}\n\n平台:\n\n{service_name}:\n\n"
|
|||
|
|
f"相似度:{similarity}%\n\n信息:\n\n{info}\n\n相关:\n\n{url}"
|
|||
|
|
)
|
|||
|
|
if repass:
|
|||
|
|
repass = "\n\n\n".join([repass, putline])
|
|||
|
|
else:
|
|||
|
|
repass = putline
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
print(format_exc())
|
|||
|
|
pass
|
|||
|
|
index += 1
|
|||
|
|
return [repass, simimax]
|
|||
|
|
|
|||
|
|
|
|||
|
|
headers = {
|
|||
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
|
|||
|
|
"Accept-Encoding": "gzip, deflate",
|
|||
|
|
"Accept-Language": "zh-CN,zh;q=0.9",
|
|||
|
|
"Cache-Control": "max-age=0",
|
|||
|
|
"Connection": "keep-alive",
|
|||
|
|
"Origin": "https://ascii2d.net",
|
|||
|
|
"Referer": "https://ascii2d.net/",
|
|||
|
|
"Sec-Fetch-Dest": "document",
|
|||
|
|
"Sec-Fetch-Mode": "navigate",
|
|||
|
|
"Sec-Fetch-Site": "same-origin",
|
|||
|
|
"Sec-Fetch-User": "?1",
|
|||
|
|
"Upgrade-Insecure-Requests": "1",
|
|||
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ascii2d:
|
|||
|
|
def __init__(self, num=2):
|
|||
|
|
self.num = num
|
|||
|
|
self.host = HOST_CUSTOM["ASCII"] or "https://ascii2d.net"
|
|||
|
|
self.header = "————>ascii2d<————"
|
|||
|
|
self.scraper = cloudscraper.create_scraper()
|
|||
|
|
|
|||
|
|
async def get_search_data(self, url: str, data=None):
|
|||
|
|
if data is not None:
|
|||
|
|
html = data
|
|||
|
|
else:
|
|||
|
|
html = await get_html_ascii2d(url)
|
|||
|
|
|
|||
|
|
all_data = html.xpath('//div[@class="row item-box"]')
|
|||
|
|
info = []
|
|||
|
|
for data in all_data[1 : self.num + 1]:
|
|||
|
|
try:
|
|||
|
|
title = ""
|
|||
|
|
member = ""
|
|||
|
|
if not data.xpath('.//img[@loading="lazy"]/@src'):
|
|||
|
|
continue
|
|||
|
|
thumb_url = data.xpath('.//img[@loading="lazy"]/@src')[0].strip()
|
|||
|
|
thumb_url = f"{self.host}{thumb_url}"
|
|||
|
|
|
|||
|
|
if not data.xpath('.//div[@class="detail-box gray-link"]/h6'):
|
|||
|
|
data2 = (
|
|||
|
|
data.xpath('.//div[@class="external"]')[0]
|
|||
|
|
if data.xpath('.//div[@class="external"]')
|
|||
|
|
else data
|
|||
|
|
)
|
|||
|
|
info_url = (
|
|||
|
|
data2.xpath(".//a/@href")[0].strip()
|
|||
|
|
if data2.xpath(".//a/@rel")
|
|||
|
|
else "no link"
|
|||
|
|
)
|
|||
|
|
tag = "外部登录" if info_url == "no link" else info_url.split("/")[2]
|
|||
|
|
else:
|
|||
|
|
data2 = data.xpath('.//div[@class="detail-box gray-link"]/h6')[0]
|
|||
|
|
info_url = data2.xpath(".//a/@href")[0].strip()
|
|||
|
|
tag = (
|
|||
|
|
data2.xpath("./small/text()") or data2.xpath(".//a/text()")
|
|||
|
|
)[0].strip()
|
|||
|
|
|
|||
|
|
if tag == "pixiv" or tag == "twitter":
|
|||
|
|
title = data2.xpath(".//a//text()")[0]
|
|||
|
|
member = data2.xpath(".//a//text()")[1]
|
|||
|
|
title = f"「{title}」/「{member}」"
|
|||
|
|
elif tag == "外部登录":
|
|||
|
|
title = data2.text.replace("\n", "") if data2.text else ""
|
|||
|
|
else:
|
|||
|
|
title = data2.text.replace("\n", "")
|
|||
|
|
title = f"「{title}」"
|
|||
|
|
|
|||
|
|
info.append([info_url, tag, thumb_url, title])
|
|||
|
|
except Exception as e:
|
|||
|
|
print(format_exc())
|
|||
|
|
logger.error(e)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
return info
|
|||
|
|
|
|||
|
|
async def add_repass(self, tag: str, data, thumbnails: dict | None = None):
|
|||
|
|
po = "——{}——".format(tag)
|
|||
|
|
index = 1
|
|||
|
|
for line in data:
|
|||
|
|
if THUMB_ON:
|
|||
|
|
try:
|
|||
|
|
thumb_content = (thumbnails or {}).get(line[2])
|
|||
|
|
if thumb_content is None:
|
|||
|
|
thumb_content = self.scraper.get(
|
|||
|
|
line[2], timeout=20, proxies=proxies
|
|||
|
|
).content
|
|||
|
|
thumbnail_image = _img_cq(
|
|||
|
|
pic2b64(ats_pic(Image.open(BytesIO(thumb_content))))
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(format_exc())
|
|||
|
|
thumbnail_image = "[预览图下载失败]"
|
|||
|
|
else:
|
|||
|
|
thumbnail_image = ""
|
|||
|
|
putline = (
|
|||
|
|
f"搜索结果{index}:\n\n{thumbnail_image}\n\n平台:\n\n{line[1]}\n\n"
|
|||
|
|
f"信息:\n\n{line[3]}\n\n相关:\n\n{line[0]}"
|
|||
|
|
)
|
|||
|
|
po = "\n\n\n".join([po, putline])
|
|||
|
|
index += 1
|
|||
|
|
|
|||
|
|
return po
|
|||
|
|
|
|||
|
|
async def get_view(self, ascii2d) -> str:
|
|||
|
|
putline1 = ""
|
|||
|
|
putline2 = ""
|
|||
|
|
url_index = f"{self.host}/search/url/{ascii2d}"
|
|||
|
|
logger.debug(f"Now starting get the {url_index}")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
html_index = await get_html_ascii2d(url_index)
|
|||
|
|
if html_index is None:
|
|||
|
|
logger.error("ascii2d 页面获取失败(Cloudflare challenge 未通过)")
|
|||
|
|
return [putline1, putline2]
|
|||
|
|
except Exception as e:
|
|||
|
|
print(format_exc())
|
|||
|
|
logger.error(f"ascii2d get html data failed: {e}")
|
|||
|
|
return [putline1, putline2]
|
|||
|
|
|
|||
|
|
neet_div = html_index.xpath(
|
|||
|
|
'//div[@class="detail-link pull-xs-right hidden-sm-down gray-link"]'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if neet_div:
|
|||
|
|
a_url_foot = neet_div[0].xpath("./span/a/@href")
|
|||
|
|
url2 = f"{self.host}{a_url_foot[1]}"
|
|||
|
|
|
|||
|
|
color = await self.get_search_data("", data=html_index)
|
|||
|
|
bovw = await self.get_search_data(url2)
|
|||
|
|
|
|||
|
|
# 用系统浏览器会话批量下载缩略图(带 CF cookie,避免被 Cloudflare 拦截)
|
|||
|
|
thumb_urls = [line[2] for line in color + bovw]
|
|||
|
|
thumbnails = await fetch_thumbs_playwright(thumb_urls)
|
|||
|
|
|
|||
|
|
if color:
|
|||
|
|
putline1 = await self.add_repass("色调检索", color, thumbnails)
|
|||
|
|
|
|||
|
|
if bovw:
|
|||
|
|
putline2 = await self.add_repass("特征检索", bovw, thumbnails)
|
|||
|
|
|
|||
|
|
return [putline1, putline2]
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def get_image_data_sauce(image_url: str, api_key: str):
|
|||
|
|
if type(image_url) == list:
|
|||
|
|
image_url = image_url[0]
|
|||
|
|
|
|||
|
|
logger.info("Loading Image Search Container……")
|
|||
|
|
NAO = SauceNAO(api_key, numres=SAUCENAO_RESULT_NUM)
|
|||
|
|
|
|||
|
|
logger.debug("Loading all view……")
|
|||
|
|
repass = ""
|
|||
|
|
simimax = 0
|
|||
|
|
# 网络抖动/免费key限流时重试,退避间隔同时规避 SauceNAO 的 4 秒限流
|
|||
|
|
for attempt in range(3):
|
|||
|
|
try:
|
|||
|
|
result = await NAO.get_view(image_url)
|
|||
|
|
if result:
|
|||
|
|
header = NAO.header
|
|||
|
|
simimax = result[1]
|
|||
|
|
repass = "\n".join([header, result[0]])
|
|||
|
|
break
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"SauceNAO 第{attempt + 1}次尝试失败: {e}")
|
|||
|
|
if attempt < 2:
|
|||
|
|
await asyncio.sleep(2 * (attempt + 1))
|
|||
|
|
else:
|
|||
|
|
return ["SauceNAO搜索失败……", 0]
|
|||
|
|
|
|||
|
|
return [repass, simimax]
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def get_image_data_ascii(image_url: str):
|
|||
|
|
if type(image_url) == list:
|
|||
|
|
image_url = image_url[0]
|
|||
|
|
|
|||
|
|
logger.info("Loading Image Search Container……")
|
|||
|
|
ii2d = ascii2d(ASCII_RESULT_NUM)
|
|||
|
|
|
|||
|
|
logger.debug("Loading all view……")
|
|||
|
|
repass1 = ""
|
|||
|
|
repass2 = ""
|
|||
|
|
try:
|
|||
|
|
putline = await ii2d.get_view(image_url)
|
|||
|
|
if putline:
|
|||
|
|
header = ii2d.header
|
|||
|
|
if putline[0]:
|
|||
|
|
repass1 = "\n".join([header, putline[0]])
|
|||
|
|
if putline[1]:
|
|||
|
|
repass2 = "\n".join([header, putline[1]])
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(format_exc())
|
|||
|
|
return ["ascii2d搜索失败……", ""]
|
|||
|
|
|
|||
|
|
return [repass1, repass2]
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 系统浏览器可执行文件(与获取 cookie 的浏览器同款 TLS 指纹)
|
|||
|
|
CHROME_PATHS = [
|
|||
|
|
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
|||
|
|
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|||
|
|
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
|
|||
|
|
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
# 专用浏览器 profile 目录(cf_clearance 持久化,首次过验证后长期有效)
|
|||
|
|
CHROME_PROFILE_DIR = os.path.join(
|
|||
|
|
os.path.dirname(os.path.abspath(__file__)), "data", "chrome_profile"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _parse_netscape_cookies(file_path: str) -> list[dict]:
|
|||
|
|
"""解析 Netscape 格式 cookies 文件(与 nonebot_plugin_video_analysis 同款)"""
|
|||
|
|
cookies = []
|
|||
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|||
|
|
for line in f:
|
|||
|
|
line = line.strip()
|
|||
|
|
if not line or line.startswith("#"):
|
|||
|
|
continue
|
|||
|
|
parts = line.split("\t")
|
|||
|
|
if len(parts) != 7:
|
|||
|
|
continue
|
|||
|
|
domain, flag, path, secure, expiry, name, value = parts
|
|||
|
|
cookie = {
|
|||
|
|
"name": name,
|
|||
|
|
"value": value,
|
|||
|
|
"domain": domain,
|
|||
|
|
"path": path,
|
|||
|
|
"secure": secure.upper() == "TRUE",
|
|||
|
|
"sameSite": "Lax",
|
|||
|
|
}
|
|||
|
|
if expiry.isdigit() and int(expiry) > 0:
|
|||
|
|
cookie["expires"] = int(expiry)
|
|||
|
|
cookies.append(cookie)
|
|||
|
|
return cookies
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _load_ascii2d_cookies():
|
|||
|
|
"""读取配置的 ascii2d cookie 文件,未配置或不存在返回 None"""
|
|||
|
|
if not ASCII2D_COOKIES_FILE or not os.path.isfile(ASCII2D_COOKIES_FILE):
|
|||
|
|
return None
|
|||
|
|
return _parse_netscape_cookies(ASCII2D_COOKIES_FILE)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _save_netscape_cookies(cookies: list[dict], file_path: str):
|
|||
|
|
"""把浏览器回传的 cookies 写回 Netscape 格式文件(实现 cf_clearance 自愈)"""
|
|||
|
|
lines = ["# Netscape HTTP Cookie File"]
|
|||
|
|
for c in cookies:
|
|||
|
|
domain = c.get("domain", "")
|
|||
|
|
include_subdomains = "TRUE" if domain.startswith(".") else "FALSE"
|
|||
|
|
path = c.get("path", "/")
|
|||
|
|
secure = "TRUE" if c.get("secure") else "FALSE"
|
|||
|
|
try:
|
|||
|
|
expires = int(c.get("expires") or 0)
|
|||
|
|
except (TypeError, ValueError):
|
|||
|
|
expires = 0
|
|||
|
|
name = c.get("name", "")
|
|||
|
|
value = c.get("value", "")
|
|||
|
|
lines.append(
|
|||
|
|
f"{domain}\t{include_subdomains}\t{path}\t{secure}\t{expires}\t{name}\t{value}"
|
|||
|
|
)
|
|||
|
|
with open(file_path, "w", encoding="utf-8") as f:
|
|||
|
|
f.write("\n".join(lines))
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def get_html_ascii2d(url):
|
|||
|
|
"""获取 ascii2d 页面 HTML:配置了 cookie 文件时注入系统浏览器直过验证"""
|
|||
|
|
return await get_html_content(url, cookies=_load_ascii2d_cookies())
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def fetch_thumbs_playwright(urls: list[str]) -> dict:
|
|||
|
|
"""用系统浏览器会话批量下载缩略图(带 CF cookie,避免 Cloudflare 拦截)"""
|
|||
|
|
if not urls:
|
|||
|
|
return {}
|
|||
|
|
result = {}
|
|||
|
|
async with async_playwright() as p:
|
|||
|
|
launch_kwargs = {
|
|||
|
|
"headless": False,
|
|||
|
|
"args": [
|
|||
|
|
"--window-position=-32000,-32000",
|
|||
|
|
"--disable-blink-features=AutomationControlled",
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
for path in CHROME_PATHS:
|
|||
|
|
if os.path.isfile(path):
|
|||
|
|
launch_kwargs["executable_path"] = path
|
|||
|
|
break
|
|||
|
|
context = await p.chromium.launch_persistent_context(
|
|||
|
|
CHROME_PROFILE_DIR, **launch_kwargs
|
|||
|
|
)
|
|||
|
|
cookies = _load_ascii2d_cookies()
|
|||
|
|
if cookies:
|
|||
|
|
await context.add_cookies(cookies)
|
|||
|
|
try:
|
|||
|
|
for u in urls:
|
|||
|
|
try:
|
|||
|
|
resp = await context.request.get(u, timeout=20000)
|
|||
|
|
if resp.status == 200 and "image" in resp.headers.get(
|
|||
|
|
"content-type", ""
|
|||
|
|
):
|
|||
|
|
result[u] = await resp.body()
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.debug(f"缩略图下载失败 {u}: {e}")
|
|||
|
|
finally:
|
|||
|
|
await context.close()
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def get_html_content(url, cookies=None):
|
|||
|
|
async with async_playwright() as p:
|
|||
|
|
# 用系统 Chrome + 固定 profile 目录(cf_clearance 持久化,与浏览器获取 cookie 时同款 TLS 指纹)
|
|||
|
|
# 不指定 UA:使用系统 Chrome 真实 UA,否则 cf_clearance(与 UA 绑定)会失效
|
|||
|
|
# headless 会被 Cloudflare 检测拒绝,用有头模式并将窗口移出屏幕避免打扰
|
|||
|
|
launch_kwargs = {
|
|||
|
|
"headless": False,
|
|||
|
|
"args": [
|
|||
|
|
"--window-position=-32000,-32000",
|
|||
|
|
"--disable-blink-features=AutomationControlled",
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
for path in CHROME_PATHS:
|
|||
|
|
if os.path.isfile(path):
|
|||
|
|
launch_kwargs["executable_path"] = path
|
|||
|
|
break
|
|||
|
|
context = await p.chromium.launch_persistent_context(
|
|||
|
|
CHROME_PROFILE_DIR, **launch_kwargs
|
|||
|
|
)
|
|||
|
|
await context.add_init_script(
|
|||
|
|
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"
|
|||
|
|
)
|
|||
|
|
if cookies:
|
|||
|
|
await context.add_cookies(cookies)
|
|||
|
|
|
|||
|
|
page = context.pages[0] if context.pages else await context.new_page()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
await page.goto(url, wait_until="domcontentloaded")
|
|||
|
|
# 轮询等待 Cloudflare challenge 自动通过(最多约 20 秒)
|
|||
|
|
passed = False
|
|||
|
|
for _ in range(8):
|
|||
|
|
await page.wait_for_timeout(2500)
|
|||
|
|
title = await page.title()
|
|||
|
|
if "Just a moment" not in title:
|
|||
|
|
passed = True
|
|||
|
|
break
|
|||
|
|
html = etree.HTML(await page.content())
|
|||
|
|
# 浏览器可能刷新了 cf_clearance,回写文件实现自愈
|
|||
|
|
if passed and ASCII2D_COOKIES_FILE:
|
|||
|
|
try:
|
|||
|
|
_save_netscape_cookies(
|
|||
|
|
await context.cookies(), ASCII2D_COOKIES_FILE
|
|||
|
|
)
|
|||
|
|
logger.info("ascii2d cookies 已刷新回写")
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"ascii2d cookies 回写失败: {e}")
|
|||
|
|
print(f"获取到的html内容:{html}")
|
|||
|
|
return html
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"Error fetching page: {e}")
|
|||
|
|
return None
|
|||
|
|
finally:
|
|||
|
|
await context.close()
|