feat: EbayCategoryCache refresh from eBay Taxonomy API with bootstrap fallback
This commit is contained in:
parent
7c73186394
commit
0b8cb63968
2 changed files with 169 additions and 0 deletions
|
|
@ -13,6 +13,8 @@ import sqlite3
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Bootstrap table — common categories for self-hosters without eBay API credentials.
|
||||
|
|
@ -114,3 +116,95 @@ class EbayCategoryCache:
|
|||
(limit,),
|
||||
)
|
||||
return [(row[0], row[1]) for row in cur.fetchall()]
|
||||
|
||||
def refresh(
|
||||
self,
|
||||
token_manager: Optional["EbayTokenManager"] = None,
|
||||
) -> int:
|
||||
"""Fetch the eBay category tree and upsert leaf nodes into SQLite.
|
||||
|
||||
Args:
|
||||
token_manager: An `EbayTokenManager` instance for the Taxonomy API.
|
||||
If None, falls back to seeding the hardcoded bootstrap table.
|
||||
|
||||
Returns:
|
||||
Number of leaf categories stored.
|
||||
"""
|
||||
if token_manager is None:
|
||||
self._seed_bootstrap()
|
||||
cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories")
|
||||
return cur.fetchone()[0]
|
||||
|
||||
try:
|
||||
token = token_manager.get_token()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Step 1: get default tree ID for EBAY_US
|
||||
id_resp = requests.get(
|
||||
"https://api.ebay.com/commerce/taxonomy/v1/get_default_category_tree_id",
|
||||
params={"marketplace_id": "EBAY_US"},
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
id_resp.raise_for_status()
|
||||
tree_id = id_resp.json()["categoryTreeId"]
|
||||
|
||||
# Step 2: fetch full tree (large response — may take several seconds)
|
||||
tree_resp = requests.get(
|
||||
f"https://api.ebay.com/commerce/taxonomy/v1/category_tree/{tree_id}",
|
||||
headers=headers,
|
||||
timeout=120,
|
||||
)
|
||||
tree_resp.raise_for_status()
|
||||
tree = tree_resp.json()
|
||||
|
||||
leaves: list[tuple[str, str, str]] = []
|
||||
_extract_leaves(tree["rootCategoryNode"], path="", leaves=leaves)
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
self._conn.executemany(
|
||||
"INSERT OR REPLACE INTO ebay_categories"
|
||||
" (category_id, name, full_path, is_leaf, refreshed_at)"
|
||||
" VALUES (?, ?, ?, 1, ?)",
|
||||
[(cid, name, path, now) for cid, name, path in leaves],
|
||||
)
|
||||
self._conn.commit()
|
||||
log.info(
|
||||
"EbayCategoryCache: refreshed %d leaf categories from eBay Taxonomy API.",
|
||||
len(leaves),
|
||||
)
|
||||
return len(leaves)
|
||||
|
||||
except Exception:
|
||||
log.warning(
|
||||
"EbayCategoryCache: Taxonomy API refresh failed — falling back to bootstrap.",
|
||||
exc_info=True,
|
||||
)
|
||||
self._seed_bootstrap()
|
||||
cur = self._conn.execute("SELECT COUNT(*) FROM ebay_categories")
|
||||
return cur.fetchone()[0]
|
||||
|
||||
|
||||
def _extract_leaves(
|
||||
node: dict,
|
||||
path: str,
|
||||
leaves: list[tuple[str, str, str]],
|
||||
) -> None:
|
||||
"""Recursively walk the eBay category tree, collecting leaf node tuples.
|
||||
|
||||
Args:
|
||||
node: A categoryTreeNode dict from the eBay Taxonomy API response.
|
||||
path: The ancestor breadcrumb, e.g. "Consumer Electronics > Computers".
|
||||
leaves: Accumulator list of (category_id, name, full_path) tuples.
|
||||
"""
|
||||
cat = node["category"]
|
||||
cat_id: str = cat["categoryId"]
|
||||
cat_name: str = cat["categoryName"]
|
||||
full_path = f"{path} > {cat_name}" if path else cat_name
|
||||
|
||||
if node.get("leafCategoryTreeNode", False):
|
||||
leaves.append((cat_id, cat_name, full_path))
|
||||
return # leaf — no children to recurse into
|
||||
|
||||
for child in node.get("childCategoryTreeNodes", []):
|
||||
_extract_leaves(child, full_path, leaves)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -87,3 +88,77 @@ def test_get_all_for_prompt_returns_rows(db):
|
|||
assert len(results) > 0
|
||||
# Each entry is (category_id, full_path)
|
||||
assert all(len(r) == 2 for r in results)
|
||||
|
||||
|
||||
def _make_tree_response() -> dict:
|
||||
"""Minimal eBay Taxonomy API tree response with two leaf nodes."""
|
||||
return {
|
||||
"categoryTreeId": "0",
|
||||
"rootCategoryNode": {
|
||||
"category": {"categoryId": "6000", "categoryName": "Root"},
|
||||
"leafCategoryTreeNode": False,
|
||||
"childCategoryTreeNodes": [
|
||||
{
|
||||
"category": {"categoryId": "6001", "categoryName": "Electronics"},
|
||||
"leafCategoryTreeNode": False,
|
||||
"childCategoryTreeNodes": [
|
||||
{
|
||||
"category": {"categoryId": "6002", "categoryName": "GPUs"},
|
||||
"leafCategoryTreeNode": True,
|
||||
"childCategoryTreeNodes": [],
|
||||
},
|
||||
{
|
||||
"category": {"categoryId": "6003", "categoryName": "CPUs"},
|
||||
"leafCategoryTreeNode": True,
|
||||
"childCategoryTreeNodes": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_refresh_inserts_leaf_nodes(db):
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_token.return_value = "fake-token"
|
||||
|
||||
tree_resp = MagicMock()
|
||||
tree_resp.raise_for_status = MagicMock()
|
||||
tree_resp.json.return_value = _make_tree_response()
|
||||
|
||||
id_resp = MagicMock()
|
||||
id_resp.raise_for_status = MagicMock()
|
||||
id_resp.json.return_value = {"categoryTreeId": "0"}
|
||||
|
||||
with patch("app.platforms.ebay.categories.requests.get") as mock_get:
|
||||
mock_get.side_effect = [id_resp, tree_resp]
|
||||
cache = EbayCategoryCache(db)
|
||||
count = cache.refresh(mock_tm)
|
||||
|
||||
assert count == 2 # two leaf nodes in our fake tree
|
||||
cur = db.execute("SELECT category_id FROM ebay_categories ORDER BY category_id")
|
||||
ids = {row[0] for row in cur.fetchall()}
|
||||
assert "6002" in ids
|
||||
assert "6003" in ids
|
||||
|
||||
|
||||
def test_refresh_no_token_manager_seeds_bootstrap(db):
|
||||
cache = EbayCategoryCache(db)
|
||||
count = cache.refresh(token_manager=None)
|
||||
assert count >= BOOTSTRAP_MIN
|
||||
|
||||
|
||||
def test_refresh_api_error_logs_warning(db, caplog):
|
||||
import logging
|
||||
mock_tm = MagicMock()
|
||||
mock_tm.get_token.return_value = "fake-token"
|
||||
|
||||
with patch("app.platforms.ebay.categories.requests.get") as mock_get:
|
||||
mock_get.side_effect = Exception("network error")
|
||||
cache = EbayCategoryCache(db)
|
||||
with caplog.at_level(logging.WARNING, logger="app.platforms.ebay.categories"):
|
||||
count = cache.refresh(mock_tm)
|
||||
|
||||
# Falls back to bootstrap on API error
|
||||
assert count >= BOOTSTRAP_MIN
|
||||
|
|
|
|||
Loading…
Reference in a new issue