Merge branch '1.17.0'

This commit is contained in:
Mike Fährmann
2021-02-15 02:32:33 +01:00
17 changed files with 587 additions and 745 deletions

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2020 Mike Fährmann # Copyright 2015-2021 Mike Fährmann
# #
# This program is free software; you can redistribute it and/or modify # This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
@@ -8,7 +8,7 @@
"""Extractors for *booru sites""" """Extractors for *booru sites"""
from .common import Extractor, Message, generate_extractors from .common import BaseExtractor, Message
from .. import text, util, exception from .. import text, util, exception
from xml.etree import ElementTree from xml.etree import ElementTree
@@ -17,7 +17,7 @@ import operator
import re import re
class BooruExtractor(Extractor): class BooruExtractor(BaseExtractor):
"""Base class for *booru extractors""" """Base class for *booru extractors"""
basecategory = "booru" basecategory = "booru"
filename_fmt = "{category}_{id}_{md5}.{extension}" filename_fmt = "{category}_{id}_{md5}.{extension}"
@@ -104,14 +104,55 @@ class BooruExtractor(Extractor):
params["pid"] += 1 params["pid"] += 1
BASE_PATTERN = BooruExtractor.update({
"rule34": {
"root": "https://rule34.xxx",
},
"safebooru": {
"root": "https://safebooru.org",
},
"realbooru": {
"root": "https://realbooru.com",
},
})
class BooruPostExtractor(BooruExtractor): class BooruPostExtractor(BooruExtractor):
subcategory = "post" subcategory = "post"
archive_fmt = "{id}" archive_fmt = "{id}"
pattern_fmt = r"/index\.php\?page=post&s=view&id=(\d+)" pattern = BASE_PATTERN + r"/index\.php\?page=post&s=view&id=(\d+)"
test = (
("https://rule34.xxx/index.php?page=post&s=view&id=1995545", {
"content": "97e4bbf86c3860be18de384d02d544251afe1d45",
"options": (("tags", True),),
"keyword": {
"tags_artist": "danraku",
"tags_character": "kashima_(kantai_collection)",
"tags_copyright": "kantai_collection",
"tags_general": str,
"tags_metadata": str,
},
}),
("https://safebooru.org/index.php?page=post&s=view&id=1169132", {
"url": "cf05e37a3c62b2d55788e2080b8eabedb00f999b",
"content": "93b293b27dabd198afafabbaf87c49863ac82f27",
"options": (("tags", True),),
"keyword": {
"tags_artist": "kawanakajima",
"tags_character": "heath_ledger ronald_mcdonald the_joker",
"tags_copyright": "dc_comics mcdonald's the_dark_knight",
"tags_general": str,
},
}),
("https://realbooru.com/index.php?page=post&s=view&id=668483", {
"url": "2421b5b0e15d5e20f9067090a8b0fd4114d3e7d9",
"content": "7f5873ce3b6cd295ea2e81fcb49583098ea9c8da",
}),
)
def __init__(self, match): def __init__(self, match):
BooruExtractor.__init__(self, match) BooruExtractor.__init__(self, match)
self.post_id = match.group(1) self.post_id = match.group(match.lastindex)
def posts(self): def posts(self):
return self._pagination({"id": self.post_id}) return self._pagination({"id": self.post_id})
@@ -121,11 +162,26 @@ class BooruTagExtractor(BooruExtractor):
subcategory = "tag" subcategory = "tag"
directory_fmt = ("{category}", "{search_tags}") directory_fmt = ("{category}", "{search_tags}")
archive_fmt = "t_{search_tags}_{id}" archive_fmt = "t_{search_tags}_{id}"
pattern_fmt = r"/index\.php\?page=post&s=list&tags=([^&#]+)" pattern = BASE_PATTERN + r"/index\.php\?page=post&s=list&tags=([^&#]+)"
test = (
("https://rule34.xxx/index.php?page=post&s=list&tags=danraku", {
"content": "97e4bbf86c3860be18de384d02d544251afe1d45",
"pattern": r"https?://.*rule34\.xxx/images/\d+/[0-9a-f]+\.jpg",
"count": 1,
}),
("https://safebooru.org/index.php?page=post&s=list&tags=bonocho", {
"url": "17c61b386530cf4c30842c9f580d15ef1cd09586",
"content": "e5ad4c5bf241b1def154958535bef6c2f6b733eb",
}),
("https://realbooru.com/index.php?page=post&s=list&tags=wine", {
"count": ">= 64",
}),
)
def __init__(self, match): def __init__(self, match):
BooruExtractor.__init__(self, match) BooruExtractor.__init__(self, match)
self.tags = text.unquote(match.group(1).replace("+", " ")) tags = match.group(match.lastindex)
self.tags = text.unquote(tags.replace("+", " "))
def metadata(self): def metadata(self):
return {"search_tags": self.tags} return {"search_tags": self.tags}
@@ -138,11 +194,22 @@ class BooruPoolExtractor(BooruExtractor):
subcategory = "pool" subcategory = "pool"
directory_fmt = ("{category}", "pool", "{pool}") directory_fmt = ("{category}", "pool", "{pool}")
archive_fmt = "p_{pool}_{id}" archive_fmt = "p_{pool}_{id}"
pattern_fmt = r"/index\.php\?page=pool&s=show&id=(\d+)" pattern = BASE_PATTERN + r"/index\.php\?page=pool&s=show&id=(\d+)"
test = (
("https://rule34.xxx/index.php?page=pool&s=show&id=179", {
"count": 3,
}),
("https://safebooru.org/index.php?page=pool&s=show&id=11", {
"count": 5,
}),
("https://realbooru.com/index.php?page=pool&s=show&id=1", {
"count": 3,
}),
)
def __init__(self, match): def __init__(self, match):
BooruExtractor.__init__(self, match) BooruExtractor.__init__(self, match)
self.pool_id = match.group(1) self.pool_id = match.group(match.lastindex)
self.post_ids = () self.post_ids = ()
def skip(self, num): def skip(self, num):
@@ -170,87 +237,3 @@ class BooruPoolExtractor(BooruExtractor):
for params["id"] in util.advance(self.post_ids, self.page_start): for params["id"] in util.advance(self.post_ids, self.page_start):
for post in self._api_request(params): for post in self._api_request(params):
yield post.attrib yield post.attrib
EXTRACTORS = {
"rule34": {
"root": "https://rule34.xxx",
"test-tag": (
("https://rule34.xxx/index.php?page=post&s=list&tags=danraku", {
"content": "97e4bbf86c3860be18de384d02d544251afe1d45",
"pattern": r"https?://.*rule34\.xxx/images/\d+/[0-9a-f]+\.jpg",
"count": 1,
}),
),
"test-pool": (
("https://rule34.xxx/index.php?page=pool&s=show&id=179", {
"count": 3,
}),
),
"test-post": (
("https://rule34.xxx/index.php?page=post&s=view&id=1995545", {
"content": "97e4bbf86c3860be18de384d02d544251afe1d45",
"options": (("tags", True),),
"keyword": {
"tags_artist": "danraku",
"tags_character": "kashima_(kantai_collection)",
"tags_copyright": "kantai_collection",
"tags_general": str,
"tags_metadata": str,
},
}),
),
},
"safebooru": {
"root": "https://safebooru.org",
"test-tag": (
("https://safebooru.org/index.php?page=post&s=list&tags=bonocho", {
"url": "17c61b386530cf4c30842c9f580d15ef1cd09586",
"content": "e5ad4c5bf241b1def154958535bef6c2f6b733eb",
}),
),
"test-pool": (
("https://safebooru.org/index.php?page=pool&s=show&id=11", {
"count": 5,
}),
),
"test-post": (
("https://safebooru.org/index.php?page=post&s=view&id=1169132", {
"url": "cf05e37a3c62b2d55788e2080b8eabedb00f999b",
"content": "93b293b27dabd198afafabbaf87c49863ac82f27",
"options": (("tags", True),),
"keyword": {
"tags_artist": "kawanakajima",
"tags_character": "heath_ledger ronald_mcdonald the_joker",
"tags_copyright": "dc_comics mcdonald's the_dark_knight",
"tags_general": str,
},
}),
),
},
"realbooru": {
"root": "https://realbooru.com",
"test-tag": (
("https://realbooru.com/index.php?page=post&s=list&tags=wine", {
"count": ">= 64",
}),
),
"test-pool": (
("https://realbooru.com/index.php?page=pool&s=show&id=1", {
"count": 3,
}),
),
"test-post": (
("https://realbooru.com/index.php?page=post&s=view&id=668483", {
"url": "2421b5b0e15d5e20f9067090a8b0fd4114d3e7d9",
"content": "7f5873ce3b6cd295ea2e81fcb49583098ea9c8da",
}),
),
},
}
generate_extractors(EXTRACTORS, globals(), (
BooruTagExtractor,
BooruPoolExtractor,
BooruPostExtractor,
))

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2014-2020 Mike Fährmann # Copyright 2014-2021 Mike Fährmann
# #
# This program is free software; you can redistribute it and/or modify # This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
@@ -527,46 +527,37 @@ class AsynchronousMixin():
messages.put(None) messages.put(None)
def generate_extractors(extractor_data, symtable, classes): class BaseExtractor(Extractor):
"""Dynamically generate Extractor classes""" instances = None
extractors = config.get(("extractor",), classes[0].basecategory)
ckey = extractor_data.get("_ckey")
prev = None
if extractors: def __init__(self, match):
extractor_data.update(extractors) if not self.category:
for index, group in enumerate(match.groups()):
if group is not None:
self.category, self.root = self.instances[index]
break
Extractor.__init__(self, match)
for category, info in extractor_data.items(): @classmethod
def update(cls, instances):
if not isinstance(info, dict) or "root" not in info: extra_instances = config.get(("extractor",), cls.basecategory)
continue if extra_instances:
for category, info in extra_instances.items():
if isinstance(info, dict) and "root" in info:
instances[category] = info
pattern_list = []
instance_list = cls.instances = []
for category, info in instances.items():
root = info["root"] root = info["root"]
domain = root[root.index(":") + 3:] instance_list.append((category, root))
pattern = info.get("pattern") or re.escape(domain)
name = (info.get("name") or category).capitalize()
for cls in classes: pattern = info.get("pattern")
if not pattern:
pattern = re.escape(root[root.index(":") + 3:])
pattern_list.append(pattern + "()")
class Extr(cls): return r"(?:https?://)?(?:" + "|".join(pattern_list) + r")"
pass
Extr.__module__ = cls.__module__
Extr.__name__ = Extr.__qualname__ = \
name + cls.subcategory.capitalize() + "Extractor"
Extr.__doc__ = \
"Extractor for " + cls.subcategory + "s from " + domain
Extr.category = category
Extr.pattern = r"(?:https?://)?" + pattern + cls.pattern_fmt
Extr.test = info.get("test-" + cls.subcategory)
Extr.root = root
if "extra" in info:
for key, value in info["extra"].items():
setattr(Extr, key, value)
if prev and ckey:
setattr(Extr, ckey, prev)
symtable[Extr.__name__] = prev = Extr
# Undo automatic pyOpenSSL injection by requests # Undo automatic pyOpenSSL injection by requests

