thermograph/backend/indexnow.py

222 lines
9 KiB
Python
Raw Permalink Normal View History

"""IndexNow — instantly notify Bing, DuckDuckGo, Yandex, Seznam and friends of new
or changed URLs so they (re)crawl within minutes instead of days.
Note: Google does NOT use IndexNow. For Google, submit the sitemap in Search
Console; this module covers everyone else (and DuckDuckGo, which is powered by
Bing's index).
A per-host key authorises submissions: it is served as a plain-text file at the
site root (``/{key}.txt``, wired up in content.register) and echoed in every
submission so the search engine can confirm we own the host. Key resolution order
mirrors push.py's VAPID handling: env var → gitignored file → generated once and
persisted.
Usage (run after a content rebuild, or once to seed a fresh site):
cd backend && python indexnow.py https://thermograph.org
# or: make indexnow URL=https://thermograph.org
"""
import hashlib
import logging
import os
import secrets
import threading
from urllib.parse import urlparse
import httpx
from api import sitemap
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
import paths
log = logging.getLogger("thermograph.indexnow")
Split the backend into domain packages (#217) * Centralize filesystem paths in a single module Add paths.py, which resolves the repo root once and derives the cache, accounts DB, logs, templates, frontend and bundled-city-data locations from it. Replace the 13 per-module `dirname(__file__)/..` anchors with references to it, so a module's location no longer determines where the app reads its data. Env overrides (accounts DB, VAPID, IndexNow) are unchanged; every resolved path is byte-identical to before. Groundwork for moving modules into packages without re-pointing paths. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62 * Split the backend into domain packages Group the flat backend modules into packages that mirror their concerns: data/ climate, grading, scoring, grid, places, cities, city_events, store web/ app, views, homepage, content, schemas notifications/ notify, digest, push, mailer, discord, discord_interactions, discord_link accounts/ models, users, api_accounts, db core/ metrics, singleton, audit Intra-project imports are rewritten to the package-qualified form. The entry scripts (indexnow, warm_cities, migrate, gen_cities, gen_flavor) and paths.py stay at the backend/ root, and backend/app.py becomes a shim re-exporting web.app:app so the launch target stays `app:app` — run.sh, the systemd units, and CI need no change. Verified: full suite (318) passes, `uvicorn app:app` boots and serves the home/SEO/static/API surfaces, and every root script imports clean. Claude-Session: https://claude.ai/code/session_01XXxmNFy9cZ6Gh8Y9thZn62
2026-07-20 05:31:03 +00:00
_DATA_DIR = paths.DATA_DIR
_KEY_PATH = os.environ.get("THERMOGRAPH_INDEXNOW_KEY_FILE") or os.path.join(_DATA_DIR, "indexnow_key.txt")
_STATE_PATH = os.environ.get("THERMOGRAPH_INDEXNOW_STATE_FILE") or os.path.join(_DATA_DIR, "indexnow_state.txt")
_ENDPOINT = "https://api.indexnow.org/indexnow" # a shared endpoint; it fans out to all participants
_BATCH = 10000 # IndexNow's max URLs per request
# One shared client instead of a bare httpx.post per batch — same reused-client
# pattern as web/app.py's _frontend_client and notifications/discord.py's _client.
# 30s default matches submit()'s own prior per-call default; still overridable
# per call (submit_all's fan-out passes its own `timeout` through unchanged).
_client = httpx.Client(timeout=30)
_lock = threading.Lock()
_key = None
def _claim_file(value: str) -> str:
"""Persist a freshly generated key as `_KEY_PATH`'s content, atomic
first-writer-wins same race, same fix as push.py's VAPID `_claim_file`
(see its docstring): on a cold /state volume several workers can hit the
missing-file branch at once, and without this each would generate and cache
its OWN key, diverging from the file and from each other (the key served at
/{key}.txt would then randomly mismatch whichever key a given worker signed
a submission with). Write to a private temp file, then claim the real path
with a hard link (atomic; a loser gets EEXIST with no half-written window);
a loser reads back the winner's file instead of keeping its own generation."""
tmp_path = f"{_KEY_PATH}.{os.getpid()}.tmp"
try:
os.makedirs(_DATA_DIR, exist_ok=True)
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(value)
os.chmod(tmp_path, 0o600)
except OSError:
log.warning("could not persist IndexNow key to %s; using an in-memory key", _KEY_PATH)
return value
try:
try:
os.link(tmp_path, _KEY_PATH)
return value
except FileExistsError:
try:
with open(_KEY_PATH, encoding="utf-8") as f:
winner = f.read().strip()
if winner:
return winner
except OSError:
pass
return value
except OSError:
log.warning("could not persist IndexNow key to %s; using an in-memory key", _KEY_PATH)
return value
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
def key() -> str:
"""The IndexNow key (env → file → generate-and-persist), cached for the process."""
global _key
if _key:
return _key
with _lock:
if _key:
return _key
env = os.environ.get("THERMOGRAPH_INDEXNOW_KEY", "").strip()
if env:
_key = env
return _key
try:
with open(_KEY_PATH, encoding="utf-8") as f:
k = f.read().strip()
if k:
_key = k
return _key
except OSError:
pass
# Cache whatever _claim_file settles on — see its docstring: our own
# generation if we won the race to create the file, the winner's key
# read back from disk if we lost it.
_key = _claim_file(secrets.token_hex(16)) # 32 hex chars — within IndexNow's 8128 range
return _key
def submit(urls, host: str, key_location: str, scheme: str = "https", timeout: float = 30) -> dict:
"""POST ``urls`` (deduped, in batches of 10k) to IndexNow. Every URL must be on
``host``; ``key_location`` is the public URL of our key file. Returns a summary."""
urls = list(dict.fromkeys(urls))
if not urls:
return {"submitted": 0, "total": 0, "batches": 0}
k = key()
submitted, statuses = 0, []
for i in range(0, len(urls), _BATCH):
batch = urls[i:i + _BATCH]
payload = {"host": host, "key": k, "keyLocation": key_location, "urlList": batch}
try:
r = _client.post(_ENDPOINT, json=payload, timeout=timeout,
headers={"Content-Type": "application/json; charset=utf-8"})
except httpx.HTTPError as e:
log.warning("IndexNow request failed: %s", e)
statuses.append("error")
continue
statuses.append(r.status_code)
if r.status_code in (200, 202):
submitted += len(batch)
else:
log.warning("IndexNow batch rejected: %s %s", r.status_code, r.text[:200])
return {"submitted": submitted, "total": len(urls),
"batches": len(statuses), "statuses": statuses, "key_location": key_location}
def submit_all(site_base_url: str, **kw) -> dict:
"""Submit every indexable page. ``site_base_url`` is the public origin + base
path, e.g. ``https://thermograph.org`` (root) or ``https://host/thermograph``."""
site = site_base_url.rstrip("/")
parsed = urlparse(site)
urls = [site + path for path in sitemap.public_paths()]
return submit(urls, host=parsed.hostname, scheme=parsed.scheme or "https",
key_location=f"{site}/{key()}.txt", **kw)
def url_signature() -> str:
"""A stable fingerprint of the indexable URL set — changes only when pages are
added or removed (e.g. a new city), not on code-only deploys. Used to skip
resubmitting an unchanged set (see the --if-changed CLI flag)."""
return hashlib.sha1("\n".join(sitemap.public_paths()).encode()).hexdigest()
def _read_state() -> str:
try:
with open(_STATE_PATH, encoding="utf-8") as f:
return f.read().strip()
except OSError:
return ""
def _write_state(sig: str) -> None:
try:
os.makedirs(_DATA_DIR, exist_ok=True)
with open(_STATE_PATH, "w", encoding="utf-8") as f:
f.write(sig)
except OSError:
log.warning("could not persist IndexNow state to %s", _STATE_PATH)
def submit_if_changed(base_url: str | None = None) -> dict | None:
"""Submit every indexable page, but only when the URL set changed since the
last successful submission the shared logic behind the CLI's --if-changed
daemon: move the Discord gateway and scheduler out of the web process into Go (#21) The gateway bot and APScheduler were long-lived stateful I/O loops running inside the async web app under a leader election. They move into a single Go binary that owns ONLY that I/O -- websocket, RESUME, heartbeat, backoff, timers. It owns no grading logic. Anything needing data calls back over a new internal-only surface (/internal/discord/grade, /internal/jobs/*). Grading depends on polars and the parquet cache; reimplementing it in Go would let the bot's grades drift from the API's. The grade route returns gateway-ready JSON and Go relays the bytes verbatim. The binary ships in the backend image and runs as a second compose service off the same tag, so the two ends of the /internal/* contract can never skew. deploy.sh rolls daemon alongside backend -- without that the service would never be created, since a single-service deploy uses --no-deps. It also probes the image first and skips the daemon when rolling a tag that predates the binary: infra tracks main while image tags are env-staged, so a host can legitimately be asked to roll an older backend image, and creating the service anyway would leave a container crash-looping on a missing binary. replicas: 1 with order: stop-first replaces the leader election -- Discord permits one gateway connection per bot token. THERMOGRAPH_INTERNAL_TOKEN is optional: both ends derive it from THERMOGRAPH_AUTH_SECRET via HMAC under a domain-separation label, so this needs no new vault entry. The derivation is pinned to a shared cross-language test vector asserted on both sides, so drift fails CI instead of 401ing every call. Fail closed when neither secret is set. Improvements over the Python: a close intended for RESUME uses 4000 rather than 1000 (Discord invalidates a session closed 1000, so the old default defeated its own resume); MESSAGE_CREATE runs on a bounded worker pool; and a malformed HELLO returns an error rather than a clean reconnect, which would otherwise reset backoff and hot-loop against the gateway. 365 Python tests pass; Go build/vet/test -race clean; shellcheck 0 findings.
2026-07-23 22:49:54 +00:00
flag and the daemon's periodic ping (thermograph-daemon's indexnow timer,
via api/internal_routes.py), so
a code-only deploy or an unremarkable scheduled tick never resubmits. Returns
the submit_all() result, or None when skipped (unchanged, or a partial/failed
submission that leaves the prior state in place so the next attempt retries)."""
base = base_url or os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org")
sig = url_signature()
if sig == _read_state():
return None
result = submit_all(base)
if result["total"] and result["submitted"] == result["total"]:
_write_state(sig) # only remember a fully-successful submission
return result
if __name__ == "__main__":
import sys
# Usage: indexnow.py [--if-changed] [BASE_URL]
# --if-changed submit only when the URL set changed since the last run
# (used by the deploy hook so code-only deploys don't resubmit).
argv = sys.argv[1:]
if_changed = "--if-changed" in argv
positional = [a for a in argv if not a.startswith("-") and a.strip()]
base = (positional[0] if positional else "") or os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org")
logging.basicConfig(level=logging.INFO)
print(f"IndexNow key: {key()} (served at {base.rstrip('/')}/{key()}.txt)")
if if_changed:
result = submit_if_changed(base)
if result is None:
print("IndexNow: URL set unchanged since last submission — skipping.")
sys.exit(0)
else:
result = submit_all(base)
print(f"Submitted {result['submitted']}/{result['total']} URLs "
f"in {result['batches']} batch(es); statuses={result['statuses']}")
if result["total"] and result["submitted"] == result["total"] and not if_changed:
sig = url_signature()
_write_state(sig) # only remember a fully-successful submission