File: scraper.py

package info (click to toggle)
dosage 3.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 1,400 kB
  • sloc: python: 12,703; sh: 55; makefile: 6
file content (631 lines) | stat: -rw-r--r-- 23,540 bytes parent folder | download
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()