Add files
This commit is contained in:
commit
2335f7ff16
118
.gitignore
vendored
Normal file
118
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# dotenv
|
||||||
|
.env
|
||||||
|
|
||||||
|
# virtualenv
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
### VisualStudioCode ###
|
||||||
|
.vscode/*
|
||||||
|
# Not needed for this project
|
||||||
|
#!.vscode/settings.json
|
||||||
|
#!.vscode/tasks.json
|
||||||
|
#!.vscode/launch.json
|
||||||
|
#!.vscode/extensions.json
|
||||||
|
|
||||||
|
### VisualStudioCode Patch ###
|
||||||
|
# Ignore all local history of files
|
||||||
|
.history
|
||||||
|
|
||||||
|
# i have no idea what this file is or what it
|
||||||
|
# was doing here, so imma just ignore it and
|
||||||
|
# hope it doesn't blow up everying...
|
||||||
|
engines.sqlite
|
||||||
129
__init__.py
Normal file
129
__init__.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
import discord
|
||||||
|
import dyphanbot.utils as utils
|
||||||
|
from dyphanbot import Plugin
|
||||||
|
|
||||||
|
from .engines import EngineError, EngineRateLimitError
|
||||||
|
from .engines.saucenao import SauceNao
|
||||||
|
|
||||||
|
from .utils import parse_image_url
|
||||||
|
|
||||||
|
|
||||||
|
class SaucePlz(Plugin):
|
||||||
|
"""
|
||||||
|
DyphanBot plugin for reverse image searching
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def help(self, message, args):
|
||||||
|
prefix = self.get_local_prefix(message)
|
||||||
|
return {
|
||||||
|
"helptext": ("__*Experimental feature, expect bugs!*__\n"
|
||||||
|
"Finds the source of an image using reverse image search engines.\n"
|
||||||
|
"Currently, only searches [SauceNAO](https://saucenao.com) databases, "
|
||||||
|
"but will hopefully support more engines in the future."),
|
||||||
|
"shorthelp": "Finds the source of an image using SauceNAO.",
|
||||||
|
"color": discord.Colour(0x1),
|
||||||
|
"sections": [{
|
||||||
|
"name": "Usage",
|
||||||
|
"value": (
|
||||||
|
"> {0}saucepls\n> {0}saucepls `URL`\n"
|
||||||
|
"Post an image or video with the above command "
|
||||||
|
"or call it with a URL.\n"
|
||||||
|
"Also works with replies!\n"
|
||||||
|
"Alias: *sauceplz*"
|
||||||
|
).format(prefix),
|
||||||
|
"inline": False
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._config_fn = "config.json"
|
||||||
|
self.config = self.load_json(self._config_fn, initial_data={
|
||||||
|
"saucenao": {
|
||||||
|
"api_key": "__SAUCENAO_API_KEY__"
|
||||||
|
}
|
||||||
|
}, save_json=self._save_config)
|
||||||
|
|
||||||
|
def _save_config(self, filename, data):
|
||||||
|
return self.dyphanbot.data.save_json(filename, data, indent=4)
|
||||||
|
|
||||||
|
async def lookup_sauce(self, message, url):
|
||||||
|
try:
|
||||||
|
sn_engine = SauceNao(config=self.config, loop=self.dyphanbot.loop)
|
||||||
|
result = await sn_engine.best_match(url, hide_nsfw=not message.channel.is_nsfw())
|
||||||
|
if not result:
|
||||||
|
return {"content": "Unable to find match."}
|
||||||
|
|
||||||
|
embed = result.generate_embed(requester=message.author)
|
||||||
|
return {"content": "Sauce Found?", "embed": embed}
|
||||||
|
except EngineRateLimitError as err:
|
||||||
|
traceback.print_exc()
|
||||||
|
return {"content": f"Ratelimited: {err}"}
|
||||||
|
except EngineError as err:
|
||||||
|
traceback.print_exc()
|
||||||
|
return {"content": f"Error: {err}"}
|
||||||
|
|
||||||
|
@Plugin.command
|
||||||
|
async def sauceplz(self, client, message, args):
|
||||||
|
url = None
|
||||||
|
if len(args) > 0:
|
||||||
|
url = " ".join(args).strip("<>")
|
||||||
|
|
||||||
|
pre_text = ""
|
||||||
|
target_message = message
|
||||||
|
if len(message.attachments) <= 0 and not url:
|
||||||
|
# check if message is a reply
|
||||||
|
if message.reference:
|
||||||
|
msg_ref = message.reference
|
||||||
|
if not msg_ref.resolved:
|
||||||
|
try:
|
||||||
|
target_message = message.channel.fetch_message(msg_ref.message_id)
|
||||||
|
except Exception:
|
||||||
|
return await message.reply("Unable to retrieve referenced message.")
|
||||||
|
elif isinstance(msg_ref.resolved, discord.DeletedReferencedMessage):
|
||||||
|
return await message.reply("Referenced message was deleted.")
|
||||||
|
else:
|
||||||
|
target_message = msg_ref.resolved
|
||||||
|
urls = re.findall(r'(https?://\S+)', target_message.content)
|
||||||
|
if urls:
|
||||||
|
if len(urls) > 1:
|
||||||
|
pre_text += "Multiple URLs found in referenced message. Using the first one.\n"
|
||||||
|
url = urls[0]
|
||||||
|
if len(target_message.attachments) <= 0 and not url:
|
||||||
|
return await message.reply("No attachment or URL found in referenced message.")
|
||||||
|
else:
|
||||||
|
return await message.reply("No attachment or URL provided.")
|
||||||
|
|
||||||
|
if len(target_message.attachments) >= 1 and url is not None:
|
||||||
|
pre_text += "Both attachment and URL provided. Using URL.\n"
|
||||||
|
elif len(target_message.attachments) > 1:
|
||||||
|
pre_text += "Multiple attachments found. Using the first one.\n"
|
||||||
|
|
||||||
|
response_msg = None
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
response_msg = await message.reply(f"{pre_text}*Getting attachment...*", mention_author=False)
|
||||||
|
image = target_message.attachments[0]
|
||||||
|
url = image.url
|
||||||
|
|
||||||
|
if response_msg:
|
||||||
|
await response_msg.edit(
|
||||||
|
allowed_mentions=discord.AllowedMentions(replied_user=False),
|
||||||
|
content=f"{pre_text}*Looking for the sauce...*")
|
||||||
|
else:
|
||||||
|
response_msg = await message.reply(f"{pre_text}*Looking for the sauce...*", mention_author=False)
|
||||||
|
|
||||||
|
async with ClientSession(loop=self.dyphanbot.loop) as session:
|
||||||
|
url = await parse_image_url(session, url)
|
||||||
|
|
||||||
|
results = await self.lookup_sauce(message, url=url)
|
||||||
|
await response_msg.edit(
|
||||||
|
allowed_mentions=discord.AllowedMentions(replied_user=True),
|
||||||
|
**results)
|
||||||
|
|
||||||
|
@Plugin.command
|
||||||
|
async def saucepls(self, client, message, args):
|
||||||
|
# alias to sauceplz
|
||||||
|
return await self.sauceplz(client, message, args)
|
||||||
99
engines/__init__.py
Normal file
99
engines/__init__.py
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import posixpath as path
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
# pyright: reportWildcardImportFromLibrary=false
|
||||||
|
from typing import *
|
||||||
|
|
||||||
|
# pyright: reportPrivateImportUsage=false
|
||||||
|
from aiohttp_client_cache import CachedSession, SQLiteBackend
|
||||||
|
|
||||||
|
class EngineError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class EngineResponseError(EngineError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class EngineRateLimitError(EngineError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ReverseImageSearchEngine(object):
|
||||||
|
"""Base class for reverse image search engines
|
||||||
|
|
||||||
|
Different reverse image search engines can inherit from this class
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url_base (str): Base url of the engine
|
||||||
|
url_path (str): Path and query to the reverse image search function.
|
||||||
|
Should contain `{image_url}` in which the input url will be placed
|
||||||
|
to perform the search needed.
|
||||||
|
name (str, optional): Name of the search engine
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def __init__(self, url_base, url_path, name=None, config={}, loop=None, **request_args) -> None:
|
||||||
|
self.url_base = url_base
|
||||||
|
self.url_path = url_path
|
||||||
|
self.name = name
|
||||||
|
self.request_args = request_args
|
||||||
|
self.engine_config = config
|
||||||
|
|
||||||
|
if not loop:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
self.loop = loop
|
||||||
|
|
||||||
|
# session used to make async cached requests
|
||||||
|
self._session = CachedSession(
|
||||||
|
cache=SQLiteBackend(
|
||||||
|
__name__.lower(),
|
||||||
|
expire_after=604800, # 1 week
|
||||||
|
allowable_methods=('GET', 'HEAD', 'POST')
|
||||||
|
),
|
||||||
|
loop=loop
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_search_url(self, url) -> str:
|
||||||
|
search_path = self.url_path.format(image_url=quote_plus(url))
|
||||||
|
return path.join(self.url_base, search_path)
|
||||||
|
|
||||||
|
async def _request(self, method, url, **kwargs) -> str:
|
||||||
|
async with self._session as session:
|
||||||
|
resp = await session.request(method, url, **kwargs)
|
||||||
|
return await resp.text()
|
||||||
|
|
||||||
|
async def _request_get(self, url, **kwargs) -> str:
|
||||||
|
return await self._request('GET', url, **kwargs)
|
||||||
|
|
||||||
|
async def _request_post(self, url, **kwargs) -> str:
|
||||||
|
return await self._request('POST', url, **kwargs)
|
||||||
|
|
||||||
|
async def search(self, url, post=False, **request_args):
|
||||||
|
search_url = self._build_search_url(url)
|
||||||
|
req_func = self._request_post if post else self._request_get
|
||||||
|
result = await req_func(search_url, **request_args, **self.request_args)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def parse_html(self, html):
|
||||||
|
"""Parses the search engine's html to a useable dictionary
|
||||||
|
|
||||||
|
Override this method when inheriting from this class with the
|
||||||
|
site-specific implementation.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def top_matches(self, url, limit=3):
|
||||||
|
"""Get a list of the top matched results from the engine
|
||||||
|
|
||||||
|
Override this method when inheriting from this class with the
|
||||||
|
site-specific implementation.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def best_match(self, url):
|
||||||
|
"""Get the best matched result from the reverse image search engine
|
||||||
|
|
||||||
|
Override this method when inheriting from this class with the
|
||||||
|
site-specific implementation.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
533
engines/saucenao.py
Normal file
533
engines/saucenao.py
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from urllib.parse import quote as url_encode
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import discord
|
||||||
|
|
||||||
|
import dyphanbot.utils as utils
|
||||||
|
|
||||||
|
from . import EngineRateLimitError, EngineResponseError, ReverseImageSearchEngine
|
||||||
|
|
||||||
|
SAUCE_INDEX = {
|
||||||
|
"0" : "H-Magazines",
|
||||||
|
"2" : "H-Game CG",
|
||||||
|
"3" : "DoujinshiDB",
|
||||||
|
"5" : "Pixiv",
|
||||||
|
"6" : "Pixiv (Historical)",
|
||||||
|
"8" : "Nico Nico Seiga",
|
||||||
|
"9" : "Danbooru",
|
||||||
|
"10": "drawr Images",
|
||||||
|
"11": "Nijie Images",
|
||||||
|
"12": "Yande.re",
|
||||||
|
"15": "Shutterstock",
|
||||||
|
"16": "FAKKU",
|
||||||
|
"18": "H-Misc (nhentai)",
|
||||||
|
"19": "2D-Market",
|
||||||
|
"20": "MediBang",
|
||||||
|
"21": "Anime",
|
||||||
|
"22": "H-Anime",
|
||||||
|
"23": "Movies",
|
||||||
|
"24": "Shows",
|
||||||
|
"25": "Gelbooru",
|
||||||
|
"26": "Konachan",
|
||||||
|
"27": "Sankaku Channel",
|
||||||
|
"28": "Anime-Pictures.net",
|
||||||
|
"29": "e621.net",
|
||||||
|
"30": "Idol Complex",
|
||||||
|
"31": "bcy.net Illust",
|
||||||
|
"32": "bcy.net Cosplay",
|
||||||
|
"33": "PortalGraphics.net (Hist)",
|
||||||
|
"34": "deviantArt",
|
||||||
|
"35": "Pawoo.net",
|
||||||
|
"36": "Madokami (Manga)",
|
||||||
|
"37": "MangaDex",
|
||||||
|
"38": "E-Hentai",
|
||||||
|
"39": "ArtStation",
|
||||||
|
"40": "FurAffinity",
|
||||||
|
"41": "Twitter",
|
||||||
|
"42": "Furry Network",
|
||||||
|
"43": "Kemono",
|
||||||
|
|
||||||
|
# fucking unlisted indexes... x_x
|
||||||
|
# these probably wont show up tho since they're sub-indexes,
|
||||||
|
# but they're added just in case...
|
||||||
|
"51": "Pixiv",
|
||||||
|
"52": "Pixiv",
|
||||||
|
"53": "Pixiv",
|
||||||
|
"211": "Anime",
|
||||||
|
|
||||||
|
# these, however, WILL show up as an index_id,
|
||||||
|
# but for some reason they weren't documented anywhere. smh
|
||||||
|
"341": "deviantArt",
|
||||||
|
"371": "MangaDex"
|
||||||
|
}
|
||||||
|
|
||||||
|
SAUCE_TYPES = {
|
||||||
|
"booru": [9, 12, 25, 26, 27, 28, 29, 30],
|
||||||
|
"manga": [0, 3, 16, 18, 36, 37, 38, 371],
|
||||||
|
"pixiv": [5, 6, 51, 52, 53],
|
||||||
|
"anime": [21, 22, 211],
|
||||||
|
"video": [23, 24],
|
||||||
|
"twitter": [41]
|
||||||
|
}
|
||||||
|
|
||||||
|
NL = "\n" # cause fuck f-strings.
|
||||||
|
|
||||||
|
class SauceNao(ReverseImageSearchEngine):
|
||||||
|
"""
|
||||||
|
SauceNAO engine
|
||||||
|
"""
|
||||||
|
|
||||||
|
url_base = "https://saucenao.com"
|
||||||
|
url_path = "search.php"
|
||||||
|
|
||||||
|
def __init__(self, config={}, loop=None, **request_args) -> None:
|
||||||
|
super().__init__(self.url_base, self.url_path, name="SauceNAO", config=config, loop=loop, **request_args)
|
||||||
|
|
||||||
|
self.config = self.engine_config.get("saucenao", {})
|
||||||
|
self.api_key = self.config.get("api_key")
|
||||||
|
|
||||||
|
async def top_matches(self, url, limit=3, hide_nsfw=True):
|
||||||
|
try:
|
||||||
|
api_req = await self.search(url, post=True, data={
|
||||||
|
"output_type": 2,
|
||||||
|
"api_key": self.api_key,
|
||||||
|
"db": 999,
|
||||||
|
"numres": limit if limit <= 10 else 10,
|
||||||
|
"url": url,
|
||||||
|
"hide": 2
|
||||||
|
})
|
||||||
|
|
||||||
|
api_data = json.loads(api_req)
|
||||||
|
|
||||||
|
meta = api_data.get("header", {})
|
||||||
|
results = api_data.get("results", [])
|
||||||
|
min_similarity = float(meta.get("minimum_similarity", 50))
|
||||||
|
|
||||||
|
returned_results = []
|
||||||
|
low_similarity_count = 0
|
||||||
|
hidden_nsfw_count = 0
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
header = result["header"]
|
||||||
|
data = result["data"]
|
||||||
|
|
||||||
|
similarity = float(header["similarity"])
|
||||||
|
if similarity < min_similarity:
|
||||||
|
low_similarity_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if header.get("hidden", 0) > 0 and hide_nsfw:
|
||||||
|
hidden_nsfw_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
sanitized_result = {}
|
||||||
|
|
||||||
|
index_id = header["index_id"]
|
||||||
|
sanitized_result["type"] = "generic"
|
||||||
|
for sauce_type, indexes in SAUCE_TYPES.items():
|
||||||
|
if index_id in indexes:
|
||||||
|
sanitized_result["type"] = sauce_type
|
||||||
|
break
|
||||||
|
|
||||||
|
sanitized_result.update({
|
||||||
|
"input_url": url,
|
||||||
|
"similarity": similarity,
|
||||||
|
"min_similarity": min_similarity,
|
||||||
|
"nsfw": header.get("hidden", 0) > 0,
|
||||||
|
"thumbnail": header.get("thumbnail"),
|
||||||
|
"index_name": header.get("index_name"),
|
||||||
|
"index_id": index_id,
|
||||||
|
"index": SAUCE_INDEX.get(str(index_id)),
|
||||||
|
"data": data
|
||||||
|
})
|
||||||
|
|
||||||
|
# Make a base "generic" class and subclass the different type,
|
||||||
|
# then call a factory function from the base class to instansiate
|
||||||
|
# the proper subclass, if applicable, from the sanitized_result. Append
|
||||||
|
# this object to returned_results, then return it.
|
||||||
|
|
||||||
|
parsed_result = GenericSauce.from_dict(sanitized_result)
|
||||||
|
await parsed_result._async_tasks()
|
||||||
|
|
||||||
|
print(parsed_result)
|
||||||
|
returned_results.append(parsed_result)
|
||||||
|
|
||||||
|
return returned_results
|
||||||
|
|
||||||
|
except aiohttp.ClientResponseError as err:
|
||||||
|
if err.status == 429:
|
||||||
|
raise EngineRateLimitError("Daily limit reached (100)")
|
||||||
|
raise EngineResponseError(f"{err.status} {err.message}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise EngineResponseError("Could not interpret result.")
|
||||||
|
|
||||||
|
async def best_match(self, url, hide_nsfw=True):
|
||||||
|
# Call self.top_matches() with the url and return the first result.
|
||||||
|
top_three = await self.top_matches(url, hide_nsfw=hide_nsfw)
|
||||||
|
if not top_three:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return top_three[0]
|
||||||
|
|
||||||
|
# Parts of the following classes were referenced from:
|
||||||
|
# https://github.com/MakotoAme/pysaucenao/blob/master/pysaucenao/containers.py
|
||||||
|
|
||||||
|
class GenericSauce(object):
|
||||||
|
""" Generic attributes that are applicable for any source, but not always """
|
||||||
|
_type = "generic"
|
||||||
|
|
||||||
|
def __init__(self, result: dict):
|
||||||
|
self.result = result
|
||||||
|
|
||||||
|
self.input_url = self.result["input_url"]
|
||||||
|
|
||||||
|
# header attribs
|
||||||
|
self.similarity = self.result["similarity"]
|
||||||
|
self.min_similarity = self.result["min_similarity"]
|
||||||
|
self.nsfw = self.result["nsfw"]
|
||||||
|
self.thumbnail = self.result["thumbnail"]
|
||||||
|
self.index_name = self.result["index_name"]
|
||||||
|
self.index_id = self.result["index_id"]
|
||||||
|
self.index = self.result["index"]
|
||||||
|
|
||||||
|
# data attribs (will be parsed later)
|
||||||
|
self.author_name = None
|
||||||
|
self.author_url = None
|
||||||
|
self.authors = None
|
||||||
|
self.title = None
|
||||||
|
self.url = None
|
||||||
|
self.urls = None
|
||||||
|
|
||||||
|
self._data = self.result["data"]
|
||||||
|
self._parse_data(self._data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, result):
|
||||||
|
""" Instantiate a sauce object from dict """
|
||||||
|
|
||||||
|
def all_subclasses(cls):
|
||||||
|
""" Make sure we get all the inherited classes """
|
||||||
|
return set(cls.__subclasses__()).union(
|
||||||
|
[s for c in cls.__subclasses__() for s in all_subclasses(c)])
|
||||||
|
|
||||||
|
res_type = result.get("type")
|
||||||
|
if res_type:
|
||||||
|
for subcls in all_subclasses(cls):
|
||||||
|
cls_type = subcls._type
|
||||||
|
if cls_type == res_type:
|
||||||
|
return subcls(result)
|
||||||
|
|
||||||
|
return cls(result)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sauce_url(self):
|
||||||
|
"""
|
||||||
|
Returns the standard source url of the result
|
||||||
|
"""
|
||||||
|
return self.url
|
||||||
|
|
||||||
|
async def _async_tasks(self):
|
||||||
|
""" Called after initialization to complete async tasks needed by the source """
|
||||||
|
return
|
||||||
|
|
||||||
|
def _parse_data(self, data: dict):
|
||||||
|
"""
|
||||||
|
Parse the data from the dict into the appropriate attributes; called at initialization
|
||||||
|
"""
|
||||||
|
|
||||||
|
# messy api... smh
|
||||||
|
# "source" can sometimes be a url instead... -_-
|
||||||
|
for title_field in ["title", "material", "eng_name", "source"]:
|
||||||
|
if title_field not in data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.title = data[title_field]
|
||||||
|
break
|
||||||
|
|
||||||
|
for author_field in ["member_name", "creator", "author_name", "author",
|
||||||
|
"pawoo_user_acct", "pawoo_user_username", "pawoo_user_display_name"]:
|
||||||
|
if author_field not in data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if isinstance(data[author_field], list):
|
||||||
|
# it can sometimes be a list of authors, so parse accordingly
|
||||||
|
self.author_name = data[author_field][0]
|
||||||
|
self.authors = data[author_field]
|
||||||
|
break
|
||||||
|
|
||||||
|
self.author_name = data[author_field]
|
||||||
|
self.authors = [data[author_field]]
|
||||||
|
|
||||||
|
if "author_url" in data:
|
||||||
|
self.author_url = data["author_url"]
|
||||||
|
elif "pawoo_id" in data and "ext_urls" in data:
|
||||||
|
self.author_url = data['ext_urls'][0]
|
||||||
|
|
||||||
|
if "ext_urls" in data:
|
||||||
|
self.url = data["ext_urls"][0]
|
||||||
|
self.urls = data["ext_urls"]
|
||||||
|
|
||||||
|
def generate_embed(self, requester=None, additional_desc="", show_links=True):
|
||||||
|
""" Returns a discord embed to display the resulting information """
|
||||||
|
nsfw_tag = '**NSFW**\n' if self.nsfw else ''
|
||||||
|
description = f"{nsfw_tag}Similarity: {self.similarity}%"
|
||||||
|
|
||||||
|
if self.index:
|
||||||
|
description += f"{NL}Matched in: {self.index}"
|
||||||
|
|
||||||
|
if self.authors:
|
||||||
|
author_str = ', '.join(self.authors)
|
||||||
|
author_text = f"[{author_str}]({self.author_url})" if self.author_url else author_str
|
||||||
|
description += f"{NL}**Author:** {author_text}"
|
||||||
|
|
||||||
|
description += f"{NL}{additional_desc}" if additional_desc else ""
|
||||||
|
|
||||||
|
if self.urls and show_links:
|
||||||
|
url_list_str = '\n'.join(self.urls)
|
||||||
|
description += f"{NL}{NL}**Links:**{NL}{url_list_str}"
|
||||||
|
|
||||||
|
embed = discord.Embed(title=self.title, url=self.sauce_url, description=description)
|
||||||
|
|
||||||
|
embed.set_author(
|
||||||
|
name="SauceNAO",
|
||||||
|
url=f"https://saucenao.com/search.php?url={url_encode(self.input_url)}",
|
||||||
|
icon_url="https://i.imgur.com/Ynoqpam.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.thumbnail:
|
||||||
|
embed.set_thumbnail(url=self.thumbnail)
|
||||||
|
|
||||||
|
if requester:
|
||||||
|
embed.set_footer(
|
||||||
|
icon_url=utils.get_user_avatar_url(requester),
|
||||||
|
text=f"Requested by {requester}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return embed
|
||||||
|
|
||||||
|
class PixivSauce(GenericSauce):
|
||||||
|
""" Pixiv source type """
|
||||||
|
_type = "pixiv"
|
||||||
|
|
||||||
|
def __init__(self, data: dict):
|
||||||
|
super().__init__(data)
|
||||||
|
|
||||||
|
def _parse_data(self, data: dict):
|
||||||
|
super()._parse_data(data)
|
||||||
|
self.author_url = f"https://pixiv.net/member.php?id={data['member_id']}"
|
||||||
|
|
||||||
|
class BooruSauce(GenericSauce):
|
||||||
|
""" Booru source type """
|
||||||
|
_type = "booru"
|
||||||
|
|
||||||
|
def __init__(self, data: dict):
|
||||||
|
super().__init__(data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sauce_url(self):
|
||||||
|
""" Returns the linked source if available """
|
||||||
|
return self._data.get("source", self.url)
|
||||||
|
|
||||||
|
def _parse_data(self, data):
|
||||||
|
super()._parse_data(data)
|
||||||
|
|
||||||
|
for booru in ["gelbooru", "danbooru", "yandere",
|
||||||
|
"konachan", "sankaku", "anime-pictures",
|
||||||
|
"e621", "idol"]:
|
||||||
|
id_field = f"{booru}_id"
|
||||||
|
if id_field not in data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.booru_type = booru
|
||||||
|
self.post_id = data.get(id_field)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.characters = data.get("characters")
|
||||||
|
self.material = data.get("material")
|
||||||
|
|
||||||
|
if self.characters:
|
||||||
|
self.characters = [x.strip() for x in self.characters.split(',')]
|
||||||
|
|
||||||
|
if self.material:
|
||||||
|
self.material = [x.strip() for x in self.material.split(',')]
|
||||||
|
|
||||||
|
if not self.title:
|
||||||
|
self.title = f"Post #{self.post_id}"
|
||||||
|
|
||||||
|
def generate_embed(self, requester=None):
|
||||||
|
additional_desc = ""
|
||||||
|
if self.characters:
|
||||||
|
characters_str = ', '.join([f'`{x}`' for x in self.characters]) if isinstance(self.characters, list) else str(self.characters)
|
||||||
|
additional_desc += f"**Characters:** {characters_str}"
|
||||||
|
|
||||||
|
if self.material:
|
||||||
|
material_str = ', '.join([f'`{x}`' for x in self.material]) if isinstance(self.material, list) else str(self.material)
|
||||||
|
additional_desc += f"{NL}**Material:** {material_str}"
|
||||||
|
|
||||||
|
return super().generate_embed(requester=requester, additional_desc=additional_desc)
|
||||||
|
|
||||||
|
class TwitterSauce(GenericSauce):
|
||||||
|
""" Twitter sauce type """
|
||||||
|
_type = "twitter"
|
||||||
|
|
||||||
|
def __init__(self, data: dict):
|
||||||
|
super().__init__(data)
|
||||||
|
|
||||||
|
def _parse_data(self, data: dict):
|
||||||
|
super()._parse_data(data)
|
||||||
|
|
||||||
|
self.tweet_id = data["tweet_id"]
|
||||||
|
self.twitter_user_id = data["twitter_user_id"]
|
||||||
|
self.twitter_user_handle = data["twitter_user_handle"]
|
||||||
|
|
||||||
|
self.author_name = self.twitter_user_handle
|
||||||
|
self.author_url = f"https://twitter.com/i/user/{self.twitter_user_id}"
|
||||||
|
self.authors = [self.author_name]
|
||||||
|
|
||||||
|
if not self.title:
|
||||||
|
self.title = f"Tweet by @{self.twitter_user_handle}"
|
||||||
|
|
||||||
|
class VideoSauce(GenericSauce):
|
||||||
|
""" Movies and Shows source """
|
||||||
|
_type = "video"
|
||||||
|
|
||||||
|
def __init__(self, data: dict):
|
||||||
|
self.episode = None
|
||||||
|
self.timestamp = None
|
||||||
|
self.year = None
|
||||||
|
|
||||||
|
super().__init__(data)
|
||||||
|
|
||||||
|
def _parse_data(self, data):
|
||||||
|
super()._parse_data(data)
|
||||||
|
|
||||||
|
self.episode = data.get("part")
|
||||||
|
self.timestamp = data.get("est_time")
|
||||||
|
self.year = data.get("year")
|
||||||
|
|
||||||
|
def generate_embed(self, requester=None, additional_desc="", show_links=True):
|
||||||
|
desc = ""
|
||||||
|
if self.year:
|
||||||
|
desc = f"**Year:** {self.year}"
|
||||||
|
|
||||||
|
if self.episode:
|
||||||
|
desc += f"{NL}**Episode:** {self.episode}"
|
||||||
|
|
||||||
|
if self.timestamp:
|
||||||
|
desc += f"{NL}**Timestamp:** {self.timestamp}"
|
||||||
|
|
||||||
|
desc += f"{NL}{additional_desc}" if additional_desc else ""
|
||||||
|
|
||||||
|
return super().generate_embed(requester, additional_desc=desc, show_links=show_links)
|
||||||
|
|
||||||
|
class AnimeSauce(VideoSauce):
|
||||||
|
""" Anime source """
|
||||||
|
_type = "anime"
|
||||||
|
|
||||||
|
def __init__(self, data):
|
||||||
|
self._logger = logging.getLogger(__name__)
|
||||||
|
self._async_done = False
|
||||||
|
|
||||||
|
self.anidb_aid = None
|
||||||
|
self.anilist_id = None
|
||||||
|
self.mal_id = None
|
||||||
|
|
||||||
|
super().__init__(data)
|
||||||
|
|
||||||
|
async def _async_tasks(self, loop=None):
|
||||||
|
if self._async_done:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.anidb_aid:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.anilist_id and self.mal_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(loop=loop, raise_for_status=True) as session:
|
||||||
|
try:
|
||||||
|
resp = await session.get(f"https://relations.yuna.moe/api/ids?source=anidb&id={self.anidb_aid}")
|
||||||
|
ids = await resp.json() or {}
|
||||||
|
|
||||||
|
if not self.anilist_id:
|
||||||
|
self.anilist_id = ids.get('anilist')
|
||||||
|
|
||||||
|
if not self.mal_id:
|
||||||
|
self.mal_id = ids.get("myanimelist")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self._logger.info(f"relations.yuna.moe lookup failed for aid: {self.anidb_aid}")
|
||||||
|
except aiohttp.ClientResponseError as err:
|
||||||
|
self._logger.info(f"relations.yuna.moe returned a {err.status} error.")
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
self._logger.info(f"unable to connect to relations.yuna.moe api")
|
||||||
|
|
||||||
|
def _parse_data(self, data):
|
||||||
|
super()._parse_data(data)
|
||||||
|
|
||||||
|
self.anidb_aid = data.get("anidb_aid")
|
||||||
|
self.anilist_id = data.get("anilist_id")
|
||||||
|
self.mal_id = data.get("mal_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def anidb_url(self):
|
||||||
|
if not self.anidb_aid:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return f"https://anidb.net/anime/{self.anidb_aid}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def anilist_url(self):
|
||||||
|
if not self.anilist_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return f"https://anilist.co/anime/{self.anilist_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mal_url(self):
|
||||||
|
if not self.mal_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return f"https://myanimelist.net/anime/{self.mal_id}"
|
||||||
|
|
||||||
|
def generate_embed(self, requester=None):
|
||||||
|
link_strs = []
|
||||||
|
if self.anidb_url:
|
||||||
|
link_strs.append(f"[AniDB]({self.anidb_url})")
|
||||||
|
|
||||||
|
if self.anilist_url:
|
||||||
|
link_strs.append(f"[AniList]({self.anilist_url})")
|
||||||
|
|
||||||
|
if self.mal_url:
|
||||||
|
link_strs.append(f"[MyAnimeList]({self.mal_url})")
|
||||||
|
|
||||||
|
desc = f"{NL}{' | '.join(link_strs)}"
|
||||||
|
return super().generate_embed(requester, additional_desc=desc, show_links=False)
|
||||||
|
|
||||||
|
class MangaSauce(GenericSauce):
|
||||||
|
""" Manga source type """
|
||||||
|
_type = "manga"
|
||||||
|
|
||||||
|
def __init__(self, data: dict):
|
||||||
|
self.chapter = None
|
||||||
|
self.artist = None
|
||||||
|
super().__init__(data)
|
||||||
|
|
||||||
|
def _parse_data(self, data):
|
||||||
|
super()._parse_data(data)
|
||||||
|
|
||||||
|
self.chapter = data.get("part")
|
||||||
|
self.artist = data.get("artist")
|
||||||
|
|
||||||
|
def generate_embed(self, requester=None):
|
||||||
|
desc = ""
|
||||||
|
|
||||||
|
if self.artist:
|
||||||
|
desc += f"**Artist:** {self.artist}"
|
||||||
|
|
||||||
|
if self.chapter:
|
||||||
|
desc += f"{NL}**Chapter:** {self.chapter}"
|
||||||
|
|
||||||
|
return super().generate_embed(requester, additional_desc=desc)
|
||||||
84
utils.py
Normal file
84
utils.py
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
REGEXES = {
|
||||||
|
# group 1 = single image id, or "gallery", or "a"
|
||||||
|
# group 2 = empty if single image, or album/gallery id
|
||||||
|
"imgur" : re.compile(r"https?://(?:www.)?imgur.com/((?:(?!\/).)+)/?((?:(?![\./]).)+)?"),
|
||||||
|
|
||||||
|
# group 1 = gfycat/redgifs id
|
||||||
|
"gfycat" : re.compile(r"https?://(?:www.|giant.)?gfycat.com/(?:gifs/detail/)?((?:(?![\./-]).)+)"),
|
||||||
|
"redgifs": re.compile(r"https?://(?:www.)?redgifs.com/watch/((?:(?![\./-]).)+)"),
|
||||||
|
|
||||||
|
# group 1 = giphy id
|
||||||
|
"giphy" : re.compile(r'https?://(?:www.)?giphy.com/gifs/(?:.*\-)?((?:(?![\./-]).)+)'),
|
||||||
|
|
||||||
|
# group 1 = tenor id
|
||||||
|
"tenor" : re.compile(r'https?://(?:www.)?tenor.com?/view/(?:.*\-)?((?:(?![\./-]).)+)')
|
||||||
|
}
|
||||||
|
|
||||||
|
GIPHY_DIRECT_URL = "https://media.giphy.com/media/{0}/giphy.gif"
|
||||||
|
|
||||||
|
def _match_url(url):
|
||||||
|
for site, regex in REGEXES.items():
|
||||||
|
match = regex.match(url)
|
||||||
|
if match:
|
||||||
|
return (site, match)
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
async def parse_image_url(session, url):
|
||||||
|
site, match = _match_url(url)
|
||||||
|
if not match:
|
||||||
|
return url
|
||||||
|
|
||||||
|
image_id = match.group(1)
|
||||||
|
if site == "imgur":
|
||||||
|
request_url = f"https://imgur.com/{image_id}"
|
||||||
|
if image_id == "gallery" or image_id == "a":
|
||||||
|
album_type = image_id
|
||||||
|
album_id = match.group(2)
|
||||||
|
request_url = f"https://imgur.com/{album_type}/{album_id}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = await session.get(request_url)
|
||||||
|
r.raise_for_status()
|
||||||
|
image_page_html = await r.text()
|
||||||
|
image_url_match = re.search(r"<meta name=\"twitter:image\" (?:.*?)content=\"(.*?)\"(?:.*?)>", image_page_html)
|
||||||
|
image_url = image_url_match.group(1).strip() if image_url_match else None
|
||||||
|
if not image_url:
|
||||||
|
return url
|
||||||
|
return image_url
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
return url
|
||||||
|
elif site == "gfycat" or site == "redgifs":
|
||||||
|
try:
|
||||||
|
r = await session.get(f"https://api.{site}.com/v1/gfycats/{image_id}")
|
||||||
|
r.raise_for_status()
|
||||||
|
gif_info = await r.json()
|
||||||
|
poster_url = gif_info.get('gfyItem', {}).get('posterUrl')
|
||||||
|
if not poster_url:
|
||||||
|
return url
|
||||||
|
return poster_url
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
return url
|
||||||
|
elif site == "giphy":
|
||||||
|
return GIPHY_DIRECT_URL.format(image_id)
|
||||||
|
elif site == "tenor":
|
||||||
|
try:
|
||||||
|
r = await session.get(f"https://tenor.com/view/{image_id}")
|
||||||
|
r.raise_for_status()
|
||||||
|
gif_page_html = await r.text()
|
||||||
|
gif_info_match = re.search(r"<script class=\"dynamic\" type=\"application/ld\+json\">(.*?)</script>", gif_page_html)
|
||||||
|
gif_info_raw = gif_info_match.group(1).strip() if gif_info_match else ""
|
||||||
|
if not gif_info_raw:
|
||||||
|
return url
|
||||||
|
gif_info = json.loads(gif_info_raw)
|
||||||
|
return gif_info['image']['contentUrl']
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
return url
|
||||||
|
else:
|
||||||
|
return url
|
||||||
Loading…
Reference in New Issue
Block a user