#!/usr/bin/env python3 """ sync-gallery-photos.py Controls the real Google Chrome browser via Selenium to fetch Unsplash data. This carries real browser cookies, localStorage, and TLS fingerprint, so it bypasses most fetch-level anti-bot measures. Workflow: 1. Launch real Chrome (headless or headed). 2. Visit https://unsplash.com to warm up cookies. 3. Navigate to the internal napi endpoint (/napi/photos) — same-origin, so it inherits all browser state. 4. Read the JSON from the page body (or intercept the network response). 5. Map & upsert into Supabase. """ from __future__ import annotations import argparse import json import os import random import re import sys import time import traceback from datetime import datetime, timezone from pathlib import Path from typing import Any # --------------------------------------------------------------------------- # Dependency check # --------------------------------------------------------------------------- try: from selenium import webdriver from selenium.webdriver.chrome.service import Service as ChromeService from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options as ChromeOptions from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException from webdriver_manager.chrome import ChromeDriverManager except ImportError as exc: # pragma: no cover print( "ERROR: selenium / webdriver-manager is not installed.\n" "Run: pip install selenium webdriver-manager", file=sys.stderr, ) raise SystemExit(1) from exc try: from supabase import create_client, Client except ImportError as exc: # pragma: no cover print( "ERROR: supabase-py is not installed.\n" "Run: pip install -r scripts/requirements.txt", file=sys.stderr, ) raise SystemExit(1) from exc try: from dotenv import load_dotenv except ImportError: load_dotenv = None # type: ignore[assignment] # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- DEFAULT_PAGE = 1 DEFAULT_PER_PAGE = 20 UNSPLASH_BASE_URL = "https://unsplash.com/napi" def build_source(topic: str | None) -> str: """Build the source identifier used in Supabase.""" if topic: return f"unsplash-{topic}" return "unsplash-photos" def build_api_url(topic: str | None) -> str: """Build the Unsplash napi URL base path.""" if topic: return f"{UNSPLASH_BASE_URL}/topics/{topic}/photos" return f"{UNSPLASH_BASE_URL}/photos" # --------------------------------------------------------------------------- # Environment helpers # --------------------------------------------------------------------------- def load_env_local() -> dict[str, str]: """Load variables from .env.local on top of the existing os.environ.""" env_map = dict(os.environ) dotenv_path = Path(".env.local") if dotenv_path.exists() and load_dotenv: load_dotenv(dotenv_path, override=True) env_map = dict(os.environ) elif dotenv_path.exists(): for line in dotenv_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, *value_parts = line.split("=") value = "=".join(value_parts).strip() env_map[key.strip()] = re.sub(r'^["\']|["\']$', "", value) return env_map def require_env(env: dict[str, str], key: str) -> str: value = env.get(key) if not value: raise RuntimeError(f"{key} is not configured.") return value # --------------------------------------------------------------------------- # Data helpers # --------------------------------------------------------------------------- def normalize_date(value: str | None) -> str | None: if not value: return None try: dt = datetime.fromisoformat(value.replace("Z", "+00:00")) return dt.isoformat() except (ValueError, TypeError): return None def get_color_family(color: str | None) -> str | None: if not color: return None normalized = color.strip().removeprefix("#") if not re.fullmatch(r"[0-9a-fA-F]{6}", normalized): return None r = int(normalized[0:2], 16) g = int(normalized[2:4], 16) b = int(normalized[4:6], 16) max_value = max(r, g, b) min_value = min(r, g, b) lightness = (max_value + min_value) / 510 if lightness > 0.78: return "light" if lightness < 0.2: return "black" delta = max_value - min_value if delta < 24: return "light" if lightness > 0.52 else "black" if max_value == r: hue = ((g - b) / delta) % 6 elif max_value == g: hue = (b - r) / delta + 2 else: hue = (r - g) / delta + 4 hue = round(hue * 60) if hue < 0: hue += 360 if hue < 18 or hue >= 345: return "red" if hue < 45: return "orange" if hue < 70: return "yellow" if hue < 170: return "green" if hue < 255: return "blue" if hue < 345: return "purple" return None def map_photo_to_row(photo: dict[str, Any], source: str) -> dict[str, Any]: return { "unsplash_id": photo["id"], "slug": photo.get("slug"), "description": photo.get("description"), "alt_description": photo.get("alt_description"), "width": photo.get("width"), "height": photo.get("height"), "color": photo.get("color"), "color_family": get_color_family(photo.get("color")), "blur_hash": photo.get("blur_hash"), "likes": photo.get("likes"), "photo_created_at": normalize_date(photo.get("created_at")), "photo_updated_at": normalize_date(photo.get("updated_at")), "urls": photo.get("urls") or {}, "links": photo.get("links") or {}, "user_info": photo.get("user") or {}, "raw": photo, "source": source, } # --------------------------------------------------------------------------- # Chrome setup # --------------------------------------------------------------------------- def create_chrome_driver(headless: bool = True) -> webdriver.Chrome: """Launch a real Chrome browser with anti-detection options.""" options = ChromeOptions() if headless: options.add_argument("--headless=new") # new headless mode # Anti-bot / stability flags options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-blink-features=AutomationControlled") options.add_argument("--disable-infobars") options.add_argument("--window-size=1920,1080") options.add_argument( "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36" ) # Exclude automation switches so navigator.webdriver === undefined options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option("useAutomationExtension", False) service = ChromeService(ChromeDriverManager().install()) driver = webdriver.Chrome(service=service, options=options) # Remove the webdriver property via CDP so sites can't detect Selenium driver.execute_cdp_cmd( "Page.addScriptToEvaluateOnNewDocument", { "source": """ Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); """ }, ) return driver # --------------------------------------------------------------------------- # Browser fetch via real Chrome # --------------------------------------------------------------------------- def fetch_photos_via_browser( driver: webdriver.Chrome, page_num: int, per_page: int, topic: str | None ) -> list[dict[str, Any]]: """ 1. Visit Unsplash homepage to warm up cookies. 2. Navigate to the internal napi endpoint. 3. Read the JSON from
or page body.
"""
# 1. Warm-up
driver.get("https://unsplash.com")
WebDriverWait(driver, 15).until(
EC.presence_of_element_located((By.TAG_NAME, "body"))
)
# 2. Navigate to the internal API
base_url = build_api_url(topic)
api_url = f"{base_url}?page={page_num}&per_page={per_page}&order_by=latest"
driver.get(api_url)
# 3. Wait until the body contains JSON (starts with '[')
try:
WebDriverWait(driver, 15).until(
lambda d: d.find_element(By.TAG_NAME, "body").text.strip().startswith("[")
)
except TimeoutException:
body = driver.find_element(By.TAG_NAME, "body").text.strip()
raise RuntimeError(
f"Timeout waiting for JSON. First 200 chars: {body[:200]}"
)
body_text = driver.find_element(By.TAG_NAME, "body").text.strip()
return json.loads(body_text)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None: # pragma: no cover
parser = argparse.ArgumentParser(
description="Sync Unsplash photos into Supabase via real Chrome."
)
parser.add_argument(
"--max-pages",
type=int,
required=True,
help="Maximum page number to fetch (script runs pages 1..max_pages inclusive).",
)
parser.add_argument(
"--topic",
type=str,
default=None,
help=(
"Unsplash topic slug (e.g. 'wallpapers', '3d-renders', 'nature'). "
"If omitted, uses the generic /napi/photos endpoint."
),
)
parser.add_argument(
"--per-page",
type=int,
default=DEFAULT_PER_PAGE,
help=f"Photos per page (default: {DEFAULT_PER_PAGE}).",
)
parser.add_argument(
"--headless",
action=argparse.BooleanOptionalAction,
default=True,
help="Run Chrome in headless mode (default: true).",
)
parser.add_argument(
"--delay",
type=int,
default=3,
help="Base delay in seconds between page requests (default: 3).",
)
args = parser.parse_args()
max_pages: int = args.max_pages
topic: str | None = args.topic
per_page: int = args.per_page
headless: bool = args.headless
base_delay: int = args.delay
source = build_source(topic)
env = load_env_local()
sb_url = require_env(env, "NEXT_PUBLIC_SUPABASE_URL")
sb_key = require_env(env, "SUPABASE_SERVICE_ROLE_KEY")
from supabase._sync.client import ClientOptions
supabase: Client = create_client(
sb_url,
sb_key,
options=ClientOptions(
persist_session=False,
auto_refresh_token=False,
),
)
# -----------------------------------------------------------------------
# Read per_page from sync state if it exists
# -----------------------------------------------------------------------
state_resp = (
supabase.table("wg_gallery_sync_state")
.select("source,per_page")
.eq("source", source)
.limit(1)
.execute()
)
state = state_resp.data[0] if state_resp.data else None
if state and state.get("per_page") and args.per_page == DEFAULT_PER_PAGE:
per_page = state["per_page"]
# -----------------------------------------------------------------------
# Launch Chrome once and reuse it for all pages
# -----------------------------------------------------------------------
driver = create_chrome_driver(headless=headless)
total_seen = 0
total_inserted = 0
failed_pages: list[int] = []
try:
for page_num in range(1, max_pages + 1):
print(f"[page {page_num}/{max_pages}] Fetching ...", flush=True)
try:
photos = fetch_photos_via_browser(driver, page_num, per_page, topic)
except Exception as exc:
print(f"[page {page_num}/{max_pages}] ERROR: {exc}", flush=True)
_record_error(supabase, source, page_num, per_page, str(exc))
failed_pages.append(page_num)
continue
# Validate shape
if not isinstance(photos, list):
msg = f"Expected a JSON array, got {type(photos).__name__}."
print(f"[page {page_num}/{max_pages}] ERROR: {msg}", flush=True)
_record_error(supabase, source, page_num, per_page, msg)
failed_pages.append(page_num)
continue
# Upsert into Supabase
rows = [map_photo_to_row(photo, source) for photo in photos]
inserted_count = 0
if rows:
upsert_resp = (
supabase.table("wg_gallery_photos")
.upsert(rows, on_conflict="unsplash_id", ignore_duplicates=True)
.execute()
)
inserted_count = len(upsert_resp.data) if upsert_resp.data else 0
total_seen += len(photos)
total_inserted += inserted_count
print(
f"[page {page_num}/{max_pages}] seen={len(photos)} inserted={inserted_count}",
flush=True,
)
# Random delay between pages to avoid rate-limiting
if page_num < max_pages:
sleep_sec = base_delay + random.uniform(0, 2)
print(f"[page {page_num}/{max_pages}] Sleeping {sleep_sec:.1f}s ...", flush=True)
time.sleep(sleep_sec)
# -------------------------------------------------------------------
# Final sync state update
# -------------------------------------------------------------------
now = datetime.now(timezone.utc).isoformat()
next_page = max_pages + 1 if not failed_pages else failed_pages[0]
(
supabase.table("wg_gallery_sync_state")
.upsert(
{
"source": source,
"next_page": next_page,
"per_page": per_page,
"last_synced_at": now,
"last_inserted_count": total_inserted,
"last_seen_count": total_seen,
"last_error": None if not failed_pages else f"Failed pages: {failed_pages}",
},
on_conflict="source",
)
.execute()
)
result = {
"ok": True,
"source": source,
"maxPages": max_pages,
"failedPages": failed_pages,
"totalSeen": total_seen,
"totalInserted": total_inserted,
"nextPage": next_page,
}
print(json.dumps(result, indent=2))
finally:
driver.quit()
def _record_error(
supabase: Client, source: str, page_num: int, per_page: int, message: str
) -> None:
"""Persist error details so the next run can retry from the same page."""
now = datetime.now(timezone.utc).isoformat()
(
supabase.table("wg_gallery_sync_state")
.upsert(
{
"source": source,
"next_page": page_num,
"per_page": per_page,
"last_synced_at": now,
"last_inserted_count": 0,
"last_seen_count": 0,
"last_error": message,
},
on_conflict="source",
)
.execute()
)
if __name__ == "__main__":
try:
main()
except Exception:
traceback.print_exc()
sys.exit(1)