0ae789752e
Both selectors ordered candidates least-recently-shown, then daily.seeded_order() ROTATED the whole list and took [0] — an arbitrary date-hashed item, undoing the ordering. Result: repeats (quote id 2 on 6/28+6/29; word "harmony" on 6/25+6/28), no guarantee a pool item is shown before it recurs. Fix: daily.freshest(rows) returns the freshest cohort only — every NEVER-shown item while any remain, else the oldest-shown group. quote/wotd _candidates use it; seeded_order now picks deterministically WITHIN that cohort. So every pool item is featured once before any repeat, then cycles oldest-first. Dropped the unused _NO_REPEAT_POOL window. Tests: no-repeat-until-exhausted (quote + wotd) + a freshest() unit test. 428 backend tests green. (Separate follow-up: expand the QOTD pool from 16 → 90+ vetted public-domain quotes for a longer no-repeat window.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
55 lines
2.2 KiB
Python
55 lines
2.2 KiB
Python
"""Shared helpers for the daily "small joys" features — On This Day, Word of the Day,
|
|
Quote of the Day (and whatever calm little delights come next).
|
|
|
|
Each joy keeps its own pool + daily table, but they all share this skeleton:
|
|
harvest -> pool → deterministic daily pick (date-seeded, least-recently-shown) →
|
|
cache row in a daily_* table → API → page.
|
|
|
|
This module holds only the genuinely shared bits (network + the deterministic pick), so a
|
|
new joy is a small self-contained module, not a copy-paste of plumbing. Network calls go
|
|
through http_json so tests can monkeypatch them.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import urllib.request
|
|
|
|
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
|
|
|
|
|
|
def http_json(url: str, timeout: int = 20) -> dict:
|
|
req = urllib.request.Request(url, headers=_UA)
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
return json.loads(r.read().decode("utf-8"))
|
|
|
|
|
|
def seeded_order(ids: list, date_str: str) -> list:
|
|
"""Rotate a list deterministically by the date, so the day's pick is the same for
|
|
everyone and varies day to day (the same trick Daily Art uses)."""
|
|
if not ids:
|
|
return ids
|
|
seed = int(hashlib.sha256(date_str.encode()).hexdigest(), 16) % len(ids)
|
|
return ids[seed:] + ids[:seed]
|
|
|
|
|
|
def freshest(rows: list) -> list:
|
|
"""The cohort to feature today, from pool rows carrying `id` + `shown_at`: every
|
|
NEVER-shown item (shown_at NULL) while any remain, else every item tied for the
|
|
OLDEST shown_at. Guarantees each pool item is featured ONCE before any repeat, then
|
|
cycles oldest-first. Pick deterministically *within* this cohort (seeded_order) —
|
|
NEVER across the whole pool, which re-feeds recent items (the QOTD/WOTD repeat bug)."""
|
|
never = [r["id"] for r in rows if r["shown_at"] is None]
|
|
if never:
|
|
return sorted(never)
|
|
if not rows:
|
|
return []
|
|
oldest = min(r["shown_at"] for r in rows)
|
|
return sorted(r["id"] for r in rows if r["shown_at"] == oldest)
|
|
|
|
|
|
def content_key(*parts) -> str:
|
|
"""A stable dedup key for a pool item (so re-harvesting never duplicates a row)."""
|
|
raw = "|".join("" if p is None else str(p) for p in parts)
|
|
return hashlib.sha256(raw.encode()).hexdigest()[:24]
|