[kemonoparty] update to new site layout / API endpoints

(#6415, #6503, #6528, #6530, #6536)

… at least for the most part. Favorites are still broken, but the rest
should be functional again.
This commit is contained in:
Mike Fährmann
2024-11-26 21:58:15 +01:00
parent 5412b22dae
commit 74d855c693
2 changed files with 200 additions and 165 deletions

View File

@@ -10,7 +10,7 @@
from .common import Extractor, Message from .common import Extractor, Message
from .. import text, util, exception from .. import text, util, exception
from ..cache import cache, memcache from ..cache import cache
import itertools import itertools
import json import json
import re import re
@@ -38,6 +38,7 @@ class KemonopartyExtractor(Extractor):
Extractor.__init__(self, match) Extractor.__init__(self, match)
def _init(self): def _init(self):
self.api = KemonoAPI(self)
self.revisions = self.config("revisions") self.revisions = self.config("revisions")
if self.revisions: if self.revisions:
self.revisions_unique = (self.revisions == "unique") self.revisions_unique = (self.revisions == "unique")
@@ -53,48 +54,53 @@ class KemonopartyExtractor(Extractor):
sort_keys=True, separators=(",", ":")).encode sort_keys=True, separators=(",", ":")).encode
def items(self): def items(self):
service = self.groups[2]
creator_id = self.groups[3]
find_hash = re.compile(HASH_PATTERN).match find_hash = re.compile(HASH_PATTERN).match
generators = self._build_file_generators(self.config("files")) generators = self._build_file_generators(self.config("files"))
duplicates = self.config("duplicates") announcements = True if self.config("announcements") else None
comments = self.config("comments") comments = True if self.config("comments") else False
username = dms = announcements = None duplicates = True if self.config("duplicates") else False
dms = True if self.config("dms") else None
profile = username = None
# prevent files from being sent with gzip compression # prevent files from being sent with gzip compression
headers = {"Accept-Encoding": "identity"} headers = {"Accept-Encoding": "identity"}
if self.config("metadata"): if self.config("metadata"):
username = text.unescape(text.extract( profile = self.api.creator_profile(service, creator_id)
self.request(self.user_url).text, username = profile["name"]
'<meta name="artist_name" content="', '"')[0])
if self.config("dms"):
dms = True
if self.config("announcements"):
announcements = True
posts = self.posts() posts = self.posts()
max_posts = self.config("max-posts") max_posts = self.config("max-posts")
if max_posts: if max_posts:
posts = itertools.islice(posts, max_posts) posts = itertools.islice(posts, max_posts)
if self.revisions:
posts = self._revisions(posts)
for post in posts: for post in posts:
headers["Referer"] = "{}/{}/user/{}/post/{}".format( headers["Referer"] = "{}/{}/user/{}/post/{}".format(
self.root, post["service"], post["user"], post["id"]) self.root, post["service"], post["user"], post["id"])
post["_http_headers"] = headers post["_http_headers"] = headers
post["date"] = self._parse_datetime( post["date"] = self._parse_datetime(
post.get("published") or post.get("added") or "") post.get("published") or post.get("added") or "")
if username: if profile is not None:
post["username"] = username post["username"] = username
post["user_profile"] = profile
if comments: if comments:
post["comments"] = self._extract_comments(post) post["comments"] = self.api.creator_post_comments(
service, creator_id, post["id"])
if dms is not None: if dms is not None:
if dms is True: if dms is True:
dms = self._extract_cards(post, "dms") dms = self.api.creator_dms(
post["service"], post["user"])
post["dms"] = dms post["dms"] = dms
if announcements is not None: if announcements is not None:
if announcements is True: if announcements is True:
announcements = self._extract_cards(post, "announcements") announcements = self.api.creator_announcements(
post["service"], post["user"])
post["announcements"] = announcements post["announcements"] = announcements
files = [] files = []
@@ -188,56 +194,21 @@ class KemonopartyExtractor(Extractor):
filetypes = filetypes.split(",") filetypes = filetypes.split(",")
return [genmap[ft] for ft in filetypes] return [genmap[ft] for ft in filetypes]
def _extract_comments(self, post):
url = "{}/{}/user/{}/post/{}".format(
self.root, post["service"], post["user"], post["id"])
page = self.request(url).text
comments = []
for comment in text.extract_iter(page, "<article", "</article>"):
extr = text.extract_from(comment)
cid = extr('id="', '"')
comments.append({
"id" : cid,
"user": extr('href="#' + cid + '"', '</').strip(" \n\r>"),
"body": extr(
'<section class="comment__body">', '</section>').strip(),
"date": extr('datetime="', '"'),
})
return comments
def _extract_cards(self, post, type):
url = "{}/{}/user/{}/{}".format(
self.root, post["service"], post["user"], type)
page = self.request(url).text
cards = []
for card in text.extract_iter(page, "<article", "</article>"):
footer = text.extr(card, "<footer", "</footer>")
cards.append({
"body": text.unescape(text.extr(
card, "<pre>", "</pre></",
).strip()),
"date": text.extr(footer, ': ', '\n'),
})
return cards
def _parse_datetime(self, date_string): def _parse_datetime(self, date_string):
if len(date_string) > 19: if len(date_string) > 19:
date_string = date_string[:19] date_string = date_string[:19]
return text.parse_datetime(date_string, "%Y-%m-%dT%H:%M:%S") return text.parse_datetime(date_string, "%Y-%m-%dT%H:%M:%S")
@memcache(keyarg=1) def _revisions(self, posts):
def _discord_channels(self, server): return itertools.chain.from_iterable(
url = "{}/api/v1/discord/channel/lookup/{}".format( self._revisions_post(post) for post in posts)
self.root, server)
return self.request(url).json()
def _revisions_post(self, post, url): def _revisions_post(self, post):
post["revision_id"] = 0 post["revision_id"] = 0
try: try:
revs = self.request(url + "/revisions").json() revs = self.api.creator_post_revisions(
post["service"], post["user"], post["id"])
except exception.HttpError: except exception.HttpError:
post["revision_hash"] = self._revision_hash(post) post["revision_hash"] = self._revision_hash(post)
post["revision_index"] = 1 post["revision_index"] = 1
@@ -268,8 +239,8 @@ class KemonopartyExtractor(Extractor):
return revs return revs
def _revisions_all(self, url): def _revisions_all(self, service, creator_id, post_id):
revs = self.request(url + "/revisions").json() revs = self.api.creator_post_revisions(service, creator_id, post_id)
cnt = idx = len(revs) cnt = idx = len(revs)
for rev in revs: for rev in revs:
@@ -305,50 +276,30 @@ def _validate(response):
class KemonopartyUserExtractor(KemonopartyExtractor): class KemonopartyUserExtractor(KemonopartyExtractor):
"""Extractor for all posts from a kemono.su user listing""" """Extractor for all posts from a kemono.su user listing"""
subcategory = "user" subcategory = "user"
pattern = USER_PATTERN + r"/?(?:\?([^#]+))?(?:$|[?#])" pattern = USER_PATTERN + r"/?(?:\?([^#]+))?(?:$|\?|#)"
example = "https://kemono.su/SERVICE/user/12345" example = "https://kemono.su/SERVICE/user/12345"
def __init__(self, match): def __init__(self, match):
_, _, service, user_id, self.query = match.groups() self.subcategory = match.group(3)
self.subcategory = service
KemonopartyExtractor.__init__(self, match) KemonopartyExtractor.__init__(self, match)
self.api_url = "{}/api/v1/{}/user/{}".format(
self.root, service, user_id)
self.user_url = "{}/{}/user/{}".format(self.root, service, user_id)
def posts(self): def posts(self):
url = self.api_url _, _, service, creator_id, query = self.groups
params = text.parse_query(self.query) params = text.parse_query(query)
params["o"] = text.parse_int(params.get("o")) return self.api.creator_posts(
service, creator_id, params.get("o"), params.get("q"))
while True:
posts = self.request(url, params=params).json()
if self.revisions:
for post in posts:
post_url = "{}/api/v1/{}/user/{}/post/{}".format(
self.root, post["service"], post["user"], post["id"])
yield from self._revisions_post(post, post_url)
else:
yield from posts
if len(posts) < 50:
break
params["o"] += 50
class KemonopartyPostsExtractor(KemonopartyExtractor): class KemonopartyPostsExtractor(KemonopartyExtractor):
"""Extractor for kemono.su post listings""" """Extractor for kemono.su post listings"""
subcategory = "posts" subcategory = "posts"
pattern = BASE_PATTERN + r"/posts(?:/?\?([^#]+))?" pattern = BASE_PATTERN + r"/posts()()(?:/?\?([^#]+))?"
example = "https://kemono.su/posts" example = "https://kemono.su/posts"
def __init__(self, match): def posts(self):
KemonopartyExtractor.__init__(self, match) params = text.parse_query(self.groups[4])
self.query = match.group(3) return self.api.posts(
self.api_url = self.root + "/api/v1/posts" params.get("o"), params.get("q"), params.get("tag"))
posts = KemonopartyUserExtractor.posts
class KemonopartyPostExtractor(KemonopartyExtractor): class KemonopartyPostExtractor(KemonopartyExtractor):
@@ -358,27 +309,23 @@ class KemonopartyPostExtractor(KemonopartyExtractor):
example = "https://kemono.su/SERVICE/user/12345/post/12345" example = "https://kemono.su/SERVICE/user/12345/post/12345"
def __init__(self, match): def __init__(self, match):
_, _, service, user_id, post_id, self.revision, self.revision_id = \ self.subcategory = match.group(3)
match.groups()
self.subcategory = service
KemonopartyExtractor.__init__(self, match) KemonopartyExtractor.__init__(self, match)
self.api_url = "{}/api/v1/{}/user/{}/post/{}".format(
self.root, service, user_id, post_id)
self.user_url = "{}/{}/user/{}".format(self.root, service, user_id)
def posts(self): def posts(self):
if not self.revision: _, _, service, creator_id, post_id, revision, revision_id = self.groups
post = self.request(self.api_url).json() post = self.api.creator_post(service, creator_id, post_id)
if self.revisions: if not revision:
return self._revisions_post(post, self.api_url) return (post["post"],)
return (post,)
revs = self._revisions_all(self.api_url) self.revisions = False
if not self.revision_id:
revs = self._revisions_all(service, creator_id, post_id)
if not revision_id:
return revs return revs
for rev in revs: for rev in revs:
if str(rev["revision_id"]) == self.revision_id: if str(rev["revision_id"]) == revision_id:
return (rev,) return (rev,)
raise exception.NotFoundError("revision") raise exception.NotFoundError("revision")
@@ -394,37 +341,35 @@ class KemonopartyDiscordExtractor(KemonopartyExtractor):
pattern = BASE_PATTERN + r"/discord/server/(\d+)(?:/channel/(\d+))?#(.*)" pattern = BASE_PATTERN + r"/discord/server/(\d+)(?:/channel/(\d+))?#(.*)"
example = "https://kemono.su/discord/server/12345#CHANNEL" example = "https://kemono.su/discord/server/12345#CHANNEL"
def __init__(self, match):
KemonopartyExtractor.__init__(self, match)
_, _, self.server, self.channel_id, self.channel = match.groups()
self.channel_name = ""
def items(self): def items(self):
self._prepare_ddosguard_cookies() self._prepare_ddosguard_cookies()
if self.channel_id: _, _, server_id, channel_id, channel = self.groups
self.channel_name = self.channel channel_name = ""
if channel_id:
channel_name = channel
else: else:
if self.channel.isdecimal() and len(self.channel) >= 16: if channel.isdecimal() and len(channel) >= 16:
key = "id" key = "id"
else: else:
key = "name" key = "name"
for channel in self._discord_channels(self.server): for ch in self.api.discord_server(server_id):
if channel[key] == self.channel: if ch[key] == channel:
break break
else: else:
raise exception.NotFoundError("channel") raise exception.NotFoundError("channel")
self.channel_id = channel["id"] channel_id = ch["id"]
self.channel_name = channel["name"] channel_name = ch["name"]
find_inline = re.compile( find_inline = re.compile(
r"https?://(?:cdn\.discordapp.com|media\.discordapp\.net)" r"https?://(?:cdn\.discordapp.com|media\.discordapp\.net)"
r"(/[A-Za-z0-9-._~:/?#\[\]@!$&'()*+,;%=]+)").findall r"(/[A-Za-z0-9-._~:/?#\[\]@!$&'()*+,;%=]+)").findall
find_hash = re.compile(HASH_PATTERN).match find_hash = re.compile(HASH_PATTERN).match
posts = self.posts() posts = self.api.discord_channel(channel_id)
max_posts = self.config("max-posts") max_posts = self.config("max-posts")
if max_posts: if max_posts:
posts = itertools.islice(posts, max_posts) posts = itertools.islice(posts, max_posts)
@@ -441,7 +386,7 @@ class KemonopartyDiscordExtractor(KemonopartyExtractor):
append({"path": "https://cdn.discordapp.com" + path, append({"path": "https://cdn.discordapp.com" + path,
"name": path, "type": "inline", "hash": ""}) "name": path, "type": "inline", "hash": ""})
post["channel_name"] = self.channel_name post["channel_name"] = channel_name
post["date"] = self._parse_datetime(post["published"]) post["date"] = self._parse_datetime(post["published"])
post["count"] = len(files) post["count"] = len(files)
yield Message.Directory, post yield Message.Directory, post
@@ -461,33 +406,17 @@ class KemonopartyDiscordExtractor(KemonopartyExtractor):
url = self.root + "/data" + url[20:] url = self.root + "/data" + url[20:]
yield Message.Url, url, post yield Message.Url, url, post
def posts(self):
url = "{}/api/v1/discord/channel/{}".format(
self.root, self.channel_id)
params = {"o": 0}
while True:
posts = self.request(url, params=params).json()
yield from posts
if len(posts) < 150:
break
params["o"] += 150
class KemonopartyDiscordServerExtractor(KemonopartyExtractor): class KemonopartyDiscordServerExtractor(KemonopartyExtractor):
subcategory = "discord-server" subcategory = "discord-server"
pattern = BASE_PATTERN + r"/discord/server/(\d+)$" pattern = BASE_PATTERN + r"/discord/server/(\d+)$"
example = "https://kemono.su/discord/server/12345" example = "https://kemono.su/discord/server/12345"
def __init__(self, match):
KemonopartyExtractor.__init__(self, match)
self.server = match.group(3)
def items(self): def items(self):
for channel in self._discord_channels(self.server): server_id = self.groups[2]
for channel in self.api.discord_server(server_id):
url = "{}/discord/server/{}/channel/{}#{}".format( url = "{}/discord/server/{}/channel/{}#{}".format(
self.root, self.server, channel["id"], channel["name"]) self.root, server_id, channel["id"], channel["name"])
channel["_extractor"] = KemonopartyDiscordExtractor channel["_extractor"] = KemonopartyDiscordExtractor
yield Message.Queue, url, channel yield Message.Queue, url, channel
@@ -541,3 +470,100 @@ class KemonopartyFavoriteExtractor(KemonopartyExtractor):
url = "{}/{}/user/{}/post/{}".format( url = "{}/{}/user/{}/post/{}".format(
self.root, post["service"], post["user"], post["id"]) self.root, post["service"], post["user"], post["id"])
yield Message.Queue, url, post yield Message.Queue, url, post
class KemonoAPI():
"""Interface for the Kemono API v1.1.0
https://kemono.su/documentation/api
"""
def __init__(self, extractor):
self.extractor = extractor
self.root = extractor.root + "/api/v1"
def posts(self, offset=0, query=None, tags=None):
endpoint = "/posts"
params = {"q": query, "o": offset, "tags": tags}
return self._pagination(endpoint, params, 50, "posts")
def creator_posts(self, service, creator_id, offset=0, query=None):
endpoint = "/{}/user/{}".format(service, creator_id)
params = {"q": query, "o": offset}
return self._pagination(endpoint, params, 50)
def creator_announcements(self, service, creator_id):
endpoint = "/{}/user/{}/announcements".format(service, creator_id)
return self._call(endpoint)
def creator_dms(self, service, creator_id):
endpoint = "/{}/user/{}/dms".format(service, creator_id)
return self._call(endpoint)
def creator_fancards(self, service, creator_id):
endpoint = "/{}/user/{}/fancards".format(service, creator_id)
return self._call(endpoint)
def creator_post(self, service, creator_id, post_id):
endpoint = "/{}/user/{}/post/{}".format(service, creator_id, post_id)
return self._call(endpoint)
def creator_post_comments(self, service, creator_id, post_id):
endpoint = "/{}/user/{}/post/{}/comments".format(
service, creator_id, post_id)
return self._call(endpoint)
def creator_post_revisions(self, service, creator_id, post_id):
endpoint = "/{}/user/{}/post/{}/revisions".format(
service, creator_id, post_id)
return self._call(endpoint)
def creator_profile(self, service, creator_id):
endpoint = "/{}/user/{}/profile".format(service, creator_id)
return self._call(endpoint)
def creator_links(self, service, creator_id):
endpoint = "/{}/user/{}/links".format(service, creator_id)
return self._call(endpoint)
def creator_tags(self, service, creator_id):
endpoint = "/{}/user/{}/tags".format(service, creator_id)
return self._call(endpoint)
def discord_channel(self, channel_id):
endpoint = "/discord/channel/{}".format(channel_id)
return self._pagination(endpoint, {}, 150)
def discord_server(self, server_id):
endpoint = "/discord/channel/lookup/{}".format(server_id)
return self._call(endpoint)
def account_favorites(self, type):
endpoint = "/account/favorites"
params = {"type": type}
return self._call(endpoint, params)
def authentication_login(self, username, password):
endpoint = "/authentication/login"
params = {"username": username, "password": password}
return self._call(endpoint, params)
def _call(self, endpoint, params=None):
url = self.root + endpoint
response = self.extractor.request(url, params=params)
return response.json()
def _pagination(self, endpoint, params, batch=50, key=False):
params["o"] = text.parse_int(params.get("o")) % 50
while True:
data = self._call(endpoint, params)
if key:
yield from data[key]
else:
yield from data
if len(data) < batch:
return
params["o"] += batch

