#!/usr/bin/env python3
"""Geht die Seiten des Shops durch und prueft jede auf Fehler.

    seiten.py <basis-url> [ausgabe.json]

Geprueft wird je Seite:
  * der Rueckgabewert
  * ob eine Shopware-Fehlerseite oder eine Ausnahme im Text steht
  * ob alle eingebundenen Bilder, Skripte und Stilvorlagen laden
  * ob Platzhalter uebrig geblieben sind (fehlende Uebersetzungen)

Die Adressen kommen aus der Sitemap plus den Wegen, die Shopware immer hat.
Bewusst NICHT aus der Navigation gecrawlt: die Sitemap ist die Liste, die der
Shop selbst fuer vollstaendig haelt - weicht sie von der Wirklichkeit ab, ist
das schon ein Befund.
"""
import gzip
import io
import json
import re
import sys
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

KOPF = {'User-Agent': 'Riccardo-Pruefung/1.0'}

# Wege, die in keiner Sitemap stehen, aber funktionieren muessen.
FESTE_WEGE = [
    '/', '/checkout/cart', '/account/login', '/account/register',
    '/widgets/checkout/info', '/search?search=liquid', '/search?search=xyzq',
    '/sitemap.xml', '/robots.txt',
]

PLATZHALTER = re.compile(r'\b(?:[a-z]+\.){2,}[a-zA-Z]+\b(?![^<]*>)')
FEHLERTEXT = re.compile(r'(Internal Server Error|Whoops|Exception|Stack Trace|'
                        r'Fehler aufgetreten|Seite nicht gefunden|404 Not Found)', re.I)


def hole(adresse: str, zeit: int = 30):
    try:
        with urllib.request.urlopen(urllib.request.Request(adresse, headers=KOPF), timeout=zeit) as a:
            roh = a.read()
            if a.headers.get('Content-Encoding') == 'gzip' or adresse.endswith('.gz'):
                try:
                    roh = gzip.decompress(roh)
                except Exception:
                    pass
            return a.status, roh
    except urllib.error.HTTPError as f:
        return f.code, b''
    except Exception as f:
        return type(f).__name__, b''


def adressen_sammeln(basis: str) -> list:
    wege = [basis.rstrip('/') + w for w in FESTE_WEGE]

    code, roh = hole(basis + '/sitemap.xml')
    teilkarten = re.findall(rb'<loc>([^<]+)</loc>', roh)
    aus_sitemap = []
    for k in teilkarten:
        c2, r2 = hole(k.decode())
        aus_sitemap += [u.decode() for u in re.findall(rb'<loc>([^<]+)</loc>', r2)]

    return wege, aus_sitemap


def ressourcen(html: str, basis: str) -> set:
    gefunden = set()
    for muster in (r'<img[^>]+src="([^"]+)"', r'<script[^>]+src="([^"]+)"',
                   r'<link[^>]+rel="stylesheet"[^>]*href="([^"]+)"'):
        for a in re.findall(muster, html):
            a = a.split('?')[0]
            if a.startswith('//'):
                a = 'https:' + a
            elif a.startswith('/'):
                a = basis.rstrip('/') + a
            if a.startswith('http'):
                gefunden.add(a)
    return gefunden


def pruefe(adresse: str, basis: str) -> dict:
    code, roh = hole(adresse)
    text = roh.decode('utf-8', 'replace')
    ergebnis = {'adresse': adresse, 'code': code, 'laenge': len(roh)}

    if code != 200:
        return ergebnis

    treffer = FEHLERTEXT.search(text)
    if treffer and '/sitemap' not in adresse:
        # "Seite nicht gefunden" im Text einer 200er-Antwort ist die
        # gestaltete 404-Seite - die ist gewollt, aber nicht auf jeder Adresse.
        ergebnis['fehlertext'] = treffer.group(0)[:60]

    # Uebriggebliebene Snippet-Schluessel wie "checkout.cartHeader"
    roher_text = re.sub(r'<script.*?</script>|<style.*?</style>|<[^>]+>', ' ', text, flags=re.S)
    platzhalter = {p for p in PLATZHALTER.findall(roher_text)
                   if not p.endswith(('.de', '.com', '.eu', '.js', '.css', '.png', '.jpg', '.webp', '.svg'))}
    if platzhalter:
        ergebnis['platzhalter'] = sorted(platzhalter)[:5]

    ergebnis['ressourcen'] = len(ressourcen(text, basis))
    return ergebnis


def main() -> int:
    basis = sys.argv[1].rstrip('/')
    ziel = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(__file__).parent / 'seiten.json'

    feste, aus_sitemap = adressen_sammeln(basis)
    print(f'  {len(feste)} feste Wege, {len(aus_sitemap)} Adressen aus der Sitemap')

    # Aus der Sitemap eine Stichprobe: alle zu pruefen dauert Stunden.
    # Genommen werden die ersten 40 und dann jede 50ste - so sind Kategorien,
    # Artikel und Inhaltsseiten alle vertreten.
    stichprobe = aus_sitemap[:40] + aus_sitemap[40::50]
    alle = feste + stichprobe
    print(f'  geprueft werden {len(alle)}\n')

    with ThreadPoolExecutor(max_workers=8) as pool:
        ergebnisse = list(pool.map(lambda a: pruefe(a, basis), alle))

    schlecht = [e for e in ergebnisse if e['code'] != 200]
    mitFehlertext = [e for e in ergebnisse if e.get('fehlertext')]
    mitPlatzhalter = [e for e in ergebnisse if e.get('platzhalter')]

    print(f'  Seiten geprueft      : {len(ergebnisse)}')
    print(f'  nicht 200            : {len(schlecht)}')
    print(f'  mit Fehlertext       : {len(mitFehlertext)}')
    print(f'  mit Platzhaltern     : {len(mitPlatzhalter)}')
    for e in (schlecht + mitFehlertext + mitPlatzhalter)[:12]:
        print('    %-5s %-70s %s' % (e['code'], e['adresse'][len(basis):][:70],
                                     e.get('fehlertext') or ','.join(e.get('platzhalter', []))[:40]))

    ziel.write_text(json.dumps({'basis': basis, 'ergebnisse': ergebnisse}, indent=2, ensure_ascii=False),
                    encoding='utf-8')
    print(f'\n  -> {ziel}')
    return 0


if __name__ == '__main__':
    raise SystemExit(main())
