"""Czysci encje i znaczniki HTML w tytulach i opisach scen i filmow sprzed poprawki normalizera. Tuby (glownie xvideos, JSON-LD) oddawaly tytuly typu `we're [AI]`, a opisy z `"` i `<br>`. Zapisywalismy je tak jak przyszly, wiec `normalize` robil z tytulu slowa `apos`, `lpar`, a w apce i na stronie SEO encje widac doslownie. Od poprawki `normalize_scene` przepuszcza tytul przez `unescape_html`, a opis przez `clean_description`, ale istniejace wiersze trzeba poprawic osobno: scraper pola juz ustawionego nie nadpisuje. Przebieg `titles` zmienia na wierszu: - `title` na wersje po `unescape_html`, - `title_normalized` na `normalize(nowy tytul)`, tak jak liczy go ingest, - `slug` tylko gdy byl wyliczony ze starego tytulu (`slugify(stary) == slug`). Przebieg `descriptions` zmienia `description` na wersje po `clean_description`. Zapis jest warunkowy (`WHERE pole = stara wartosc`), wiec wiersz zmieniony w miedzyczasie przez ingest albo merge jest pomijany. Ponowne odpalenie jest bezpieczne: bierze tylko wiersze, w ktorych nadal jest encja albo znacznik. Uzycie: python3 -u /srv/scripts/fix_html_entities.py --dry-run python3 -u /srv/scripts/fix_html_entities.py --only descriptions """ from __future__ import annotations import argparse import logging import random import sys import sqlalchemy as sa from app.db import session_scope from app.normalize.text import clean_description, normalize, slugify, unescape_html log = logging.getLogger("fix_html_entities") # Ten sam zestaw co `_HTML_ENTITY_RE` w app/normalize/text.py, w skladni Postgresa. _PG_ENTITY_RE = r"&(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});" # Wstepny filtr na znaczniki z `_BREAK_TAG_RE`/`_INLINE_TAG_RE`; decyduje `clean_description`. _PG_TAG_RE = ( r"])" ) _TABLES = ("scenes", "movies") def _plan_titles(table: str, *, limit: int | None) -> list[dict]: with session_scope() as session: rows = session.execute( sa.text( f"SELECT id, title, title_normalized, slug FROM {table} " "WHERE title ~ :re" + (" LIMIT :limit" if limit else "") ), {"re": _PG_ENTITY_RE, "limit": limit}, ).all() plan: list[dict] = [] for row_id, title, title_norm, slug in rows: new_title = unescape_html(title) if not new_title or new_title == title: continue new_slug = slug if slug is None or slug == slugify(title): new_slug = slugify(new_title) or slug plan.append( { "id": row_id, "old_title": title, "title": new_title, "old_title_normalized": title_norm, "title_normalized": normalize(new_title), "old_slug": slug, "slug": new_slug, } ) log.info("%s.title: %d wierszy z encja, %d do zmiany", table, len(rows), len(plan)) return plan def _plan_descriptions(table: str, *, limit: int | None) -> list[dict]: with session_scope() as session: rows = session.execute( sa.text( f"SELECT id, description FROM {table} " "WHERE description ~ :ent OR description ~* :tag" + (" LIMIT :limit" if limit else "") ), {"ent": _PG_ENTITY_RE, "tag": _PG_TAG_RE, "limit": limit}, ).all() plan = [ {"id": row_id, "old_description": desc, "description": new} for row_id, desc in rows if (new := clean_description(desc)) != desc ] log.info("%s.description: %d wierszy z encja lub znacznikiem, %d do zmiany", table, len(rows), len(plan)) return plan def _apply_titles(table: str, plan: list[dict], *, batch_size: int) -> int: # Jedno UPDATE na partie przez unnest: rowcount jest wtedy wiarygodny. stmt = sa.text( f"UPDATE {table} t SET title = v.title, title_normalized = v.title_normalized, slug = v.slug " "FROM unnest(CAST(:ids AS uuid[]), CAST(:old_titles AS text[]), CAST(:titles AS text[]), " "CAST(:norms AS text[]), CAST(:slugs AS text[])) " "AS v(id, old_title, title, title_normalized, slug) " "WHERE t.id = v.id AND t.title = v.old_title" ) return _run_batches( f"{table}.title", stmt, plan, batch_size=batch_size, columns={"ids": "id", "old_titles": "old_title", "titles": "title", "norms": "title_normalized", "slugs": "slug"}, ) def _apply_descriptions(table: str, plan: list[dict], *, batch_size: int) -> int: stmt = sa.text( f"UPDATE {table} t SET description = v.description " "FROM unnest(CAST(:ids AS uuid[]), CAST(:old AS text[]), CAST(:new AS text[])) " "AS v(id, old_description, description) " "WHERE t.id = v.id AND t.description = v.old_description" ) return _run_batches( f"{table}.description", stmt, plan, batch_size=batch_size, columns={"ids": "id", "old": "old_description", "new": "description"}, ) def _run_batches(label: str, stmt, plan: list[dict], *, batch_size: int, columns: dict[str, str]) -> int: updated = 0 for start in range(0, len(plan), batch_size): batch = plan[start : start + batch_size] params = { param: [str(item[key]) if key == "id" else item[key] for item in batch] for param, key in columns.items() } with session_scope() as session: updated += session.execute(stmt, params).rowcount log.info("%s: %d/%d przejrzane, %d zapisane", label, start + len(batch), len(plan), updated) return updated def _diff_window(old: str, new: str | None, width: int = 90) -> tuple[str, str]: """Wycinek wokol pierwszej roznicy, bo opisy sa dlugie, a zmiana bywa w srodku.""" new = new or "" i = next((k for k, (a, b) in enumerate(zip(old, new)) if a != b), min(len(old), len(new))) start = max(0, i - width // 3) return old[start : start + width], new[start : start + width] def _print_title_sample(table: str, plan: list[dict], n: int) -> None: for item in random.sample(plan, min(n, len(plan))): print(f"[{table}.title] {item['id']}") print(f" title: {item['old_title']!r}") print(f" -> {item['title']!r}") print(f" norm: {item['old_title_normalized']!r}") print(f" -> {item['title_normalized']!r}") if item["slug"] != item["old_slug"]: print(f" slug: {item['old_slug']!r}") print(f" -> {item['slug']!r}") def _print_description_sample(table: str, plan: list[dict], n: int) -> None: for item in random.sample(plan, min(n, len(plan))): old, new = _diff_window(item["old_description"], item["description"]) print(f"[{table}.description] {item['id']}") print(f" ...{old!r}") print(f" ...{new!r}") def main() -> int: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--dry-run", action="store_true", help="tylko policz i pokaz probke") p.add_argument("--only", choices=("titles", "descriptions"), default=None) p.add_argument("--batch-size", type=int, default=500) p.add_argument("--sample", type=int, default=20, help="ile losowych zmian pokazac na przebieg") p.add_argument("--limit", type=int, default=None, help="max wierszy na tabele (test)") args = p.parse_args() logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") passes = [] if args.only in (None, "titles"): passes.append((_plan_titles, _print_title_sample, _apply_titles)) if args.only in (None, "descriptions"): passes.append((_plan_descriptions, _print_description_sample, _apply_descriptions)) total_planned = total_updated = 0 for plan_fn, sample_fn, apply_fn in passes: for table in _TABLES: plan = plan_fn(table, limit=args.limit) if not plan: continue total_planned += len(plan) sample_fn(table, plan, args.sample) sys.stdout.flush() if not args.dry_run: total_updated += apply_fn(table, plan, batch_size=args.batch_size) if args.dry_run: log.info("DRY RUN: %d zmian w planie, nic nie zapisane", total_planned) else: log.info("zapisane %d z %d (reszta zmieniona w miedzyczasie)", total_updated, total_planned) return 0 if __name__ == "__main__": sys.exit(main())