goon/scripts/fix_title_normalized.py
goon-foss d2b3378d93 fix(ingest): czysc opisy z encji i znacznikow HTML, title_normalized zawsze przez normalize
Opisy scen szly do bazy z `&#34;`, `&lt;br&gt;` i surowymi `<br />`, a apka i strona
SEO pokazuja opis jako tekst, wiec bylo to widac doslownie. clean_description
dekoduje encje, `<br>` i koniec akapitu zamienia na nowa linie, a znane znaczniki
wycina (`<3` zostaje tekstem). Wpiete w normalize_scene i normalize_movie.

bulk_rescrape_hqporner.py i repair_truncated_titles.py liczyly title_normalized
jako lower(title), przez co 6005 scen mialo interpunkcje w kluczu dopasowania.
Oba licza teraz normalize() i dekoduja encje w tytule.

Skrypty: fix_html_entity_titles.py przemianowany na fix_html_entities.py z
przebiegiem po opisach; nowy fix_title_normalized.py przelicza klucz tam, gdzie
rozjechal sie z normalize(title).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 15:59:39 +02:00

120 lines
4.8 KiB
Python

"""Przelicza `title_normalized` scen i filmow tam, gdzie rozjechal sie z `normalize(title)`.
Ingest liczy `title_normalized = normalize(title)`, ale stare skrypty naprawcze
(`bulk_rescrape_hqporner.py`, `repair_truncated_titles.py`) zapisywaly samo `lower(title)`.
Takie sceny maja w kluczu interpunkcje (`cindy hope - horny boss`, `matty's virgin`), wiec
nie trafiaja w dokladne dopasowanie tytulu w resolverze ani w dedup. Pelny skan z 2026-09-15:
6005 scen, wszystkie w formie `lower(title)`, innych rozjazdow nie bylo.
Skrypt czyta cala tabele strumieniowo (4,3 mln scen to ok. 1,5 minuty), zbiera wiersze,
w ktorych klucz sie nie zgadza, i zapisuje partiami. Zapis jest warunkowy
(`WHERE title = stary AND title_normalized = stary`), wiec wiersz zmieniony w miedzyczasie
jest pomijany. `title` i `slug` zostaja bez zmian.
Uzycie:
python3 -u /srv/scripts/fix_title_normalized.py --dry-run
python3 -u /srv/scripts/fix_title_normalized.py
"""
from __future__ import annotations
import argparse
import collections
import logging
import random
import sys
import time
import sqlalchemy as sa
from app.db import engine, session_scope
from app.normalize.text import normalize
log = logging.getLogger("fix_title_normalized")
_TABLES = ("scenes", "movies")
def _plan(table: str) -> tuple[list[dict], collections.Counter]:
t0 = time.monotonic()
plan: list[dict] = []
kinds: collections.Counter = collections.Counter()
scanned = 0
with engine.connect().execution_options(stream_results=True, yield_per=20000) as conn:
for row_id, title, title_norm in conn.execute(sa.text(f"SELECT id, title, title_normalized FROM {table}")):
scanned += 1
new = normalize(title)
if new == title_norm:
continue
kinds["lower" if title_norm == (title or "").lower() else "inne"] += 1
plan.append({"id": row_id, "title": title, "old": title_norm, "new": new})
log.info(
"%s: przejrzane %d w %ds, do zmiany %d %s",
table, scanned, time.monotonic() - t0, len(plan), dict(kinds),
)
return plan, kinds
def _apply(table: str, plan: list[dict], *, batch_size: int) -> int:
stmt = sa.text(
f"UPDATE {table} t SET title_normalized = v.new "
"FROM unnest(CAST(:ids AS uuid[]), CAST(:titles AS text[]), CAST(:olds AS text[]), "
"CAST(:news AS text[])) AS v(id, title, old, new) "
"WHERE t.id = v.id AND t.title = v.title AND t.title_normalized = v.old"
)
updated = 0
for start in range(0, len(plan), batch_size):
batch = plan[start : start + batch_size]
params = {
"ids": [str(item["id"]) for item in batch],
"titles": [item["title"] for item in batch],
"olds": [item["old"] for item in batch],
"news": [item["new"] for item in batch],
}
with session_scope() as session:
updated += session.execute(stmt, params).rowcount
log.info("%s: %d/%d przejrzane, %d zapisane", table, start + len(batch), len(plan), updated)
return updated
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("--batch-size", type=int, default=500)
p.add_argument("--sample", type=int, default=20, help="ile losowych zmian pokazac na tabele")
p.add_argument(
"--only-lower",
action="store_true",
help="zapisz tylko rozjazdy w formie lower(title), inne tylko pokaz",
)
args = p.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
total_planned = total_updated = 0
for table in _TABLES:
plan, kinds = _plan(table)
if kinds.get("inne"):
others = [item for item in plan if item["old"] != (item["title"] or "").lower()]
for item in random.sample(others, min(args.sample, len(others))):
print(f"[{table} inne] {item['title']!r}: {item['old']!r} -> {item['new']!r}")
if args.only_lower:
plan = [item for item in plan if item["old"] == (item["title"] or "").lower()]
if not plan:
continue
for item in random.sample(plan, min(args.sample, len(plan))):
print(f"[{table}] {item['title']!r}")
print(f" {item['old']!r}")
print(f" -> {item['new']!r}")
sys.stdout.flush()
total_planned += len(plan)
if not args.dry_run:
total_updated += _apply(table, plan, batch_size=args.batch_size)
if args.dry_run:
log.info("DRY RUN: %d wierszy do zmiany, 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())