View File

@@ -8,21 +8,21 @@
"""Extractors for 4chan archives based on FoolFuuka""" """Extractors for 4chan archives based on FoolFuuka"""
from .common import Extractor, Message, generate_extractors from .common import BaseExtractor, Message
from .. import text from .. import text
import itertools import itertools
class FoolfuukaExtractor(Extractor): class FoolfuukaExtractor(BaseExtractor):
"""Base extractor for FoolFuuka based boards/archives""" """Base extractor for FoolFuuka based boards/archives"""
basecategory = "foolfuuka" basecategory = "foolfuuka"
archive_fmt = "{board[shortname]}_{num}_{timestamp}" archive_fmt = "{board[shortname]}_{num}_{timestamp}"
external = "default" external = "default"
def __init__(self, match): def __init__(self, match):
Extractor.__init__(self, match) BaseExtractor.__init__(self, match)
self.session.headers["Referer"] = self.root self.session.headers["Referer"] = self.root
if self.external == "direct": if self.category == "b4k":
self.remote = self._remote_direct self.remote = self._remote_direct
def items(self): def items(self):
@@ -43,7 +43,7 @@ class FoolfuukaExtractor(Extractor):
yield Message.Url, url, post yield Message.Url, url, post
def metadata(self): def metadata(self):
""" """ """Return general metadata"""
def posts(self): def posts(self):
"""Return an iterable with all relevant posts""" """Return an iterable with all relevant posts"""
@@ -59,16 +59,90 @@ class FoolfuukaExtractor(Extractor):
return media["remote_media_link"] return media["remote_media_link"]
BASE_PATTERN = FoolfuukaExtractor.update({
"4plebs": {
"root": "https://archive.4plebs.org",
"pattern": r"(?:archive\.)?4plebs\.org",
},
"archivedmoe": {
"root": "https://archived.moe",
},
"archiveofsins": {
"root": "https://archiveofsins.com",
"pattern": r"(?:www\.)?archiveofsins\.com",
},
"b4k": {
"root": "https://arch.b4k.co",
},
"desuarchive": {
"root": "https://desuarchive.org",
},
"fireden": {
"root": "https://boards.fireden.net",
},
"nyafuu": {
"root": "https://archive.nyafuu.org",
"pattern": r"(?:archive\.)?nyafuu\.org",
},
"rbt": {
"root": "https://rbt.asia",
"pattern": r"(?:rbt\.asia|(?:archive\.)?rebeccablacktech\.com)",
},
"thebarchive": {
"root": "https://thebarchive.com",
"pattern": r"thebarchive\.com",
},
})
class FoolfuukaThreadExtractor(FoolfuukaExtractor): class FoolfuukaThreadExtractor(FoolfuukaExtractor):
"""Base extractor for threads on FoolFuuka based boards/archives""" """Base extractor for threads on FoolFuuka based boards/archives"""
subcategory = "thread" subcategory = "thread"
directory_fmt = ("{category}", "{board[shortname]}", directory_fmt = ("{category}", "{board[shortname]}",
"{thread_num}{title:? - //}") "{thread_num}{title:? - //}")
pattern_fmt = r"/([^/?#]+)/thread/(\d+)" pattern = BASE_PATTERN + r"/([^/?#]+)/thread/(\d+)"
test = (
("https://archive.4plebs.org/tg/thread/54059290", {
"url": "07452944164b602502b02b24521f8cee5c484d2a",
}),
("https://archived.moe/gd/thread/309639/", {
"url": "fdd533840e2d535abd162c02d6dfadbc12e2dcd8",
"content": "c27e2a7be3bc989b5dd859f7789cc854db3f5573",
}),
("https://archived.moe/a/thread/159767162/", {
"url": "ffec05a1a1b906b5ca85992513671c9155ee9e87",
}),
("https://archiveofsins.com/h/thread/4668813/", {
"url": "f612d287087e10a228ef69517cf811539db9a102",
"content": "0dd92d0d8a7bf6e2f7d1f5ac8954c1bcf18c22a4",
}),
("https://arch.b4k.co/meta/thread/196/", {
"url": "d309713d2f838797096b3e9cb44fe514a9c9d07a",
}),
("https://desuarchive.org/a/thread/159542679/", {
"url": "3ae1473f6916ac831efe5cc4d4e7d3298ce79406",
}),
("https://boards.fireden.net/sci/thread/11264294/", {
"url": "3adfe181ee86a8c23021c705f623b3657a9b0a43",
}),
("https://archive.nyafuu.org/c/thread/2849220/", {
"url": "bbe6f82944a45e359f5c8daf53f565913dc13e4f",
}),
("https://rbt.asia/g/thread/61487650/", {
"url": "61896d9d9a2edb556b619000a308a984307b6d30",
}),
("https://archive.rebeccablacktech.com/g/thread/61487650/", {
"url": "61896d9d9a2edb556b619000a308a984307b6d30",
}),
("https://thebarchive.com/b/thread/739772332/", {
"url": "e8b18001307d130d67db31740ce57c8561b5d80c",
}),
)
def __init__(self, match): def __init__(self, match):
FoolfuukaExtractor.__init__(self, match) FoolfuukaExtractor.__init__(self, match)
self.board, self.thread = match.groups() self.board = match.group(match.lastindex-1)
self.thread = match.group(match.lastindex)
self.data = None self.data = None
def metadata(self): def metadata(self):
@@ -78,23 +152,34 @@ class FoolfuukaThreadExtractor(FoolfuukaExtractor):
return self.data["op"] return self.data["op"]
def posts(self): def posts(self):
op = (self.data["op"],)
posts = self.data.get("posts") posts = self.data.get("posts")
if posts: if posts:
posts = list(posts.values()) posts = list(posts.values())
posts.sort(key=lambda p: p["timestamp"]) posts.sort(key=lambda p: p["timestamp"])
else: return itertools.chain(op, posts)
posts = () return op
return itertools.chain((self.data["op"],), posts)
class FoolfuukaBoardExtractor(FoolfuukaExtractor): class FoolfuukaBoardExtractor(FoolfuukaExtractor):
"""Base extractor for FoolFuuka based boards/archives""" """Base extractor for FoolFuuka based boards/archives"""
subcategory = "board" subcategory = "board"
pattern_fmt = r"/([^/?#]+)/\d*$" pattern = BASE_PATTERN + r"/([^/?#]+)/\d*$"
test = (
("https://archive.4plebs.org/tg/"),
("https://archived.moe/gd/"),
("https://archiveofsins.com/h/"),
("https://arch.b4k.co/meta/"),
("https://desuarchive.org/a/"),
("https://boards.fireden.net/sci/"),
("https://archive.nyafuu.org/c/"),
("https://rbt.asia/g/"),
("https://thebarchive.com/b/"),
)
def __init__(self, match): def __init__(self, match):
FoolfuukaExtractor.__init__(self, match) FoolfuukaExtractor.__init__(self, match)
self.board = match.group(1) self.board = match.group(match.lastindex)
def items(self): def items(self):
index_base = "{}/_/api/chan/index/?board={}&page=".format( index_base = "{}/_/api/chan/index/?board={}&page=".format(
@@ -113,7 +198,7 @@ class FoolfuukaBoardExtractor(FoolfuukaExtractor):
for num, thread in threads.items(): for num, thread in threads.items():
thread["url"] = thread_base + format(num) thread["url"] = thread_base + format(num)
thread["_extractor"] = self.childclass thread["_extractor"] = FoolfuukaThreadExtractor
yield Message.Queue, thread["url"], thread yield Message.Queue, thread["url"], thread
@@ -121,15 +206,24 @@ class FoolfuukaSearchExtractor(FoolfuukaExtractor):
"""Base extractor for search results on FoolFuuka based boards/archives""" """Base extractor for search results on FoolFuuka based boards/archives"""
subcategory = "search" subcategory = "search"
directory_fmt = ("{category}", "search", "{search}") directory_fmt = ("{category}", "search", "{search}")
pattern_fmt = r"/([^/?#]+)/search((?:/[^/?#]+/[^/?#]+)+)" pattern = BASE_PATTERN + r"/([^/?#]+)/search((?:/[^/?#]+/[^/?#]+)+)"
request_interval = 1.0 request_interval = 1.0
test = (
("https://archive.4plebs.org/_/search/text/test/"),
("https://archived.moe/_/search/text/test/"),
("https://archiveofsins.com/_/search/text/test/"),
("https://archiveofsins.com/_/search/text/test/"),
("https://desuarchive.org/_/search/text/test/"),
("https://boards.fireden.net/_/search/text/test/"),
("https://archive.nyafuu.org/_/search/text/test/"),
("https://rbt.asia/_/search/text/test/"),
("https://thebarchive.com/_/search/text/test/"),
)
def __init__(self, match): def __init__(self, match):
FoolfuukaExtractor.__init__(self, match) FoolfuukaExtractor.__init__(self, match)
board, search = match.groups()
self.params = params = {} self.params = params = {}
args = search.split("/") args = match.group(match.lastindex).split("/")
key = None key = None
for arg in args: for arg in args:
@@ -138,6 +232,8 @@ class FoolfuukaSearchExtractor(FoolfuukaExtractor):
key = None key = None
else: else:
key = arg key = arg
board = match.group(match.lastindex-1)
if board != "_": if board != "_":
params["boards"] = board params["boards"] = board
@@ -170,105 +266,3 @@ class FoolfuukaSearchExtractor(FoolfuukaExtractor):
if len(posts) <= 3: if len(posts) <= 3:
return return
params["page"] += 1 params["page"] += 1
EXTRACTORS = {
"4plebs": {
"name": "_4plebs",
"root": "https://archive.4plebs.org",
"pattern": r"(?:archive\.)?4plebs\.org",
"test-thread": ("https://archive.4plebs.org/tg/thread/54059290", {
"url": "07452944164b602502b02b24521f8cee5c484d2a",
}),
"test-board": ("https://archive.4plebs.org/tg/",),
"test-search": ("https://archive.4plebs.org/_/search/text/test/",),
},
"archivedmoe": {
"root": "https://archived.moe",
"test-thread": (
("https://archived.moe/gd/thread/309639/", {
"url": "fdd533840e2d535abd162c02d6dfadbc12e2dcd8",
"content": "c27e2a7be3bc989b5dd859f7789cc854db3f5573",
}),
("https://archived.moe/a/thread/159767162/", {
"url": "ffec05a1a1b906b5ca85992513671c9155ee9e87",
}),
),
"test-board": ("https://archived.moe/gd/",),
"test-search": ("https://archived.moe/_/search/text/test/",),
},
"archiveofsins": {
"root": "https://archiveofsins.com",
"pattern": r"(?:www\.)?archiveofsins\.com",
"test-thread": ("https://archiveofsins.com/h/thread/4668813/", {
"url": "f612d287087e10a228ef69517cf811539db9a102",
"content": "0dd92d0d8a7bf6e2f7d1f5ac8954c1bcf18c22a4",
}),
"test-board": ("https://archiveofsins.com/h/",),
"test-search": ("https://archiveofsins.com/_/search/text/test/",),
},
"b4k": {
"root": "https://arch.b4k.co",
"extra": {"external": "direct"},
"test-thread": ("https://arch.b4k.co/meta/thread/196/", {
"url": "d309713d2f838797096b3e9cb44fe514a9c9d07a",
}),
"test-board": ("https://arch.b4k.co/meta/",),
"test-search": ("https://arch.b4k.co/_/search/text/test/",),
},
"desuarchive": {
"root": "https://desuarchive.org",
"test-thread": ("https://desuarchive.org/a/thread/159542679/", {
"url": "3ae1473f6916ac831efe5cc4d4e7d3298ce79406",
}),
"test-board": ("https://desuarchive.org/a/",),
"test-search": ("https://desuarchive.org/_/search/text/test/",),
},
"fireden": {
"root": "https://boards.fireden.net",
"test-thread": ("https://boards.fireden.net/sci/thread/11264294/", {
"url": "3adfe181ee86a8c23021c705f623b3657a9b0a43",
}),
"test-board": ("https://boards.fireden.net/sci/",),
"test-search": ("https://boards.fireden.net/_/search/text/test/",),
},
"nyafuu": {
"root": "https://archive.nyafuu.org",
"pattern": r"(?:archive\.)?nyafuu\.org",
"test-thread": ("https://archive.nyafuu.org/c/thread/2849220/", {
"url": "bbe6f82944a45e359f5c8daf53f565913dc13e4f",
}),
"test-board": ("https://archive.nyafuu.org/c/",),
"test-search": ("https://archive.nyafuu.org/_/search/text/test/",),
},
"rbt": {
"root": "https://rbt.asia",
"pattern": r"(?:rbt\.asia|(?:archive\.)?rebeccablacktech\.com)",
"test-thread": (
("https://rbt.asia/g/thread/61487650/", {
"url": "61896d9d9a2edb556b619000a308a984307b6d30",
}),
("https://archive.rebeccablacktech.com/g/thread/61487650/", {
"url": "61896d9d9a2edb556b619000a308a984307b6d30",
}),
),
"test-board": ("https://rbt.asia/g/",),
"test-search": ("https://rbt.asia/_/search/text/test/",),
},
"thebarchive": {
"root": "https://thebarchive.com",
"pattern": r"thebarchive\.com",
"test-thread": ("https://thebarchive.com/b/thread/739772332/", {
"url": "e8b18001307d130d67db31740ce57c8561b5d80c",
}),
"test-board": ("https://thebarchive.com/b/",),
"test-search": ("https://thebarchive.com/_/search/text/test/",),
},
"_ckey": "childclass",
}
generate_extractors(EXTRACTORS, globals(), (
FoolfuukaThreadExtractor,
FoolfuukaBoardExtractor,
FoolfuukaSearchExtractor,
))

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2016-2020 Mike Fährmann # Copyright 2016-2021 Mike Fährmann
# #
# This program is free software; you can redistribute it and/or modify # This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
@@ -8,23 +8,21 @@
"""Extractors for FoOlSlide based sites""" """Extractors for FoOlSlide based sites"""
from .common import ( from .common import BaseExtractor, Message
Extractor,
ChapterExtractor,
MangaExtractor,
Message,
generate_extractors,
)
from .. import text, util from .. import text, util
import json import json
class FoolslideBase(): class FoolslideExtractor(BaseExtractor):
"""Base class for FoOlSlide extractors""" """Base class for FoOlSlide extractors"""
basecategory = "foolslide" basecategory = "foolslide"
def __init__(self, match):
BaseExtractor.__init__(self, match)
self.gallery_url = self.root + match.group(match.lastindex)
def request(self, url): def request(self, url):
return Extractor.request( return BaseExtractor.request(
self, url, encoding="utf-8", method="POST", data={"adult": "true"}) self, url, encoding="utf-8", method="POST", data={"adult": "true"})
@staticmethod @staticmethod
@@ -40,12 +38,53 @@ class FoolslideBase():
return data return data
class FoolslideChapterExtractor(FoolslideBase, ChapterExtractor): BASE_PATTERN = FoolslideExtractor.update({
"dokireader": {
"root": "https://kobato.hologfx.com/reader",
},
"kireicake": {
"root": "https://reader.kireicake.com",
},
"powermanga": {
"root": "https://read.powermanga.org",
"pattern": r"read(?:er)?\.powermanga\.org",
},
"sensescans": {
"root": "https://sensescans.com/reader",
"pattern": r"(?:(?:www\.)?sensescans\.com/reader"
r"|reader\.sensescans\.com)",
},
})
class FoolslideChapterExtractor(FoolslideExtractor):
"""Base class for chapter extractors for FoOlSlide based sites""" """Base class for chapter extractors for FoOlSlide based sites"""
subcategory = "chapter"
directory_fmt = ("{category}", "{manga}", "{chapter_string}") directory_fmt = ("{category}", "{manga}", "{chapter_string}")
filename_fmt = (
"{manga}_c{chapter:>03}{chapter_minor:?//}_{page:>03}.{extension}")
archive_fmt = "{id}" archive_fmt = "{id}"
pattern_fmt = r"(/read/[^/?#]+/[a-z-]+/\d+/\d+(?:/\d+)?)" pattern = BASE_PATTERN + r"(/read/[^/?#]+/[a-z-]+/\d+/\d+(?:/\d+)?)"
decode = "default" test = (
(("https://kobato.hologfx.com/reader/read/"
"hitoribocchi_no_oo_seikatsu/en/3/34"), {
"keyword": "6e719ac86f0c6dab89390dd7e507e678459e0dbc",
}),
("https://reader.kireicake.com/read/wonderland/en/1/1/", {
"url": "b2d36bc0bc67e4c461c3a4d6444a2fd339f5d07e",
"keyword": "9f80947920a325e33aea7f5cd69ea669171903b6",
}),
(("https://read.powermanga.org"
"/read/one_piece_digital_colour_comics/en/0/75/"), {
"url": "854c5817f8f767e1bccd05fa9d58ffb5a4b09384",
"keyword": "a60c42f2634b7387899299d411ff494ed0ad6dbe",
}),
("https://sensescans.com/reader/read/ao_no_orchestra/en/0/26/", {
"url": "bbd428dc578f5055e9f86ad635b510386cd317cd",
"keyword": "083ef6f8831c84127fe4096fa340a249be9d1424",
}),
("https://reader.sensescans.com/read/ao_no_orchestra/en/0/26/"),
)
def items(self): def items(self):
page = self.request(self.gallery_url).text page = self.request(self.gallery_url).text
@@ -83,9 +122,51 @@ class FoolslideChapterExtractor(FoolslideBase, ChapterExtractor):
return json.loads(text.extract(page, "var pages = ", ";")[0]) return json.loads(text.extract(page, "var pages = ", ";")[0])
class FoolslideMangaExtractor(FoolslideBase, MangaExtractor): class FoolslideMangaExtractor(FoolslideExtractor):
"""Base class for manga extractors for FoOlSlide based sites""" """Base class for manga extractors for FoOlSlide based sites"""
pattern_fmt = r"(/series/[^/?#]+)" subcategory = "manga"
categorytransfer = True
pattern = BASE_PATTERN + r"(/series/[^/?#]+)"
test = (
(("https://kobato.hologfx.com/reader/series/"
"boku_ha_ohimesama_ni_narenai/"), {
"url": "1c1f5a7258ce4f631f5fc32be548d78a6a57990d",
"keyword": "614d89a6045b85c822cbd3e67578ea7577dfc995",
}),
("https://reader.kireicake.com/series/wonderland/", {
"url": "d067b649af1cc88fa8c8b698fde04a10909fd169",
"keyword": "268f43772fb239888ca5c5f6a4f65f99ffb3eefb",
}),
(("https://read.powermanga.org"
"/series/one_piece_digital_colour_comics/"), {
"count": ">= 1",
"keyword": {
"chapter": int,
"chapter_minor": str,
"chapter_string": str,
"group": "PowerManga",
"lang": "en",
"language": "English",
"manga": "One Piece Digital Colour Comics",
"title": str,
"volume": int,
},
}),
("https://sensescans.com/reader/series/yotsubato/", {
"count": ">= 3",
}),
)
def items(self):
page = self.request(self.gallery_url).text
chapters = self.chapters(page)
if not self.config("chapter-reverse", False):
chapters.reverse()
for chapter, data in chapters:
data["_extractor"] = FoolslideChapterExtractor
yield Message.Queue, chapter, data
def chapters(self, page): def chapters(self, page):
extr = text.extract_from(page) extr = text.extract_from(page)
@@ -103,82 +184,3 @@ class FoolslideMangaExtractor(FoolslideBase, MangaExtractor):
"chapter_string": extr('title="', '"'), "chapter_string": extr('title="', '"'),
"group" : extr('title="', '"'), "group" : extr('title="', '"'),
}))) })))
EXTRACTORS = {
"dokireader": {
"root": "https://kobato.hologfx.com/reader",
"test-chapter":
(("https://kobato.hologfx.com/reader/read/"
"hitoribocchi_no_oo_seikatsu/en/3/34"), {
"keyword": "6e719ac86f0c6dab89390dd7e507e678459e0dbc",
}),
"test-manga":
(("https://kobato.hologfx.com/reader/series/"
"boku_ha_ohimesama_ni_narenai/"), {
"url": "1c1f5a7258ce4f631f5fc32be548d78a6a57990d",
"keyword": "614d89a6045b85c822cbd3e67578ea7577dfc995",
}),
},
"kireicake": {
"root": "https://reader.kireicake.com",
"test-chapter":
("https://reader.kireicake.com/read/wonderland/en/1/1/", {
"url": "b2d36bc0bc67e4c461c3a4d6444a2fd339f5d07e",
"keyword": "9f80947920a325e33aea7f5cd69ea669171903b6",
}),
"test-manga":
("https://reader.kireicake.com/series/wonderland/", {
"url": "d067b649af1cc88fa8c8b698fde04a10909fd169",
"keyword": "268f43772fb239888ca5c5f6a4f65f99ffb3eefb",
}),
},
"powermanga": {
"root": "https://read.powermanga.org",
"pattern": r"read(?:er)?\.powermanga\.org",
"test-chapter":
(("https://read.powermanga.org"
"/read/one_piece_digital_colour_comics/en/0/75/"), {
"url": "854c5817f8f767e1bccd05fa9d58ffb5a4b09384",
"keyword": "a60c42f2634b7387899299d411ff494ed0ad6dbe",
}),
"test-manga":
(("https://read.powermanga.org"
"/series/one_piece_digital_colour_comics/"), {
"count": ">= 1",
"keyword": {
"chapter": int,
"chapter_minor": str,
"chapter_string": str,
"group": "PowerManga",
"lang": "en",
"language": "English",
"manga": "One Piece Digital Colour Comics",
"title": str,
"volume": int,
},
}),
},
"sensescans": {
"root": "https://sensescans.com/reader",
"pattern": r"(?:(?:www\.)?sensescans\.com/reader"
r"|reader\.sensescans\.com)",
"test-chapter": (
("https://sensescans.com/reader/read/ao_no_orchestra/en/0/26/", {
"url": "bbd428dc578f5055e9f86ad635b510386cd317cd",
"keyword": "083ef6f8831c84127fe4096fa340a249be9d1424",
}),
("https://reader.sensescans.com/read/ao_no_orchestra/en/0/26/"),
),
"test-manga":
("https://sensescans.com/reader/series/yotsubato/", {
"count": ">= 3",
}),
},
"_ckey": "chapterclass",
}
generate_extractors(EXTRACTORS, globals(), (
FoolslideChapterExtractor,
FoolslideMangaExtractor,
))

