20 KiB
CLAUDE.md — Profit Lounge monitor
Orientation for working in this repo. This is one monitor (or the Discord bot)
in a polyrepo system that scrapes a retailer, normalizes its products into a
shared Product model, writes them to MongoDB, and lets a Discord bot consume
them and post deals. Shared code lives in the installable monitor_utils
package; each repo pins its own version of it.
This file is portable — most of it is true in every repo. Only the Per-repo facts block at the top changes between monitors.
Per-repo facts (the only section that varies)
- Monitor name:
<bestbuy | hp | lg | samsung | woot | northerntool>(the string passed toconfigure_logging()andsetup_config()). - Entry script:
<name>_monitor.py(e.g.lg_monitor.py). category_idsshape: list of<ints | category codes | category paths>— varies by retailer (e.g. Best Buy uses numeric category ids, HP uses category paths, Woot uses category names).- Pagination mode:
<page | offset>. - Site-specific quirks:
<record known data hazards here — see HP/Woot notes in "Known data hazards" for the pattern.>
Architecture (settled — do not relitigate)
- Polyrepo, not monorepo. Each retailer monitor and the Discord bot is its
own repo. Anything shared across them lives in
monitor_utils, an installable package consumed as a pinned dependency.monitor_utilsis public (the Gitea repo is public), so no token is needed to install it. monitor_utilsis pinned per-repo and upgraded manually. A repo gets a specific pinned version viauv.lock. To pick up changes, bump it deliberately:uv lock --upgrade-package monitor-utilsthen rebuild. There is no auto-latest — pinning is intentional for reproducible builds.- Each repo builds its own Docker image that installs the pinned
monitor_utils. The Dockerfile installs fromuv.lock; Docker never reads local editable installs (those are dev-only — see Local dev). - No
.envfiles in any form. Not committed, not copied into images, not mounted, not viaenv_file. Configuration that varies by environment is resolved throughmonitor_utils.configand theDevelopmentModeenum, not env files. (One narrow exception used in dev only: thelog_dirargument toconfigure_logging.)
monitor_utils (the shared package)
runner.py— the monitor execution contract. Exposes:setup_config(monitor_name, development_mode)→ returns the triple(category_identifiers, channel_ids, collection). It reads the JSON config viaget_configand returnsconfig["categories"],config["channels"], and the Mongo collection.categoriescan be a list OR a dict — when it's a dict (e.g. Best Buy),execute_monitor_logiciterates.items()and passes the value asquery_category_id. The collection is the monitor name in production, butf"{monitor_name}_testing"in development — dev writes to a separate_testingcollection.execute_monitor_logic(category_identifiers, task_func, headers=None, cookies=None, pre_task_func=None, use_tls=False, **kwargs)— runs forever in awhile Trueloop, recreating the session each pass and sleeping 0.30s between passes. Each pass: opens a session (get_tls_sessionifuse_tlselseget_base_session), calls optionalpre_task_func(session=session, **kwargs)once, then builds per-category tasks andasyncio.gathers them. Ifcategory_identifiersis a dict, each task getstask_func(category_id, session, query_category_id=query, **kwargs); if a list,task_func(category_identifier, session, **kwargs). Results:EXPECTED_TASK_ERRORS→LOGGER.warning+ continue; any otherException→LOGGER.error+ raise (kills the loop).task_funcis called with the category as the first positional arg andsessionsecond.execute_pagination(category_identifier, total_products, products_per_page, func, session, max_concurrent_tasks=5, start_offset=24, pagination_mode= "offset", **kwargs)— paginates pages 2..N concurrently under a semaphore.pagination_mode="offset"callsfunc(session=session, offset=..., **kwargs)withoffset = start_offset + page_index * products_per_page;"page"callsfunc(session=session, page_number=page_index, **kwargs). Failed pages are retried (retry_failed_pages, exp backoff, 3 attempts).stop_eventis internal to this function (early-stop plumbing) — it is NOT a parameter you pass in.build_product_details(product_dict, channel_ids, min_percentage, min_price_drop=None)(consumer side) — takes a Mongo doc (camelCase keys) and returns aProductorNone. Computespercentage, applies the two-rule gate (percentage >= min_percentageOR price-drop ≥min_price_drop), and setsmetadata.channel_idviaget_channel_id. ReturnsNonewhen the product fails the gate or has no/zerooriginalPrice.get_channel_id(product, channel_ids, use_price_drop_bucket=False)— routing precedence: app-exclusive → sold-out → ending-within-1-day event →FLAG_WEBHOOK_MAPflags (member pick/deal) → percentage bucket (BUCKET_WEBHOOK_MAP, capped at 90). Used by the consumer, not the scrapers.
config.py— configuration + environment. Holds:get_config(reads JSON configs — production pathPROJECT_ROOT/configs/<name>.json, dev pathPROJECT_ROOT/tests/configs/<name>.json),get_bot_token(byis_productionbool), the logging setup (configure_logging, theJSONFormatter) — logging lives HERE, there is noobservability.py,get_proxies, theDevelopmentModeenum (astrEnum:PRODUCTION/DEVELOPMENT), andis_production.configure_logging(monitor_name, log_dir="/logs")clears root handlers, adds aStreamHandlerand aRotatingFileHandler(<log_dir>/<monitor_name>.jsonl, 10MB × 5 backups), both usingJSONFormatter, and pinsaiohttp/pymongoto WARNING. A filter injectsmonitor_nameonto every record.- The
JSONFormatteremitstime,level,logger,function,message, then merges any non-reservedextrakeys, and addsexcon exceptions. So the injected field is literallymonitor_name(notmonitor), and yourextra={...}keys appear at the top level of the JSON. get_proxiesparsesproxies.txt: a linehost:port:user:password→http://user:password@host:port; otherwise the line is used ashttp://<line>. Cached; empty list raises.
database.py— Mongo controls.add_products_to_database(products, collection, category_identifier)upserts with a composite_idoff"{product_id}_{category_identifier}"(NOT bare product_id — the same product under two categories is two docs). It compares new vs existingcurrentPrice: a new product or a price drop setsstatus=pending(andpriceBeforeDrop);discoveredDateis$setOnInsertonly.bulk_write(..., ordered=False).get_product_from_database(collection, product_id)is a barefind_one({"_id": product_id})— note it does NOT apply the composite-id scheme, so callers using it (HP) must pass whatever id matches their stored_id.convert_product_to_mongomaps the dataclass (snake_case) to the stored Mongo doc (camelCase:currentPrice,imageUrl,appExclusive, …) — this snake→camel mapping is the boundary; theProductmodel is snake_case, Mongo is camelCase.dsql_shortlinks.py— affiliate short-link client (generate_short_linkasync /generate_short_link_sync). Posts to the Profit Lounge affiliate API; on any failure it returns the original url ({"short_url": url}) rather than raising — link generation never blocks a post.sessions.py— HTTP session creation + thesend_requestwrapper.get_base_session(aiohttp) andget_tls_session(curl_cffiAsyncSessionwithimpersonate=random.choice(CLIENT_IDENTIFIERS)) are async context managers;execute_monitor_logicpicks viause_tls.curl_cffireplaced the oldnoble_tlsfor Python 3.13-compatible TLS fingerprint spoofing.send_request(session, url, method, params=None, json_data=None, data=None, reraise_json_error=False, development_proxy=None)— tenacity-retried (RETRY_LIMIT× on network/timeout errors). Picks a random proxy fromget_proxies()unlessdevelopment_proxyis set (which also disables TLS verify — that's the local-mitmproxy path, e.g. HP'shttp://localhost:8001). Returns parsed JSON, or the raw text onJSONDecodeErrorunlessreraise_json_error=True(then it raises — used where a non-JSON response must be treated as a failure). Branches on session type internally; callers just pass the session.use_tlsis a parameter ofexecute_monitor_logic, not ofsend_request.
models.py— the shared product schema (see Models).constants.py— fixed values + secrets currently live here:MONGO_URL,PRODUCTION_BOT_TOKEN/DEVELOPMENT_BOT_TOKEN,PROFIT_API_KEY, plusEXPECTED_TASK_ERRORS(the known-error tuple: curl_cffiRequestsError,aiohttp.ClientError,asyncio.TimeoutError,requests.Timeout/HTTPError,PyMongoError,JSONDecodeError), status constants (STATUS_PENDING/SENT/SKIPPED/FAILED), delays (BACKOFF_DELAY/FAILURE_DELAY— both0.001),RETRY_LIMIT/RETRY_WAIT,LOGGING_LEVEL,CLIENT_IDENTIFIERS,FLAG_WEBHOOK_MAP,BUCKET_WEBHOOK_MAP, andPROJECT_ROOT/PROXIES_PATH.
Models (monitor_utils.models, pydantic dataclasses)
Product is the normalized shape every monitor produces and the consumer reads.
Built with pydantic.dataclasses.dataclass (validation on construction — a bad
field raises at build time, which is the boundary where bad data should be
caught).
Product(product_id, title, url, pricing, metadata=…, timing=…, availability=…)—metadata/timing/availabilitydefault viafield(default_factory=…).PricingInfo(original_price, current_price, price_before_drop=None, edu_price=None).AvailabilityInfo(available=None, condition=None, app_exclusive=None, member_pick=None, member_deal=None).ProductMetadata(image_url, channel_id, customization_index, member_non_member_comparison, overstock_text, percentage, affiliate_url — all optional).ProductTiming(discovered_date, start_date, end_date — all optional).ConsumerConfig(monitor_name, collection, channel_ids, min_percentage, ping_threshold=None)— one per retailer for the Discord consumer.ping_thresholdis the price floor for the secondary 40% rule (Woot pings on 40%+ if the item is ≥ this price);Nonedisables it.
Standard monitor flow (the canonical shape — every monitor follows it)
configure_logging(monitor_name="<name>") # logging first, from config.py
DEVELOPMENT_MODE = DevelopmentMode.<MODE> # set in THIS script (see below)
category_ids, _, collection = setup_config( # second arg is positional
monitor_name="<name>", development_mode=DEVELOPMENT_MODE
)
async def main():
await execute_monitor_logic( # runs forever (while True)
category_ids, # list OR dict (dict → query_category_id)
task_func=get_category_products,
pre_task_func=<optional>, # e.g. exclusive deals, called once/pass
headers=..., cookies=..., use_tls=...,
)
# category first, session second — matches how execute_monitor_logic calls it.
# pagination passes offset= or page_number= as a kwarg, so accept it with a default.
async def get_category_products(category_id, session, offset=<n>): # or page_number=<n>
response = await send_request(session, url=..., method=...)
products = await extract_products(response, ...) # parse → build Product(s)
await add_products_to_database(products, collection, category_identifier=category_id)
if <first page/offset>: # only kick pagination once
await execute_pagination(
category_identifier=category_id,
total_products=response[...],
products_per_page=<n>,
func=get_category_products,
session=session,
pagination_mode="page"|"offset",
category_id=category_id, # flows through **kwargs
)
asyncio.run(main())
Note: execute_monitor_logic already loops forever and recreates the session each
pass — the monitor does NOT add its own outer loop. A category task raising a
non-EXPECTED_TASK_ERRORS exception kills the whole loop (intentional fail-loud).
Where DevelopmentMode is set:
- Monitors: in the monitor's own entry script (
<name>_monitor.py). - Discord bot: in
discord_bot/helpers/config.py(multiple cogs depend on it, so it's defined once there, not per-cog).
Critical invariants (do not break)
- Structured logging only — never f-string interpolation in log calls. Use
LOGGER.info("message", extra={...}). Standardextrakeys, kept consistent for queryability:product_id,category,page/offset,count,config_id. The message is a short static string; variable data goes inextra.configure_logging(monitor_name)injectsmonitor_nameon every record via a filter (so don't also passmonitor_nameinextra) and emits JSON. TheJSONFormatterreserves the standardLogRecordattrs and merges everything else, so yourextrakeys land at the JSON top level. Pick one name per value (product_id, not a mix ofsku/product_id). - Two-tier error handling. Known/expected failures →
LOGGER.warning(or.exceptionfor tracebacks) and continue. Unknown failures →LOGGER.exceptionand raise.EXPECTED_TASK_ERRORS(inconstants.py) is the known-error tuple caught throughout. - One bad product must never kill a category. Per-product build is wrapped in
try/except EXPECTED_TASK_ERRORS(log withproduct_id,continue). Guard indexing into possibly-empty lists (if not items:beforeitems[0]) — a single malformed item silently skips, it does not crash the run. (This is why the WootIndexErroron emptyAttributestook the monitor down before the guard was added.) - Validate implausible data at the boundary, skip per-item, don't emit. Bad
upstream values (negative/zero prices) are rejected where they're read by
raising
ValueError, caught per-item in the loop so other items still process — never let a false deal reach the consumer. (See HP negative-cart note.) - Mongo
_idis composite:f"{product_id}_{category_identifier}". The same product under two categories is two documents.add_products_to_databasebuilds this; anything querying products by id must account for it. (Product.product_iditself stays the bare retailer id — the composite only exists in the stored doc.) - Dev writes to a separate
_testingcollection.setup_configreturns the<name>_testingcollection in development mode and<name>in production, and reads configs fromtests/configs/vsconfigs/. So running in development never touches production data. Productis snake_case; the Mongo doc is camelCase.convert_product_to_mongois the only translation point. Don't hand-write camelCase into aProductor snake_case into a Mongo filter.- Logs write to
/logs/<name>.jsonlinside the container, host-mounted to a shared folder. The host folder must be owned by the container user (UID 1000) — bind mounts override the image's ownership, sochown -R 1000:1000 <host log dir>is required on the host, NOT fixable in the Dockerfile alone.
Coding conventions
- Python 3.10+,
src/layout where applicable, type hints on public funcs. - Functional-first. Avoid OOP unless a framework requires it (discord.py cogs). Prefer plain functions and module-level helpers.
- Direct dict access
[]over.get()for controlled schemas where the key is guaranteed; use.get()only where a key is genuinely optional. - Required dataclass fields over optional-with-defaults when the field is always present.
- Doc-strings only — do not over-comment. No narrating obvious code; a doc-string on a function is enough. Doc-strings start lowercase, no trailing period.
- No trailing whitespace; trailing newline on every file; 4-space indent.
- No hardcoded paths or secrets in code — tunable/env-varying state goes
through
config.py; fixed values go inconstants.py. - Terse, direct edits. When changing code, return the change without surrounding explanation unless options are being compared.
Local dev (debugger uses local monitor_utils, Docker does not)
- To make the debugger / interpreter use your local
monitor_utilsworking copy instead of the pinned site-packages build, editable-install it into the repo's venv:uv pip install -e <path to monitor-shared-utils>. This touches only the local venv — it does NOT modifypyproject.toml/uv.lock, so the Docker build still installs the pinned published version. - Verify which copy is loaded:
python -c "import monitor_utils.config as c; print(c.__file__)"— should point at the local clone, notsite-packages. - VS Code: select the repo's
.venvinterpreter; run via a launch config with"module": "<name>_monitor"(or the bot'smodule) and"cwd": "${workspaceFolder}", not the editor's run-file button (which runs the file directly and breaks package imports). log_dirforconfigure_loggingdefaults to/logs(container). Locally, either create the dir or pass a locallog_dir; do not commit a local override into the entry script.
Discord bot (when this repo is the bot)
- Cogs architecture.
controller.pyis the entry; cogs live indiscord_bot/cogs/(e.g.channels.py,consumer.py). Commands are cog methods;DevelopmentMode/IS_PRODUCTIONcome fromdiscord_bot/helpers/config.py. - Consumer reads pending products per monitor from Mongo
(
find({"status": STATUS_PENDING}).sort("discoveredDate", 1).limit(...)), builds embeds, posts, and updates status. A compound index on(status, discoveredDate)backs this query (ESR: equality field first, sort field second) — create it at startup; it is idempotent. - Stop/start control is via a DB flag, NOT the Docker socket. Server
hardening blocks Docker socket access, so the consumer (and, where wired, the
scraper loops) checks a
monitor_controlcollection (one doc per monitor, keyed by name) each pass and goes idle onstop: truewithout exiting. The bot flips the flag through a command; readers and writers communicate only through Mongo. Do not reintroduce Docker-socket control. - Owner/admin gating. Use a check that passes for the bot owner (global,
independent of server perms — works even in servers where you have none) OR a
guild admin. Prefer a
app_commands.checkdecorator routing failures throughon_app_command_errorover inline per-command guards.
Known data hazards (record per-repo as you find them)
- HP — negative/zero cart totals on CTO products. HP's cart API returns
genuinely negative
grandTotalfor some configure-to-order configs (promo components exceeding base, or a config that doesn't register a valid BOM). Guardgrand_total <= 0at the cart boundary; raise and skip that config in the pricing loop so other configs still price. Also: configurator picks must include every option's path (don't dropdisplay: falseoptions from the path map) and the base SKU, or HP misprices. - Woot — empty
Attributes/Itemslists.parse_conditionmust guardif not items/if not attributesbefore indexing[0]; an empty list otherwise raisesIndexErrorand (without the per-product try/except) kills the category.