"""Groepeer de pagina's van een site op inhoud, met hun vectoren (embeddings).

Bij het stuk "Ontbrekende content vinden met embeddings in 6 stappen" op rankfellow.com.
Een vector en een embedding zijn hier hetzelfde: de reeks getallen per pagina.

Invoer: een CSV met per pagina een regel. Verplichte kolommen:
  url      het adres van de pagina
  vector   de getallen van de vector, gescheiden door spaties of komma's
           (mag tussen blokhaken staan, zoals [0.12, -0.03, ...])
Optionele kolommen:
  woorden  het aantal woorden op de pagina (Word Count in Screaming Frog)
  tekens   het aantal tekens tekst waarvan de vector is gemaakt

Twee pagina's horen bij elkaar als ze elkaars dichtstbijzijnde buren zijn
(standaard: elk staat bij de ander in de twee dichtstbijzijnde). Een groep is
een aaneengesloten keten van zulke paren. Overeenkomst is de cosinusovereenkomst.
Een pagina heet kort als hij minder dan de helft van het middelste aantal woorden
van de site (de mediaan) heeft, tenzij je met --kort zelf een grens kiest.

Met --links leest het script ook de export "All Inlinks" van Screaming Frog
(kolommen Type, Source, Destination, Link Position) en telt het per groep de
links in de tekst tussen de pagina's van die groep. Een link naar een adres waar
60% of meer van de pagina's naar linkt (menu, voettekst, vaste blokken) telt niet mee.

Gebruik:
  python groepeer_paginas.py vectoren.csv
  python groepeer_paginas.py vectoren.csv --links all_inlinks.csv --uit groepen.csv

Nodig: Python 3.9 of nieuwer en numpy (pip install numpy).
"""

import argparse
import csv
import re
import sys

import numpy as np


def schoon(url):
    url = url.strip().lower()
    url = re.sub(r"[?#].*$", "", url)
    url = re.sub(r"^https?://(www\.)?", "", url)
    return url.rstrip("/")


def lees_vectoren(pad, min_tekens):
    paginas, vectoren, overgeslagen = [], [], []
    with open(pad, newline="", encoding="utf-8-sig") as f:
        lezer = csv.DictReader(f)
        kolommen = {k.lower().strip(): k for k in lezer.fieldnames or []}
        for nodig in ("url", "vector"):
            if nodig not in kolommen:
                sys.exit(f"Kolom '{nodig}' ontbreekt in {pad}. Gevonden: {', '.join(lezer.fieldnames or [])}")
        for rij in lezer:
            url = rij[kolommen["url"]]
            tekens = rij.get(kolommen.get("tekens", ""), "") or ""
            if min_tekens and tekens.strip() and float(tekens) < min_tekens:
                overgeslagen.append((url, f"{int(float(tekens))} tekens"))
                continue
            getallen = re.findall(r"-?\d+(?:\.\d+)?(?:[eE]-?\d+)?", rij[kolommen["vector"]])
            if not getallen:
                overgeslagen.append((url, "geen vector"))
                continue
            woorden = rij.get(kolommen.get("woorden", ""), "") or ""
            paginas.append({"url": url, "woorden": int(float(woorden)) if woorden.strip() else None})
            vectoren.append([float(g) for g in getallen])
    lengtes = {len(v) for v in vectoren}
    if len(lengtes) > 1:
        sys.exit(f"De vectoren zijn niet even lang ({sorted(lengtes)}). Gebruik voor alle pagina's hetzelfde model.")
    return paginas, np.array(vectoren), overgeslagen


def groepeer(v, buren):
    v = v / np.linalg.norm(v, axis=1, keepdims=True)
    s = v @ v.T
    np.fill_diagonal(s, -np.inf)
    dichtst = np.argsort(-s, axis=1)[:, :buren]
    n = len(v)
    ouder = list(range(n))

    def wortel(i):
        while ouder[i] != i:
            ouder[i] = ouder[ouder[i]]
            i = ouder[i]
        return i

    paren = []
    for i in range(n):
        for j in dichtst[i]:
            if i < j and i in dichtst[j]:
                paren.append((i, int(j), float(s[i, j])))
                ouder[wortel(i)] = wortel(int(j))
    groepen = {}
    for i in range(n):
        groepen.setdefault(wortel(i), []).append(i)
    np.fill_diagonal(s, np.nan)
    zwakste = {}
    for g, leden in groepen.items():
        if len(leden) > 1:
            zwakste[g] = float(np.nanmin(s[np.ix_(leden, leden)]))
    return list(groepen.items()), zwakste, s


