1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631
|
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: © 2004 Tristan Seligmann and Jonathan Jacobs
# SPDX-FileCopyrightText: © 2012 Bastian Kleineidam
# SPDX-FileCopyrightText: © 2015 Tobias Gruetzmacher
from __future__ import annotations
import html
import logging
import os
import pathlib
import re
import warnings
from typing import Collection, Dict, List, Optional, Pattern, Sequence, Union
from urllib.parse import urljoin
import lxml
from lxml.html.defs import link_attrs as lxml_link_attrs
try:
import pycountry
except ImportError:
pycountry = None
from . import configuration, http, languages, loader, util, xml
from .comic import ComicStrip
from .events import getHandler
logger = logging.getLogger(__name__)
ARCHIVE_ORG_URL = re.compile(r'https?://web\.archive\.org/web/[^/]*/')
html_link_attrs = lxml_link_attrs - {'usemap'}
if lxml.etree.LIBXML_VERSION < (2, 9, 3):
warnings.warn( # noqa: B028 - false positive
'Your libxml2 is very old (< 2.9.3), some dosage modules might missbehave')
class GeoblockedException(IOError):
def __init__(self):
super().__init__('It seems your current location is geo-blocked.')
class Scraper:
'''Base class for all comic scraper, but without a specific scrape
implementation.'''
# The URL for the comic strip
url: Optional[str] = None
# A string that is interpolated with the strip index to yield the URL for a
# particular strip.
stripUrl: Optional[str] = None
# Stop search for previous URLs at this URL
firstStripUrl: Optional[str] = None
# if more than one image per URL is expected
multipleImagesPerStrip: bool = False
# set to True if this comic contains adult content
adult: bool = False
# set to True if this comic will not get updated anymore
endOfLife: bool = False
# langauge of the comic (two-letter ISO 639-1 code)
lang: str = 'en'
# an expression that will locate the URL for the previous strip in a page
# this can also be a list or tuple
prevSearch: Optional[Union[Sequence[Union[str, Pattern]], str, Pattern]] = None
# an expression that will locate the strip image URLs strip in a page
# this can also be a list or tuple
imageSearch: Optional[Union[Sequence[Union[str, Pattern]], str, Pattern]] = None
# an expression to store a text together with the image
# sometimes comic strips have additional text info for each comic
textSearch: Optional[Union[Sequence[Union[str, Pattern]], str, Pattern]] = None
# Is the additional text required or optional? When it is required (the
# default), you see an error message whenever a comic page is encountered
# that does not have the text
textOptional: bool = False
# usually the index format help
help: str = ''
# Specifing a list of HTTP error codes which should be handled as a
# successful request. This is a workaround for some comics which return
# regular pages with strange HTTP codes. By default, all HTTP errors raise
# exceptions.
allow_errors: Sequence[int] = ()
# HTTP session for configuration & cookies
session: http.Session = http.default_session
@classmethod
def getmodules(cls) -> Collection[Scraper]:
if cls.url is None:
return ()
name = cls.__name__
if hasattr(cls, 'name'):
name = cls.name
return (cls(name),)
@property
def indexes(self):
return self._indexes
@indexes.setter
def indexes(self, val):
if val:
self._indexes = tuple(sorted(val))
def __init__(self, name: str) -> None:
"""Initialize internal variables."""
self.name = name
self.urls: set[str] = set()
self._indexes = ()
self.skippedUrls: set[str] = set()
self.hitFirstStripUrl = False
def __hash__(self) -> int:
"""Get hash value from name and index list."""
return hash((self.name, self.indexes))
def shouldSkipUrl(self, url: str, data) -> bool:
"""Determine if search for images in given URL should be skipped."""
return False
def getComicStrip(self, url, data) -> ComicStrip:
"""Get comic strip downloader for given URL and data."""
urls = self.extract_image_urls(url, data)
# map modifier function on image URLs
urls = [self.imageUrlModifier(x, data) for x in urls]
# remove duplicate URLs
urls = util.uniq(urls)
if len(urls) > 1 and not self.multipleImagesPerStrip:
logger.warning("Found %d images instead of 1 at %s with expressions %r",
len(urls), url, util.prettyMatcherList(self.imageSearch))
image = urls[0]
logger.warning("Choosing image %r", image)
urls = (image,)
elif not urls:
logger.warning("Found no images at %r with expressions %r", url,
util.prettyMatcherList(self.imageSearch))
if self.textSearch:
text = self.fetchText(url, data, self.textSearch,
optional=self.textOptional)
else:
text = None
return ComicStrip(self, url, urls, text=text)
def getStrips(self, maxstrips=None):
"""Get comic strips."""
if maxstrips:
word = u"strip" if maxstrips == 1 else "strips"
msg = u'Retrieving %d %s' % (maxstrips, word)
else:
msg = u'Retrieving all strips'
if self.indexes:
if len(self.indexes) == 1:
msg += u" for index %s" % self.indexes[0]
else:
msg += u" for indexes %s" % self.indexes
# Always call starter() since it might initialize cookies.
# See for example Oglaf comic.
self.starter()
urls = [self.getIndexStripUrl(index) for index in self.indexes]
else:
urls = [self.starter()]
if self.adult:
msg += u" (including adult content)"
logger.info(msg)
for url in urls:
for strip in self.getStripsFor(url, maxstrips):
yield strip
def getStripsFor(self, url, maxstrips):
"""Get comic strips for an URL. If maxstrips is a positive number, stop after
retrieving the given number of strips."""
self.hitFirstStripUrl = False
seen_urls = set()
while url:
logger.moreinfo('Get strip URL %r', url)
data = self.getPage(url)
if self.shouldSkipUrl(url, data):
logger.info('Skipping URL %r', url)
self.skippedUrls.add(url)
else:
try:
yield self.getComicStrip(url, data)
except ValueError as msg:
logger.exception(msg) # noqa: LOG010
if self.isfirststrip(url):
logger.debug("Stop at first URL %r", url)
self.hitFirstStripUrl = True
break
if maxstrips is not None:
maxstrips -= 1
if maxstrips <= 0:
break
prevUrl = self.getPrevUrl(url, data)
seen_urls.add(url)
if prevUrl in seen_urls:
# avoid recursive URL loops
logger.warning("Already seen previous URL %r", prevUrl)
break
url = prevUrl
def isfirststrip(self, url: str) -> bool:
"""Check if the specified URL is the first strip of a comic. This is
specially for comics taken from archive.org, since the base URL of
archive.org changes whenever pages are taken from a different
snapshot."""
if not self.firstStripUrl:
return False
firsturl = ARCHIVE_ORG_URL.sub('', self.firstStripUrl)
currenturl = ARCHIVE_ORG_URL.sub('', url)
return firsturl == currenturl
def getPrevUrl(self, url: str, data) -> str | None:
"""Find previous URL."""
prevUrl = None
if self.prevSearch:
try:
prevUrl = self.fetchUrl(url, data, self.prevSearch)
except ValueError as msg:
# assume there is no previous URL, but print a warning
logger.warning("%s Assuming no previous comic strips exist.", msg)
else:
prevUrl = self.link_modifier(url, prevUrl)
logger.debug("Found previous URL %r", prevUrl)
getHandler().comicPageLink(self, url, prevUrl)
return prevUrl
def getIndexStripUrl(self, index: str) -> str:
"""Get comic strip URL from index."""
if not self.stripUrl:
raise ValueError("Getting by index is not supported!")
return self.stripUrl % index
def starter(self) -> str | None:
"""Get starter URL from where to scrape comic strips."""
return self.url
def namer(self, image_url: str, page_url: str) -> str:
"""Return filename for given image and page URL."""
return util.urlpathsplit(image_url)[-1]
def link_modifier(self, fromurl: str, tourl: str) -> str:
"""Optional modification of parsed link (previous/back/latest) URLs.
Useful if there are domain redirects. The default implementation does
not modify the URL.
"""
return tourl
def imageUrlModifier(self, image_url: str, data) -> str:
"""Optional modification of parsed image URLs. Useful if the URL
needs to be fixed before usage. The default implementation does
not modify the URL. The given data is the URL page data.
"""
return image_url
def vote(self) -> None:
"""Cast a public vote for this comic."""
uid = util.get_system_uid()
data = {"name": self.name.replace('/', '_'), "uid": uid}
response = self.session.post(configuration.VoteUrl, data=data)
response.raise_for_status()
def get_download_dir(self, basepath: str) -> str:
"""Try to find the corect download directory, ignoring case
differences."""
path = basepath
for part in self.name.split('/'):
done = False
if (os.path.isdir(path) and
not os.path.isdir(os.path.join(path, part))):
for entry in os.listdir(path):
if (entry.lower() == part.lower() and
os.path.isdir(os.path.join(path, entry))):
path = os.path.join(path, entry)
done = True
break
if not done:
path = os.path.join(path, part)
return path
def getCompleteFile(self, basepath: str) -> str:
"""Get filename indicating all comics are downloaded."""
dirname = self.get_download_dir(basepath)
return os.path.join(dirname, "complete.txt")
def isComplete(self, basepath: str) -> bool:
"""Check if all comics are downloaded."""
return os.path.isfile(self.getCompleteFile(basepath))
def setComplete(self, basepath: str) -> None:
"""Set complete flag for this comic, ie. all comics are downloaded."""
if self.endOfLife:
filename = self.getCompleteFile(basepath)
if not os.path.exists(filename):
with open(filename, 'w') as f:
f.write('All comics should be downloaded here.')
def getPage(self, url):
"""
Fetch a page and return the opaque repesentation for the data parameter
of fetchUrls and fetchText.
Implementation notes: While this base class does not restrict how the
returned data is structured, subclasses (specific scrapers) should
specify how this data works, since the stracture is passed into
different methods which can be defined by comic modules and these
methods should be able to use the data if they so desire... (Affected
methods: shouldSkipUrl, imageUrlModifier)
"""
return util.get_page(url, self.session, allow_errors=self.allow_errors)
def extract_image_urls(self, url, data):
"""
Extract image URLs from page data using the classes imageSearch attribute.
"""
return self.fetchUrls(url, data, self.imageSearch)
def fetchUrls(self, url, data, urlsearch):
raise ValueError("No implementation for fetchUrls!")
def fetchUrl(self, url, data, urlsearch):
return self.fetchUrls(url, data, urlsearch)[0]
def fetchText(self, url, data, textsearch, optional):
raise ValueError("No implementation for fetchText!")
def getDisabledReasons(self):
"""
Get a dict of reasons why this comic module is disabled. The key is a
short (unique) identifier, the value is a string explaining why the
module is deactivated. If the module is not disabled, just return an
empty dict.
"""
return {}
def language(self):
"""
Return language of the comic as a human-readable language name instead
of a 2-character ISO639-1 code.
"""
if pycountry is None:
if self.lang in languages.Languages:
return languages.Languages[self.lang]
else:
try:
return pycountry.languages.get(alpha_2=self.lang).name
except KeyError:
try:
return pycountry.languages.get(alpha2=self.lang).name
except KeyError:
pass
return 'Unknown (%s)' % self.lang
def geoblocked(self):
"""Helper method to indicate that the user is most probably geo-blocked."""
raise GeoblockedException()
class BasicScraper(Scraper):
"""
Scraper base class that matches regular expressions against HTML pages.
Subclasses of this scraper should use compiled regular expressions as
values for prevSearch, imageSearch and textSearch.
Implementation note: The return value of getPage is a tuple: the first
element is the raw HTML page text, the second element is the base URL (if
any).
"""
BASE_SEARCH = re.compile(util.tagre("base", "href", '([^"]*)'))
def getPage(self, url):
content = super().getPage(url).text
# determine base URL
baseUrl = None
match = self.BASE_SEARCH.search(content)
if match:
baseUrl = match.group(1)
else:
baseUrl = url
return (content, baseUrl)
def fetchUrls(self, url, data, urlSearch):
"""Search all entries for given URL pattern(s) in a HTML page."""
searchUrls = []
searches = util.makeSequence(urlSearch)
for search in searches:
for match in search.finditer(data[0]):
searchUrl = match.group(1)
if not searchUrl:
raise ValueError("Pattern %s matched empty URL at %s." %
(search.pattern, url))
logger.debug('matched URL %r with pattern %r', searchUrl, search.pattern)
searchUrls.append(util.normaliseURL(urljoin(data[1], searchUrl)))
if searchUrls:
# do not search other links if one pattern matched
break
if not searchUrls:
patterns = [x.pattern for x in searches]
raise ValueError("Patterns %s not found at URL %s." %
(patterns, url))
return searchUrls
def fetchText(self, url, data, textSearch, optional):
"""Search text entry for given text pattern in a HTML page."""
if textSearch:
match = textSearch.search(data[0])
if match:
text = match.group(1)
logger.debug('matched text %r with pattern %r', text, textSearch.pattern)
return html.unescape(text).strip()
if optional:
return None
else:
raise ValueError("Pattern %s not found at URL %s." %
(textSearch.pattern, url))
else:
return None
class ParserScraper(Scraper):
"""
Scraper base class that uses a HTML parser and XPath expressions.
All links are resolved before XPath searches are applied, so all URLs are
absolute!
Subclasses of this class should use XPath expressions as values for
prevSearch, imageSearch and textSearch. When the XPath directly selects an
attribute, it is used as the output.
All those searches try to do something intelligent when they match a
complete HTML Element: prevSearch and imageSearch try to find a "link
attribute" and use that as URL. textSearch strips all tags from the content
of the HTML element and returns that.
"""
# Taken directly from LXML
XML_DECL = re.compile(
r'^(<\?xml[^>]+)\s+encoding\s*=\s*["\'][^"\']*["\'](\s*\?>|)', re.U)
def getPage(self, url):
page = super().getPage(url)
if page.encoding:
# Requests figured out the encoding, so we can deliver Unicode to
# LXML. Unfortunatly, LXML feels betrayed if there is still an XML
# declaration with (probably wrong!) encoding at the top of the
# document. Web browsers ignore such if the encoding was specified
# in the HTTP header and so do we.
text = self.XML_DECL.sub('\1\2', page.text, count=1)
tree = self._parse_page(text)
else:
tree = self._parse_page(page.content)
tree.make_links_absolute(url)
return tree
def _parse_page(self, data):
return lxml.html.document_fromstring(data)
def fetchUrls(self, url, data, urlSearch):
"""Search all entries for given XPath in a HTML page."""
searchUrls = []
for match, search in self._matchPattern(data, urlSearch):
searchUrl = None
try:
for attrib in html_link_attrs:
if attrib in match.attrib:
searchUrl = match.get(attrib)
except AttributeError:
searchUrl = str(match)
logger.debug('Matched URL %r with pattern %r', searchUrl, search)
if searchUrl is not None:
searchUrls.append(searchUrl)
if not searchUrls:
raise ValueError("XPath %s not found at URL %s." %
(urlSearch, url))
return searchUrls
def fetchText(self, url, data, textSearch, optional):
"""Search text entry for given text XPath in a HTML page."""
if not textSearch:
return None
text = []
for match, search in self._matchPattern(data, textSearch):
try:
text.append(match.text_content())
except AttributeError:
text.append(match)
logger.debug('Matched text %r with XPath %r', text, search)
text = u' '.join(text)
if text.strip() == '':
if optional:
return None
else:
raise ValueError("XPath %s did not match anything at URL %s." %
(textSearch, url))
return text.strip()
def _matchPattern(self, data, patterns):
patterns = util.makeSequence(patterns)
for search in patterns:
matched = False
for match in self.match(data, search):
matched = True
yield match, search
if matched and not self.multipleImagesPerStrip:
# do not search other links if one pattern matched
break
def match(self, data, pattern):
"""Match an XPath pattern against a page."""
return data.xpath(pattern, namespaces=xml.NS)
# Legacy aliases
_BasicScraper = BasicScraper
_ParserScraper = ParserScraper
class Cache:
"""Cache for comic scraper objects. The cache is initialized on first use.
This is cached, since iterating & loading a complete package might be quite
slow.
"""
def __init__(self) -> None:
self.data: List[Scraper] = []
self.userdirs: set[pathlib.Path] = set()
def find(self, comic: str) -> Scraper:
"""Find a comic scraper object based on its name. This prefers a
perfect match, but falls back to a substring match, if that is unique.
Otharwise a ValueError is thrown.
"""
if not comic:
raise ValueError("empty comic name")
candidates = []
cname = comic.lower()
for scraper in self.all(include_removed=True):
lname = scraper.name.lower()
if lname == cname:
# perfect match
return scraper
elif cname in lname and scraper.url:
candidates.append(scraper)
if len(candidates) > 1:
comics = ", ".join(x.name for x in candidates)
raise ValueError('multiple comics found: %s' % comics)
elif not candidates:
raise ValueError('comic %r not found' % comic)
return candidates[0]
def load(self) -> None:
logger.debug("Loading comic modules...")
modules = 0
classes = 0
for module in loader.get_plugin_modules():
modules += 1
classes += self.addmodule(module)
self.validate()
logger.debug("... %d scrapers loaded from %d classes in %d modules.",
len(self.data), classes, modules)
def adddir(self, path: pathlib.Path) -> None:
"""Add an additional directory with python modules to the scraper list.
These are handled as if the were part of the plugins package.
"""
if path in self.userdirs:
return
if not self.data:
self.load()
modules = 0
classes = 0
logger.debug("Loading user scrapers from %r...", path)
for module in loader.get_plugin_modules_from_dir(path):
modules += 1
classes += self.addmodule(module)
self.validate()
self.userdirs.add(path)
if classes > 0:
logger.debug("Added %d user classes from %d modules.",
classes, modules)
def addmodule(self, module) -> int:
"""Adds all valid plugin classes from the specified module to the cache.
@return: number of classes added
"""
classes = 0
for plugin in loader.get_module_plugins(module, Scraper):
classes += 1
self.data.extend(plugin.getmodules())
return classes
def all(self, include_removed=False) -> list[Scraper]:
"""Return all comic scraper classes in the plugins directory.
@return: list of Scraper classes
@rtype: list of Scraper
"""
if not self.data:
self.load()
if include_removed:
return self.data
else:
return [x for x in self.data if x.url]
def validate(self) -> None:
"""Check for duplicate scraper names."""
d: Dict[str, Scraper] = {}
for scraper in self.data:
name = scraper.name.lower()
if name in d:
name1 = scraper.name
name2 = d[name].name
raise ValueError('duplicate scrapers %s and %s found' %
(name1, name2))
d[name] = scraper
scrapers = Cache()
|