View File

@@ -366,13 +366,6 @@ class InstagramUserExtractor(InstagramExtractor):
) )
def items(self): def items(self):
if self.config("highlights"):
self.log.warning("'highlights' is deprecated, "
"use '\"include\": \"…,highlights\"' instead")
default = ("highlights", "posts")
else:
default = ("posts",)
base = "{}/{}/".format(self.root, self.item) base = "{}/{}/".format(self.root, self.item)
stories = "{}/stories/{}/".format(self.root, self.item) stories = "{}/stories/{}/".format(self.root, self.item)
return self._dispatch_extractors(( return self._dispatch_extractors((
@@ -380,7 +373,7 @@ class InstagramUserExtractor(InstagramExtractor):
(InstagramHighlightsExtractor, base + "highlights/"), (InstagramHighlightsExtractor, base + "highlights/"),
(InstagramPostsExtractor , base + "posts/"), (InstagramPostsExtractor , base + "posts/"),
(InstagramChannelExtractor , base + "channel/"), (InstagramChannelExtractor , base + "channel/"),
), default) ), ("posts",))
class InstagramPostsExtractor(InstagramExtractor): class InstagramPostsExtractor(InstagramExtractor):

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2019-2020 Mike Fährmann # Copyright 2019-2021 Mike Fährmann
# #
# This program is free software; you can redistribute it and/or modify # This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
@@ -8,35 +8,25 @@
"""Extractors for mastodon instances""" """Extractors for mastodon instances"""
from .common import Extractor, Message from .common import BaseExtractor, Message
from .. import text, util, config, exception from .. import text, exception
import re from ..cache import cache
class MastodonExtractor(Extractor): class MastodonExtractor(BaseExtractor):
"""Base class for mastodon extractors""" """Base class for mastodon extractors"""
basecategory = "mastodon" basecategory = "mastodon"
directory_fmt = ("mastodon", "{instance}", "{account[username]}") directory_fmt = ("mastodon", "{instance}", "{account[username]}")
filename_fmt = "{category}_{id}_{media[id]}.{extension}" filename_fmt = "{category}_{id}_{media[id]}.{extension}"
archive_fmt = "{media[id]}" archive_fmt = "{media[id]}"
cookiedomain = None cookiedomain = None
instance = None
root = None
def __init__(self, match): def __init__(self, match):
Extractor.__init__(self, match) BaseExtractor.__init__(self, match)
self.api = MastodonAPI(self) self.instance = self.root.partition("://")[2]
self.item = match.group(match.lastindex)
def config(self, key, default=None):
return config.interpolate_common(
("extractor",), (
(self.category, self.subcategory),
(self.basecategory, self.instance, self.subcategory),
), key, default,
)
def items(self): def items(self):
yield Message.Version, 1
for status in self.statuses(): for status in self.statuses():
attachments = status["media_attachments"] attachments = status["media_attachments"]
if attachments: if attachments:
@@ -60,34 +50,81 @@ class MastodonExtractor(Extractor):
status["created_at"][:19], "%Y-%m-%dT%H:%M:%S") status["created_at"][:19], "%Y-%m-%dT%H:%M:%S")
INSTANCES = {
"mastodon.social": {
"root" : "https://mastodon.social",
"access-token" : "Y06R36SMvuXXN5_wiPKFAEFiQaMSQg0o_hGgc86Jj48",
"client-id" : "dBSHdpsnOUZgxOnjKSQrWEPakO3ctM7HmsyoOd4FcRo",
"client-secret": "DdrODTHs_XoeOsNVXnILTMabtdpWrWOAtrmw91wU1zI",
},
"pawoo": {
"root" : "https://pawoo.net",
"access-token" : "c12c9d275050bce0dc92169a28db09d7"
"0d62d0a75a8525953098c167eacd3668",
"client-id" : "978a25f843ec01e53d09be2c290cd75c"
"782bc3b7fdbd7ea4164b9f3c3780c8ff",
"client-secret": "9208e3d4a7997032cf4f1b0e12e5df38"
"8428ef1fadb446dcfeb4f5ed6872d97b",
},
"baraag": {
"root" : "https://baraag.net",
"access-token" : "53P1Mdigf4EJMH-RmeFOOSM9gdSDztmrAYFgabOKKE0",
"client-id" : "czxx2qilLElYHQ_sm-lO8yXuGwOHxLX9RYYaD0-nq1o",
"client-secret": "haMaFdMBgK_-BIxufakmI2gFgkYjqmgXGEO2tB-R2xY",
}
}
BASE_PATTERN = MastodonExtractor.update(INSTANCES)
class MastodonUserExtractor(MastodonExtractor): class MastodonUserExtractor(MastodonExtractor):
"""Extractor for all images of an account/user""" """Extractor for all images of an account/user"""
subcategory = "user" subcategory = "user"
pattern = BASE_PATTERN + r"/@([^/?#]+)(?:/media)?/?$"
def __init__(self, match): test = (
MastodonExtractor.__init__(self, match) ("https://mastodon.social/@jk", {
self.account_name = match.group(1) "pattern": r"https://files.mastodon.social/media_attachments"
r"/files/(\d+/){3,}original/\w+",
"range": "1-60",
"count": 60,
}),
("https://pawoo.net/@yoru_nine/", {
"range": "1-60",
"count": 60,
}),
("https://baraag.net/@pumpkinnsfw"),
)
def statuses(self): def statuses(self):
handle = "@{}@{}".format(self.account_name, self.instance) api = MastodonAPI(self)
for account in self.api.account_search(handle, 1): username = self.item
if account["username"] == self.account_name: handle = "@{}@{}".format(username, self.instance)
for account in api.account_search(handle, 1):
if account["username"] == username:
break break
else: else:
raise exception.NotFoundError("account") raise exception.NotFoundError("account")
return self.api.account_statuses(account["id"]) return api.account_statuses(account["id"])
class MastodonStatusExtractor(MastodonExtractor): class MastodonStatusExtractor(MastodonExtractor):
"""Extractor for images from a status""" """Extractor for images from a status"""
subcategory = "status" subcategory = "status"
pattern = BASE_PATTERN + r"/@[^/?#]+/(\d+)"
def __init__(self, match): test = (
MastodonExtractor.__init__(self, match) ("https://mastodon.social/@jk/103794036899778366", {
self.status_id = match.group(1) "count": 4,
}),
("https://pawoo.net/@yoru_nine/105038878897832922", {
"content": "b52e807f8ab548d6f896b09218ece01eba83987a",
}),
("https://baraag.net/@pumpkinnsfw/104364170556898443", {
"content": "67748c1b828c58ad60d0fe5729b59fb29c872244",
}),
)
def statuses(self): def statuses(self):
return (self.api.status(self.status_id),) return (MastodonAPI(self).status(self.item),)
class MastodonAPI(): class MastodonAPI():
@@ -97,35 +134,46 @@ class MastodonAPI():
https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md
""" """
def __init__(self, extractor, access_token=None): def __init__(self, extractor):
self.root = extractor.root self.root = extractor.root
self.extractor = extractor self.extractor = extractor
access_token = extractor.config("access-token")
if access_token is None or access_token == "cache":
access_token = _access_token_cache(extractor.instance)
if not access_token: if not access_token:
access_token = extractor.config( try:
"access-token", extractor.access_token) access_token = INSTANCES[extractor.category]["access-token"]
self.headers = {"Authorization": "Bearer {}".format(access_token)} except (KeyError, TypeError):
raise exception.StopExtraction(
"Missing access token.\n"
"Run 'gallery-dl oauth:mastodon:%s' to obtain one.",
extractor.instance)
self.headers = {"Authorization": "Bearer " + access_token}
def account_search(self, query, limit=40): def account_search(self, query, limit=40):
"""Search for content""" """Search for content"""
endpoint = "/v1/accounts/search"
params = {"q": query, "limit": limit} params = {"q": query, "limit": limit}
return self._call("accounts/search", params).json() return self._call(endpoint, params).json()
def account_statuses(self, account_id): def account_statuses(self, account_id):
"""Get an account's statuses""" """Get an account's statuses"""
endpoint = "accounts/{}/statuses".format(account_id) endpoint = "/v1/accounts/{}/statuses".format(account_id)
params = {"only_media": "1"} params = {"only_media": "1"}
return self._pagination(endpoint, params) return self._pagination(endpoint, params)
def status(self, status_id): def status(self, status_id):
"""Fetch a Status""" """Fetch a status"""
return self._call("statuses/" + status_id).json() endpoint = "/v1/statuses/" + status_id
return self._call(endpoint).json()
def _call(self, endpoint, params=None): def _call(self, endpoint, params=None):
if endpoint.startswith("http"): if endpoint.startswith("http"):
url = endpoint url = endpoint
else: else:
url = "{}/api/v1/{}".format(self.root, endpoint) url = self.root + "/api" + endpoint
while True: while True:
response = self.extractor.request( response = self.extractor.request(
@@ -145,7 +193,7 @@ class MastodonAPI():
raise exception.StopExtraction(response.json().get("error")) raise exception.StopExtraction(response.json().get("error"))
def _pagination(self, endpoint, params): def _pagination(self, endpoint, params):
url = "{}/api/v1/{}".format(self.root, endpoint) url = endpoint
while url: while url:
response = self._call(url, params) response = self._call(url, params)
yield from response.json() yield from response.json()
@@ -156,86 +204,6 @@ class MastodonAPI():
url = url["url"] url = url["url"]
def generate_extractors(): @cache(maxage=100*365*24*3600, keyarg=0)
"""Dynamically generate Extractor classes for Mastodon instances""" def _access_token_cache(instance):
return None
symtable = globals()
extractors = config.get(("extractor",), "mastodon")
if extractors:
util.combine_dict(EXTRACTORS, extractors)
config.set(("extractor",), "mastodon", EXTRACTORS)
for instance, info in EXTRACTORS.items():
if not isinstance(info, dict):
continue
category = info.get("category") or instance.replace(".", "")
root = info.get("root") or "https://" + instance
name = (info.get("name") or category).capitalize()
token = info.get("access-token")
pattern = info.get("pattern") or re.escape(instance)
class Extr(MastodonUserExtractor):
pass
Extr.__name__ = Extr.__qualname__ = name + "UserExtractor"
Extr.__doc__ = "Extractor for all images of a user on " + instance
Extr.category = category
Extr.instance = instance
Extr.pattern = (r"(?:https?://)?" + pattern +
r"/@([^/?#]+)(?:/media)?/?$")
Extr.test = info.get("test-user")
Extr.root = root
Extr.access_token = token
symtable[Extr.__name__] = Extr
class Extr(MastodonStatusExtractor):
pass
Extr.__name__ = Extr.__qualname__ = name + "StatusExtractor"
Extr.__doc__ = "Extractor for images from a status on " + instance
Extr.category = category
Extr.instance = instance
Extr.pattern = r"(?:https?://)?" + pattern + r"/@[^/?#]+/(\d+)"
Extr.test = info.get("test-status")
Extr.root = root
Extr.access_token = token
symtable[Extr.__name__] = Extr
EXTRACTORS = {
"mastodon.social": {
"category" : "mastodon.social",
"access-token" : "Y06R36SMvuXXN5_wiPKFAEFiQaMSQg0o_hGgc86Jj48",
"client-id" : "dBSHdpsnOUZgxOnjKSQrWEPakO3ctM7HmsyoOd4FcRo",
"client-secret": "DdrODTHs_XoeOsNVXnILTMabtdpWrWOAtrmw91wU1zI",
"test-user" : ("https://mastodon.social/@jk", {
"pattern": r"https://files.mastodon.social/media_attachments"
r"/files/(\d+/){3,}original/\w+",
"range": "1-60",
"count": 60,
}),
"test-status" : ("https://mastodon.social/@jk/103794036899778366", {
"count": 4,
}),
},
"pawoo.net": {
"category" : "pawoo",
"access-token" : "c12c9d275050bce0dc92169a28db09d7"
"0d62d0a75a8525953098c167eacd3668",
"client-id" : "978a25f843ec01e53d09be2c290cd75c"
"782bc3b7fdbd7ea4164b9f3c3780c8ff",
"client-secret": "9208e3d4a7997032cf4f1b0e12e5df38"
"8428ef1fadb446dcfeb4f5ed6872d97b",
},
"baraag.net": {
"category" : "baraag",
"access-token" : "53P1Mdigf4EJMH-RmeFOOSM9gdSDztmrAYFgabOKKE0",
"client-id" : "czxx2qilLElYHQ_sm-lO8yXuGwOHxLX9RYYaD0-nq1o",
"client-secret": "haMaFdMBgK_-BIxufakmI2gFgkYjqmgXGEO2tB-R2xY",
},
}
generate_extractors()

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2015-2018 Mike Fährmann # Copyright 2015-2021 Mike Fährmann
# #
# This program is free software; you can redistribute it and/or modify # This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
@@ -52,4 +52,4 @@ class Message():
# Cookies = 5 # Cookies = 5
Queue = 6 Queue = 6
# Urllist = 7 # Urllist = 7
Metadata = 8 # Metadata = 8

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2020 Mike Fährmann # Copyright 2020-2021 Mike Fährmann
# #
# This program is free software; you can redistribute it and/or modify # This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
@@ -8,7 +8,6 @@
"""Extractors for Moebooru based sites""" """Extractors for Moebooru based sites"""
from .common import generate_extractors
from .booru import BooruExtractor from .booru import BooruExtractor
from .. import text from .. import text
@@ -52,15 +51,93 @@ class MoebooruExtractor(BooruExtractor):
params["page"] += 1 params["page"] += 1
BASE_PATTERN = MoebooruExtractor.update({
"yandere": {
"root": "https://yande.re",
},
"konachan": {
"root": "https://konachan.com",
"pattern": r"konachan\.(?:com|net)",
},
"hypnohub": {
"root": "https://hypnohub.net",
},
"sakugabooru": {
"root": "https://www.sakugabooru.com",
"pattern": r"(?:www\.)?sakugabooru\.com",
},
"lolibooru": {
"root": "https://lolibooru.moe",
},
})
class MoebooruPostExtractor(MoebooruExtractor):
subcategory = "post"
archive_fmt = "{id}"
pattern = BASE_PATTERN + r"/post/show/(\d+)"
test = (
("https://yande.re/post/show/51824", {
"content": "59201811c728096b2d95ce6896fd0009235fe683",
"options": (("tags", True),),
"keyword": {
"tags_artist": "sasaki_tamaru",
"tags_circle": "softhouse_chara",
"tags_copyright": "ouzoku",
"tags_general": str,
},
}),
("https://konachan.com/post/show/205189", {
"content": "674e75a753df82f5ad80803f575818b8e46e4b65",
"options": (("tags", True),),
"keyword": {
"tags_artist": "patata",
"tags_character": "clownpiece",
"tags_copyright": "touhou",
"tags_general": str,
},
}),
("https://konachan.net/post/show/205189"),
("https://hypnohub.net/post/show/73964", {
"content": "02d5f5a8396b621a6efc04c5f8ef1b7225dfc6ee",
}),
("https://www.sakugabooru.com/post/show/125570"),
("https://lolibooru.moe/post/show/287835"),
)
def __init__(self, match):
MoebooruExtractor.__init__(self, match)
self.post_id = match.group(match.lastindex)
def posts(self):
params = {"tags": "id:" + self.post_id}
return self.request(self.root + "/post.json", params=params).json()
class MoebooruTagExtractor(MoebooruExtractor): class MoebooruTagExtractor(MoebooruExtractor):
subcategory = "tag" subcategory = "tag"
directory_fmt = ("{category}", "{search_tags}") directory_fmt = ("{category}", "{search_tags}")
archive_fmt = "t_{search_tags}_{id}" archive_fmt = "t_{search_tags}_{id}"
pattern_fmt = r"/post\?(?:[^&#]*&)*tags=([^&#]+)" pattern = BASE_PATTERN + r"/post\?(?:[^&#]*&)*tags=([^&#]+)"
test = (
("https://yande.re/post?tags=ouzoku+armor", {
"content": "59201811c728096b2d95ce6896fd0009235fe683",
}),
("https://konachan.com/post?tags=patata", {
"content": "838cfb815e31f48160855435655ddf7bfc4ecb8d",
}),
("https://konachan.net/post?tags=patata"),
("https://hypnohub.net/post?tags=gonoike_biwa", {
"url": "072330c34a1e773d0cafd00e64b8060d34b078b6",
}),
("https://www.sakugabooru.com/post?tags=nichijou"),
("https://lolibooru.moe/post?tags=ruu_%28tksymkw%29"),
)
def __init__(self, match): def __init__(self, match):
MoebooruExtractor.__init__(self, match) MoebooruExtractor.__init__(self, match)
self.tags = text.unquote(match.group(1).replace("+", " ")) tags = match.group(match.lastindex)
self.tags = text.unquote(tags.replace("+", " "))
def metadata(self): def metadata(self):
return {"search_tags": self.tags} return {"search_tags": self.tags}
@@ -74,11 +151,25 @@ class MoebooruPoolExtractor(MoebooruExtractor):
subcategory = "pool" subcategory = "pool"
directory_fmt = ("{category}", "pool", "{pool}") directory_fmt = ("{category}", "pool", "{pool}")
archive_fmt = "p_{pool}_{id}" archive_fmt = "p_{pool}_{id}"
pattern_fmt = r"/pool/show/(\d+)" pattern = BASE_PATTERN + r"/pool/show/(\d+)"
test = (
("https://yande.re/pool/show/318", {
"content": "2a35b9d6edecce11cc2918c6dce4de2198342b68",
}),
("https://konachan.com/pool/show/95", {
"content": "cf0546e38a93c2c510a478f8744e60687b7a8426",
}),
("https://konachan.net/pool/show/95"),
("https://hypnohub.net/pool/show/61", {
"url": "fd74991c8729e77acd3c35eb6ddc4128ff445adf",
}),
("https://www.sakugabooru.com/pool/show/54"),
("https://lolibooru.moe/pool/show/239"),
)
def __init__(self, match): def __init__(self, match):
MoebooruExtractor.__init__(self, match) MoebooruExtractor.__init__(self, match)
self.pool_id = match.group(1) self.pool_id = match.group(match.lastindex)
def metadata(self): def metadata(self):
return {"pool": text.parse_int(self.pool_id)} return {"pool": text.parse_int(self.pool_id)}
@@ -88,29 +179,34 @@ class MoebooruPoolExtractor(MoebooruExtractor):
return self._pagination(self.root + "/post.json", params) return self._pagination(self.root + "/post.json", params)
class MoebooruPostExtractor(MoebooruExtractor):
subcategory = "post"
archive_fmt = "{id}"
pattern_fmt = r"/post/show/(\d+)"
def __init__(self, match):
MoebooruExtractor.__init__(self, match)
self.post_id = match.group(1)
def posts(self):
params = {"tags": "id:" + self.post_id}
return self.request(self.root + "/post.json", params=params).json()
class MoebooruPopularExtractor(MoebooruExtractor): class MoebooruPopularExtractor(MoebooruExtractor):
subcategory = "popular" subcategory = "popular"
directory_fmt = ("{category}", "popular", "{scale}", "{date}") directory_fmt = ("{category}", "popular", "{scale}", "{date}")
archive_fmt = "P_{scale[0]}_{date}_{id}" archive_fmt = "P_{scale[0]}_{date}_{id}"
pattern_fmt = r"/post/popular_(by_(?:day|week|month)|recent)(?:\?([^#]*))?" pattern = BASE_PATTERN + \
r"/post/popular_(by_(?:day|week|month)|recent)(?:\?([^#]*))?"
test = (
("https://yande.re/post/popular_by_month?month=6&year=2014", {
"count": 40,
}),
("https://yande.re/post/popular_recent"),
("https://konachan.com/post/popular_by_month?month=11&year=2010", {
"count": 20,
}),
("https://konachan.com/post/popular_recent"),
("https://konachan.net/post/popular_recent"),
("https://hypnohub.net/post/popular_by_month?month=6&year=2014", {
"count": 20,
}),
("https://hypnohub.net/post/popular_recent"),
("https://www.sakugabooru.com/post/popular_recent"),
("https://lolibooru.moe/post/popular_recent"),
)
def __init__(self, match): def __init__(self, match):
MoebooruExtractor.__init__(self, match) MoebooruExtractor.__init__(self, match)
self.scale, self.query = match.groups() self.scale = match.group(match.lastindex-1)
self.query = match.group(match.lastindex)
def metadata(self): def metadata(self):
self.params = params = text.parse_query(self.query) self.params = params = text.parse_query(self.query)
@@ -138,108 +234,3 @@ class MoebooruPopularExtractor(MoebooruExtractor):
def posts(self): def posts(self):
url = "{}/post/popular_{}.json".format(self.root, self.scale) url = "{}/post/popular_{}.json".format(self.root, self.scale)
return self.request(url, params=self.params).json() return self.request(url, params=self.params).json()
EXTRACTORS = {
"yandere": {
"root": "https://yande.re",
"test-tag": ("https://yande.re/post?tags=ouzoku+armor", {
"content": "59201811c728096b2d95ce6896fd0009235fe683",
}),
"test-pool": ("https://yande.re/pool/show/318", {
"content": "2a35b9d6edecce11cc2918c6dce4de2198342b68",
}),
"test-post": ("https://yande.re/post/show/51824", {
"content": "59201811c728096b2d95ce6896fd0009235fe683",
"options": (("tags", True),),
"keyword": {
"tags_artist": "sasaki_tamaru",
"tags_circle": "softhouse_chara",
"tags_copyright": "ouzoku",
"tags_general": str,
},
}),
"test-popular": (
("https://yande.re/post/popular_by_month?month=6&year=2014", {
"count": 40,
}),
("https://yande.re/post/popular_recent"),
),
},
"konachan": {
"root": "https://konachan.com",
"pattern": r"konachan\.(?:com|net)",
"test-tag": (
("https://konachan.com/post?tags=patata", {
"content": "838cfb815e31f48160855435655ddf7bfc4ecb8d",
}),
("https://konachan.net/post?tags=patata"),
),
"test-pool": (
("https://konachan.com/pool/show/95", {
"content": "cf0546e38a93c2c510a478f8744e60687b7a8426",
}),
("https://konachan.net/pool/show/95"),
),
"test-post": (
("https://konachan.com/post/show/205189", {
"content": "674e75a753df82f5ad80803f575818b8e46e4b65",
"options": (("tags", True),),
"keyword": {
"tags_artist": "patata",
"tags_character": "clownpiece",
"tags_copyright": "touhou",
"tags_general": str,
},
}),
("https://konachan.net/post/show/205189"),
),
"test-popular": (
("https://konachan.com/post/popular_by_month?month=11&year=2010", {
"count": 20,
}),
("https://konachan.com/post/popular_recent"),
("https://konachan.net/post/popular_recent"),
),
},
"hypnohub": {
"root": "https://hypnohub.net",
"test-tag": ("https://hypnohub.net/post?tags=gonoike_biwa", {
"url": "072330c34a1e773d0cafd00e64b8060d34b078b6",
}),
"test-pool": ("https://hypnohub.net/pool/show/61", {
"url": "fd74991c8729e77acd3c35eb6ddc4128ff445adf",
}),
"test-post": ("https://hypnohub.net/post/show/73964", {
"content": "02d5f5a8396b621a6efc04c5f8ef1b7225dfc6ee",
}),
"test-popular": (
("https://hypnohub.net/post/popular_by_month?month=6&year=2014", {
"count": 20,
}),
("https://hypnohub.net/post/popular_recent"),
),
},
"lolibooru": {
"root": "https://lolibooru.moe",
"test-tag" : ("https://lolibooru.moe/post?tags=ruu_%28tksymkw%29",),
"test-pool" : ("https://lolibooru.moe/pool/show/239",),
"test-post" : ("https://lolibooru.moe/post/show/287835",),
"test-popular": ("https://lolibooru.moe/post/popular_recent",),
},
"sakugabooru": {
"root": "https://www.sakugabooru.com",
"pattern": r"(?:www\.)?sakugabooru\.com",
"test-tag" : ("https://www.sakugabooru.com/post?tags=nichijou",),
"test-pool" : ("https://www.sakugabooru.com/pool/show/54",),
"test-post" : ("https://www.sakugabooru.com/post/show/125570",),
"test-popular": ("https://www.sakugabooru.com/post/popular_recent",),
},
}
generate_extractors(EXTRACTORS, globals(), (
MoebooruTagExtractor,
MoebooruPoolExtractor,
MoebooruPostExtractor,
MoebooruPopularExtractor,
))

View File

@@ -9,7 +9,7 @@
"""Utility classes to setup OAuth and link accounts to gallery-dl""" """Utility classes to setup OAuth and link accounts to gallery-dl"""
from .common import Extractor, Message from .common import Extractor, Message
from . import deviantart, flickr, pixiv, reddit, smugmug, tumblr from . import deviantart, flickr, mastodon, pixiv, reddit, smugmug, tumblr
from .. import text, oauth, util, config, exception from .. import text, oauth, util, config, exception
from ..cache import cache from ..cache import cache
import urllib.parse import urllib.parse
@@ -106,9 +106,9 @@ class OAuthBase(Extractor):
)) ))
def _oauth2_authorization_code_grant( def _oauth2_authorization_code_grant(
self, client_id, client_secret, auth_url, token_url, self, client_id, client_secret, auth_url, token_url, *,
scope="read", key="refresh_token", auth=True, scope="read", key="refresh_token", auth=True,
message_template=None, cache=None): cache=None, instance=None):
"""Perform an OAuth2 authorization code grant""" """Perform an OAuth2 authorization code grant"""
state = "gallery-dl_{}_{}".format( state = "gallery-dl_{}_{}".format(
@@ -117,12 +117,12 @@ class OAuthBase(Extractor):
) )
auth_params = { auth_params = {
"client_id": client_id, "client_id" : client_id,
"response_type": "code", "response_type": "code",
"state": state, "state" : state,
"redirect_uri": self.redirect_uri, "redirect_uri" : self.redirect_uri,
"duration": "permanent", "duration" : "permanent",
"scope": scope, "scope" : scope,
} }
# receive an authorization code # receive an authorization code
@@ -140,8 +140,8 @@ class OAuthBase(Extractor):
# exchange the authorization code for a token # exchange the authorization code for a token
data = { data = {
"grant_type": "authorization_code", "grant_type" : "authorization_code",
"code": params["code"], "code" : params["code"],
"redirect_uri": self.redirect_uri, "redirect_uri": self.redirect_uri,
} }
@@ -159,27 +159,18 @@ class OAuthBase(Extractor):
self.send(data["error"]) self.send(data["error"])
return return
token = data[key]
token_name = key.replace("_", "-")
# write to cache # write to cache
if self.cache and cache: if self.cache and cache:
cache.update("#" + str(client_id), data[key]) cache.update(instance or ("#" + str(client_id)), token)
self.log.info("Writing 'refresh-token' to cache") self.log.info("Writing '%s' to cache", token_name)
# display token # display token
if message_template: self.send(self._generate_message(
msg = message_template.format( (token_name,), (token,),
category=self.subcategory, ))
key=key.partition("_")[0],
token=data[key],
instance=getattr(self, "instance", ""),
client_id=client_id,
client_secret=client_secret,
)
else:
msg = self._generate_message(
("refresh-token",),
(data[key],),
)
self.send(msg)
def _generate_message(self, names, values): def _generate_message(self, names, values):
_vh, _va, _is, _it = ( _vh, _va, _is, _it = (
@@ -326,8 +317,10 @@ class OAuthMastodon(OAuthBase):
def items(self): def items(self):
yield Message.Version, 1 yield Message.Version, 1
application = self.oauth_config(self.instance) for application in mastodon.INSTANCES.values():
if not application: if self.instance == application["root"].partition("://")[2]:
break
else:
application = self._register(self.instance) application = self._register(self.instance)
self._oauth2_authorization_code_grant( self._oauth2_authorization_code_grant(
@@ -335,8 +328,9 @@ class OAuthMastodon(OAuthBase):
application["client-secret"], application["client-secret"],
"https://{}/oauth/authorize".format(self.instance), "https://{}/oauth/authorize".format(self.instance),
"https://{}/oauth/token".format(self.instance), "https://{}/oauth/token".format(self.instance),
instance=self.instance,
key="access_token", key="access_token",
message_template=MASTODON_MSG_TEMPLATE, cache=mastodon._access_token_cache,
) )
@cache(maxage=10*365*24*3600, keyarg=1) @cache(maxage=10*365*24*3600, keyarg=1)
@@ -425,29 +419,3 @@ class OAuthPixiv(OAuthBase):
""") """)
code = input("code: ") code = input("code: ")
return code.rpartition("=")[2].strip() return code.rpartition("=")[2].strip()
MASTODON_MSG_TEMPLATE = """
Your 'access-token' is
{token}
Put this value into your configuration file as
'extractor.mastodon.{instance}.{key}-token'.
You can also add your 'client-id' and 'client-secret' values
if you want to register another account in the future.
Example:
{{
"extractor": {{
"mastodon": {{
"{instance}": {{
"{key}-token": "{token}",
"client-id": "{client_id}",
"client-secret": "{client_secret}"
}}
}}
}}
}}
"""

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2019-2020 Mike Fährmann # Copyright 2019-2021 Mike Fährmann
# #
# This program is free software; you can redistribute it and/or modify # This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
@@ -42,8 +42,6 @@ class PatreonExtractor(Extractor):
hashes = set() hashes = set()
yield Message.Directory, post yield Message.Directory, post
yield Message.Metadata, post
for kind, url, name in itertools.chain( for kind, url, name in itertools.chain(
self._images(post), self._images(post),
self._attachments(post), self._attachments(post),

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2019-2020 Mike Fährmann # Copyright 2019-2021 Mike Fährmann
# #
# This program is free software; you can redistribute it and/or modify # This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
@@ -8,28 +8,23 @@
"""Extractors for Shopify instances""" """Extractors for Shopify instances"""
from .common import Extractor, Message, generate_extractors from .common import BaseExtractor, Message
from .. import text from .. import text
import re import re
class ShopifyExtractor(Extractor): class ShopifyExtractor(BaseExtractor):
"""Base class for Shopify extractors""" """Base class for Shopify extractors"""
basecategory = "shopify" basecategory = "shopify"
filename_fmt = "{product[title]}_{num:>02}_{id}.{extension}" filename_fmt = "{product[title]}_{num:>02}_{id}.{extension}"
archive_fmt = "{id}" archive_fmt = "{id}"
def __init__(self, match): def __init__(self, match):
Extractor.__init__(self, match) BaseExtractor.__init__(self, match)
self.item_url = self.root + match.group(1) self.item_url = self.root + match.group(match.lastindex)
def request(self, url, **kwargs):
kwargs["retries"] = float("inf")
return Extractor.request(self, url, **kwargs)
def items(self): def items(self):
data = self.metadata() data = self.metadata()
yield Message.Version, 1
yield Message.Directory, data yield Message.Directory, data
headers = {"X-Requested-With": "XMLHttpRequest"} headers = {"X-Requested-With": "XMLHttpRequest"}
@@ -58,22 +53,34 @@ class ShopifyExtractor(Extractor):
"""Return an iterable with all relevant product URLs""" """Return an iterable with all relevant product URLs"""
BASE_PATTERN = ShopifyExtractor.update({
"fashionnova": {
"root": "https://www.fashionnova.com",
"pattern": r"(?:www\.)?fashionnova\.com",
},
})
class ShopifyCollectionExtractor(ShopifyExtractor): class ShopifyCollectionExtractor(ShopifyExtractor):
"""Base class for collection extractors for Shopify based sites""" """Base class for collection extractors for Shopify based sites"""
subcategory = "collection" subcategory = "collection"
directory_fmt = ("{category}", "{collection[title]}") directory_fmt = ("{category}", "{collection[title]}")
pattern_fmt = r"(/collections/[\w-]+)/?(?:\?([^#]+))?(?:$|#)" pattern = BASE_PATTERN + r"(/collections/[\w-]+)/?(?:$|[?#])"
test = (
def __init__(self, match): ("https://www.fashionnova.com/collections/mini-dresses", {
ShopifyExtractor.__init__(self, match) "range": "1-20",
self.params = match.group(2) "count": 20,
"archive": False,
}),
("https://www.fashionnova.com/collections/mini-dresses/?page=1"),
("https://www.fashionnova.com/collections/mini-dresses#1"),
)
def metadata(self): def metadata(self):
return self.request(self.item_url + ".json").json() return self.request(self.item_url + ".json").json()
def products(self): def products(self):
params = text.parse_query(self.params) params = {"page": 1}
params["page"] = text.parse_int(params.get("page"), 1)
fetch = True fetch = True
last = None last = None
@@ -107,36 +114,14 @@ class ShopifyProductExtractor(ShopifyExtractor):
"""Base class for product extractors for Shopify based sites""" """Base class for product extractors for Shopify based sites"""
subcategory = "product" subcategory = "product"
directory_fmt = ("{category}", "Products") directory_fmt = ("{category}", "Products")
pattern_fmt = r"((?:/collections/[\w-]+)?/products/[\w-]+)" pattern = BASE_PATTERN + r"((?:/collections/[\w-]+)?/products/[\w-]+)"
test = (
def products(self):
return (self.item_url,)
EXTRACTORS = {
"fashionnova": {
"root": "https://www.fashionnova.com",
"pattern": r"(?:www\.)?fashionnova\.com",
"test-product": (
("https://www.fashionnova.com/products/essential-slide-red", { ("https://www.fashionnova.com/products/essential-slide-red", {
"pattern": r"https?://cdn\d*\.shopify.com/", "pattern": r"https?://cdn\d*\.shopify.com/",
"count": 3, "count": 3,
}), }),
("https://www.fashionnova.com/collections/flats/products/name"), ("https://www.fashionnova.com/collections/flats/products/name"),
), )
"test-collection": (
("https://www.fashionnova.com/collections/mini-dresses", {
"range": "1-20",
"count": 20,
"archive": False,
}),
("https://www.fashionnova.com/collections/mini-dresses/?page=1"),
("https://www.fashionnova.com/collections/mini-dresses#1"),
),
},
}
generate_extractors(EXTRACTORS, globals(), ( def products(self):
ShopifyProductExtractor, return (self.item_url,)
ShopifyCollectionExtractor,
))

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2015-2020 Mike Fährmann # Copyright 2015-2021 Mike Fährmann
# #
# This program is free software; you can redistribute it and/or modify # This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
@@ -111,10 +111,6 @@ class Job():
if self.pred_queue(url, kwds): if self.pred_queue(url, kwds):
self.handle_queue(url, kwds) self.handle_queue(url, kwds)
elif msg[0] == Message.Metadata:
self.update_kwdict(msg[1])
self.handle_metadata(msg[1])
elif msg[0] == Message.Version: elif msg[0] == Message.Version:
if msg[1] != 1: if msg[1] != 1:
raise "unsupported message-version ({}, {})".format( raise "unsupported message-version ({}, {})".format(
@@ -128,9 +124,6 @@ class Job():
def handle_directory(self, kwdict): def handle_directory(self, kwdict):
"""Handle Message.Directory""" """Handle Message.Directory"""
def handle_metadata(self, kwdict):
"""Handle Message.Metadata"""
def handle_queue(self, url, kwdict): def handle_queue(self, url, kwdict):
"""Handle Message.Queue""" """Handle Message.Queue"""
@@ -280,15 +273,6 @@ class DownloadJob(Job):
for callback in self.hooks["post"]: for callback in self.hooks["post"]:
callback(self.pathfmt) callback(self.pathfmt)
def handle_metadata(self, kwdict):
"""Run postprocessors with metadata from 'kwdict'"""
if "metadata" in self.hooks:
kwdict["extension"] = "metadata"
pathfmt = self.pathfmt
pathfmt.set_filename(kwdict)
for callback in self.hooks["metadata"]:
callback(pathfmt)
def handle_queue(self, url, kwdict): def handle_queue(self, url, kwdict):
if url in self.visited: if url in self.visited:
return return
@@ -624,8 +608,5 @@ class DataJob(Job):
def handle_directory(self, kwdict): def handle_directory(self, kwdict):
self.data.append((Message.Directory, self.filter(kwdict))) self.data.append((Message.Directory, self.filter(kwdict)))
def handle_metadata(self, kwdict):
self.data.append((Message.Metadata, self.filter(kwdict)))
def handle_queue(self, url, kwdict): def handle_queue(self, url, kwdict):
self.data.append((Message.Queue, url, self.filter(kwdict))) self.data.append((Message.Queue, url, self.filter(kwdict)))

View File

@@ -136,9 +136,9 @@ def build_parser():
help="Print URLs instead of downloading", help="Print URLs instead of downloading",
) )
output.add_argument( output.add_argument(
"-G", "-G", "--resolve-urls",
dest="list_urls", action="store_const", const=128, dest="list_urls", action="store_const", const=128,
help=argparse.SUPPRESS, help="Print URLs instead of downloading; resolve intermediary URLs",
) )
output.add_argument( output.add_argument(
"-j", "--dump-json", "-j", "--dump-json",

View File

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2018-2020 Mike Fährmann # Copyright 2018-2021 Mike Fährmann
# #
# This program is free software; you can redistribute it and/or modify # This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
@@ -39,10 +39,6 @@ class ExecPP(PostProcessor):
events = options.get("event") events = options.get("event")
if events is None: if events is None:
events = ("after",) events = ("after",)
if options.get("final"):
self.log.warning("'final' is deprecated, "
"use '\"event\": \"finalize\"' instead")
events = ("finalize",)
elif isinstance(events, str): elif isinstance(events, str):
events = events.split(",") events = events.split(",")
for event in events: for event in events:

View File

@@ -55,10 +55,6 @@ class MetadataPP(PostProcessor):
events = options.get("event") events = options.get("event")
if events is None: if events is None:
events = ("file",) events = ("file",)
if options.get("bypost"):
self.log.warning("'bypost' is deprecated, use '\"event\": "
"\"post\"' and 'filename' instead")
events = ("metadata",)
elif isinstance(events, str): elif isinstance(events, str):
events = events.split(",") events = events.split(",")
for event in events: for event in events:

View File

@@ -6,4 +6,4 @@
# it under the terms of the GNU General Public License version 2 as # it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation. # published by the Free Software Foundation.
__version__ = "1.16.5" __version__ = "1.17.0-dev"

View File

@@ -219,10 +219,6 @@ class TestExtractorWait(unittest.TestCase):
class TextExtractorOAuth(unittest.TestCase): class TextExtractorOAuth(unittest.TestCase):
@classmethod
def setUpClass(cls):
mastodon.generate_extractors()
def test_oauth1(self): def test_oauth1(self):
for category in ("flickr", "smugmug", "tumblr"): for category in ("flickr", "smugmug", "tumblr"):
extr = extractor.find("oauth:" + category) extr = extractor.find("oauth:" + category)