Spaces:
Sleeping
Sleeping
Sync from kink_cli (Docker Space)
Browse files- api.py +23 -0
- backend/user_snapshot_hub.py +30 -6
- frontend/discover.js +0 -8
- frontend/dist/assets/index-Rd0bHvq-.js +0 -0
- frontend/dist/index.html +1 -1
- scripts/collect_fetlife_profile_pictures.py +440 -0
- scripts/filter_fetlife_profile_candidates.py +209 -0
- scripts/verify_hf_stack.py +1 -1
- tests/test_collect_fetlife_profile_pictures.py +54 -0
- tests/test_filter_fetlife_profile_candidates.py +76 -0
api.py
CHANGED
|
@@ -247,6 +247,10 @@ async def lifespan(_app: FastAPI):
|
|
| 247 |
user_snapshot_hub = None # type: ignore[assignment]
|
| 248 |
if user_snapshot_hub is not None and user_snapshot_hub.snapshot_enabled():
|
| 249 |
interval_s = float(os.environ.get("KINK_USER_SNAPSHOT_INTERVAL_S", "60") or "60")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
async def _start_when_ready() -> None:
|
| 252 |
while _backend_impl is None:
|
|
@@ -254,6 +258,13 @@ async def lifespan(_app: FastAPI):
|
|
| 254 |
await _user_snapshot_flusher(_backend_impl.path, interval_s)
|
| 255 |
|
| 256 |
flusher_task = asyncio.create_task(_start_when_ready())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
try:
|
| 259 |
yield
|
|
@@ -567,6 +578,17 @@ def health() -> dict:
|
|
| 567 |
if isinstance(cache_payload, dict) and isinstance(cache_payload.get("users", {}), dict)
|
| 568 |
else None
|
| 569 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
return {
|
| 571 |
"ok": True,
|
| 572 |
"store_path": str(store_path),
|
|
@@ -575,6 +597,7 @@ def health() -> dict:
|
|
| 575 |
"warnings": warnings,
|
| 576 |
"media": _media_health_payload(),
|
| 577 |
"stats": b.catalog_stats(),
|
|
|
|
| 578 |
"recsys": {
|
| 579 |
"source": b.recsys_settings.source,
|
| 580 |
"settings_path": str(settings_path),
|
|
|
|
| 247 |
user_snapshot_hub = None # type: ignore[assignment]
|
| 248 |
if user_snapshot_hub is not None and user_snapshot_hub.snapshot_enabled():
|
| 249 |
interval_s = float(os.environ.get("KINK_USER_SNAPSHOT_INTERVAL_S", "60") or "60")
|
| 250 |
+
print(
|
| 251 |
+
f"[kink_cli] user-state snapshot ENABLED — repo={user_snapshot_hub.snapshot_repo()} "
|
| 252 |
+
f"file={user_snapshot_hub.snapshot_filename()} interval={interval_s:.0f}s"
|
| 253 |
+
)
|
| 254 |
|
| 255 |
async def _start_when_ready() -> None:
|
| 256 |
while _backend_impl is None:
|
|
|
|
| 258 |
await _user_snapshot_flusher(_backend_impl.path, interval_s)
|
| 259 |
|
| 260 |
flusher_task = asyncio.create_task(_start_when_ready())
|
| 261 |
+
else:
|
| 262 |
+
repo = user_snapshot_hub.snapshot_repo() if user_snapshot_hub is not None else "<no module>"
|
| 263 |
+
has_token = bool((os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip())
|
| 264 |
+
print(
|
| 265 |
+
f"[kink_cli] user-state snapshot DISABLED — repo={repo!r} HF_TOKEN={'set' if has_token else 'missing'}; "
|
| 266 |
+
"users will NOT persist across container restarts. Set HF_TOKEN (and KINK_USER_SNAPSHOT_REPO if needed) to enable."
|
| 267 |
+
)
|
| 268 |
|
| 269 |
try:
|
| 270 |
yield
|
|
|
|
| 578 |
if isinstance(cache_payload, dict) and isinstance(cache_payload.get("users", {}), dict)
|
| 579 |
else None
|
| 580 |
)
|
| 581 |
+
snapshot_payload: dict[str, Any] = {"enabled": False}
|
| 582 |
+
try:
|
| 583 |
+
from backend import user_snapshot_hub as _hub
|
| 584 |
+
snapshot_payload = {
|
| 585 |
+
"enabled": _hub.snapshot_enabled(),
|
| 586 |
+
"repo": _hub.snapshot_repo() or None,
|
| 587 |
+
"filename": _hub.snapshot_filename(),
|
| 588 |
+
"has_hf_token": bool((os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") or "").strip()),
|
| 589 |
+
}
|
| 590 |
+
except Exception: # noqa: BLE001
|
| 591 |
+
pass
|
| 592 |
return {
|
| 593 |
"ok": True,
|
| 594 |
"store_path": str(store_path),
|
|
|
|
| 597 |
"warnings": warnings,
|
| 598 |
"media": _media_health_payload(),
|
| 599 |
"stats": b.catalog_stats(),
|
| 600 |
+
"user_snapshot": snapshot_payload,
|
| 601 |
"recsys": {
|
| 602 |
"source": b.recsys_settings.source,
|
| 603 |
"settings_path": str(settings_path),
|
backend/user_snapshot_hub.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
| 1 |
"""Hugging Face Hub dataset I/O for the user-state snapshot.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
@@ -14,10 +18,28 @@ from pathlib import Path
|
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
| 16 |
DEFAULT_SNAPSHOT_FILENAME = "user_state.db"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def snapshot_repo() -> str:
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def snapshot_filename() -> str:
|
|
@@ -25,7 +47,9 @@ def snapshot_filename() -> str:
|
|
| 25 |
|
| 26 |
|
| 27 |
def snapshot_enabled() -> bool:
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
def _hf_token() -> str | None:
|
|
|
|
| 1 |
"""Hugging Face Hub dataset I/O for the user-state snapshot.
|
| 2 |
|
| 3 |
+
Snapshot subsystem activates whenever an HF_TOKEN is present and a snapshot repo is configured.
|
| 4 |
+
The repo defaults to the deploy owner's ``kink-userstate`` dataset so user persistence works
|
| 5 |
+
out of the box without needing a Space-variable flip:
|
| 6 |
+
|
| 7 |
+
HF_TOKEN write token; reuses the same secret as the catalog/Space deploy.
|
| 8 |
+
KINK_USER_SNAPSHOT_REPO overrides ``owner/dataset`` of the private Hub dataset.
|
| 9 |
+
KINK_USER_SNAPSHOT_FILENAME defaults to ``user_state.db``.
|
| 10 |
+
HF_SPACE_REPO when set, falls back to ``<owner>/kink-userstate``.
|
| 11 |
"""
|
| 12 |
from __future__ import annotations
|
| 13 |
|
|
|
|
| 18 |
logger = logging.getLogger(__name__)
|
| 19 |
|
| 20 |
DEFAULT_SNAPSHOT_FILENAME = "user_state.db"
|
| 21 |
+
# Hard-coded fallback so persistence works on the canonical Perplexed7675 deploy without any
|
| 22 |
+
# Space-variable setup. Override with KINK_USER_SNAPSHOT_REPO for any other deploy.
|
| 23 |
+
HARDCODED_SNAPSHOT_REPO_FALLBACK = "Perplexed7675/kink-userstate"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _derive_snapshot_repo_from_space() -> str:
|
| 27 |
+
"""If HF_SPACE_REPO=owner/space, default the snapshot dataset to ``owner/kink-userstate``."""
|
| 28 |
+
space = (os.environ.get("HF_SPACE_REPO") or "").strip()
|
| 29 |
+
if "/" not in space:
|
| 30 |
+
return ""
|
| 31 |
+
owner = space.split("/", 1)[0].strip()
|
| 32 |
+
return f"{owner}/kink-userstate" if owner else ""
|
| 33 |
|
| 34 |
|
| 35 |
def snapshot_repo() -> str:
|
| 36 |
+
explicit = (os.environ.get("KINK_USER_SNAPSHOT_REPO") or "").strip()
|
| 37 |
+
if explicit:
|
| 38 |
+
return explicit
|
| 39 |
+
derived = _derive_snapshot_repo_from_space()
|
| 40 |
+
if derived:
|
| 41 |
+
return derived
|
| 42 |
+
return HARDCODED_SNAPSHOT_REPO_FALLBACK
|
| 43 |
|
| 44 |
|
| 45 |
def snapshot_filename() -> str:
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def snapshot_enabled() -> bool:
|
| 50 |
+
"""True only when both a repo and a write token are available; otherwise the flusher would
|
| 51 |
+
silently fail every minute and we'd rather log the miss once on boot."""
|
| 52 |
+
return bool(snapshot_repo()) and bool(_hf_token())
|
| 53 |
|
| 54 |
|
| 55 |
def _hf_token() -> str | None:
|
frontend/discover.js
CHANGED
|
@@ -39,8 +39,6 @@ export function DiscoverView({
|
|
| 39 |
const [searchDebounced, setSearchDebounced] = useState("");
|
| 40 |
const [infoExpanded, setInfoExpanded] = useState(false);
|
| 41 |
const [submittingRating, setSubmittingRating] = useState(false);
|
| 42 |
-
const [savedFlashKey, setSavedFlashKey] = useState(0);
|
| 43 |
-
const savedFlashTimerRef = useRef(null);
|
| 44 |
/** Synchronous guard so rapid taps (Playwright ``force``) cannot fire before ``setSubmittingRating`` re-renders. */
|
| 45 |
const ratingBusyRef = useRef(false);
|
| 46 |
const [scenarioHint, setScenarioHint] = useState(null);
|
|
@@ -177,9 +175,6 @@ export function DiscoverView({
|
|
| 177 |
try {
|
| 178 |
const directionsOverride = (kink?.direction_shape === "mutual") ? ["together"] : null;
|
| 179 |
await Promise.resolve(onRate(kinkId, rating, directionsOverride));
|
| 180 |
-
setSavedFlashKey((k) => k + 1);
|
| 181 |
-
if (savedFlashTimerRef.current) clearTimeout(savedFlashTimerRef.current);
|
| 182 |
-
savedFlashTimerRef.current = setTimeout(() => setSavedFlashKey(0), 1200);
|
| 183 |
onDiscoverSheetDirectionsChange(["together"]);
|
| 184 |
setImageRevealed(false);
|
| 185 |
setInfoExpanded(false);
|
|
@@ -384,9 +379,6 @@ export function DiscoverView({
|
|
| 384 |
</div>
|
| 385 |
</div>
|
| 386 |
`}
|
| 387 |
-
${savedFlashKey
|
| 388 |
-
? html`<div className="discover-saved-flash" data-testid="discover-saved-flash" key=${savedFlashKey}>Saved ✓</div>`
|
| 389 |
-
: null}
|
| 390 |
<${ActionBar} onRate=${handleRate} />
|
| 391 |
`
|
| 392 |
: null}
|
|
|
|
| 39 |
const [searchDebounced, setSearchDebounced] = useState("");
|
| 40 |
const [infoExpanded, setInfoExpanded] = useState(false);
|
| 41 |
const [submittingRating, setSubmittingRating] = useState(false);
|
|
|
|
|
|
|
| 42 |
/** Synchronous guard so rapid taps (Playwright ``force``) cannot fire before ``setSubmittingRating`` re-renders. */
|
| 43 |
const ratingBusyRef = useRef(false);
|
| 44 |
const [scenarioHint, setScenarioHint] = useState(null);
|
|
|
|
| 175 |
try {
|
| 176 |
const directionsOverride = (kink?.direction_shape === "mutual") ? ["together"] : null;
|
| 177 |
await Promise.resolve(onRate(kinkId, rating, directionsOverride));
|
|
|
|
|
|
|
|
|
|
| 178 |
onDiscoverSheetDirectionsChange(["together"]);
|
| 179 |
setImageRevealed(false);
|
| 180 |
setInfoExpanded(false);
|
|
|
|
| 379 |
</div>
|
| 380 |
</div>
|
| 381 |
`}
|
|
|
|
|
|
|
|
|
|
| 382 |
<${ActionBar} onRate=${handleRate} />
|
| 383 |
`
|
| 384 |
: null}
|
frontend/dist/assets/index-Rd0bHvq-.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>Play List</title>
|
| 7 |
<link rel="icon" href="/favicon.ico" type="image/jpeg" />
|
| 8 |
-
<script type="module" crossorigin src="/frontend/assets/index-
|
| 9 |
<link rel="stylesheet" crossorigin href="/frontend/assets/index-DUwIsfuc.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>Play List</title>
|
| 7 |
<link rel="icon" href="/favicon.ico" type="image/jpeg" />
|
| 8 |
+
<script type="module" crossorigin src="/frontend/assets/index-Rd0bHvq-.js"></script>
|
| 9 |
<link rel="stylesheet" crossorigin href="/frontend/assets/index-DUwIsfuc.css">
|
| 10 |
</head>
|
| 11 |
<body>
|
scripts/collect_fetlife_profile_pictures.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import hashlib
|
| 6 |
+
import hmac
|
| 7 |
+
import json
|
| 8 |
+
import random
|
| 9 |
+
import re
|
| 10 |
+
import secrets
|
| 11 |
+
import sys
|
| 12 |
+
import time
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from html import unescape
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from urllib.parse import urlparse
|
| 17 |
+
|
| 18 |
+
import httpx
|
| 19 |
+
from bs4 import BeautifulSoup
|
| 20 |
+
|
| 21 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 22 |
+
if str(ROOT) not in sys.path:
|
| 23 |
+
sys.path.insert(0, str(ROOT))
|
| 24 |
+
|
| 25 |
+
from fetlife_collect import (
|
| 26 |
+
BASE_URL,
|
| 27 |
+
DEFAULT_HEADERS,
|
| 28 |
+
RAW_ROOT,
|
| 29 |
+
CDPFetcher,
|
| 30 |
+
best_image_url_from_img,
|
| 31 |
+
classify_html,
|
| 32 |
+
cookies_client,
|
| 33 |
+
trusted_profile,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
DEFAULT_OUT_ROOT = Path("data/cached_assets/fetlife_profiles")
|
| 38 |
+
DEFAULT_SALT_PATH = Path("data/fetlife_auth/profile_hash_salt")
|
| 39 |
+
PROFILE_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_-]*$")
|
| 40 |
+
USER_ID_RE = re.compile(r"^users/(?P<user_id>\d+)(?:/.*)?$")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass(frozen=True)
|
| 44 |
+
class ProfileSeed:
|
| 45 |
+
nickname: str
|
| 46 |
+
locale: str = ""
|
| 47 |
+
consent_basis: str = ""
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass(frozen=True)
|
| 51 |
+
class ProfilePictureCard:
|
| 52 |
+
picture_path: str
|
| 53 |
+
image_url: str
|
| 54 |
+
picture_hash: str
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def parse_args() -> argparse.Namespace:
|
| 58 |
+
parser = argparse.ArgumentParser(
|
| 59 |
+
description="Collect profile-picture thumbnails for a consented list of FetLife profiles."
|
| 60 |
+
)
|
| 61 |
+
parser.add_argument("--input", required=True, help="TXT or CSV of consented profile nicknames/URLs.")
|
| 62 |
+
parser.add_argument("--locale", default="Tulsa, Oklahoma", help="Locale tag written to manifests.")
|
| 63 |
+
parser.add_argument("--profile", default=None, help="Chrome profile dir override.")
|
| 64 |
+
parser.add_argument("--out-root", default=str(DEFAULT_OUT_ROOT), help="Private image cache root.")
|
| 65 |
+
parser.add_argument("--raw-root", default=str(RAW_ROOT), help="Raw HTML capture root.")
|
| 66 |
+
parser.add_argument("--salt-file", default=str(DEFAULT_SALT_PATH), help="Salt file for profile/path hashes.")
|
| 67 |
+
parser.add_argument("--limit", type=int, default=0, help="Max profiles to process; 0 means all.")
|
| 68 |
+
parser.add_argument("--offset", type=int, default=0, help="Skip this many input profiles.")
|
| 69 |
+
parser.add_argument("--min-delay-ms", type=int, default=2200)
|
| 70 |
+
parser.add_argument("--jitter-ms", type=int, default=1200)
|
| 71 |
+
parser.add_argument("--force", action="store_true", help="Redownload images and overwrite existing manifest.")
|
| 72 |
+
parser.add_argument("--dry-run", action="store_true", help="Fetch/parse pages but do not download images.")
|
| 73 |
+
parser.add_argument(
|
| 74 |
+
"--use-existing-html",
|
| 75 |
+
action="store_true",
|
| 76 |
+
help="Parse existing raw HTML instead of opening the browser. Useful for tests/reports; signed image URLs may expire.",
|
| 77 |
+
)
|
| 78 |
+
return parser.parse_args()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def load_or_create_salt(path: Path) -> bytes:
|
| 82 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 83 |
+
if path.exists():
|
| 84 |
+
text = path.read_text(encoding="utf-8").strip()
|
| 85 |
+
if text:
|
| 86 |
+
return text.encode("utf-8")
|
| 87 |
+
salt = secrets.token_hex(32)
|
| 88 |
+
path.write_text(salt + "\n", encoding="utf-8")
|
| 89 |
+
path.chmod(0o600)
|
| 90 |
+
return salt.encode("utf-8")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def keyed_hash(value: str, salt: bytes, length: int = 16) -> str:
|
| 94 |
+
digest = hmac.new(salt, value.lower().encode("utf-8"), hashlib.sha256).hexdigest()
|
| 95 |
+
return digest[:length]
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def normalize_profile_token(value: str) -> str | None:
|
| 99 |
+
token = value.strip()
|
| 100 |
+
if not token or token.startswith("#"):
|
| 101 |
+
return None
|
| 102 |
+
parsed = urlparse(token)
|
| 103 |
+
path = parsed.path.strip("/") if parsed.scheme or parsed.netloc else token.strip("/")
|
| 104 |
+
if not path:
|
| 105 |
+
return None
|
| 106 |
+
user_id = USER_ID_RE.match(path)
|
| 107 |
+
if user_id:
|
| 108 |
+
return f"users/{user_id.group('user_id')}"
|
| 109 |
+
first_segment = path.split("/", 1)[0]
|
| 110 |
+
if PROFILE_RE.match(first_segment):
|
| 111 |
+
return first_segment
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def load_profile_seeds(path: Path, default_locale: str) -> list[ProfileSeed]:
|
| 116 |
+
if path.suffix.lower() == ".csv":
|
| 117 |
+
return load_profile_seeds_csv(path, default_locale)
|
| 118 |
+
return load_profile_seeds_text(path, default_locale)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def load_profile_seeds_text(path: Path, default_locale: str) -> list[ProfileSeed]:
|
| 122 |
+
seeds: list[ProfileSeed] = []
|
| 123 |
+
seen: set[str] = set()
|
| 124 |
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
| 125 |
+
token = normalize_profile_token(raw_line)
|
| 126 |
+
if not token or token.lower() in seen:
|
| 127 |
+
continue
|
| 128 |
+
seen.add(token.lower())
|
| 129 |
+
seeds.append(ProfileSeed(nickname=token, locale=default_locale))
|
| 130 |
+
return seeds
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def load_profile_seeds_csv(path: Path, default_locale: str) -> list[ProfileSeed]:
|
| 134 |
+
seeds: list[ProfileSeed] = []
|
| 135 |
+
seen: set[str] = set()
|
| 136 |
+
with path.open(newline="", encoding="utf-8") as fh:
|
| 137 |
+
sample = fh.read(2048)
|
| 138 |
+
fh.seek(0)
|
| 139 |
+
has_header = csv.Sniffer().has_header(sample) if sample.strip() else False
|
| 140 |
+
if has_header:
|
| 141 |
+
reader = csv.DictReader(fh)
|
| 142 |
+
for row in reader:
|
| 143 |
+
token = normalize_profile_token(
|
| 144 |
+
row.get("nickname", "") or row.get("profile_url", "") or row.get("url", "")
|
| 145 |
+
)
|
| 146 |
+
if not token or token.lower() in seen:
|
| 147 |
+
continue
|
| 148 |
+
seen.add(token.lower())
|
| 149 |
+
seeds.append(
|
| 150 |
+
ProfileSeed(
|
| 151 |
+
nickname=token,
|
| 152 |
+
locale=(row.get("locale") or default_locale).strip(),
|
| 153 |
+
consent_basis=(row.get("consent_basis") or "").strip(),
|
| 154 |
+
)
|
| 155 |
+
)
|
| 156 |
+
else:
|
| 157 |
+
reader = csv.reader(fh)
|
| 158 |
+
for row in reader:
|
| 159 |
+
if not row:
|
| 160 |
+
continue
|
| 161 |
+
token = normalize_profile_token(row[0])
|
| 162 |
+
if not token or token.lower() in seen:
|
| 163 |
+
continue
|
| 164 |
+
seen.add(token.lower())
|
| 165 |
+
seeds.append(ProfileSeed(nickname=token, locale=default_locale))
|
| 166 |
+
return seeds
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def profile_path(seed: ProfileSeed) -> str:
|
| 170 |
+
return f"/{seed.nickname}" if not seed.nickname.startswith("users/") else f"/{seed.nickname}"
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def profile_pictures_url(seed: ProfileSeed) -> str:
|
| 174 |
+
return f"{BASE_URL}{profile_path(seed)}/pictures"
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def parse_profile_picture_cards(html: str, seed: ProfileSeed, salt: bytes) -> list[ProfilePictureCard]:
|
| 178 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 179 |
+
path_prefix = re.escape(profile_path(seed))
|
| 180 |
+
picture_path_re = re.compile(rf"^{path_prefix}/pictures/\d+$")
|
| 181 |
+
cards: list[ProfilePictureCard] = []
|
| 182 |
+
seen_paths: set[str] = set()
|
| 183 |
+
for link in soup.find_all("a", href=True):
|
| 184 |
+
raw_href = link["href"]
|
| 185 |
+
path = urlparse(raw_href).path if raw_href.startswith("http") else raw_href.split("?", 1)[0]
|
| 186 |
+
if not picture_path_re.match(path) or path in seen_paths:
|
| 187 |
+
continue
|
| 188 |
+
img = link.find("img")
|
| 189 |
+
if not img:
|
| 190 |
+
continue
|
| 191 |
+
image_url = unescape(best_image_url_from_img(img))
|
| 192 |
+
if not image_url.startswith("http"):
|
| 193 |
+
continue
|
| 194 |
+
if "picv2-" not in image_url and "picture/attachments/" not in image_url:
|
| 195 |
+
continue
|
| 196 |
+
seen_paths.add(path)
|
| 197 |
+
cards.append(
|
| 198 |
+
ProfilePictureCard(
|
| 199 |
+
picture_path=path,
|
| 200 |
+
image_url=image_url,
|
| 201 |
+
picture_hash=keyed_hash(path, salt),
|
| 202 |
+
)
|
| 203 |
+
)
|
| 204 |
+
return cards
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def content_suffix(url: str, content_type: str = "") -> str:
|
| 208 |
+
suffix = Path(urlparse(url).path).suffix.lower()
|
| 209 |
+
if suffix in {".jpg", ".jpeg", ".webp", ".png"}:
|
| 210 |
+
return suffix
|
| 211 |
+
if "png" in content_type:
|
| 212 |
+
return ".png"
|
| 213 |
+
if "webp" in content_type:
|
| 214 |
+
return ".webp"
|
| 215 |
+
return ".jpg"
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def serial_delay(min_delay_ms: int, jitter_ms: int) -> None:
|
| 219 |
+
delay_ms = max(0, min_delay_ms) + random.randint(0, max(0, jitter_ms))
|
| 220 |
+
if delay_ms:
|
| 221 |
+
time.sleep(delay_ms / 1000.0)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def existing_manifest_complete(manifest_path: Path) -> bool:
|
| 225 |
+
if not manifest_path.exists():
|
| 226 |
+
return False
|
| 227 |
+
try:
|
| 228 |
+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 229 |
+
except json.JSONDecodeError:
|
| 230 |
+
return False
|
| 231 |
+
return payload.get("state") == "ok"
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def fetch_html(
|
| 235 |
+
seed: ProfileSeed,
|
| 236 |
+
*,
|
| 237 |
+
cdp: CDPFetcher | None,
|
| 238 |
+
raw_path: Path,
|
| 239 |
+
use_existing_html: bool,
|
| 240 |
+
) -> tuple[str, str]:
|
| 241 |
+
if use_existing_html:
|
| 242 |
+
existing_path = raw_path
|
| 243 |
+
if not existing_path.exists():
|
| 244 |
+
legacy_stem = re.sub(r"[^a-zA-Z0-9._-]+", "__", seed.nickname)
|
| 245 |
+
legacy_path = raw_path.parent / f"{legacy_stem}__pictures.html"
|
| 246 |
+
if legacy_path.exists():
|
| 247 |
+
existing_path = legacy_path
|
| 248 |
+
if not existing_path.exists():
|
| 249 |
+
raise FileNotFoundError(f"missing raw html for {seed.nickname}: {raw_path}")
|
| 250 |
+
return existing_path.read_text(encoding="utf-8", errors="ignore"), "existing_html"
|
| 251 |
+
if cdp is None:
|
| 252 |
+
raise RuntimeError("CDP fetcher is required unless --use-existing-html is set")
|
| 253 |
+
capture = cdp.fetch(profile_pictures_url(seed), scroll=True)
|
| 254 |
+
html = str(capture["html"])
|
| 255 |
+
raw_path.parent.mkdir(parents=True, exist_ok=True)
|
| 256 |
+
raw_path.write_text(html, encoding="utf-8")
|
| 257 |
+
return html, str(capture.get("url", ""))
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def download_cards(
|
| 261 |
+
client: httpx.Client,
|
| 262 |
+
cards: list[ProfilePictureCard],
|
| 263 |
+
*,
|
| 264 |
+
out_dir: Path,
|
| 265 |
+
dry_run: bool,
|
| 266 |
+
force: bool,
|
| 267 |
+
) -> list[dict[str, object]]:
|
| 268 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 269 |
+
rows: list[dict[str, object]] = []
|
| 270 |
+
for index, card in enumerate(cards, start=1):
|
| 271 |
+
referer = f"{BASE_URL}{card.picture_path}"
|
| 272 |
+
planned_name = f"profile_picture_{index:03d}_{card.picture_hash}"
|
| 273 |
+
existing = next(out_dir.glob(planned_name + ".*"), None)
|
| 274 |
+
if existing and not force:
|
| 275 |
+
rows.append(
|
| 276 |
+
{
|
| 277 |
+
"index": index,
|
| 278 |
+
"ok": True,
|
| 279 |
+
"state": "exists",
|
| 280 |
+
"file": existing.name,
|
| 281 |
+
"bytes": existing.stat().st_size,
|
| 282 |
+
"picture_hash": card.picture_hash,
|
| 283 |
+
}
|
| 284 |
+
)
|
| 285 |
+
continue
|
| 286 |
+
if dry_run:
|
| 287 |
+
rows.append(
|
| 288 |
+
{
|
| 289 |
+
"index": index,
|
| 290 |
+
"ok": True,
|
| 291 |
+
"state": "dry_run",
|
| 292 |
+
"picture_hash": card.picture_hash,
|
| 293 |
+
}
|
| 294 |
+
)
|
| 295 |
+
continue
|
| 296 |
+
try:
|
| 297 |
+
response = client.get(card.image_url, headers={**DEFAULT_HEADERS, "referer": referer})
|
| 298 |
+
except httpx.HTTPError as exc:
|
| 299 |
+
rows.append({"index": index, "ok": False, "state": "http_error", "error": str(exc)})
|
| 300 |
+
continue
|
| 301 |
+
content_type = response.headers.get("content-type", "")
|
| 302 |
+
if response.status_code != 200 or not content_type.startswith("image/"):
|
| 303 |
+
rows.append(
|
| 304 |
+
{
|
| 305 |
+
"index": index,
|
| 306 |
+
"ok": False,
|
| 307 |
+
"state": "download_failed",
|
| 308 |
+
"status": response.status_code,
|
| 309 |
+
"content_type": content_type,
|
| 310 |
+
}
|
| 311 |
+
)
|
| 312 |
+
continue
|
| 313 |
+
target = out_dir / f"{planned_name}{content_suffix(card.image_url, content_type)}"
|
| 314 |
+
target.write_bytes(response.content)
|
| 315 |
+
rows.append(
|
| 316 |
+
{
|
| 317 |
+
"index": index,
|
| 318 |
+
"ok": True,
|
| 319 |
+
"state": "downloaded",
|
| 320 |
+
"file": target.name,
|
| 321 |
+
"bytes": len(response.content),
|
| 322 |
+
"picture_hash": card.picture_hash,
|
| 323 |
+
}
|
| 324 |
+
)
|
| 325 |
+
return rows
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def process_seed(
|
| 329 |
+
seed: ProfileSeed,
|
| 330 |
+
*,
|
| 331 |
+
profile_hash: str,
|
| 332 |
+
salt: bytes,
|
| 333 |
+
raw_root: Path,
|
| 334 |
+
out_root: Path,
|
| 335 |
+
cdp: CDPFetcher | None,
|
| 336 |
+
client: httpx.Client,
|
| 337 |
+
use_existing_html: bool,
|
| 338 |
+
dry_run: bool,
|
| 339 |
+
force: bool,
|
| 340 |
+
) -> dict[str, object]:
|
| 341 |
+
out_dir = out_root / profile_hash
|
| 342 |
+
manifest_path = out_dir / "manifest.json"
|
| 343 |
+
if not force and not dry_run and existing_manifest_complete(manifest_path):
|
| 344 |
+
return {
|
| 345 |
+
"profile_hash": profile_hash,
|
| 346 |
+
"state": "skipped_existing",
|
| 347 |
+
"output_dir": str(out_dir),
|
| 348 |
+
}
|
| 349 |
+
raw_path = raw_root / f"profile_pictures__{profile_hash}.html"
|
| 350 |
+
html, final_url = fetch_html(seed, cdp=cdp, raw_path=raw_path, use_existing_html=use_existing_html)
|
| 351 |
+
page_state = classify_html(html)
|
| 352 |
+
cards = parse_profile_picture_cards(html, seed, salt)
|
| 353 |
+
downloads = download_cards(client, cards, out_dir=out_dir, dry_run=dry_run, force=force)
|
| 354 |
+
ok_downloads = sum(1 for row in downloads if row.get("ok"))
|
| 355 |
+
manifest = {
|
| 356 |
+
"schema": "fetlife_profile_pictures_v1",
|
| 357 |
+
"state": "ok" if page_state == "ok" else page_state,
|
| 358 |
+
"profile_hash": profile_hash,
|
| 359 |
+
"locale": seed.locale,
|
| 360 |
+
"consent_basis": seed.consent_basis,
|
| 361 |
+
"source": "consented_seed_list",
|
| 362 |
+
"profile_path_hash": keyed_hash(profile_path(seed), salt),
|
| 363 |
+
"pictures_path_hash": keyed_hash(f"{profile_path(seed)}/pictures", salt),
|
| 364 |
+
"source_html": str(raw_path),
|
| 365 |
+
"final_url_hash": keyed_hash(final_url, salt) if final_url else "",
|
| 366 |
+
"candidate_count": len(cards),
|
| 367 |
+
"downloaded_ok": ok_downloads,
|
| 368 |
+
"downloads": downloads,
|
| 369 |
+
}
|
| 370 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 371 |
+
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
| 372 |
+
return {
|
| 373 |
+
"profile_hash": profile_hash,
|
| 374 |
+
"state": manifest["state"],
|
| 375 |
+
"candidates": len(cards),
|
| 376 |
+
"downloaded_ok": ok_downloads,
|
| 377 |
+
"output_dir": str(out_dir),
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def main() -> int:
|
| 382 |
+
args = parse_args()
|
| 383 |
+
input_path = Path(args.input)
|
| 384 |
+
out_root = Path(args.out_root)
|
| 385 |
+
raw_root = Path(args.raw_root)
|
| 386 |
+
salt = load_or_create_salt(Path(args.salt_file))
|
| 387 |
+
seeds = load_profile_seeds(input_path, args.locale)
|
| 388 |
+
if args.offset:
|
| 389 |
+
seeds = seeds[args.offset :]
|
| 390 |
+
if args.limit:
|
| 391 |
+
seeds = seeds[: args.limit]
|
| 392 |
+
if not seeds:
|
| 393 |
+
print(json.dumps({"state": "no_profiles", "input": str(input_path)}), file=sys.stderr)
|
| 394 |
+
return 1
|
| 395 |
+
|
| 396 |
+
cdp: CDPFetcher | None = None
|
| 397 |
+
client = cookies_client()
|
| 398 |
+
results: list[dict[str, object]] = []
|
| 399 |
+
try:
|
| 400 |
+
if not args.use_existing_html:
|
| 401 |
+
cdp = CDPFetcher(args.profile or trusted_profile())
|
| 402 |
+
for index, seed in enumerate(seeds, start=1):
|
| 403 |
+
profile_hash = keyed_hash(seed.nickname, salt)
|
| 404 |
+
try:
|
| 405 |
+
result = process_seed(
|
| 406 |
+
seed,
|
| 407 |
+
profile_hash=profile_hash,
|
| 408 |
+
salt=salt,
|
| 409 |
+
raw_root=raw_root,
|
| 410 |
+
out_root=out_root,
|
| 411 |
+
cdp=cdp,
|
| 412 |
+
client=client,
|
| 413 |
+
use_existing_html=args.use_existing_html,
|
| 414 |
+
dry_run=args.dry_run,
|
| 415 |
+
force=args.force,
|
| 416 |
+
)
|
| 417 |
+
except Exception as exc: # noqa: BLE001
|
| 418 |
+
result = {"profile_hash": profile_hash, "state": "failed", "error": str(exc)}
|
| 419 |
+
result["index"] = index
|
| 420 |
+
results.append(result)
|
| 421 |
+
print(json.dumps(result), flush=True)
|
| 422 |
+
if index < len(seeds):
|
| 423 |
+
serial_delay(args.min_delay_ms, args.jitter_ms)
|
| 424 |
+
finally:
|
| 425 |
+
client.close()
|
| 426 |
+
if cdp is not None:
|
| 427 |
+
cdp.close()
|
| 428 |
+
summary = {
|
| 429 |
+
"state": "done",
|
| 430 |
+
"profiles": len(results),
|
| 431 |
+
"ok": sum(1 for row in results if row.get("state") in {"ok", "skipped_existing"}),
|
| 432 |
+
"failed": sum(1 for row in results if row.get("state") == "failed"),
|
| 433 |
+
"downloaded_ok": sum(int(row.get("downloaded_ok") or 0) for row in results),
|
| 434 |
+
}
|
| 435 |
+
print(json.dumps(summary), flush=True)
|
| 436 |
+
return 0 if summary["failed"] == 0 else 2
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
if __name__ == "__main__":
|
| 440 |
+
raise SystemExit(main())
|
scripts/filter_fetlife_profile_candidates.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import random
|
| 6 |
+
import re
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 11 |
+
if str(ROOT) not in sys.path:
|
| 12 |
+
sys.path.insert(0, str(ROOT))
|
| 13 |
+
|
| 14 |
+
from scripts.collect_fetlife_profile_pictures import normalize_profile_token
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
TRUTHY = {"1", "true", "yes", "y", "on"}
|
| 18 |
+
FALSEY = {"0", "false", "no", "n", "off"}
|
| 19 |
+
PROFILE_URL_PREFIX = "https://fetlife.com/"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def parse_args() -> argparse.Namespace:
|
| 23 |
+
parser = argparse.ArgumentParser(description="Filter a supplied FetLife profile candidate CSV for review.")
|
| 24 |
+
parser.add_argument("--input", required=True, help="CSV containing supplied profile metadata.")
|
| 25 |
+
parser.add_argument("--output", required=True, help="Filtered CSV to write.")
|
| 26 |
+
parser.add_argument("--locale", action="append", default=[], help="Case-insensitive substring match on locale/location.")
|
| 27 |
+
parser.add_argument("--city", action="append", default=[], help="Case-insensitive city filter.")
|
| 28 |
+
parser.add_argument("--state", action="append", default=[], help="Case-insensitive state/region filter.")
|
| 29 |
+
parser.add_argument("--gender", action="append", default=[], help="Case-insensitive gender filter; repeatable.")
|
| 30 |
+
parser.add_argument("--role", action="append", default=[], help="Case-insensitive role filter; repeatable.")
|
| 31 |
+
parser.add_argument("--orientation", action="append", default=[], help="Case-insensitive orientation filter; repeatable.")
|
| 32 |
+
parser.add_argument("--age-min", type=int, default=None)
|
| 33 |
+
parser.add_argument("--age-max", type=int, default=None)
|
| 34 |
+
parser.add_argument("--has-pictures", action="store_true", help="Keep rows whose has_pictures column is truthy.")
|
| 35 |
+
parser.add_argument("--consent-status", action="append", default=[], help="Optional consent_status filter.")
|
| 36 |
+
parser.add_argument("--limit", type=int, default=0)
|
| 37 |
+
parser.add_argument("--sample", type=int, default=0, help="Randomly sample N rows after filtering.")
|
| 38 |
+
parser.add_argument("--seed", type=int, default=1, help="Random seed for --sample.")
|
| 39 |
+
return parser.parse_args()
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def split_terms(values: list[str]) -> list[str]:
|
| 43 |
+
terms: list[str] = []
|
| 44 |
+
for value in values:
|
| 45 |
+
for part in value.split(","):
|
| 46 |
+
term = part.strip().lower()
|
| 47 |
+
if term:
|
| 48 |
+
terms.append(term)
|
| 49 |
+
return terms
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def first_value(row: dict[str, str], names: tuple[str, ...]) -> str:
|
| 53 |
+
lower_map = {key.strip().lower(): value for key, value in row.items()}
|
| 54 |
+
for name in names:
|
| 55 |
+
value = lower_map.get(name)
|
| 56 |
+
if value is not None and str(value).strip():
|
| 57 |
+
return str(value).strip()
|
| 58 |
+
return ""
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def normalized_profile(row: dict[str, str]) -> tuple[str, str]:
|
| 62 |
+
raw = first_value(row, ("nickname", "profile_url", "url", "profile", "handle"))
|
| 63 |
+
nickname = normalize_profile_token(raw) or ""
|
| 64 |
+
profile_url = first_value(row, ("profile_url", "url"))
|
| 65 |
+
if nickname and not profile_url:
|
| 66 |
+
profile_url = PROFILE_URL_PREFIX + nickname
|
| 67 |
+
return nickname, profile_url
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def parse_age(value: str) -> int | None:
|
| 71 |
+
text = value.strip()
|
| 72 |
+
if not text:
|
| 73 |
+
return None
|
| 74 |
+
match = re.search(r"\d{1,3}", text)
|
| 75 |
+
if not match:
|
| 76 |
+
return None
|
| 77 |
+
age = int(match.group(0))
|
| 78 |
+
if 18 <= age <= 120:
|
| 79 |
+
return age
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def truthy(value: str) -> bool | None:
|
| 84 |
+
normalized = value.strip().lower()
|
| 85 |
+
if normalized in TRUTHY:
|
| 86 |
+
return True
|
| 87 |
+
if normalized in FALSEY:
|
| 88 |
+
return False
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def contains_any(value: str, terms: list[str]) -> bool:
|
| 93 |
+
if not terms:
|
| 94 |
+
return True
|
| 95 |
+
haystack = value.lower()
|
| 96 |
+
return any(term in haystack for term in terms)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def exactish_any(value: str, terms: list[str]) -> bool:
|
| 100 |
+
if not terms:
|
| 101 |
+
return True
|
| 102 |
+
normalized = value.strip().lower()
|
| 103 |
+
return any(normalized == term or term in normalized for term in terms)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def row_matches(row: dict[str, str], args: argparse.Namespace) -> bool:
|
| 107 |
+
locale_terms = split_terms(args.locale)
|
| 108 |
+
city_terms = split_terms(args.city)
|
| 109 |
+
state_terms = split_terms(args.state)
|
| 110 |
+
gender_terms = split_terms(args.gender)
|
| 111 |
+
role_terms = split_terms(args.role)
|
| 112 |
+
orientation_terms = split_terms(args.orientation)
|
| 113 |
+
consent_terms = split_terms(args.consent_status)
|
| 114 |
+
|
| 115 |
+
locale = first_value(row, ("locale", "location", "region"))
|
| 116 |
+
city = first_value(row, ("city", "town"))
|
| 117 |
+
state = first_value(row, ("state", "province", "region"))
|
| 118 |
+
gender = first_value(row, ("gender", "sex"))
|
| 119 |
+
role = first_value(row, ("role", "relationship_role"))
|
| 120 |
+
orientation = first_value(row, ("orientation", "sexual_orientation"))
|
| 121 |
+
consent_status = first_value(row, ("consent_status", "consent"))
|
| 122 |
+
|
| 123 |
+
if not contains_any(" ".join([locale, city, state]), locale_terms):
|
| 124 |
+
return False
|
| 125 |
+
if not exactish_any(city, city_terms):
|
| 126 |
+
return False
|
| 127 |
+
if not exactish_any(state, state_terms):
|
| 128 |
+
return False
|
| 129 |
+
if not exactish_any(gender, gender_terms):
|
| 130 |
+
return False
|
| 131 |
+
if not exactish_any(role, role_terms):
|
| 132 |
+
return False
|
| 133 |
+
if not exactish_any(orientation, orientation_terms):
|
| 134 |
+
return False
|
| 135 |
+
if not exactish_any(consent_status, consent_terms):
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
age = parse_age(first_value(row, ("age", "years_old")))
|
| 139 |
+
if args.age_min is not None and (age is None or age < args.age_min):
|
| 140 |
+
return False
|
| 141 |
+
if args.age_max is not None and (age is None or age > args.age_max):
|
| 142 |
+
return False
|
| 143 |
+
if args.has_pictures:
|
| 144 |
+
has_pictures = truthy(first_value(row, ("has_pictures", "pictures", "profile_pictures")))
|
| 145 |
+
if has_pictures is not True:
|
| 146 |
+
return False
|
| 147 |
+
|
| 148 |
+
nickname, _ = normalized_profile(row)
|
| 149 |
+
return bool(nickname)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def output_fieldnames(input_fieldnames: list[str]) -> list[str]:
|
| 153 |
+
fields = ["nickname", "profile_url"]
|
| 154 |
+
for field in input_fieldnames:
|
| 155 |
+
if field not in fields:
|
| 156 |
+
fields.append(field)
|
| 157 |
+
for field in ("consent_status", "consent_basis", "review_notes"):
|
| 158 |
+
if field not in fields:
|
| 159 |
+
fields.append(field)
|
| 160 |
+
return fields
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def filter_rows(rows: list[dict[str, str]], args: argparse.Namespace) -> list[dict[str, str]]:
|
| 164 |
+
filtered: list[dict[str, str]] = []
|
| 165 |
+
seen: set[str] = set()
|
| 166 |
+
for row in rows:
|
| 167 |
+
if not row_matches(row, args):
|
| 168 |
+
continue
|
| 169 |
+
nickname, profile_url = normalized_profile(row)
|
| 170 |
+
dedupe_key = nickname.lower()
|
| 171 |
+
if dedupe_key in seen:
|
| 172 |
+
continue
|
| 173 |
+
seen.add(dedupe_key)
|
| 174 |
+
updated = dict(row)
|
| 175 |
+
updated["nickname"] = nickname
|
| 176 |
+
updated["profile_url"] = profile_url
|
| 177 |
+
updated.setdefault("consent_status", "")
|
| 178 |
+
updated.setdefault("consent_basis", "")
|
| 179 |
+
updated.setdefault("review_notes", "")
|
| 180 |
+
filtered.append(updated)
|
| 181 |
+
|
| 182 |
+
if args.sample:
|
| 183 |
+
rng = random.Random(args.seed)
|
| 184 |
+
filtered = rng.sample(filtered, min(args.sample, len(filtered)))
|
| 185 |
+
if args.limit:
|
| 186 |
+
filtered = filtered[: args.limit]
|
| 187 |
+
return filtered
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def main() -> int:
|
| 191 |
+
args = parse_args()
|
| 192 |
+
input_path = Path(args.input)
|
| 193 |
+
output_path = Path(args.output)
|
| 194 |
+
with input_path.open(newline="", encoding="utf-8") as fh:
|
| 195 |
+
reader = csv.DictReader(fh)
|
| 196 |
+
rows = list(reader)
|
| 197 |
+
input_fieldnames = list(reader.fieldnames or [])
|
| 198 |
+
filtered = filter_rows(rows, args)
|
| 199 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 200 |
+
with output_path.open("w", newline="", encoding="utf-8") as fh:
|
| 201 |
+
writer = csv.DictWriter(fh, fieldnames=output_fieldnames(input_fieldnames), extrasaction="ignore")
|
| 202 |
+
writer.writeheader()
|
| 203 |
+
writer.writerows(filtered)
|
| 204 |
+
print(f"read={len(rows)} wrote={len(filtered)} output={output_path}")
|
| 205 |
+
return 0
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
if __name__ == "__main__":
|
| 209 |
+
raise SystemExit(main())
|
scripts/verify_hf_stack.py
CHANGED
|
@@ -68,7 +68,7 @@ def _assert_live_frontend_recs_error_shaping_once(root: str) -> tuple[str, str]:
|
|
| 68 |
for label, needle in {
|
| 69 |
"HTML API error shaping": "the response was a web page, not JSON",
|
| 70 |
"generic API failure shaping": "Request failed (",
|
| 71 |
-
"recommendations
|
| 72 |
}.items():
|
| 73 |
if needle not in bundle_js:
|
| 74 |
raise RuntimeError(
|
|
|
|
| 68 |
for label, needle in {
|
| 69 |
"HTML API error shaping": "the response was a web page, not JSON",
|
| 70 |
"generic API failure shaping": "Request failed (",
|
| 71 |
+
"recommendations retry control": "discover-recs-retry",
|
| 72 |
}.items():
|
| 73 |
if needle not in bundle_js:
|
| 74 |
raise RuntimeError(
|
tests/test_collect_fetlife_profile_pictures.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from scripts.collect_fetlife_profile_pictures import (
|
| 6 |
+
ProfileSeed,
|
| 7 |
+
load_profile_seeds,
|
| 8 |
+
normalize_profile_token,
|
| 9 |
+
parse_profile_picture_cards,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_normalize_profile_token_accepts_nickname_and_url() -> None:
|
| 14 |
+
assert normalize_profile_token("art6b") == "art6b"
|
| 15 |
+
assert normalize_profile_token("https://fetlife.com/art6b/pictures") == "art6b"
|
| 16 |
+
assert normalize_profile_token(" https://fetlife.com/users/12345 ") == "users/12345"
|
| 17 |
+
assert normalize_profile_token(" https://fetlife.com/users/12345/pictures ") == "users/12345"
|
| 18 |
+
assert normalize_profile_token("# comment") is None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_load_profile_seeds_text_deduplicates(tmp_path: Path) -> None:
|
| 22 |
+
seed_file = tmp_path / "profiles.txt"
|
| 23 |
+
seed_file.write_text("art6b\nhttps://fetlife.com/art6b\nOther_User\n", encoding="utf-8")
|
| 24 |
+
|
| 25 |
+
seeds = load_profile_seeds(seed_file, "Tulsa, Oklahoma")
|
| 26 |
+
|
| 27 |
+
assert seeds == [
|
| 28 |
+
ProfileSeed(nickname="art6b", locale="Tulsa, Oklahoma"),
|
| 29 |
+
ProfileSeed(nickname="Other_User", locale="Tulsa, Oklahoma"),
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_parse_profile_picture_cards_uses_profile_picture_paths_only() -> None:
|
| 34 |
+
html = """
|
| 35 |
+
<a href="/art6b/pictures">Pictures</a>
|
| 36 |
+
<a href="/art6b/pictures/101">
|
| 37 |
+
<img
|
| 38 |
+
src="https://picv2-c160.cdn.fetlife.com/7398125/a/c160.jpg"
|
| 39 |
+
srcset="https://picv2-c160.cdn.fetlife.com/7398125/a/c160.jpg 1x,
|
| 40 |
+
https://picv2-c400.cdn.fetlife.com/7398125/a/c400.jpg 2x">
|
| 41 |
+
</a>
|
| 42 |
+
<a href="/SomeoneElse/pictures/202">
|
| 43 |
+
<img src="https://picv2-c400.cdn.fetlife.com/1/b/c400.jpg">
|
| 44 |
+
</a>
|
| 45 |
+
<a href="/art6b/pictures/303">
|
| 46 |
+
<img src="https://static.example/not-a-profile-image.jpg">
|
| 47 |
+
</a>
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
cards = parse_profile_picture_cards(html, ProfileSeed(nickname="art6b"), b"test-salt")
|
| 51 |
+
|
| 52 |
+
assert len(cards) == 1
|
| 53 |
+
assert cards[0].picture_path == "/art6b/pictures/101"
|
| 54 |
+
assert "c400.jpg" in cards[0].image_url
|
tests/test_filter_fetlife_profile_candidates.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from argparse import Namespace
|
| 4 |
+
|
| 5 |
+
from scripts.filter_fetlife_profile_candidates import filter_rows
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _args(**overrides: object) -> Namespace:
|
| 9 |
+
defaults = {
|
| 10 |
+
"locale": [],
|
| 11 |
+
"city": [],
|
| 12 |
+
"state": [],
|
| 13 |
+
"gender": [],
|
| 14 |
+
"role": [],
|
| 15 |
+
"orientation": [],
|
| 16 |
+
"age_min": None,
|
| 17 |
+
"age_max": None,
|
| 18 |
+
"has_pictures": False,
|
| 19 |
+
"consent_status": [],
|
| 20 |
+
"limit": 0,
|
| 21 |
+
"sample": 0,
|
| 22 |
+
"seed": 1,
|
| 23 |
+
}
|
| 24 |
+
defaults.update(overrides)
|
| 25 |
+
return Namespace(**defaults)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def test_filter_rows_by_locale_gender_age_and_pictures() -> None:
|
| 29 |
+
rows = [
|
| 30 |
+
{
|
| 31 |
+
"profile_url": "https://fetlife.com/alpha",
|
| 32 |
+
"city": "Tulsa",
|
| 33 |
+
"state": "Oklahoma",
|
| 34 |
+
"gender": "Female",
|
| 35 |
+
"age": "35",
|
| 36 |
+
"has_pictures": "yes",
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"profile_url": "https://fetlife.com/bravo",
|
| 40 |
+
"city": "Tulsa",
|
| 41 |
+
"state": "Oklahoma",
|
| 42 |
+
"gender": "Male",
|
| 43 |
+
"age": "39",
|
| 44 |
+
"has_pictures": "yes",
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"profile_url": "https://fetlife.com/charlie",
|
| 48 |
+
"city": "Oklahoma City",
|
| 49 |
+
"state": "Oklahoma",
|
| 50 |
+
"gender": "Female",
|
| 51 |
+
"age": "35",
|
| 52 |
+
"has_pictures": "yes",
|
| 53 |
+
},
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
filtered = filter_rows(
|
| 57 |
+
rows,
|
| 58 |
+
_args(city=["Tulsa"], state=["Oklahoma"], gender=["Female"], age_min=30, age_max=40, has_pictures=True),
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
assert [row["nickname"] for row in filtered] == ["alpha"]
|
| 62 |
+
assert filtered[0]["profile_url"] == "https://fetlife.com/alpha"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_filter_rows_dedupes_and_adds_review_columns() -> None:
|
| 66 |
+
rows = [
|
| 67 |
+
{"nickname": "alpha", "city": "Tulsa", "state": "Oklahoma"},
|
| 68 |
+
{"profile_url": "https://fetlife.com/alpha/pictures", "city": "Tulsa", "state": "Oklahoma"},
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
filtered = filter_rows(rows, _args(city=["Tulsa"]))
|
| 72 |
+
|
| 73 |
+
assert len(filtered) == 1
|
| 74 |
+
assert filtered[0]["consent_status"] == ""
|
| 75 |
+
assert filtered[0]["consent_basis"] == ""
|
| 76 |
+
assert filtered[0]["review_notes"] == ""
|