View File

@@ -23,7 +23,7 @@ __tests__ = (
"#category": ("", "kemonoparty", "patreon"), "#category": ("", "kemonoparty", "patreon"),
"#class" : kemonoparty.KemonopartyUserExtractor, "#class" : kemonoparty.KemonopartyUserExtractor,
"#options" : {"max-posts": 100}, "#options" : {"max-posts": 100},
"#count" : range(200, 300), "#count" : range(200, 400),
}, },
{ {
@@ -92,7 +92,7 @@ __tests__ = (
"#url" : "https://kemono.su/gumroad/user/3101696181060/post/tOWyf", "#url" : "https://kemono.su/gumroad/user/3101696181060/post/tOWyf",
"#category": ("", "kemonoparty", "gumroad"), "#category": ("", "kemonoparty", "gumroad"),
"#class" : kemonoparty.KemonopartyPostExtractor, "#class" : kemonoparty.KemonopartyPostExtractor,
"#urls" : "https://kemono.su/data/6f/13/6f1394b19516396ea520254350662c254bbea30c1e111fd4b0f042c61c426d07.zip", "#count" : 12,
}, },
{ {
@@ -129,10 +129,19 @@ __tests__ = (
"#class" : kemonoparty.KemonopartyPostExtractor, "#class" : kemonoparty.KemonopartyPostExtractor,
"#options" : {"dms": True}, "#options" : {"dms": True},
"dms": [{ "dms": [
"body": r"re:Hi! Thank you very much for supporting the work I did in May. Here's your reward pack! I hope you find something you enjoy in it. :\)\n\nhttps://www.mediafire.com/file/\w+/Set13_tier_2.zip/file", {
"date": "2021-06", "added" : "2021-07-31T02:47:51.327865",
}], "artist" : None,
"content" : "Hi! Thank you very much for supporting the work I did in May. Here's your reward pack! I hope you find something you enjoy in it. :)\n\nhttps://www.mediafire.com/file/n9ppjpip0r3f01v/Set13_tier_2.zip/file",
"embed" : {},
"file" : {},
"hash" : "f8d4962fb7908614c9b7c8c0de1b5f8985f01b62a9b06d74d640c5b2bcedf758",
"published": "2021-06-09T03:28:51.431000",
"service" : "patreon",
"user" : "34134344",
},
],
}, },
{ {
@@ -142,10 +151,16 @@ __tests__ = (
"#class" : kemonoparty.KemonopartyPostExtractor, "#class" : kemonoparty.KemonopartyPostExtractor,
"#options" : {"announcements": True}, "#options" : {"announcements": True},
"announcements": [{ "announcements": [
"body": "<div><strong>Thank you so much for the support!</strong><strong><br></strong>This Patreon is more of a tip jar for supporting what I make. I have to clarify that there are <strong>no exclusive Patreon animations</strong> because all are released for the public. You will get earlier access to WIPs. Direct downloads to my works are also available for $5 and $10 Tiers.</div>", {
"date": "2023-02", "added" : "2023-02-01T22:44:34.670719",
}], "content" : "<div style=\"text-align: center;\"><strong>Thank you so much for the support!</strong><strong><br></strong>This Patreon is more of a tip jar for supporting what I make. I have to clarify that there are <strong>no exclusive Patreon animations</strong>&nbsp;because all are released for the public. You will get earlier access to WIPs. Direct downloads to my works are also available for $5 and $10 Tiers.</div>",
"hash" : "815648d41c60d1d546437e475a0888fd4a77fd098b1ec61a3648ea6da30c1034",
"published": None,
"service" : "patreon",
"user_id" : "3161935",
},
],
}, },
{ {
@@ -207,7 +222,7 @@ __tests__ = (
"hash" : "88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86", "hash" : "88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86",
"revision_id" : 142470, "revision_id" : 142470,
"revision_index": 2, "revision_index": 2,
"revision_count": 9, "revision_count": 10,
"revision_hash" : "e0e93281495e151b11636c156e52bfe9234c2a40", "revision_hash" : "e0e93281495e151b11636c156e52bfe9234c2a40",
}, },
@@ -218,13 +233,15 @@ __tests__ = (
"#class" : kemonoparty.KemonopartyPostExtractor, "#class" : kemonoparty.KemonopartyPostExtractor,
"#options" : {"revisions": "unique"}, "#options" : {"revisions": "unique"},
"#urls" : "https://kemono.su/data/88/52/88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86.jpg", "#urls" : "https://kemono.su/data/88/52/88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86.jpg",
"#archive" : False,
"filename" : "wip update", "filename" : "wip update",
"hash" : "88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86", "hash" : "88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86",
"revision_id" : 0, "revision_id" : {9277608, 0},
"revision_index": 1, "revision_index": {1, 2},
"revision_count": 1, "revision_count": 2,
"revision_hash" : "e0e93281495e151b11636c156e52bfe9234c2a40", "revision_hash" : {"e0e93281495e151b11636c156e52bfe9234c2a40",
"79d5967719583a6fa52b2fc143e6a80fcdf75fb8"},
}, },
{ {
@@ -233,12 +250,12 @@ __tests__ = (
"#category": ("", "kemonoparty", "patreon"), "#category": ("", "kemonoparty", "patreon"),
"#class" : kemonoparty.KemonopartyPostExtractor, "#class" : kemonoparty.KemonopartyPostExtractor,
"#pattern" : r"https://kemono\.su/data/88/52/88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86\.jpg", "#pattern" : r"https://kemono\.su/data/88/52/88521f71822dfa2f42df3beba319ea4fceda2a2d6dc59da0276a75238f743f86\.jpg",
"#count" : 9, "#count" : 10,
"#archive" : False, "#archive" : False,
"revision_id": range(134996, 3052965), "revision_id": range(134996, 9277608),
"revision_index": range(1, 9), "revision_index": range(1, 10),
"revision_count": 9, "revision_count": 10,
"revision_hash": "e0e93281495e151b11636c156e52bfe9234c2a40", "revision_hash": "e0e93281495e151b11636c156e52bfe9234c2a40",
}, },
@@ -341,15 +358,7 @@ __tests__ = (
"#category": ("", "kemonoparty", "discord-server"), "#category": ("", "kemonoparty", "discord-server"),
"#class" : kemonoparty.KemonopartyDiscordServerExtractor, "#class" : kemonoparty.KemonopartyDiscordServerExtractor,
"#pattern" : kemonoparty.KemonopartyDiscordExtractor.pattern, "#pattern" : kemonoparty.KemonopartyDiscordExtractor.pattern,
"#count" : 13, "#count" : 15,
},
{
"#url" : "https://kemono.su/discord/server/488668827274444803",
"#category": ("", "kemonoparty", "discord-server"),
"#class" : kemonoparty.KemonopartyDiscordServerExtractor,
"#pattern" : kemonoparty.KemonopartyDiscordExtractor.pattern,
"#count" : 13,
}, },
{ {