diff --git a/app/platforms/ebay/categories.py b/app/platforms/ebay/categories.py new file mode 100644 index 0000000..d02896d --- /dev/null +++ b/app/platforms/ebay/categories.py @@ -0,0 +1,78 @@ +# app/platforms/ebay/categories.py +# MIT License +"""eBay category cache — fetches leaf categories from the Taxonomy API and stores them +in the local SQLite DB for injection into LLM query-builder prompts. + +Refreshed weekly. Falls back to a hardcoded bootstrap table when no eBay API +credentials are configured (scraper-only users still get usable category hints). +""" +from __future__ import annotations + +import logging +import sqlite3 +from datetime import datetime, timedelta, timezone +from typing import Optional + +log = logging.getLogger(__name__) + +# Bootstrap table — common categories for self-hosters without eBay API credentials. +# category_id values are stable eBay leaf IDs (US marketplace, as of 2026). +_BOOTSTRAP_CATEGORIES: list[tuple[str, str, str]] = [ + ("27386", "Graphics Cards", "Consumer Electronics > Computers > Components > Graphics/Video Cards"), + ("164", "CPUs/Processors", "Consumer Electronics > Computers > Components > CPUs/Processors"), + ("170083","RAM", "Consumer Electronics > Computers > Components > Memory (RAM)"), + ("175669","Solid State Drives", "Consumer Electronics > Computers > Components > Drives > Solid State Drives"), + ("177089","Hard Drives", "Consumer Electronics > Computers > Components > Drives > Hard Drives"), + ("179142","Laptops", "Consumer Electronics > Computers > Laptops & Netbooks"), + ("171957","Desktop Computers", "Consumer Electronics > Computers > Desktops & All-in-Ones"), + ("293", "Consumer Electronics","Consumer Electronics"), + ("625", "Cameras", "Consumer Electronics > Cameras & Photography > Digital Cameras"), + ("15052", "Vintage Cameras", "Consumer Electronics > Cameras & Photography > Vintage Movie Cameras"), + ("11724", "Audio Equipment", "Consumer Electronics > TV, Video & Home Audio > Home Audio"), + ("3676", "Vinyl Records", "Music > Records"), + ("870", "Musical Instruments","Musical Instruments & Gear"), + ("31388", "Video Game Consoles","Video Games & Consoles > Video Game Consoles"), + ("139971","Video Games", "Video Games & Consoles > Video Games"), + ("139973","Video Game Accessories", "Video Games & Consoles > Video Game Accessories"), + ("14308", "Networking Gear", "Computers/Tablets & Networking > Home Networking & Connectivity"), + ("182062","Smartphones", "Cell Phones & Smartphones"), + ("9394", "Tablets", "Computers/Tablets & Networking > Tablets & eBook Readers"), + ("11233", "Collectibles", "Collectibles"), +] + + +class EbayCategoryCache: + """Caches eBay leaf categories in SQLite for LLM prompt injection. + + Args: + conn: An open sqlite3.Connection with migration 011 already applied. + """ + + def __init__(self, conn: sqlite3.Connection) -> None: + self._conn = conn + + def is_stale(self, max_age_days: int = 7) -> bool: + """Return True if the cache is empty or all entries are older than max_age_days.""" + cur = self._conn.execute("SELECT MAX(refreshed_at) FROM ebay_categories") + row = cur.fetchone() + if not row or not row[0]: + return True + try: + latest = datetime.fromisoformat(row[0]) + if latest.tzinfo is None: + latest = latest.replace(tzinfo=timezone.utc) + return datetime.now(timezone.utc) - latest > timedelta(days=max_age_days) + except ValueError: + return True + + def _seed_bootstrap(self) -> None: + """Insert the hardcoded bootstrap categories. Idempotent (ON CONFLICT IGNORE).""" + now = datetime.now(timezone.utc).isoformat() + self._conn.executemany( + "INSERT OR IGNORE INTO ebay_categories" + " (category_id, name, full_path, is_leaf, refreshed_at)" + " VALUES (?, ?, ?, 1, ?)", + [(cid, name, path, now) for cid, name, path in _BOOTSTRAP_CATEGORIES], + ) + self._conn.commit() + log.info("EbayCategoryCache: seeded %d bootstrap categories.", len(_BOOTSTRAP_CATEGORIES)) diff --git a/tests/test_ebay_categories.py b/tests/test_ebay_categories.py new file mode 100644 index 0000000..de791e4 --- /dev/null +++ b/tests/test_ebay_categories.py @@ -0,0 +1,58 @@ +"""Unit tests for EbayCategoryCache.""" +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from app.platforms.ebay.categories import EbayCategoryCache + +BOOTSTRAP_MIN = 10 # bootstrap must seed at least this many rows + + +@pytest.fixture +def db(tmp_path): + """In-memory SQLite with migrations applied.""" + from circuitforge_core.db import get_connection, run_migrations + conn = get_connection(tmp_path / "test.db") + run_migrations(conn, Path("app/db/migrations")) + return conn + + +def test_is_stale_empty_db(db): + cache = EbayCategoryCache(db) + assert cache.is_stale() is True + + +def test_is_stale_fresh(db): + now = datetime.now(timezone.utc).isoformat() + db.execute( + "INSERT INTO ebay_categories (category_id, name, full_path, is_leaf, refreshed_at)" + " VALUES (?, ?, ?, 1, ?)", + ("12345", "Graphics Cards", "Consumer Electronics > GPUs > Graphics Cards", now), + ) + db.commit() + cache = EbayCategoryCache(db) + assert cache.is_stale() is False + + +def test_is_stale_old(db): + old = (datetime.now(timezone.utc) - timedelta(days=8)).isoformat() + db.execute( + "INSERT INTO ebay_categories (category_id, name, full_path, is_leaf, refreshed_at)" + " VALUES (?, ?, ?, 1, ?)", + ("12345", "Graphics Cards", "Consumer Electronics > GPUs > Graphics Cards", old), + ) + db.commit() + cache = EbayCategoryCache(db) + assert cache.is_stale() is True + + +def test_seed_bootstrap_populates_rows(db): + cache = EbayCategoryCache(db) + cache._seed_bootstrap() + cur = db.execute("SELECT COUNT(*) FROM ebay_categories") + count = cur.fetchone()[0] + assert count >= BOOTSTRAP_MIN