Perplexed7675 commited on
Commit
b5e0fd9
·
verified ·
1 Parent(s): ad98c97

Sync from kink_cli (Docker Space)

Browse files
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
- Auth + repo come from environment so the snapshotter is a no-op when not configured:
4
- KINK_USER_SNAPSHOT_REPO ``owner/dataset`` of a private Hub dataset (required to enable)
5
- KINK_USER_SNAPSHOT_FILENAME default ``user_state.db``
6
- HF_TOKEN write token; reuses the same secret as the catalog/Space deploy
 
 
 
 
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
- return (os.environ.get("KINK_USER_SNAPSHOT_REPO") or "").strip()
 
 
 
 
 
 
21
 
22
 
23
  def snapshot_filename() -> str:
@@ -25,7 +47,9 @@ def snapshot_filename() -> str:
25
 
26
 
27
  def snapshot_enabled() -> bool:
28
- return bool(snapshot_repo())
 
 
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-Cu3GV3BP.js"></script>
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 UI fallback": "Could not load recommendations (server error)",
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"] == ""