def lees_links(pad, sjabloon_aandeel):
    rijen = []
    with open(pad, newline="", encoding="utf-8-sig") as f:
        lezer = csv.DictReader(f)
        k = {x.lower().strip(): x for x in lezer.fieldnames or []}
        for nodig in ("type", "source", "destination", "link position"):
            if nodig not in k:
                sys.exit(f"Kolom '{nodig}' ontbreekt in {pad}. Exporteer All Inlinks uit Screaming Frog.")
        for r in lezer:
            rijen.append((r[k["type"]], r[k["source"]], r[k["destination"]], r[k["link position"]]))
    bronnen = {r[1] for r in rijen}
    per_doel = {}
    for typ, bron, doel, positie in rijen:
        per_doel.setdefault((doel, positie), set()).add(bron)
    inhoud = set()
    for typ, bron, doel, positie in rijen:
        if typ != "Hyperlink" or positie != "Content" or bron == doel:
            continue
        if len(per_doel[(doel, positie)]) / len(bronnen) >= sjabloon_aandeel:
            continue
        inhoud.add((schoon(bron), schoon(doel)))
    return inhoud


def main():
    p = argparse.ArgumentParser(description="Groepeer pagina's op inhoud met hun vectoren.")
    p.add_argument("vectoren", help="CSV met de kolommen url en vector (optioneel woorden, tekens)")
    p.add_argument("--buren", type=int, default=2, help="aantal dichtstbijzijnde buren (standaard 2)")
    p.add_argument("--min-tekens", type=int, default=500, help="sla pagina's met minder tekst over (standaard 500)")
    p.add_argument("--kort", type=int, help="pagina's met minder woorden heten kort (standaard: de helft van de mediaan van de site)")
    p.add_argument("--links", help="export All Inlinks van Screaming Frog")
    p.add_argument("--sjabloon", type=float, default=0.60, help="aandeel pagina's vanaf waar een link als menu of vast blok telt (standaard 0,60)")
    p.add_argument("--uit", help="schrijf de groepen ook naar deze CSV")
    a = p.parse_args()
    try:
        sys.stdout.reconfigure(encoding="utf-8")
    except AttributeError:
        pass

    paginas, v, overgeslagen = lees_vectoren(a.vectoren, a.min_tekens)
    if len(paginas) <= a.buren:
        sys.exit("Te weinig pagina's om te groeperen.")
    groepen, zwakste, s = groepeer(v, a.buren)
    groepen.sort(key=lambda g: (-len(g[1]), paginas[g[1][0]]["url"]))
    links = lees_links(a.links, a.sjabloon) if a.links else None

    aantallen = [x["woorden"] for x in paginas if x["woorden"] is not None]
    kort = a.kort if a.kort is not None else (int(np.median(aantallen) / 2) if aantallen else None)

    boven = s[np.triu_indices(len(v), 1)]
    print(f"{len(paginas)} pagina's, {len(groepen)} groepen, waarvan {sum(len(g) > 1 for _, g in groepen)} met meer dan één pagina")
    print(f"Overeenkomst over alle paren: gemiddeld {np.nanmean(boven):.2f}, laagste {np.nanmin(boven):.2f}, hoogste {np.nanmax(boven):.2f}")
    if kort is not None:
        bron = "eigen grens" if a.kort is not None else f"de helft van de mediaan van {int(np.median(aantallen))} woorden"
        print(f"Kort: minder dan {kort} woorden ({bron})")
    if overgeslagen:
        print("Overgeslagen: " + "; ".join(f"{u} ({r})" for u, r in overgeslagen))
    print()

    uit = []
    for nr, (wortel, leden) in enumerate(groepen, start=1):
        kop = f"Groep {nr}: {len(leden)} pagina" + ("'s" if len(leden) > 1 else "")
        if len(leden) > 1:
            kop += f", laagste overeenkomst binnen de groep {zwakste[wortel]:.2f}"
            if links is not None:
                urls = [schoon(paginas[i]["url"]) for i in leden]
                onderling = sum((x, y) in links for x in urls for y in urls if x != y)
                naar_alle = [u for u in urls if all((u, y) in links for y in urls if y != u)]
                van_alle = [u for u in urls if all((x, u) in links for x in urls if x != u)]
                mogelijk = len(urls) * (len(urls) - 1)
                kop += f", links in de tekst onderling {onderling} van {mogelijk}"
                if len(urls) >= 3:
                    centraal = sorted(set(naar_alle) | set(van_alle))
                    if onderling == mogelijk:
                        kop += ", alle pagina's linken naar elkaar"
                    else:
                        kop += ", centrale pagina: " + (", ".join(centraal) or "geen")
        print(kop)
        for i in sorted(leden, key=lambda i: paginas[i]["url"]):
            w = paginas[i]["woorden"]
            is_kort = w is not None and kort is not None and w < kort
            print(f"  {paginas[i]['url']}" + (f"  ({w} woorden{', kort' if is_kort else ''})" if w is not None else ""))
            uit.append({"groep": nr, "paginas_in_groep": len(leden), "url": paginas[i]["url"],
                        "woorden": "" if w is None else w, "kort": "ja" if is_kort else ""})

    if a.uit:
        with open(a.uit, "w", newline="", encoding="utf-8") as f:
            schrijver = csv.DictWriter(f, fieldnames=list(uit[0].keys()))
            schrijver.writeheader()
            schrijver.writerows(uit)
        print(f"\nGeschreven naar {a.uit}")


if __name__ == "__main__":
    main()
