Spaces:
Sleeping
Sleeping
| """Scraped catalog text classifiers. | |
| The FetLife imports historically stored UI chrome in ``kink.notes``: profile | |
| bucket labels, popularity counters, repeated titles, and Kinktionary section | |
| headings. These are useful as audit evidence, but not as user-facing copy. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from collections import Counter, defaultdict | |
| from typing import Any | |
| PROFILE_BUCKET_KEYS = frozenset( | |
| { | |
| "into", | |
| "curious", | |
| "curious_about", | |
| "hard_limits", | |
| "soft_limits", | |
| }, | |
| ) | |
| PROFILE_BUCKET_VALUES = frozenset( | |
| { | |
| "everything to do with it", | |
| "giving", | |
| "none", | |
| "receiving", | |
| "watching", | |
| "watching others wear", | |
| "wearing", | |
| }, | |
| ) | |
| FETLIFE_METRIC_KEYS = frozenset( | |
| { | |
| "kinksters", | |
| "lists", | |
| "pictures", | |
| "related groups", | |
| "similar fetishes", | |
| "statuses", | |
| "videos", | |
| "writings", | |
| }, | |
| ) | |
| ARTIFACT_CATEGORIES = ( | |
| "profile_bucket_line", | |
| "fetlife_metric_line", | |
| "fetlife_title_popularity_line", | |
| "kinktionary_toc_note", | |
| "title_echo_line", | |
| ) | |
| _COUNT_VALUE_RE = re.compile(r"^[\d,.]+\s*[kmb]?$", re.IGNORECASE) | |
| _KINKSTERS_ONLY_RE = re.compile( | |
| r"^[\d,.]+\s*[kmb]?\s+kinksters?(?:\s+into(?:\s+and\s+curious)?)?$", | |
| re.IGNORECASE, | |
| ) | |
| def _collapse_spaces(text: str) -> str: | |
| return " ".join(str(text or "").split()) | |
| def _line_key(text: str) -> str: | |
| return _collapse_spaces(text).casefold() | |
| def _split_label_value(text: str) -> tuple[str, str] | None: | |
| if ":" not in text: | |
| return None | |
| label, value = text.split(":", 1) | |
| return label.strip().casefold(), value.strip().casefold() | |
| def _strip_title_echo(kink_name: str, line: str) -> str: | |
| title = _collapse_spaces(kink_name) | |
| text = _collapse_spaces(line) | |
| if not title or not text: | |
| return text | |
| stripped = re.sub(r"^" + re.escape(title) + r"\s*", "", text, flags=re.IGNORECASE) | |
| return stripped.strip(" \t-:|,.!?") | |
| def classify_scraped_note_line(kink_id: str, kink_name: str, line: str) -> str: | |
| """Return the artifact category for a notes line, or ``""`` if it is real text.""" | |
| text = _collapse_spaces(line) | |
| if not text: | |
| return "" | |
| if str(kink_id).startswith("fetlife_kinktionary_"): | |
| return "kinktionary_toc_note" | |
| key = text.casefold() | |
| if key == _line_key(kink_name): | |
| return "title_echo_line" | |
| if key in PROFILE_BUCKET_KEYS: | |
| return "profile_bucket_line" | |
| label_value = _split_label_value(text) | |
| if label_value is not None: | |
| label, value = label_value | |
| if label in PROFILE_BUCKET_KEYS and value in PROFILE_BUCKET_VALUES: | |
| return "profile_bucket_line" | |
| if label in FETLIFE_METRIC_KEYS and _COUNT_VALUE_RE.fullmatch(value): | |
| return "fetlife_metric_line" | |
| if _KINKSTERS_ONLY_RE.fullmatch(_strip_title_echo(kink_name, text)): | |
| return "fetlife_title_popularity_line" | |
| return "" | |
| def clean_scraped_notes(kink_id: str, kink_name: str, notes: str) -> str: | |
| """Remove known scrape leftovers from ``kink.notes`` while preserving real future notes.""" | |
| kept = [] | |
| for raw_line in str(notes or "").replace("\r", "").splitlines(): | |
| line = raw_line.strip() | |
| if not line: | |
| continue | |
| if classify_scraped_note_line(kink_id, kink_name, line): | |
| continue | |
| kept.append(line) | |
| return "\n".join(kept) | |
| def clean_scraped_catalog_text(kink_name: str, text: str) -> str: | |
| """Remove cross-field scrape leftovers without treating Kinktionary prose as TOC notes.""" | |
| kept = [] | |
| for raw_line in str(text or "").replace("\r", "").splitlines(): | |
| line = raw_line.strip() | |
| if not line: | |
| continue | |
| if classify_scraped_note_line("", kink_name, line): | |
| continue | |
| kept.append(line) | |
| return "\n".join(kept) | |
| def audit_scraped_notes(rows: list[tuple[str, str, str]], *, sample_limit: int = 8) -> dict[str, Any]: | |
| """Classify notes rows for audit scripts and tests.""" | |
| line_counts: Counter[str] = Counter() | |
| row_counts: Counter[str] = Counter() | |
| samples: dict[str, list[dict[str, str]]] = defaultdict(list) | |
| nonempty_rows = 0 | |
| artifact_rows = 0 | |
| remaining_rows_after_filter = 0 | |
| for kink_id, kink_name, raw_notes in rows: | |
| notes = str(raw_notes or "") | |
| if not notes.strip(): | |
| continue | |
| nonempty_rows += 1 | |
| row_categories: set[str] = set() | |
| for raw_line in notes.replace("\r", "").splitlines(): | |
| category = classify_scraped_note_line(kink_id, kink_name, raw_line) | |
| if not category: | |
| continue | |
| line_counts[category] += 1 | |
| row_categories.add(category) | |
| if len(samples[category]) < sample_limit: | |
| samples[category].append( | |
| { | |
| "id": str(kink_id), | |
| "name": str(kink_name), | |
| "line": _collapse_spaces(raw_line), | |
| }, | |
| ) | |
| if row_categories: | |
| artifact_rows += 1 | |
| for category in row_categories: | |
| row_counts[category] += 1 | |
| if clean_scraped_notes(kink_id, kink_name, notes): | |
| remaining_rows_after_filter += 1 | |
| return { | |
| "nonempty_rows": nonempty_rows, | |
| "artifact_rows": artifact_rows, | |
| "remaining_rows_after_filter": remaining_rows_after_filter, | |
| "line_counts": {category: line_counts[category] for category in ARTIFACT_CATEGORIES}, | |
| "row_counts": {category: row_counts[category] for category in ARTIFACT_CATEGORIES}, | |
| "samples": dict(samples), | |
| } | |