added asins for woot listings

This commit is contained in:
TPV committed 2026-09-08 20:14:49 -07:00
1 parent 93013c5d55
commit 03cc3a35cb
24 files changed
+1565 -2

No files matched your search

+319
View File
@@ -0,0 +1,319 @@
"""Bootstrap the shared Mongo database: collections, indexes, and config seeds.
Rebuilds what the monitors and the discord consumer expect to find: the
configs/control/alerts collections in both modes, the alerts indexes the
consumer relies on for claim ordering and dedupe, one empty product
collection per monitor and mode, the per-monitor config documents from
scripts/db_seed/<mode>/<monitor>.json, and the control docs from
scripts/db_seed/control.json (the monitors registry; log_channel and
bot_owner hold Discord ids only the bot's commands know, and migrations
must stay absent so the consumer re-runs them on a fresh database).
Idempotent: existing collections and indexes are left alone and seed
documents use $setOnInsert, so re-running never clobbers operator edits.
Usage:
python scripts/bootstrap_db.py [--dry-run] [--url URL] [--database NAME]
"""
import argparse
import json
import logging
import sys
from pathlib import Path
from pymongo import ASCENDING, DESCENDING, MongoClient
from pymongo.collection import Collection
from pymongo.database import Database
from monitor_utils.constants import MONGO_DATABASE_NAME, MONGO_URL
LOGGER = logging.getLogger("bootstrap_db")
SEED_ROOT = Path(__file__).resolve().parent / "db_seed"
# collection suffix -> seed directory
MODES = {"": "production", "_testing": "testing"}
# monitors that call init_monitor and so own a product collection per mode
MONITORS = [
"apple",
"bestbuy",
"corsair",
"hp",
"lg",
"northerntool",
"samsung",
"samsung_shop",
"woot",
]
SEED_KEYS = {"categories", "channels"}
# categories shape each monitor's task function accepts: run_monitor_cycle
# passes query_category_id= only for a mapping, and only bestbuy (required
# query) and apple (node -> route) take that argument; the rest take a list
CATEGORY_SHAPES = {
"apple": dict,
"bestbuy": dict,
"corsair": list,
"hp": list,
"lg": list,
"northerntool": list,
"samsung": list,
"samsung_shop": list,
"woot": list,
}
CONTROL_SEED = SEED_ROOT / "control.json"
# shape setup_config (runner.py) reads: _id is the monitor name, categories is
# a list of names or a name -> query mapping, channels maps a bucket to ONE
# channel id as a digit string — the consumer's find_invalid_channel_ids
# rejects lists (the pre-07-16 spreading shape). Extra fields stay allowed.
CHANNEL_ID = {"bsonType": "string", "pattern": "^[0-9]+$"}
CONFIG_VALIDATOR = {
"$jsonSchema": {
"bsonType": "object",
"required": ["_id", "categories", "channels"],
"properties": {
"_id": {"bsonType": "string"},
"categories": {
"anyOf": [
{"bsonType": "array", "items": {"bsonType": "string"}},
{
"bsonType": "object",
"additionalProperties": {"bsonType": "string"},
},
]
},
"channels": {
"bsonType": "object",
"additionalProperties": CHANNEL_ID,
},
},
}
}
# identical to the consumer's cog_load create_index calls; a differing spec
# under the same key pattern would make its startup fail
ALERT_INDEXES = [
([("claimed", ASCENDING), ("priority", DESCENDING), ("createdAt", ASCENDING)], {}),
(
[("product_id", ASCENDING)],
{"unique": True, "partialFilterExpression": {"claimed": False}},
),
]
def ensure_collection(
database: Database, name: str, dry_run: bool = False, **options
) -> bool:
"""Create a collection when absent; return True when it was created."""
if name in database.list_collection_names():
LOGGER.info("collection exists", extra={"collection": name})
return False
LOGGER.info("creating collection", extra={"collection": name, "dry_run": dry_run})
if not dry_run:
database.create_collection(name, **options)
return True
def ensure_config_validator(
database: Database, name: str, dry_run: bool = False
) -> bool:
"""Apply CONFIG_VALIDATOR to an existing collection; True when it changed."""
info = next(iter(database.list_collections(filter={"name": name})), None)
if info is None or info["options"].get("validator") == CONFIG_VALIDATOR:
return False
LOGGER.info("updating validator", extra={"collection": name, "dry_run": dry_run})
if not dry_run:
database.command("collMod", name, validator=CONFIG_VALIDATOR)
return True
def ensure_alert_indexes(collection: Collection, dry_run: bool = False) -> list[str]:
"""Create the consumer's alert indexes; return the index names."""
names = []
for keys, options in ALERT_INDEXES:
if dry_run:
names.append("+".join(f"{field}_{direction}" for field, direction in keys))
continue
names.append(collection.create_index(keys, **options))
LOGGER.info(
"alert indexes ensured",
extra={"collection": collection.name, "indexes": names, "dry_run": dry_run},
)
return names
def load_seed_configs(seed_dir: Path) -> dict[str, dict]:
"""Load <monitor>.json seed docs keyed by monitor name.
Raises:
ValueError: When a seed file lacks categories or channels.
"""
docs = {}
for path in sorted(seed_dir.glob("*.json")):
with path.open() as seed_file:
doc = json.load(seed_file)
missing = SEED_KEYS - set(doc)
if missing:
raise ValueError(f"{path}: missing {sorted(missing)}")
bad_channels = {
bucket: value
for bucket, value in doc["channels"].items()
if not (isinstance(value, str) and value.isdigit())
}
if bad_channels:
raise ValueError(
f"{path}: channels must be single id strings {bad_channels}"
)
expected = CATEGORY_SHAPES.get(path.stem)
if expected is not None and not isinstance(doc["categories"], expected):
raise ValueError(
f"{path}: categories must be {expected.__name__} for {path.stem}"
)
docs[path.stem] = {key: doc[key] for key in SEED_KEYS}
return docs
def seed_configs(
collection: Collection, docs: dict[str, dict], dry_run: bool = False
) -> list[str]:
"""Insert absent config docs via $setOnInsert; return the ids inserted."""
inserted = []
for monitor_name, doc in docs.items():
if dry_run:
if collection.find_one({"_id": monitor_name}, {"_id": 1}) is None:
inserted.append(monitor_name)
continue
result = collection.update_one(
{"_id": monitor_name}, {"$setOnInsert": doc}, upsert=True
)
if result.upserted_id is not None:
inserted.append(monitor_name)
LOGGER.info(
"configs seeded",
extra={
"collection": collection.name,
"inserted": inserted,
"skipped": sorted(set(docs) - set(inserted)),
"dry_run": dry_run,
},
)
return inserted
def load_control_seed(path: Path) -> dict[str, dict]:
"""Load control docs keyed by _id from one JSON file."""
with path.open() as seed_file:
docs = json.load(seed_file)
for doc_id, doc in docs.items():
if not isinstance(doc, dict):
raise ValueError(f"{path}: {doc_id} must be an object")
return docs
def seed_control(
collection: Collection, docs: dict[str, dict], dry_run: bool = False
) -> list[str]:
"""Insert absent control docs via $setOnInsert; return the ids inserted."""
inserted = []
for doc_id, doc in docs.items():
if dry_run:
if collection.find_one({"_id": doc_id}, {"_id": 1}) is None:
inserted.append(doc_id)
continue
result = collection.update_one(
{"_id": doc_id}, {"$setOnInsert": doc}, upsert=True
)
if result.upserted_id is not None:
inserted.append(doc_id)
LOGGER.info(
"control seeded",
extra={
"collection": collection.name,
"inserted": inserted,
"skipped": sorted(set(docs) - set(inserted)),
"dry_run": dry_run,
},
)
return inserted
def bootstrap(
database: Database, seed_root: Path = SEED_ROOT, dry_run: bool = False
) -> dict:
"""Run every step for both modes and return a summary of what changed."""
summary = {"created": [], "validators": [], "indexes": {}, "seeded": {}}
for suffix, seed_mode in MODES.items():
configs_name = f"configs{suffix}"
if ensure_collection(
database, configs_name, dry_run, validator=CONFIG_VALIDATOR
):
summary["created"].append(configs_name)
elif ensure_config_validator(database, configs_name, dry_run):
summary["validators"].append(configs_name)
for base in ("control", "alerts", *MONITORS):
name = f"{base}{suffix}"
if ensure_collection(database, name, dry_run):
summary["created"].append(name)
alerts_name = f"alerts{suffix}"
summary["indexes"][alerts_name] = ensure_alert_indexes(
database[alerts_name], dry_run
)
docs = load_seed_configs(seed_root / seed_mode)
summary["seeded"][configs_name] = seed_configs(
database[configs_name], docs, dry_run
)
control_name = f"control{suffix}"
summary["seeded"][control_name] = seed_control(
database[control_name],
load_control_seed(seed_root / "control.json"),
dry_run,
)
return summary
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--url", default=MONGO_URL, help="Mongo connection url")
parser.add_argument("--database", default=MONGO_DATABASE_NAME)
parser.add_argument(
"--dry-run", action="store_true", help="report actions without writing"
)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
client = MongoClient(args.url, serverSelectionTimeoutMS=30_000)
LOGGER.info("connected", extra={"server": client.server_info()["version"]})
summary = bootstrap(client[args.database], dry_run=args.dry_run)
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())