Compare commits

...
55 Commits
Author SHA1 Message Date
TPV 61c4887c38 added has_msrp for no msrp based products(new lists) 2026-08-04 10:04:32 -07:00
TPV 2e6e7ebc69 added passing headers 2026-07-30 14:29:58 -07:00
TPV 8903be2685 abstracted out bounded cycles 2026-07-30 14:13:26 -07:00
TPV aa6ed4cb06 added pagination fan out pagination abstraction 2026-07-30 11:52:36 -07:00
TPV 3f823b3a6e added price history to build_product_details/models 2026-07-28 11:29:15 -07:00
TPV 3573551957 removed past_prices 2026-07-28 10:38:33 -07:00
TPV 8a1c1893b7 added bounded close session for curl_cffi/past_prices to pricing model 2026-07-28 09:15:38 -07:00
TPV c29032a765 cleanup 2026-07-27 12:02:56 -07:00
TPV 1e53904cc6 added max_clients arg to get_default_session/cycle timeout seconds 2026-07-27 12:02:41 -07:00
TPV 99e68b1fa9 added first_error for asyncio task groups 2026-07-27 10:27:57 -07:00
TPV b6cd692258 added first_error for asyncio task groups 2026-07-27 10:23:07 -07:00
TPV 763f40988f added cycle timeout seconds 2026-07-27 08:49:59 -07:00
TPV d4f4830ff2 added motor/pinned the curl version 2026-07-27 08:48:04 -07:00
TPV 4c1033057a added combined discount for consumer so codes can be calculated on the fly for any monitor 2026-07-26 17:55:13 -07:00
TPV e9632470fd small refactor 2026-07-26 14:17:42 -07:00
TPV 5effb835a7 Merge pull request 'removed timeout from send_request and added promo options to product metadata' (#6) from aioweb-migration into main
Reviewed-on: #6
2026-07-26 21:09:51 +00:00
TPV b876c3d7b3 removed timeout from send_request and added promo options to product metadata 2026-07-26 14:09:05 -07:00
TPV 9af7e7adcf Merge pull request 'added optional semphore' (#5) from aioweb-migration into main
Reviewed-on: #5
2026-07-21 03:05:50 +00:00
TPV 4b63a2d39c added optional semphore 2026-07-20 19:27:47 -07:00
TPV 3cbe5beb43 Merge pull request 'removed user agent setting since curl_impersonate does it' (#4) from aioweb-migration into main
Reviewed-on: #4
2026-07-19 16:36:29 +00:00
TPV 0cfcca01bb removed user agent setting since curl_impersonate does it 2026-07-19 09:36:03 -07:00
TPV 65ddb40bc3 Merge pull request 'Aioweb migration' (#3) from aioweb-migration into main
Reviewed-on: #3
2026-07-19 16:29:00 +00:00
TPV 17219507bf added requiring call sites to set own content-type for anything other than json(aioweb doesn't set content-type properly) 2026-07-19 09:28:22 -07:00
TPV db947a6354 raise default session timeout to 30s 2026-07-19 09:04:40 -07:00
TPV 638d8fb359 raise default session timeout to 30s 2026-07-19 08:55:13 -07:00
TPV 666046bdd1 Merge pull request 'added ability to set default user agent' (#2) from aioweb-migration into main
Reviewed-on: #2
2026-07-19 04:33:03 +00:00
TPV 20ad4c6535 added ability to set default user agent 2026-07-18 19:31:02 -07:00
TPV e9559f3248 Merge pull request 'Aioweb migration' (#1) from aioweb-migration into main
Reviewed-on: #1
2026-07-19 01:26:35 +00:00
TPV d307be60b0 added default headers for get_default_session 2026-07-18 11:45:46 -07:00
TPV bb24d0c13a named collection as a required arg in run_monitor_cycle 2026-07-18 11:40:35 -07:00
TPV 3515af6fd8 added proxies to default session 2026-07-17 20:37:16 -07:00
TPV 8e077515ff added proxies to default session 2026-07-17 20:22:57 -07:00
TPV 1441e7179c migrate session handling to aioweb_tls/refactoring of helpers 2026-07-17 20:06:37 -07:00
TPV 3a8a12ac6e added concurrency limiting 2026-07-17 14:24:53 -07:00
TPV 4aa0e43289 small bug fix 2026-07-16 14:19:33 -07:00
TPV cdce46c033 small bug fix 2026-07-16 05:41:15 -07:00
TPV 06c0366d2a readded pre_task_func for functions that must promise order 2026-07-16 05:36:52 -07:00
TPV 1e0663d972 readded pre_task_func for functions that must promise order 2026-07-16 05:36:04 -07:00
TPV 83b15231ee updated proxy development path 2026-07-15 13:39:20 -07:00
TPV f2e50819ac fixed small pagination issue in execute_pagination/added availability field in mongo schema 2026-07-14 12:02:07 -07:00
TPV ae8b204a2c added running pre task same time as category tasks 2026-07-14 07:35:44 -07:00
TPV 00d3c12ee0 fixed price drops/moved logging outside of init_monitor 2026-07-13 18:57:23 -07:00
TPV 7d070dec40 moving config storage to mongo 2026-07-13 10:25:07 -07:00
TPV 9bc34d4dc9 added resolving alerts collection 2026-07-12 15:27:59 -07:00
TPV 8270c9d7fd forgot to remove reference to prod bot token in get_bot_token and moved said function into discord bot repo 2026-07-12 12:09:36 -07:00
TPV bd46b92724 moved discord specifics into discord bot repo 2026-07-12 12:02:55 -07:00
TPV 44f8184864 small update 2026-07-11 13:26:37 -07:00
TPV 015996309f updated bot token 2026-07-09 14:41:03 -07:00
TPV 15acb97c86 added alerts collection dsql suggested 2026-07-07 11:56:32 -07:00
TPV 6ad9cf5d6d added alerts collection dsql suggested 2026-07-07 11:03:21 -07:00
TPV 7e44a5cdb2 added alerts collection dsql suggested 2026-07-07 09:17:38 -07:00
TPV 9e2ede16b1 added alerts collection dsql suggested 2026-07-06 19:55:43 -07:00
TPV 9496d0dfde added updating status on upsert 2026-07-06 18:40:43 -07:00
TPV d6f48afeed removed if check from add_products_to_database 2026-07-06 14:32:12 -07:00
TPV 9e8ab938c7 removed if check from add_products_to_database 2026-07-06 14:30:43 -07:00
15 changed files with 1523 additions and 422 deletions

No files matched your search

+24 -4
View File
@@ -8,8 +8,9 @@ version = "0.1.0"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"aiohttp>=3.14.1", "aiohttp>=3.14.1",
"aioproxies", "aioproxies @ git+https://git.rethinkstudios.io/rethink-public/aioproxies.git",
"curl-cffi>=0.15.0", "aioweb-tls[curl] @ git+https://git.rethinkstudios.io/rethink-public/aioweb_tls.git",
"curl-cffi==0.15.0",
"fake-useragent>=2.2.0", "fake-useragent>=2.2.0",
"motor>=3.7.1", "motor>=3.7.1",
"pydantic>=2.13.4", "pydantic>=2.13.4",
@@ -17,11 +18,30 @@ dependencies = [
"requests>=2.34.2", "requests>=2.34.2",
"tenacity>=9.1.4", "tenacity>=9.1.4",
"log-setup @ git+https://git.rethinkstudios.io/rethink-public/log_setup.git", "log-setup @ git+https://git.rethinkstudios.io/rethink-public/log_setup.git",
"httpx>=0.28.1",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
include = ["monitor_utils*"] include = ["monitor_utils*"]
[tool.uv.sources] [dependency-groups]
aioproxies = { git = "https://git.rethinkstudios.io/rethink-public/aioproxies.git" } dev = [
"pytest>=8.0",
"ruff>=0.6",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 88
target-version = "py310"
# dsql_shortlinks.py is vendored third-party code; leave it untouched.
extend-exclude = ["src/monitor_utils/dsql_shortlinks.py"]
[tool.ruff.lint]
select = ["E", "F", "I", "W", "UP", "B"]
# B905: zip(strict=) is opinionated; the zips here are equal-length by
# construction and adding strict= would change working code for no gain.
ignore = ["B905"]
+29 -28
View File
@@ -1,11 +1,16 @@
"""Config loading and mode helpers shared across monitors and the bot.""" """Config loading and mode helpers shared across monitors and the bot."""
import json
import logging import logging
import pathlib
from enum import Enum from enum import Enum
from monitor_utils.constants import DEVELOPMENT_BOT_TOKEN, LOGGERS, PRODUCTION_BOT_TOKEN from monitor_utils.constants import LOGGERS
from monitor_utils.database import get_collection
CONFIGS_COLLECTION_NAME = "configs"
class ConfigNotFoundError(Exception):
"""Raised when a monitor has no config document in Mongo."""
class DevelopmentMode(str, Enum): class DevelopmentMode(str, Enum):
@@ -21,38 +26,34 @@ def is_production(development_mode: DevelopmentMode) -> bool:
return development_mode == DevelopmentMode.PRODUCTION return development_mode == DevelopmentMode.PRODUCTION
def get_bot_token(is_production: bool) -> str: async def get_config(monitor_name: str, use_production: bool = False) -> dict:
"""Return the production or development bot token for the mode.""" """Load a monitor's config document from Mongo.
if is_production: Reads the configs collection in production and configs_testing in
return PRODUCTION_BOT_TOKEN development, so a dev run never reads production routing.
return DEVELOPMENT_BOT_TOKEN
def get_config(
monitor_name: str,
config_dir: str | pathlib.Path = "configs",
test_config_dir: str | pathlib.Path = "tests/configs",
use_production_path: bool = False,
) -> tuple[dict, pathlib.Path]:
"""Load a monitor's JSON config from the production or test directory.
Args: Args:
monitor_name: Monitor name with or without a .json suffix. monitor_name: Monitor identifier (the config document _id).
config_dir: Directory holding production configs. use_production: Read the production configs collection instead of
test_config_dir: Directory holding test configs. configs_testing.
use_production_path: Load from config_dir instead of test_config_dir.
Returns: Returns:
Tuple of the parsed config dict and the resolved config path. The config document, including its categories and channels.
Raises:
ConfigNotFoundError: When no document exists for the monitor.
""" """
monitor_name = monitor_name.replace(".json", "") collection_name = (
base = config_dir if use_production_path else test_config_dir CONFIGS_COLLECTION_NAME
config_path = pathlib.Path(base) / f"{monitor_name}.json" if use_production
with open(config_path) as f: else f"{CONFIGS_COLLECTION_NAME}_testing"
return json.load(f), config_path )
config = await get_collection(collection_name).find_one({"_id": monitor_name})
if config is None:
raise ConfigNotFoundError(monitor_name)
return config
def set_logger_levels(loggers: list[str] = LOGGERS, level: int = logging.WARNING): def set_logger_levels(loggers: list[str] = LOGGERS, level: int = logging.WARNING):
+14 -24
View File
@@ -11,6 +11,7 @@ from json import JSONDecodeError
import aiohttp import aiohttp
import requests import requests
from aioweb import AiowebError
from curl_cffi.requests.errors import RequestsError from curl_cffi.requests.errors import RequestsError
from pymongo.errors import PyMongoError from pymongo.errors import PyMongoError
@@ -18,13 +19,6 @@ MONGO_DATABASE_NAME = "tpv_woot"
MONGO_URL = ( MONGO_URL = (
"mongodb://tpv:n3%23QI07GnbZCbvGJ@zelda.disqualifier.me:27017/?authSource=tpv_woot" "mongodb://tpv:n3%23QI07GnbZCbvGJ@zelda.disqualifier.me:27017/?authSource=tpv_woot"
) )
PRODUCTION_BOT_TOKEN = (
"MTQwMDE4NDk2MzY4NDYzMDUyOQ.GOrqOb.6frT3upUQ5B9XubVOIT9pdzVcYVXSrnEMK7RlA"
)
DEVELOPMENT_BOT_TOKEN = (
"MTUyMzA2NDkyNTc1OTYwMjg1MA.Ge7Vic._u6J1EcM9_yv63UJCAZry8d80PjEfUPhjAnRvU"
)
PROFIT_API_KEY = "dev-XvIu8S83BJ2N1C9ZCsoLAAav5jan7kj5"
LOGGING_LEVEL = "INFO" LOGGING_LEVEL = "INFO"
LOGGERS = [ LOGGERS = [
"discord", "discord",
@@ -34,32 +28,27 @@ LOGGERS = [
RETRY_LIMIT = 5 RETRY_LIMIT = 5
RETRY_WAIT = 3 RETRY_WAIT = 3
CYCLE_DELAY = 0.30
MIN_BACKOFF_DELAY = 0.1 MIN_BACKOFF_DELAY = 0.1
MAX_BACKOFF_DELAY = 2.0 MAX_BACKOFF_DELAY = 2.0
STOP_POLL_DELAY = 5
FAILURE_DELAY = 3 FAILURE_DELAY = 3
TIMEOUT_SECONDS = 30 TIMEOUT_SECONDS = 30
CYCLE_TIMEOUT_SECONDS = 600
# curl_cffi's AsyncSession defaults to 10 handles and blocks on an untimed
# pool.get() past that, so a monitor fanning out wider silently serialises with
# no timeout covering the wait. Handles are allocated lazily (the pool holds
# None placeholders until popped), so a generous ceiling costs a low-concurrency
# monitor nothing. Monitors fanning out past this pass max_clients explicitly.
MAX_CURL_HANDLES = 64
# Budget for closing a session. curl_cffi's close awaits the multi handle with
# no timeout of its own, so a wedged transfer would otherwise park whatever is
# closing the session, including an `async with` exit.
SESSION_CLOSE_TIMEOUT_SECONDS = 10
CLIENT_IDENTIFIERS = ["chrome", "chrome_android", "safari", "safari_ios"] CLIENT_IDENTIFIERS = ["chrome", "chrome_android", "safari", "safari_ios"]
CLIENT_TIMEOUT = aiohttp.ClientTimeout( CLIENT_TIMEOUT = aiohttp.ClientTimeout(
total=TIMEOUT_SECONDS, sock_connect=30, sock_read=30 total=TIMEOUT_SECONDS, sock_connect=30, sock_read=30
) )
FLAG_WEBHOOK_MAP = (
("member_pick", "memberPick"),
("member_deal", "memberDeal"),
)
BUCKET_WEBHOOK_MAP = {
40: "40-49",
50: "50-60",
60: "60-70",
70: "70-80",
80: "80-90",
90: "90-100",
"price_drop": "40-49",
}
EXPECTED_TASK_ERRORS = ( EXPECTED_TASK_ERRORS = (
RequestsError, RequestsError,
aiohttp.ClientError, aiohttp.ClientError,
@@ -68,6 +57,7 @@ EXPECTED_TASK_ERRORS = (
requests.HTTPError, requests.HTTPError,
PyMongoError, PyMongoError,
JSONDecodeError, JSONDecodeError,
AiowebError,
) )
+209 -30
View File
@@ -2,7 +2,8 @@
Provides the shared Motor client, collection access, and the upsert path that Provides the shared Motor client, collection access, and the upsert path that
rewrites current product fields while preserving prior prices in a pastPrices rewrites current product fields while preserving prior prices in a pastPrices
map and stamping lastUpdated on every write. array deduplicated by price, stamping lastUpdated on every write, and emitting
alert references for new products and price changes.
""" """
import logging import logging
@@ -12,9 +13,9 @@ from functools import cache
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
from pymongo import UpdateOne from pymongo import UpdateOne
from pymongo.errors import PyMongoError from pymongo.errors import BulkWriteError, PyMongoError
from monitor_utils.constants import ProductStatus, MONGO_DATABASE_NAME, MONGO_URL from monitor_utils.constants import MONGO_DATABASE_NAME, MONGO_URL
from monitor_utils.models import Product from monitor_utils.models import Product
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@@ -33,6 +34,29 @@ def get_collection(collection_name: str) -> AsyncIOMotorCollection:
return get_mongo_client()[MONGO_DATABASE_NAME][collection_name] return get_mongo_client()[MONGO_DATABASE_NAME][collection_name]
def resolve_alerts_collection(
product_collection: AsyncIOMotorCollection,
) -> AsyncIOMotorCollection:
"""Return the alerts collection beside a product collection.
Alerts co-locate with the products they reference: the same database and
the same development mode. The mode is read from the product collection's
own name (setup_config appends a _testing suffix outside production), so a
monitor writing products to <name>_testing also writes alerts to
alerts_testing and the development bot draining alerts_testing sees them.
Args:
product_collection: The mode-resolved product collection the monitor
already writes to.
Returns:
The matching alerts collection in the same database and mode.
"""
suffix = "_testing" if product_collection.name.endswith("_testing") else ""
return product_collection.database[f"alerts{suffix}"]
@cache @cache
def get_mongo_client() -> AsyncIOMotorClient: def get_mongo_client() -> AsyncIOMotorClient:
"""Return a cached Motor client configured for the shared cluster. """Return a cached Motor client configured for the shared cluster.
@@ -43,8 +67,10 @@ def get_mongo_client() -> AsyncIOMotorClient:
return AsyncIOMotorClient( return AsyncIOMotorClient(
MONGO_URL, MONGO_URL,
maxPoolSize=200,
minPoolSize=10, minPoolSize=10,
connectTimeoutMS=20_000,
socketTimeoutMS=60_000,
serverSelectionTimeoutMS=30_000,
retryWrites=True, retryWrites=True,
retryReads=True, retryReads=True,
tz_aware=True, tz_aware=True,
@@ -65,60 +91,87 @@ async def add_products_to_database(
products: list[Product], products: list[Product],
collection: AsyncIOMotorCollection, collection: AsyncIOMotorCollection,
category_identifier: str, category_identifier: str,
monitor_name: str,
use_composite: bool = False, use_composite: bool = False,
): ):
"""Upsert products, tracking price history and last-updated time. """Upsert products, tracking deduplicated price history and emitting alerts.
Rewrites current fields on each upsert while preserving prior prices in a Rewrites current fields on each upsert. priceBeforeDrop is derived here,
pastPrices map keyed by epoch-millisecond timestamps, appending only when not by monitors: it is set to the previous stored currentPrice when the new
the price differs from the stored value. Flags dropped prices as pending price is lower (a drop) and cleared otherwise, so any price_before_drop on
and records the drop's prior price. the incoming product is ignored. Prices are recorded in a pastPrices array
of price/date entries; a price already present in the array is never
recorded again, so each distinct price keeps only its first-seen timestamp.
New products and never-before-recorded prices
upsert an alert referencing the product; a price returning to a value
already in pastPrices emits nothing, so per-source price oscillation
(member vs non-member quotes for the same SKU) alerts once per distinct
price instead of every run. Alerts are deduplicated so a product holds
at most one unclaimed alert regardless of concurrent category or page
tasks detecting it, and write only after the product write succeeds so
a failed upsert cannot produce an orphaned alert.
Args: Args:
products: Products to upsert. products: Products to upsert.
collection: Target MongoDB collection. collection: Target MongoDB collection.
category_identifier: Category suffix appended to composite ids. category_identifier: Category suffix appended to composite ids.
monitor_name: Monitor name stamped onto emitted alerts for consumer
routing.
use_composite: Build composite ids so multi-variant monitors (Woot) use_composite: Build composite ids so multi-variant monitors (Woot)
keep one doc per variant; other monitors collapse to one doc per keep one doc per variant; other monitors collapse to one doc per
product_id. product_id.
""" """
# Callers are expected to filter empty batches, but guard here too: an empty
# products list would make collection.bulk_write([]) raise InvalidOperation,
# which callers would then log as a spurious category failure every cycle.
if not products: if not products:
return return
doc_ids = [ doc_ids = [
build_doc_id(p.product_id, category_identifier, use_composite) for p in products build_doc_id(p.product_id, category_identifier, use_composite) for p in products
] ]
cursor = collection.find({"_id": {"$in": doc_ids}}, {"currentPrice": 1}) cursor = collection.find(
existing_prices = {str(doc["_id"]): doc.get("currentPrice") async for doc in cursor} {"_id": {"$in": doc_ids}}, {"currentPrice": 1, "pastPrices": 1}
)
existing = {str(doc["_id"]): doc async for doc in cursor}
ops = [] ops = []
alert_ops = []
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
price_key = str(int(now.timestamp() * 1000))
for product, doc_id in zip(products, doc_ids): for product, doc_id in zip(products, doc_ids):
document = convert_product_to_mongo(product, category_identifier) document = convert_product_to_mongo(product)
document["_id"] = doc_id document["_id"] = doc_id
old_price = existing_prices.get(doc_id) existing_doc = existing.get(doc_id)
new_price = document.get("currentPrice") new_price = document.get("currentPrice")
previous_price = existing_doc.get("currentPrice") if existing_doc else None
price_dropped = old_price is None or ( document["priceBeforeDrop"] = resolve_price_before_drop(
new_price is not None and new_price < old_price previous_price, new_price
) )
recorded_prices = extract_recorded_prices(existing_doc)
never_seen_price = new_price is not None and new_price not in recorded_prices
set_on_insert = {"discoveredDate": now} set_on_insert = {"discoveredDate": now}
if price_dropped: if existing_doc is None or never_seen_price:
document["status"] = ProductStatus.PENDING alert = build_alert(monitor_name, document, now)
document["priceBeforeDrop"] = old_price alert_ops.append(
else: UpdateOne(
set_on_insert["status"] = ProductStatus.PENDING {"product_id": doc_id, "claimed": False},
{"$setOnInsert": alert},
upsert=True,
)
)
update = {"$set": document, "$setOnInsert": set_on_insert} update = {"$set": document, "$setOnInsert": set_on_insert}
if new_price is not None and new_price != old_price: if never_seen_price:
update["$set"][f"pastPrices.{price_key}"] = new_price update["$push"] = {"pastPrices": {"price": new_price, "date": now}}
ops.append(UpdateOne({"_id": doc_id}, update, upsert=True)) ops.append(UpdateOne({"_id": doc_id}, update, upsert=True))
try: try:
await collection.bulk_write(ops, ordered=False) await collection.bulk_write(ops, ordered=False)
if alert_ops:
await write_alert_ops(alert_ops, resolve_alerts_collection(collection))
except PyMongoError: except PyMongoError:
LOGGER.exception( LOGGER.exception(
"failed to add products to database", "failed to add products to database",
@@ -127,6 +180,126 @@ async def add_products_to_database(
raise raise
async def write_alert_ops(
alert_ops: list[UpdateOne], alerts_collection: AsyncIOMotorCollection
):
"""Write alert upserts, swallowing duplicate-key races.
Concurrent tasks upserting an alert for the same product can both pass
the upsert's match phase and race the insert; the partial unique index
on unclaimed product_id makes the loser fail with a duplicate-key
error, which is the dedupe working as intended and is ignored. Any
other write error re-raises.
Args:
alert_ops: Alert upsert operations to write.
alerts_collection: The mode-resolved alerts collection to write to,
supplied by the caller so the writer stays mode-agnostic.
"""
try:
await alerts_collection.bulk_write(alert_ops, ordered=False)
except BulkWriteError as exc:
real_errors = [
write_error
for write_error in exc.details["writeErrors"]
if write_error.get("code") != 11000
]
if real_errors:
raise
def resolve_price_before_drop(
previous_price: float | None, new_price: float | None
) -> float | None:
"""Return the pre-drop price when new_price is a drop from previous_price.
Owns price-drop detection so monitors do not: a drop is a new price strictly
below the previously stored one, and its priceBeforeDrop is that prior price.
Any other case (no prior price, unchanged, or a rise) is not a drop.
Args:
previous_price: The product's last stored currentPrice, or None when new.
new_price: The incoming currentPrice.
Returns:
previous_price on a drop, otherwise None.
"""
if (
previous_price is not None
and new_price is not None
and new_price < previous_price
):
return previous_price
return None
def extract_recorded_prices(existing_doc: dict | None) -> set[float]:
"""Return the set of prices already recorded in a document's pastPrices.
Args:
existing_doc: Existing product document, or None for new products.
Returns:
Prices present in the pastPrices array, empty when none exist.
"""
if not existing_doc:
return set()
return {entry["price"] for entry in existing_doc.get("pastPrices", [])}
def build_alert(monitor_name: str, document: dict, now: datetime) -> dict:
"""Build an alert referencing a product for consumer delivery.
Args:
monitor_name: Monitor that produced the product, used by the
consumer to resolve the owning collection and thresholds.
document: Product document supplying the id and pricing for the
priority calculation.
now: Detection timestamp stored as createdAt.
Returns:
The alert document for upsert via $setOnInsert.
"""
return {
"monitor": monitor_name,
"product_id": document["_id"],
"priority": calculate_priority(document),
"claimed": False,
"attempts": 0,
"createdAt": now,
}
def calculate_priority(document: dict) -> int:
"""Derive an alert's priority bucket from its discount percentage.
Always recalculated from current and original price so the priority
reflects the prices at alert time rather than any stale stored
percentage. Missing prices, zero original prices, and price increases
resolve to priority 0.
Args:
document: Product document supplying the prices.
Returns:
Priority from 0 to 10, one bucket per 10 percent of discount.
"""
current_price = document.get("currentPrice")
original_price = document.get("originalPrice")
if current_price is None or not original_price:
return 0
percentage = int(((original_price - current_price) / original_price) * 100)
return min(max(percentage // 10, 0), 10)
def build_doc_id( def build_doc_id(
product_id: str, category_identifier: str, use_composite: bool = False product_id: str, category_identifier: str, use_composite: bool = False
) -> str: ) -> str:
@@ -140,14 +313,17 @@ def build_doc_id(
Returns: Returns:
The composite id when use_composite is set, otherwise product_id. The composite id when use_composite is set, otherwise product_id.
""" """
return f"{product_id}_{category_identifier}" if use_composite else product_id return f"{product_id}_{category_identifier}" if use_composite else product_id
def convert_product_to_mongo(product: Product) -> dict: def convert_product_to_mongo(product: Product) -> dict:
"""Flatten a Product into its MongoDB document representation. """Flatten a Product into its MongoDB document representation.
Coerces string prices to floats and stamps lastUpdated with the current Stamps lastUpdated with the current UTC time. Prices are already floats via
UTC time. The _id is assigned by the caller via build_doc_id. the Product's pydantic validation (numeric strings coerced, comma strings
rejected at construction), so no coercion happens here. The _id is assigned
by the caller via build_doc_id.
Args: Args:
product: Product to convert. product: Product to convert.
@@ -163,25 +339,28 @@ def convert_product_to_mongo(product: Product) -> dict:
availability = data["availability"] availability = data["availability"]
current_price = pricing["current_price"] current_price = pricing["current_price"]
original_price = pricing["original_price"] original_price = pricing["original_price"]
if isinstance(current_price, str):
current_price = float(current_price.replace(",", ""))
original_price = float(original_price.replace(",", ""))
return { return {
"title": data["title"], "title": data["title"],
"url": data["url"], "url": data["url"],
"currentPrice": current_price, "currentPrice": current_price,
"originalPrice": original_price, "originalPrice": original_price,
"percentage": metadata["percentage"],
"imageUrl": metadata["image_url"], "imageUrl": metadata["image_url"],
"customizationIndex": metadata["customization_index"], "customizationIndex": metadata["customization_index"],
"memberNonMemberComparison": metadata["member_non_member_comparison"], "memberNonMemberComparison": metadata["member_non_member_comparison"],
"overstockText": metadata["overstock_text"], "overstockText": metadata["overstock_text"],
"affiliateUrl": metadata["affiliate_url"],
"promoCode": metadata["promo_code"],
"promoPercentage": metadata["promo_percentage"],
"promoDollarOff": metadata["promo_dollar_off"],
"promoMinSpend": metadata["promo_min_spend"],
"startDate": timing["start_date"], "startDate": timing["start_date"],
"endDate": timing["end_date"], "endDate": timing["end_date"],
"priceBeforeDrop": pricing["price_before_drop"], "priceBeforeDrop": pricing["price_before_drop"],
"eduPrice": pricing["edu_price"], "eduPrice": pricing["edu_price"],
"hasMsrp": pricing["has_msrp"],
"available": availability["available"], "available": availability["available"],
"availabilityLastChecked": availability["availability_last_checked"],
"condition": availability["condition"], "condition": availability["condition"],
"appExclusive": availability["app_exclusive"], "appExclusive": availability["app_exclusive"],
"memberPick": availability["member_pick"], "memberPick": availability["member_pick"],
+1
View File
@@ -1,3 +1,4 @@
# Idk who made this i was provided it by louis
import asyncio import asyncio
from typing import Dict from typing import Dict
+38
View File
@@ -6,6 +6,24 @@ from datetime import datetime
from pydantic.dataclasses import dataclass from pydantic.dataclasses import dataclass
@dataclass
class CycleResult:
"""Tallied outcome of one monitor cycle.
Pure counts for the caller to log or act on as it sees fit; no policy is
baked in.
Attributes:
successes: Category tasks that completed without an expected error.
failures: Category tasks that raised an expected error.
skipped: True when pre_task_func failed and no category tasks ran.
"""
successes: int = 0
failures: int = 0
skipped: bool = False
@dataclass @dataclass
class ConsumerConfig: class ConsumerConfig:
"""Configuration for a single retailer's consumer. """Configuration for a single retailer's consumer.
@@ -36,12 +54,15 @@ class PricingInfo:
current_price: Current listed price. current_price: Current listed price.
price_before_drop: Stored price prior to the last detected drop. price_before_drop: Stored price prior to the last detected drop.
edu_price: Education-store price when offered. edu_price: Education-store price when offered.
past_prices: Prior price history as price/date entries, oldest first.
""" """
original_price: float original_price: float
current_price: float current_price: float
price_before_drop: float | None = None price_before_drop: float | None = None
edu_price: float | None = None edu_price: float | None = None
past_prices: list[dict] | None = None
has_msrp: bool = True
@dataclass @dataclass
@@ -54,6 +75,8 @@ class AvailabilityInfo:
app_exclusive: Only purchasable through the retailer's app. app_exclusive: Only purchasable through the retailer's app.
member_pick: Flagged as a member pick offer. member_pick: Flagged as a member pick offer.
member_deal: Flagged as a member deal offer. member_deal: Flagged as a member deal offer.
availability_last_checked: When available was last verified against
the retailer, letting monitors skip rechecks within an interval.
""" """
available: bool | None = None available: bool | None = None
@@ -61,6 +84,7 @@ class AvailabilityInfo:
app_exclusive: bool | None = None app_exclusive: bool | None = None
member_pick: bool | None = None member_pick: bool | None = None
member_deal: bool | None = None member_deal: bool | None = None
availability_last_checked: datetime | None = None
@dataclass @dataclass
@@ -76,6 +100,16 @@ class ProductMetadata:
overstock_text: Overstock label text when present. overstock_text: Overstock label text when present.
percentage: Computed discount percentage. percentage: Computed discount percentage.
affiliate_url: Affiliate link when available. affiliate_url: Affiliate link when available.
promo_code: Event-level checkout code (e.g. "BIRTHDAY26") when the
product's event advertises one, else None.
promo_percentage: Advertised percent off from a percentage code
("extra 22% off" -> 22); None for dollar codes or no code. This is
the claimed discount, not the actual price delta.
promo_dollar_off: Advertised dollar amount off from a spend-threshold
code ("get an extra $22 off" -> 22); None for percentage codes or
no code. Pairs with promo_min_spend.
promo_min_spend: Spend threshold a dollar code requires ("Spend $100"
-> 100); None unless promo_dollar_off is set.
""" """
image_url: str | None = None image_url: str | None = None
@@ -85,6 +119,10 @@ class ProductMetadata:
overstock_text: str | None = None overstock_text: str | None = None
percentage: int | None = None percentage: int | None = None
affiliate_url: str | None = None affiliate_url: str | None = None
promo_code: str | None = None
promo_percentage: int | None = None
promo_dollar_off: int | None = None
promo_min_spend: int | None = None
@dataclass @dataclass
+322 -170
View File
@@ -1,7 +1,7 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime, timedelta, timezone from collections.abc import Awaitable, Callable, Iterable
from typing import Awaitable, Callable, Iterable from contextlib import asynccontextmanager, nullcontext
import aiohttp import aiohttp
from aioproxies import AioProxies from aioproxies import AioProxies
@@ -16,50 +16,39 @@ from monitor_utils.config import (
set_logger_levels, set_logger_levels,
) )
from monitor_utils.constants import ( from monitor_utils.constants import (
BUCKET_WEBHOOK_MAP, CYCLE_TIMEOUT_SECONDS,
EXPECTED_TASK_ERRORS, EXPECTED_TASK_ERRORS,
FLAG_WEBHOOK_MAP,
LOGGING_LEVEL, LOGGING_LEVEL,
MAX_CURL_HANDLES,
TIMEOUT_SECONDS,
) )
from monitor_utils.database import get_collection from monitor_utils.database import get_collection
from monitor_utils.sessions import get_default_session
from monitor_utils.models import ( from monitor_utils.models import (
AvailabilityInfo, AvailabilityInfo,
CycleResult,
PricingInfo, PricingInfo,
Product, Product,
ProductMetadata, ProductMetadata,
ProductTiming, ProductTiming,
) )
from monitor_utils.sessions import get_base_session, get_tls_session
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def init_monitor( def setup_monitor_logging(monitor_name: str, logging_level=LOGGING_LEVEL):
monitor_name: str, """Configure JSON logging for a monitor and quiet noisy libraries.
development_mode: DevelopmentMode,
production_proxy_path: str = "proxies.txt",
testing_proxy_path: str = "configs/proxies.txt",
logging_level=LOGGING_LEVEL,
) -> tuple[list[str], AsyncIOMotorCollection, AioProxies]:
"""Run shared monitor startup and return categories, collection, and proxies.
Configures JSON logging under the monitor's name, applies logger levels, Call once at module import, at global scope, before creating module loggers,
loads the monitor config and Mongo collection, and loads proxies from the so logging is configured synchronously at startup rather than inside the
production or testing path based on development_mode. async init_monitor. Logs go to logs/latest.log with rotated history keyed by
monitor_name.
Args: Args:
monitor_name: Monitor identifier used for logging and config lookup. monitor_name: Monitor name used as the rotated-history base.
development_mode: Selects production or testing config, collection, logging_level: Root logging level.
and proxy path.
production_proxy_path: Proxy file path used in production mode.
testing_proxy_path: Proxy file path used in testing mode.
logging_level: Level passed to setup_logging.
Returns:
Tuple of category names, Mongo collection, and loaded proxies.
""" """
use_production = is_production(development_mode)
setup_logging( setup_logging(
name="logs/latest.log", name="logs/latest.log",
history_name=monitor_name, history_name=monitor_name,
@@ -67,7 +56,33 @@ def init_monitor(
output="json", output="json",
) )
set_logger_levels() set_logger_levels()
category_names, _, collection = setup_config(
async def init_monitor(
monitor_name: str,
development_mode: DevelopmentMode,
production_proxy_path: str = "proxies.txt",
testing_proxy_path: str = "tests/configs/proxies.txt",
) -> tuple[list[str] | dict[str, str], AsyncIOMotorCollection, AioProxies]:
"""Load a monitor's config, collection, and proxies for the mode.
Async because config now lives in Mongo; call it from inside the monitor's
event loop (in main), not at module import. Logging is configured separately
by setup_monitor_logging at module scope and is not touched here.
Args:
monitor_name: Monitor identifier used for config and collection lookup.
development_mode: Selects production or testing config, collection,
and proxy path.
production_proxy_path: Proxy file path used in production mode.
testing_proxy_path: Proxy file path used in testing mode.
Returns:
Tuple of category names, Mongo collection, and loaded proxies.
"""
use_production = is_production(development_mode)
category_names, _, collection = await setup_config(
monitor_name=monitor_name, development_mode=development_mode monitor_name=monitor_name, development_mode=development_mode
) )
proxy_path = production_proxy_path if use_production else testing_proxy_path proxy_path = production_proxy_path if use_production else testing_proxy_path
@@ -75,17 +90,20 @@ def init_monitor(
return category_names, collection, proxies return category_names, collection, proxies
def setup_config( async def setup_config(
monitor_name: str, development_mode: DevelopmentMode monitor_name: str, development_mode: DevelopmentMode
) -> tuple[list[str], dict[int], AsyncIOMotorCollection]: ) -> tuple[
list[str] | dict[str, str], dict[str, str | list[str]], AsyncIOMotorCollection
]:
"""Load monitor config and resolve the Mongo collection for the mode. """Load monitor config and resolve the Mongo collection for the mode.
Testing mode appends a _testing suffix to the collection name so Testing mode reads the configs_testing collection and appends a _testing
production data is never touched during development. suffix to the product collection name so production data is never touched
during development.
Args: Args:
monitor_name: Monitor identifier used for config and collection lookup. monitor_name: Monitor identifier used for config and collection lookup.
development_mode: Selects the production or testing config path and development_mode: Selects the production or testing config and
collection. collection.
Returns: Returns:
@@ -93,9 +111,9 @@ def setup_config(
""" """
use_production = is_production(development_mode) use_production = is_production(development_mode)
config, _ = get_config( config = await get_config(
monitor_name=monitor_name, monitor_name=monitor_name,
use_production_path=use_production, use_production=use_production,
) )
category_names = config["categories"] category_names = config["categories"]
@@ -105,73 +123,40 @@ def setup_config(
return category_names, config["channels"], collection return category_names, config["channels"], collection
def get_channel_id( def combined_discount(base_percentage: int, promo_percentage: int | None) -> int:
product: Product, channel_ids: dict, use_price_drop_bucket: bool = False """Combine an on-sale discount with a percentage code applied at checkout.
) -> str:
"""Resolve the Discord channel id for a product based on its attributes.
Checks flags in priority order: app exclusive, sold out, event ending A "extra N% off" code discounts the already-reduced sale price, so the two
within a day, availability flag mappings, then the percentage or price compound rather than add: 20% off then an extra 30% code is 44% off list,
drop bucket. A KeyError on bucket lookup is acceptable because each not 50%. Returns base_percentage unchanged when there is no percentage code.
monitor's percentage filtering guarantees the bucket exists.
Args: Args:
product: Built product whose attributes select the channel. base_percentage: The on-sale discount off list, as a whole percent.
channel_ids: Mapping of channel keys to a channel id or list of ids. promo_percentage: The code's advertised percent off, or None.
use_price_drop_bucket: Route to the price drop bucket instead of the
percentage bucket.
Returns: Returns:
The resolved channel id. The combined discount off list, rounded to a whole percent.
""" """
if product.availability.app_exclusive: if not promo_percentage:
return get_single_channel_id(channel_ids["appExclusive"]) return base_percentage
if product.availability.available is False: return round(100 - (100 - base_percentage) * (100 - promo_percentage) / 100)
return get_single_channel_id(channel_ids["soldOut"])
if product.timing.end_date:
now = datetime.now(timezone.utc)
if (product.timing.end_date - now) <= timedelta(days=1):
return get_single_channel_id(channel_ids["event"])
for attr, key in FLAG_WEBHOOK_MAP:
if getattr(product.availability, attr, False):
return get_single_channel_id(channel_ids[key])
percentage = product.metadata.percentage or 0
percentage_bucket = min(90, (percentage // 10) * 10)
if use_price_drop_bucket:
return get_single_channel_id(channel_ids[BUCKET_WEBHOOK_MAP["price_drop"]])
return get_single_channel_id(channel_ids[BUCKET_WEBHOOK_MAP[percentage_bucket]])
def get_single_channel_id(value: str | list[str]) -> str:
"""Return the first channel id when given a list, else the value itself."""
if isinstance(value, list):
return value[0]
return value
def build_product_details( def build_product_details(
product: dict, product: dict,
channel_ids: dict,
min_percentage: int, min_percentage: int,
min_price_drop: float | None = None, min_price_drop: float | None = None,
) -> Product | None: ) -> Product | None:
"""Build a Product from a Mongo document if it passes discount thresholds. """Build a Product from a Mongo document if it passes discount thresholds.
Skips documents with a missing or zero original price. Passing requires Skips documents with a missing or zero original price. Passing requires
meeting min_percentage, or min_price_drop when provided. The resolved meeting min_percentage, or min_price_drop when provided. Channel routing
channel id is attached to the product's metadata before returning. is owned by the consumer bot; no channel id is attached here.
Args: Args:
product: Mongo document for the product. product: Mongo document for the product.
channel_ids: Mapping of channel keys passed to get_channel_id.
min_percentage: Minimum discount percentage to pass. min_percentage: Minimum discount percentage to pass.
min_price_drop: Optional minimum absolute price drop to pass. min_price_drop: Optional minimum absolute price drop to pass.
@@ -182,19 +167,30 @@ def build_product_details(
product_id = product["_id"] product_id = product["_id"]
current_price = product["currentPrice"] current_price = product["currentPrice"]
original_price = product["originalPrice"] original_price = product["originalPrice"]
if not original_price:
LOGGER.warning(
"missing or zero original price, skipping",
extra={"product_id": product_id},
)
return
percentage = int(((original_price - current_price) / original_price) * 100) percentage = 0
price_drop = original_price - current_price has_msrp = product["has_msrp"]
passes_percentage = percentage >= min_percentage if has_msrp:
passes_price_drop = min_price_drop is not None and price_drop >= min_price_drop if not original_price:
if not passes_percentage and not passes_price_drop: LOGGER.warning(
return "missing or zero original price, skipping",
extra={"product_id": product_id},
)
return
percentage = int(((original_price - current_price) / original_price) * 100)
price_drop = original_price - current_price
# A percentage code compounds onto the sale price, so an item can clear the
# threshold on the combined discount even when the base discount would not.
# Dollar codes do not map to a percent and never affect the threshold.
effective_percentage = combined_discount(
percentage, product.get("promoPercentage")
)
passes_percentage = effective_percentage >= min_percentage
passes_price_drop = min_price_drop is not None and price_drop >= min_price_drop
if not passes_percentage and not passes_price_drop:
return
built = Product( built = Product(
product_id=product_id, product_id=product_id,
@@ -207,6 +203,10 @@ def build_product_details(
overstock_text=product["overstockText"], overstock_text=product["overstockText"],
percentage=percentage, percentage=percentage,
affiliate_url=product.get("affiliateUrl"), affiliate_url=product.get("affiliateUrl"),
promo_code=product.get("promoCode"),
promo_percentage=product.get("promoPercentage"),
promo_dollar_off=product.get("promoDollarOff"),
promo_min_spend=product.get("promoMinSpend"),
), ),
timing=ProductTiming( timing=ProductTiming(
discovered_date=product["discoveredDate"], discovered_date=product["discoveredDate"],
@@ -218,6 +218,8 @@ def build_product_details(
original_price=original_price, original_price=original_price,
price_before_drop=product["priceBeforeDrop"], price_before_drop=product["priceBeforeDrop"],
edu_price=product["eduPrice"], edu_price=product["eduPrice"],
past_prices=product["pastPrices"],
has_msrp=has_msrp,
), ),
availability=AvailabilityInfo( availability=AvailabilityInfo(
available=product["available"], available=product["available"],
@@ -227,95 +229,186 @@ def build_product_details(
member_deal=product["memberDeal"], member_deal=product["memberDeal"],
), ),
) )
built.metadata.channel_id = get_channel_id(
built, channel_ids, passes_price_drop and not passes_percentage
)
return built return built
async def execute_monitor_logic( async def run_monitor_cycle(
category_identifiers: list[str] | dict[str, str], category_identifiers: list[str] | dict[str, str],
task_func: Callable[..., Awaitable[None]], task_func: Callable[..., Awaitable[None]],
session: aiohttp.ClientSession | AsyncSession,
collection: AsyncIOMotorCollection,
*, *,
proxies: AioProxies,
headers: dict | None = None,
cookies: dict | None = None,
pre_task_func: Callable[..., Awaitable[None]] | None = None, pre_task_func: Callable[..., Awaitable[None]] | None = None,
use_tls: bool = False, concurrent_pre_task_func: Callable[..., Awaitable[None]] | None = None,
**kwargs, **kwargs,
): ) -> CycleResult:
"""Run the monitor's category tasks in a loop with a fresh session per cycle. """Run one monitor cycle over a caller-supplied session and report the outcome.
Each cycle opens a TLS or base session, optionally runs pre_task_func, Awaits pre_task_func if given, dispatches task_func per category
dispatches task_func per category concurrently, logs expected failures, concurrently, logs expected failures, and raises unexpected ones. The
raises unexpected ones, then sleeps briefly before the next cycle. caller owns the loop, the session lifetime, and the inter-cycle delay: wrap
this in a `while True` that opens a fresh session per cycle and sleeps
between cycles — or use run_bounded_cycle, which owns the session and a
hard cycle budget and leaves only the loop and the sleep to the caller.
The returned CycleResult is pure counts for the caller to log or act on;
this function applies no policy to it.
session and collection are the two per-cycle resources, passed to every
callback (pre_task_func, concurrent_pre_task_func, and each task_func) just
like each other. A callback that does not need one still receives it, so
every callback must accept **kwargs to absorb the ones it ignores.
Args: Args:
category_identifiers: List of category ids, or a mapping of category category_identifiers: List of category ids, or a mapping of category
id to query id; mapping entries pass query_category_id to id to query id; mapping entries pass query_category_id to
task_func. task_func.
task_func: Awaitable invoked per category with the identifier, task_func: Awaitable invoked per category with the identifier, session,
session, and **kwargs. collection, and **kwargs.
proxies: Proxies applied to the session. session: Open HTTP session for this cycle, built and closed by the
headers: Headers applied to the session. caller. Proxies are the caller's responsibility, bound to the
cookies: Cookies applied to the base session; ignored for TLS. session (see get_default_session).
pre_task_func: Optional awaitable run once per cycle before category collection: Mongo collection the tasks persist products to; forwarded to
tasks, receiving session and **kwargs. every callback so any that writes has it.
use_tls: Use the TLS fingerprint session instead of the base session. pre_task_func: Optional awaitable run once at the start of the cycle,
**kwargs: Forwarded verbatim to pre_task_func and every task_func call. awaited before any category tasks are dispatched and receiving
session, collection, and **kwargs. Use it for setup the category
tasks depend on (session headers, cookies, fetched state); if it
raises an expected task error the cycle is skipped and a skipped
CycleResult returned.
concurrent_pre_task_func: Optional awaitable run once per cycle, gathered
alongside the category tasks and receiving session, collection, and
**kwargs. Category tasks must not depend on its side effects
(session headers, cookies, fetched state) since no ordering is
guaranteed.
**kwargs: Forwarded verbatim to pre_task_func, concurrent_pre_task_func,
and every task_func call.
Returns:
A CycleResult tallying task successes and expected-error failures, or a
skipped result when pre_task_func failed.
Raises:
Exception: Any unexpected (non-EXPECTED_TASK_ERRORS) error raised by a
task or pre_task_func propagates, failing loud so the caller's loop
crashes rather than silently retrying a broken monitor.
""" """
while True: if pre_task_func:
session_ctx = ( try:
get_tls_session( await pre_task_func(session=session, collection=collection, **kwargs)
headers=headers, except EXPECTED_TASK_ERRORS as error:
proxies=proxies, LOGGER.warning("pre task failed, skipping cycle", exc_info=error)
return CycleResult(skipped=True)
LOGGER.info("starting tasks")
if isinstance(category_identifiers, dict):
tasks = [
task_func(
category_id, session, collection, query_category_id=query, **kwargs
) )
if use_tls for category_id, query in category_identifiers.items()
else get_base_session(headers=headers, cookies=cookies, proxies=proxies) ]
category_keys = list(category_identifiers.keys())
else:
tasks = [
task_func(category_identifier, session, collection, **kwargs)
for category_identifier in category_identifiers
]
category_keys = list(category_identifiers)
if concurrent_pre_task_func:
tasks.append(
concurrent_pre_task_func(session=session, collection=collection, **kwargs)
) )
async with session_ctx as session: category_keys.append("pre_task")
LOGGER.info("starting tasks")
if pre_task_func:
try:
await pre_task_func(session=session, **kwargs)
except EXPECTED_TASK_ERRORS as error:
LOGGER.warning("failed to get products", exc_info=error)
except Exception:
LOGGER.exception("unexpected error")
raise
if isinstance(category_identifiers, dict): results = await asyncio.gather(*tasks, return_exceptions=True)
tasks = [ successes = 0
task_func(category_id, session, query_category_id=query, **kwargs) failures = 0
for category_id, query in category_identifiers.items() for category_identifier, result in zip(category_keys, results):
] if isinstance(result, EXPECTED_TASK_ERRORS):
category_keys = list(category_identifiers.keys()) LOGGER.warning(
else: "failed to get products",
tasks = [ extra={"category": category_identifier},
task_func(category_identifier, session, **kwargs) exc_info=result,
for category_identifier in category_identifiers )
] failures += 1
category_keys = category_identifiers elif isinstance(result, Exception):
LOGGER.error(
"unexpected error",
extra={"category": category_identifier},
exc_info=result,
)
raise result
else:
successes += 1
results = await asyncio.gather(*tasks, return_exceptions=True) return CycleResult(successes=successes, failures=failures)
for category_identifier, result in zip(category_keys, results):
if isinstance(result, EXPECTED_TASK_ERRORS):
LOGGER.warning(
"failed to get products",
extra={"category": category_identifier},
exc_info=result,
)
elif isinstance(result, Exception):
LOGGER.error(
"unexpected error",
extra={"category": category_identifier},
exc_info=result,
)
raise result
LOGGER.info("sleeping before next tasks", extra={"delay": 0.30})
await asyncio.sleep(0.30) async def run_bounded_cycle(
category_identifiers: list[str] | dict[str, str],
task_func: Callable[..., Awaitable[None]],
proxies: AioProxies,
collection: AsyncIOMotorCollection,
*,
headers: dict | None = None,
session_timeout: int = TIMEOUT_SECONDS,
max_clients: int = MAX_CURL_HANDLES,
cycle_timeout: int = CYCLE_TIMEOUT_SECONDS,
**kwargs,
) -> CycleResult | None:
"""Run one monitor cycle on a fresh session under a hard time budget.
The session is opened and closed inside the call, so curl multi-handle
state cannot outlive one cycle: a wedged transfer dies with its session
instead of parking the process. The budget turns an engine-level lost
wakeup into a caught TimeoutError and an abandoned cycle, never a
permanent wedge; the session's own close is bounded by
get_default_session, so the exit cannot park past the budget either.
The caller still owns the loop and the inter-cycle delay:
while True:
await run_bounded_cycle(ids, task_func, proxies, collection)
await asyncio.sleep(CYCLE_DELAY)
Args:
category_identifiers: Categories, passed through to run_monitor_cycle.
task_func: Awaitable invoked per category.
proxies: Pool bound to each cycle's fresh session.
collection: Mongo collection forwarded to every callback.
headers: Monitor-wide static headers merged into every request.
session_timeout: Per-request timeout on the cycle's session.
max_clients: Curl handle pool size. Keep at or above the monitor's
whole fan-out; see get_default_session for why.
cycle_timeout: Hard budget in seconds for the whole cycle.
**kwargs: Forwarded to run_monitor_cycle (pre_task_func,
concurrent_pre_task_func, and per-task kwargs).
Returns:
The cycle's CycleResult, or None when the budget expired and the
cycle was abandoned.
"""
async with get_default_session(
proxies, headers, timeout=session_timeout, max_clients=max_clients
) as session:
try:
async with asyncio.timeout(cycle_timeout):
return await run_monitor_cycle(
category_identifiers,
task_func,
session=session,
collection=collection,
**kwargs,
)
except TimeoutError:
LOGGER.error(
"cycle exceeded budget, abandoning cycle",
extra={"timeout": cycle_timeout},
)
return None
async def execute_pagination( async def execute_pagination(
@@ -325,15 +418,16 @@ async def execute_pagination(
func: Callable[..., Awaitable[None]], func: Callable[..., Awaitable[None]],
session: aiohttp.ClientSession | AsyncSession, session: aiohttp.ClientSession | AsyncSession,
*, *,
max_concurrent_tasks: int = 5, semaphore: asyncio.Semaphore | None = None,
start_offset: int = 24, start_offset: int = 0,
pagination_mode: str = "offset", pagination_mode: str = "offset",
**kwargs, **kwargs,
): ):
"""Paginate a category by dispatching concurrent page or offset tasks to func. """Paginate a category by dispatching concurrent page or offset tasks to func.
Runs pages 2..N under a semaphore, retries failed pages with exponential Runs pages 2..N under a semaphore, retries failed pages with exponential
backoff, and logs any pages that still fail after retries. backoff, and logs any pages that still fail after retries. Page 1 is
assumed to have been fetched by the caller.
Args: Args:
category_identifier: Label used for logging only; not passed to func. category_identifier: Label used for logging only; not passed to func.
@@ -342,8 +436,15 @@ async def execute_pagination(
func: Awaitable task invoked per page; receives session plus either func: Awaitable task invoked per page; receives session plus either
offset or page_number depending on pagination_mode. offset or page_number depending on pagination_mode.
session: HTTP session forwarded to each func call. session: HTTP session forwarded to each func call.
max_concurrent_tasks: Semaphore limit for concurrent page fetches. semaphore: Optional bound on concurrent page fetches. Omit it to let a
start_offset: Base offset added when pagination_mode is offset. monitor with few pages fan out unbounded; pass one to cap monitors
with many pages. Pass a single shared semaphore across all categories
so the limit caps total fan-out; a per-call semaphore would instead
bound each category in isolation, making the effective limit this
value times the category count.
start_offset: Additive base applied to every offset, for APIs whose
offset space does not begin at zero. Page N requests offset
start_offset + (N - 1) * products_per_page.
pagination_mode: Either offset or page, selecting the kwarg sent to func. pagination_mode: Either offset or page, selecting the kwarg sent to func.
**kwargs: Forwarded verbatim to every func call. Any identifier func **kwargs: Forwarded verbatim to every func call. Any identifier func
needs (category_id, category_name, category_path, or bestbuy's needs (category_id, category_name, category_path, or bestbuy's
@@ -351,21 +452,23 @@ async def execute_pagination(
per monitor. per monitor.
""" """
LOGGER.info("executing pagination", extra={"category": category_identifier})
total_pages = (int(total_products) + products_per_page - 1) // products_per_page total_pages = (int(total_products) + products_per_page - 1) // products_per_page
semaphore = asyncio.Semaphore(max_concurrent_tasks) LOGGER.info(
"executing pagination",
extra={"category": category_identifier, "total": total_pages},
)
stop_event = asyncio.Event() stop_event = asyncio.Event()
pagination_label = "offset" if pagination_mode == "offset" else "page" pagination_label = "offset" if pagination_mode == "offset" else "page"
page_range = range(2, total_pages + 1) page_range = range(2, total_pages + 1)
async def fetch_page(page_index: int): async def fetch_page(page_index: int):
"""Fetch a single page or offset under the shared semaphore.""" """Fetch a single page or offset, optionally under the shared semaphore."""
async with semaphore: async with semaphore or nullcontext():
if stop_event.is_set(): if stop_event.is_set():
return return
if pagination_mode == "offset": if pagination_mode == "offset":
current_offset = start_offset + page_index * products_per_page current_offset = start_offset + (page_index - 1) * products_per_page
await func( await func(
session=session, session=session,
offset=current_offset, offset=current_offset,
@@ -533,3 +636,52 @@ async def retry_failed_pages(
retry_attempt += 1 retry_attempt += 1
return failed_pages return failed_pages
def first_error(group_exc: BaseExceptionGroup) -> BaseException:
"""Return a leaf exception of a possibly nested group, preferring an
unexpected one so a real bug is never masked by a routine network error."""
_, unexpected = group_exc.split(EXPECTED_TASK_ERRORS)
if unexpected is not None:
group_exc = unexpected
error = group_exc.exceptions[0]
while isinstance(error, BaseExceptionGroup):
error = error.exceptions[0]
return error
@asynccontextmanager
async def fan_out_group():
"""A TaskGroup for page-one fan-out whose failure surfaces unwrapped.
Use it where one page's processing must be bound to the pagination that
page spawns: create the processing task on the yielded group, then await
execute_pagination in the body. The group cancels whichever sibling is
still running when the other fails, so a dead category stops doing work
nobody will use, and a cancelled cycle takes both down together.
The unwrapping is the reason this is shared rather than inlined. A bare
TaskGroup always raises an ExceptionGroup, and run_monitor_cycle classifies
task results with isinstance against EXPECTED_TASK_ERRORS -- an
ExceptionGroup matches neither branch as intended, so a routine page
failure would be reclassified as an unexpected one and kill the monitor.
first_error unwraps to a leaf, preferring an unexpected exception so a real
bug is never masked by a network error.
Note the caller's own body is inside the group, so a plain exception raised
there (not from a child) propagates unchanged rather than being wrapped,
and an early `return` still awaits the child tasks.
Example:
async with fan_out_group() as group:
group.create_task(process_page(data, ...))
await execute_pagination(...)
"""
try:
async with asyncio.TaskGroup() as group:
yield group
except BaseExceptionGroup as group_exc:
raise first_error(group_exc) from None
+119 -166
View File
@@ -1,220 +1,173 @@
"""Shared HTTP session helpers for all monitors. """Shared HTTP session helpers for all monitors.
Known gap: UA/TLS fingerprint does not always match headers/header order, A single aioweb_tls TLSSession (curl_cffi backend, random client fingerprint per
but targeted antibots do not currently seem to care. session) replaces the old aiohttp/curl_cffi split. send_request wraps aioweb's
request_with_retries, adding per-request proxy rotation and old-style JSON/text
return values so monitor call sites change as little as possible.
""" """
import asyncio import asyncio
import json
import logging import logging
import random import random
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import aiohttp
import requests
from aioproxies import AioProxies from aioproxies import AioProxies
from curl_cffi.requests import AsyncSession from aioweb import Response
from curl_cffi.requests.errors import RequestsError from aioweb_tls import CurlCffi, TLSSession
from curl_cffi.requests.exceptions import Timeout as CurlTimeout
from fake_useragent import FakeUserAgent
from tenacity import (
after_log,
before_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_fixed,
)
from monitor_utils.constants import ( from monitor_utils.constants import (
CLIENT_IDENTIFIERS, CLIENT_IDENTIFIERS,
CLIENT_TIMEOUT, MAX_CURL_HANDLES,
RETRY_LIMIT, RETRY_LIMIT,
RETRY_WAIT, SESSION_CLOSE_TIMEOUT_SECONDS,
TIMEOUT_SECONDS, TIMEOUT_SECONDS,
) )
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
UA = FakeUserAgent()
@asynccontextmanager class BoundedCloseSession(TLSSession):
async def get_base_session( """A TLSSession whose close cannot outlive SESSION_CLOSE_TIMEOUT_SECONDS.
headers: dict | None = None,
cookies: dict | None = None,
connector: aiohttp.TCPConnector | None = None,
timeout: aiohttp.ClientTimeout = CLIENT_TIMEOUT,
proxies: AioProxies | None = None,
) -> AsyncGenerator[aiohttp.ClientSession, None]:
"""Yield an aiohttp session with a random User-Agent and attached proxy pool.
The proxy pool is stored on the session as proxy_pool for send_request to curl_cffi's close awaits the multi handle with no timeout of its own, so a
rotate per request. The session is always closed on exit. wedged transfer parks whatever is closing the session. A caller-side budget
cannot cover this reliably: asyncio.timeout cancels its task once, so a
cycle budget that has already fired leaves the close running as a fresh,
uncancelled await, and the process parks in the `async with` exit that was
meant to reclaim it.
Args: Bounding it here keeps that guarantee attached to the session itself, so
headers: Headers applied to the session; User-Agent is defaulted if every `async with get_default_session(...)` gets it without the call site
absent. having to arrange anything. Abandoning a session leaks its handle until the
cookies: Cookies applied to the session. process restarts, which is strictly better than a wedged monitor.
connector: Optional TCP connector.
timeout: Client timeout applied to the session.
proxies: Proxy pool attached to the session.
Yields: Known inconsistency, deliberate: an abandoned session reports itself closed
The configured aiohttp session. while its curl handle is still open. TLSSession.close sets _closed before
awaiting the backend, so _is_closed() returns True on this path, and since
aioweb's __del__ only warns when _is_closed() is False, its leak warning is
suppressed for exactly the sessions that leaked. The "session close exceeded
budget" error logged below is therefore the only signal that it happened —
alert on it rather than on the library's warning.
Correcting the flag would mean overriding _is_closed too, and aioweb_tls is
a third-party library: this class deliberately overrides one documented
lifecycle method and nothing private, so an upstream release has one narrow
way to break it. A stale flag on a session discarded the same instant is the
cheaper side of that trade.
""" """
headers = headers if headers else {} async def close(self) -> None:
headers.setdefault("User-Agent", UA.random) try:
session = aiohttp.ClientSession( async with asyncio.timeout(SESSION_CLOSE_TIMEOUT_SECONDS):
headers=headers, cookies=cookies, connector=connector, timeout=timeout await super().close()
) except TimeoutError:
session.proxy_pool = proxies LOGGER.error(
try: "session close exceeded budget, abandoning session",
yield session extra={"timeout": SESSION_CLOSE_TIMEOUT_SECONDS},
finally: )
await session.close()
@asynccontextmanager def get_default_session(
async def get_tls_session( proxies: AioProxies,
headers: dict | None = None, headers: dict | None = None,
timeout: int = TIMEOUT_SECONDS, timeout: int = TIMEOUT_SECONDS,
proxies: AioProxies | None = None, max_clients: int = MAX_CURL_HANDLES,
) -> AsyncGenerator[AsyncSession, None]: ) -> TLSSession:
"""Yield a curl_cffi session impersonating a random client fingerprint. """Return a TLSSession impersonating a random client fingerprint.
The proxy pool is stored on the session as proxy_pool for send_request to Fingerprint rotation lives here so every monitor rotates the same way; the
rotate per request. caller owns the session lifetime via ``async with``.
Cookies are not set here: aioweb has no constructor cookie path, and
monitors usually obtain them at runtime, so call session.set_cookie(name,
value, domain=...) where they are fetched (e.g. a pre_task_func) instead.
Args: Args:
headers: Headers applied to the session; a Chrome User-Agent is proxies: Pool attached to the session as proxy_pool, which send_request
defaulted if absent. rotates through per request. Required so a session can never send
timeout: Request timeout in seconds. un-proxied.
proxies: Proxy pool attached to the session. headers: Default headers merged into every request by aioweb. Use for
monitor-wide static headers; per-request headers still go through
send_request. The curl_cffi impersonation already supplies a
matching User-Agent, so one is not defaulted here.
timeout: Total request timeout in seconds, defaulting to TIMEOUT_SECONDS.
aioweb's own default is only 10s, so this restores the wider budget
the old sessions used and slow retailers depend on.
max_clients: Size of the curl handle pool, defaulting to
MAX_CURL_HANDLES. This is a hard concurrency ceiling: a request
finding the pool empty waits in pop_curl's untimed pool.get(), and
`timeout` does not cover that wait, only the transfer after
checkout. Keep it at or above the monitor's real fan-out (the sum
of its pagination semaphores plus any unsemaphored per-cycle
requests) so the wait is never reachable.
Yields: Returns:
The configured curl_cffi session. A BoundedCloseSession (a TLSSession whose close is bounded) over a
curl_cffi backend with a random impersonation, with the proxy pool
attached as proxy_pool.
""" """
impersonate = random.choice(CLIENT_IDENTIFIERS)
headers = headers if headers else {} session = BoundedCloseSession(
headers.setdefault("User-Agent", UA.chrome) backend=CurlCffi(impersonate=impersonate),
async with AsyncSession(
impersonate=random.choice(CLIENT_IDENTIFIERS),
timeout=timeout,
headers=headers, headers=headers,
) as session: timeout=timeout,
session.proxy_pool = proxies max_clients=max_clients,
yield session )
session.proxy_pool = proxies
return session
def build_session_details(
session: aiohttp.ClientSession | AsyncSession, headers: dict = None
):
"""Merge headers into an existing session, defaulting a random User-Agent.
Args:
session: Session whose headers are updated in place.
headers: Headers to merge; User-Agent is defaulted if absent.
"""
headers = headers or {}
headers.setdefault("User-Agent", UA.random)
session.headers.update(headers)
@retry(
stop=stop_after_attempt(RETRY_LIMIT),
wait=wait_fixed(RETRY_WAIT),
retry=retry_if_exception_type(
(
aiohttp.ClientError,
asyncio.TimeoutError,
RequestsError,
requests.Timeout,
CurlTimeout,
)
),
before=before_log(LOGGER, logging.DEBUG),
after=after_log(LOGGER, logging.DEBUG),
reraise=True,
)
async def send_request( async def send_request(
session: aiohttp.ClientSession | AsyncSession, session: TLSSession,
url: str, url: str,
method: str, method: str,
*,
params: dict | None = None, params: dict | None = None,
json_data: dict | None = None, json_data: dict | None = None,
data: dict | None = None, data: dict | None = None,
reraise_json_error: bool = False, headers: dict | None = None,
development_proxy: str | None = None, ) -> Response:
) -> dict | str: """Send a request through a TLSSession with retries and proxy rotation.
"""Send a request through either session type with retries and proxy rotation.
Retries transport errors and timeouts up to RETRY_LIMIT with a fixed wait. Wraps aioweb's request_with_retries, which retries transport errors,
When development_proxy is set it overrides the session's proxy pool and timeouts, and retryable statuses (429/5xx) up to RETRY_LIMIT attempts with
disables TLS verification for local traffic inspection; otherwise the next exponential backoff. The next proxy is drawn from the pool per call and
proxy is drawn from the session's proxy_pool when present. Responses are applied to both schemes. A non-2xx response or a fully failed request raises
parsed as JSON when possible. AiowebError, an expected task error, so a failed fetch surfaces the same way
the old HTTPError path did. On success the aioweb Response is returned for
the caller to read via response.json(), response.text(), etc.
Args: Args:
session: aiohttp or curl_cffi session to send through. session: The TLSSession to send through.
url: Request URL. url: Request URL.
method: HTTP method. method: HTTP method.
params: Query parameters. params: Query parameters.
json_data: JSON body. json_data: JSON body. A dict here is sent as JSON with the
data: Form body. application/json content-type set by aioweb.
reraise_json_error: Raise JSONDecodeError instead of returning raw data: Raw request body, passed through unchanged (e.g. a url-encoded
text when the response is not valid JSON. form string). The caller owns the matching content-type via headers.
development_proxy: Local proxy URL that overrides the pool and A dict is JSON-encoded by aioweb, so use json_data for JSON bodies.
disables verification. headers: Per-request headers merged over the session defaults.
Returns: Returns:
Parsed JSON dict, or raw response text when parsing fails and The aioweb Response, guaranteed 2xx (non-2xx raises). Read the body with
reraise_json_error is False. response.json() (dict/list, or None if not valid JSON) or
response.text().
Raises: Raises:
json.JSONDecodeError: When parsing fails and reraise_json_error is AiowebError: On a non-2xx response or after all retries fail.
True.
""" """
verify = True proxy_url = session.proxy_pool.next().url()
if development_proxy: proxy = {"http": proxy_url, "https": proxy_url}
proxy = development_proxy
verify = False
else:
manager = getattr(session, "proxy_pool", None)
proxy = manager.next().url() if manager else None
if isinstance(session, aiohttp.ClientSession): body = json_data if json_data is not None else data
async with session.request( response = await session.request_with_retries(
method=method, method.upper(),
url=url, url,
params=params, params=params,
json=json_data, data=body,
data=data, headers=headers,
proxy=proxy, proxies=proxy,
ssl=verify, attempts=RETRY_LIMIT,
) as response: )
response.raise_for_status() response.raise_for_status()
text = await response.text() return response
else:
response = await session.request(
method=method.upper(),
url=url,
params=params,
json=json_data,
data=data,
proxy=proxy,
verify=verify,
)
response.raise_for_status()
text = response.text
try:
return json.loads(text)
except json.JSONDecodeError:
if reraise_json_error:
raise
return text
+124
View File
@@ -0,0 +1,124 @@
"""unit test: build_product_details thresholding, promo read, and combined_discount
run from the repo root:
uv run pytest tests/test_build_product_details.py
pure and offline — no Mongo. Builds a Product from a raw doc dict and checks the
discount threshold (with percentage codes compounding onto the sale price), the
promo fields read through via .get, and the combined_discount helper.
"""
from datetime import datetime, timezone
from monitor_utils.runner import build_product_details, combined_discount
def _doc(**overrides) -> dict:
"""A well-formed product doc; override prices/promo per test."""
doc = {
"_id": "p1_tools",
"title": "Widget",
"url": "http://x/p1",
"currentPrice": 80.0,
"originalPrice": 100.0,
"imageUrl": "http://img/1",
"customizationIndex": None,
"memberNonMemberComparison": None,
"overstockText": None,
"discoveredDate": datetime(2026, 7, 26, tzinfo=timezone.utc),
"startDate": None,
"endDate": None,
"priceBeforeDrop": None,
"eduPrice": None,
"pastPrices": [
{"price": 80.0, "date": datetime(2026, 7, 26, tzinfo=timezone.utc)}
],
"available": True,
"condition": None,
"appExclusive": None,
"memberPick": None,
"memberDeal": None,
}
doc.update(overrides)
return doc
# ------------------------------------------------------------ combined_discount
def test_combined_discount_no_promo_is_base():
assert combined_discount(24, None) == 24
assert combined_discount(24, 0) == 24
def test_combined_discount_compounds_not_adds():
# 20% off then extra 30% code -> 44% off list (customer pays $56), not 50%
assert combined_discount(20, 30) == 44
def test_combined_discount_rounds_to_nearest():
# 24% sale + 22% code: 1 - .76*.78 = .4072 -> 41 (rounded, not truncated 40)
assert combined_discount(24, 22) == 41
# --------------------------------------------------------- build_product_details
def test_promo_fields_read_through():
doc = _doc(
promoCode="BIRTHDAY26",
promoPercentage=22,
promoDollarOff=None,
promoMinSpend=None,
)
product = build_product_details(doc, min_percentage=0)
assert product.metadata.promo_code == "BIRTHDAY26"
assert product.metadata.promo_percentage == 22
# base percentage is the raw price delta, unchanged by the code
assert product.metadata.percentage == 20
def test_missing_promo_keys_default_to_none():
"""a doc from a lagging monitor lacks promo keys -> None, not KeyError"""
product = build_product_details(_doc(), min_percentage=0)
assert product.metadata.promo_code is None
assert product.metadata.promo_percentage is None
def test_percentage_code_lifts_item_over_threshold():
# 30% base fails min 40, but +22% code -> 45% combined clears it
doc = _doc(currentPrice=70.0, originalPrice=100.0, promoPercentage=22)
assert build_product_details(doc, min_percentage=40) is not None
def test_below_threshold_without_code_is_skipped():
doc = _doc(currentPrice=70.0, originalPrice=100.0) # 30%, no code
assert build_product_details(doc, min_percentage=40) is None
def test_dollar_code_does_not_affect_threshold():
# a dollar code has no promoPercentage, so 30% base stays below min 40
doc = _doc(
currentPrice=70.0,
originalPrice=100.0,
promoCode="STORE22",
promoDollarOff=22,
promoMinSpend=100,
)
assert build_product_details(doc, min_percentage=40) is None
# ----------------------------------------------------------------- pastPrices
def test_past_prices_read_through():
"""Price history passes through unchanged."""
history = [
{"price": 95.0, "date": datetime(2026, 7, 20, tzinfo=timezone.utc)},
{"price": 90.0, "date": datetime(2026, 7, 24, tzinfo=timezone.utc)},
]
product = build_product_details(_doc(pastPrices=history), min_percentage=0)
assert product.pricing.past_prices == history
+103
View File
@@ -0,0 +1,103 @@
"""unit test: convert_product_to_mongo flattens a Product to its Mongo document
run from the repo root:
uv run pytest tests/test_convert_product_to_mongo.py
pure and offline — no Mongo. Also pins the fact that prices are already floats
by the time they reach the flattener (pydantic coerces numeric strings on the
Product), which is why convert_product_to_mongo no longer coerces them itself.
"""
from datetime import datetime
from monitor_utils.database import convert_product_to_mongo
from monitor_utils.models import PricingInfo, Product, ProductMetadata
def _product(metadata: ProductMetadata | None = None, **pricing) -> Product:
return Product(
product_id="p1",
title="Widget",
url="http://x/p1",
pricing=PricingInfo(**pricing),
metadata=metadata or ProductMetadata(),
)
def test_numeric_string_prices_arrive_as_floats():
"""a numeric-string price is a float in the doc (pydantic coerced it on init)"""
doc = convert_product_to_mongo(_product(original_price="200", current_price=150.0))
assert doc["originalPrice"] == 200.0
assert isinstance(doc["originalPrice"], float)
assert doc["currentPrice"] == 150.0
assert isinstance(doc["currentPrice"], float)
def test_lastupdated_is_stamped():
"""every document gets a UTC lastUpdated timestamp"""
doc = convert_product_to_mongo(_product(original_price=200.0, current_price=150.0))
assert isinstance(doc["lastUpdated"], datetime)
def test_core_fields_map_through():
"""title and url pass straight through to the document"""
doc = convert_product_to_mongo(_product(original_price=200.0, current_price=150.0))
assert doc["title"] == "Widget"
assert doc["url"] == "http://x/p1"
def test_promo_and_affiliate_fields_serialize():
"""promo specifics + affiliate url reach the document under camelCase keys"""
doc = convert_product_to_mongo(
_product(
metadata=ProductMetadata(
affiliate_url="http://aff/p1",
promo_code="BIRTHDAY26",
promo_percentage=22,
promo_dollar_off=None,
promo_min_spend=None,
),
original_price=200.0,
current_price=150.0,
)
)
assert doc["affiliateUrl"] == "http://aff/p1"
assert doc["promoCode"] == "BIRTHDAY26"
assert doc["promoPercentage"] == 22
assert doc["promoDollarOff"] is None
assert doc["promoMinSpend"] is None
def test_dollar_promo_fields_serialize():
"""a spend-threshold code stores amount + min spend, not a percentage"""
doc = convert_product_to_mongo(
_product(
metadata=ProductMetadata(
promo_code="STORE22",
promo_dollar_off=22,
promo_min_spend=100,
),
original_price=200.0,
current_price=150.0,
)
)
assert doc["promoCode"] == "STORE22"
assert doc["promoDollarOff"] == 22
assert doc["promoMinSpend"] == 100
assert doc["promoPercentage"] is None
def test_promo_fields_default_to_none():
"""a product with no promo still carries the keys as None (uniform doc shape)"""
doc = convert_product_to_mongo(_product(original_price=200.0, current_price=150.0))
for key in ("promoCode", "promoPercentage", "promoDollarOff", "promoMinSpend"):
assert doc[key] is None
# percentage and channelId stay OUT of the doc on purpose
assert "percentage" not in doc
assert "channelId" not in doc
+126
View File
@@ -0,0 +1,126 @@
"""unit test: fan_out_group binds sibling tasks and surfaces errors unwrapped
run from the repo root:
uv run pytest tests/test_fan_out_group.py
offline — no network. These lock the contract monitors depend on: a bare
TaskGroup always raises an ExceptionGroup, which run_monitor_cycle would
misclassify (it matches neither the EXPECTED_TASK_ERRORS branch nor the
unexpected-Exception branch as intended), so the unwrapping is load-bearing and
not cosmetic.
"""
import asyncio
import aiohttp
import pytest
from monitor_utils.runner import fan_out_group
async def _boom(error: BaseException):
raise error
def test_child_error_surfaces_unwrapped():
"""A failing child arrives as itself, not wrapped in an ExceptionGroup."""
async def scenario():
async with fan_out_group() as group:
group.create_task(_boom(RuntimeError("child")))
await asyncio.sleep(0)
with pytest.raises(RuntimeError) as exc_info:
asyncio.run(scenario())
assert not isinstance(exc_info.value, BaseExceptionGroup)
assert str(exc_info.value) == "child"
def test_body_error_surfaces_unwrapped():
"""An error raised by the caller's own body propagates unchanged."""
async def ok():
await asyncio.sleep(0)
async def scenario():
async with fan_out_group() as group:
group.create_task(ok())
raise ValueError("body")
with pytest.raises(ValueError) as exc_info:
asyncio.run(scenario())
assert not isinstance(exc_info.value, BaseExceptionGroup)
def test_unexpected_error_is_preferred_over_an_expected_one():
"""first_error must not let a routine network error mask a real bug."""
async def scenario():
async with fan_out_group() as group:
group.create_task(_boom(aiohttp.ClientError("routine")))
await asyncio.sleep(0)
raise ValueError("real bug")
with pytest.raises(ValueError):
asyncio.run(scenario())
def test_early_return_still_awaits_children():
"""A `return` inside the body must not abandon an in-flight child task."""
finished = []
async def child():
await asyncio.sleep(0.01)
finished.append("done")
async def scenario():
async with fan_out_group() as group:
group.create_task(child())
return "returned"
assert asyncio.run(scenario()) == "returned"
assert finished == ["done"]
def test_parked_sibling_is_cancelled_when_the_body_fails():
"""The binding is the point: a child that never finishes must not hang.
Without the group, a create_task + `finally: await` shape would park here
forever. The deadline is the assertion — a hang fails this test.
"""
async def parked():
await asyncio.Event().wait()
async def scenario():
async with asyncio.timeout(2):
async with fan_out_group() as group:
group.create_task(parked())
await asyncio.sleep(0)
raise RuntimeError("body")
with pytest.raises(RuntimeError):
asyncio.run(scenario())
def test_children_run_concurrently_with_the_body():
"""Both siblings make progress; the group is not a sequencing device."""
order = []
async def child():
order.append("child-start")
await asyncio.sleep(0.01)
order.append("child-end")
async def scenario():
async with fan_out_group() as group:
group.create_task(child())
order.append("body")
await asyncio.sleep(0.02)
asyncio.run(scenario())
assert order == ["body", "child-start", "child-end"]
+107
View File
@@ -0,0 +1,107 @@
"""unit test: execute_pagination honours a caller-supplied shared semaphore
run from the repo root:
uv run pytest tests/test_pagination_semaphore.py
offline — no network. A fake page func records peak concurrency so we can
assert the fan-out limit actually holds, both for the shared-semaphore path
(one instance capping many categories at once) and a single call bounding one
category with its own semaphore.
"""
import asyncio
from monitor_utils.runner import execute_pagination
def _make_tracking_func(state: dict):
"""Return a page func that tracks peak simultaneous in-flight calls."""
async def func(session, offset, **kwargs):
state["live"] += 1
state["peak"] = max(state["peak"], state["live"])
await asyncio.sleep(0.01)
state["live"] -= 1
return func
def test_shared_semaphore_caps_total_concurrency_across_categories():
"""One shared semaphore bounds combined fan-out over concurrent categories.
Two categories paginate at once through the same semaphore(limit); without
sharing, each would build its own semaphore and the peak could reach twice
the limit. Peak must stay at or below the shared limit.
"""
limit = 3
shared = asyncio.Semaphore(limit)
state = {"live": 0, "peak": 0}
func = _make_tracking_func(state)
async def run():
await asyncio.gather(
execute_pagination(
"catA",
total_products=300,
products_per_page=30,
func=func,
session=None,
semaphore=shared,
),
execute_pagination(
"catB",
total_products=300,
products_per_page=30,
func=func,
session=None,
semaphore=shared,
),
)
asyncio.run(run())
assert state["peak"] <= limit, f"peak {state['peak']} exceeded shared limit {limit}"
def test_single_call_respects_its_semaphore():
"""A single call bounds its own fan-out to the supplied semaphore limit."""
limit = 2
state = {"live": 0, "peak": 0}
func = _make_tracking_func(state)
async def run():
await execute_pagination(
"cat",
total_products=300,
products_per_page=30,
func=func,
session=None,
semaphore=asyncio.Semaphore(limit),
)
asyncio.run(run())
assert state["peak"] <= limit, f"peak {state['peak']} over limit {limit}"
def test_omitted_semaphore_runs_unbounded():
"""Omitting the semaphore lets all pages fan out concurrently.
With no semaphore the only bound is the page count; pages 2..10 (nine pages)
should all be in flight at once, so peak concurrency reaches nine.
"""
state = {"live": 0, "peak": 0}
func = _make_tracking_func(state)
async def run():
await execute_pagination(
"cat",
total_products=300,
products_per_page=30,
func=func,
session=None,
)
asyncio.run(run())
assert state["peak"] == 9, f"expected all 9 pages concurrent, got {state['peak']}"
+25
View File
@@ -0,0 +1,25 @@
"""unit test: resolve_price_before_drop detects a drop from the prior stored price
run from the repo root:
uv run pytest tests/test_price_before_drop.py
pure and offline — no Mongo, no product construction; just the drop rule
add_products_to_database relies on
"""
from monitor_utils.database import resolve_price_before_drop
def test_drop_returns_previous_price():
"""a strictly lower new price is a drop; the prior price is returned"""
assert resolve_price_before_drop(200.0, 150.0) == 200.0
def test_non_drops_return_none():
"""no prior price, an unchanged price, or a rise are not drops"""
assert resolve_price_before_drop(None, 150.0) is None # new product
assert resolve_price_before_drop(150.0, 150.0) is None # unchanged
assert resolve_price_before_drop(150.0, 200.0) is None # price rose
assert resolve_price_before_drop(200.0, None) is None # missing new price
+147
View File
@@ -0,0 +1,147 @@
"""unit test: run_bounded_cycle session lifetime, budget, and pass-through
run from the repo root:
uv run pytest tests/test_run_bounded_cycle.py
offline — get_default_session is faked at the runner's namespace so no proxies
or network are touched; run_monitor_cycle underneath runs real. Gap: the real
session wiring (timeout/max_clients reaching curl) is covered by the monitors'
own pool-size tests, not here.
"""
import asyncio
import logging
import aiohttp
import pytest
from monitor_utils import runner
from monitor_utils.models import CycleResult
from monitor_utils.runner import run_bounded_cycle
class FakeSession:
def __init__(self):
self.closed = False
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
self.closed = True
def stub_session(monkeypatch) -> dict:
"""Fake get_default_session, recording each call's kwargs and session."""
calls = []
def fake(proxies, headers=None, timeout=None, max_clients=None):
session = FakeSession()
calls.append(
{
"headers": headers,
"timeout": timeout,
"max_clients": max_clients,
"session": session,
}
)
return session
monkeypatch.setattr(runner, "get_default_session", fake)
return calls
def test_returns_cycle_result_and_closes_session(monkeypatch):
calls = stub_session(monkeypatch)
async def task(category, session, collection, **kwargs):
pass
result = asyncio.run(run_bounded_cycle(["a", "b"], task, None, None))
assert result == CycleResult(successes=2, failures=0)
assert calls[0]["session"].closed
def test_session_params_reach_the_factory(monkeypatch):
calls = stub_session(monkeypatch)
async def task(category, session, collection, **kwargs):
pass
asyncio.run(
run_bounded_cycle(
["a"],
task,
None,
None,
headers={"referer": "https://x/"},
session_timeout=8,
max_clients=81,
)
)
assert calls[0]["headers"] == {"referer": "https://x/"}
assert calls[0]["timeout"] == 8
assert calls[0]["max_clients"] == 81
def test_expired_budget_returns_none_not_raise(monkeypatch, caplog):
"""A cycle past its budget is abandoned and logged, and the loop lives on."""
calls = stub_session(monkeypatch)
async def parked(category, session, collection, **kwargs):
await asyncio.Event().wait()
with caplog.at_level(logging.ERROR, logger="monitor_utils.runner"):
result = asyncio.run(
run_bounded_cycle(["a"], parked, None, None, cycle_timeout=0.05)
)
assert result is None
assert calls[0]["session"].closed
assert any(
r.message == "cycle exceeded budget, abandoning cycle" for r in caplog.records
)
def test_expected_task_error_counts_as_failure(monkeypatch):
stub_session(monkeypatch)
async def flaky(category, session, collection, **kwargs):
raise aiohttp.ClientError("routine")
result = asyncio.run(run_bounded_cycle(["a"], flaky, None, None))
assert result == CycleResult(successes=0, failures=1)
def test_unexpected_error_propagates_and_closes_session(monkeypatch):
"""fail loud: a real bug must escape the helper, not vanish into a log."""
calls = stub_session(monkeypatch)
async def broken(category, session, collection, **kwargs):
raise RuntimeError("bug")
with pytest.raises(RuntimeError):
asyncio.run(run_bounded_cycle(["a"], broken, None, None))
assert calls[0]["session"].closed
def test_each_call_opens_a_fresh_session(monkeypatch):
calls = stub_session(monkeypatch)
async def task(category, session, collection, **kwargs):
pass
async def two_cycles():
await run_bounded_cycle(["a"], task, None, None)
await run_bounded_cycle(["a"], task, None, None)
asyncio.run(two_cycles())
assert len(calls) == 2
assert calls[0]["session"] is not calls[1]["session"]
+135
View File
@@ -0,0 +1,135 @@
"""unit test: a session's close cannot park the caller indefinitely
run from the repo root:
uv run pytest tests/test_session_close_budget.py
offline — no network. curl_cffi's close awaits the multi handle with no timeout
of its own, so a wedged transfer would park whatever closes the session,
including an `async with` exit. These build a real session and wedge the
backend's close, asserting the bound lives on the session itself so every call
site gets it without arranging anything.
"""
import asyncio
import logging
from aioproxies import AioProxies
from monitor_utils import sessions
from monitor_utils.sessions import BoundedCloseSession, get_default_session
def _proxies(tmp_path) -> AioProxies:
"""A one-entry pool in the host:port:user:pass format monitors ship."""
proxy_file = tmp_path / "proxies.txt"
proxy_file.write_text("proxy.example.io:8080:user:secret\n")
return AioProxies.from_file(path=str(proxy_file))
async def _wedge_close(session) -> asyncio.Event:
"""Replace the backend's close with one that never returns.
Builds the backend session first so TLSSession.close actually reaches the
backend rather than short-circuiting on an unbuilt session.
"""
session.session # force lazy construction
started = asyncio.Event()
async def wedged(inner_session):
started.set()
await asyncio.sleep(3600)
session.backend.close = wedged
return started
async def _really_close(session, original_close):
"""Close for real, so the test does not leak the curl handle it allocated."""
session.backend.close = original_close
await session.close()
def test_close_is_abandoned_when_it_exceeds_the_budget(monkeypatch, caplog, tmp_path):
"""A close that never returns is abandoned and logged, not awaited forever."""
monkeypatch.setattr(sessions, "SESSION_CLOSE_TIMEOUT_SECONDS", 0.05)
proxies = _proxies(tmp_path)
async def run():
session = get_default_session(proxies, timeout=8)
original_close = session.backend.close
started = await _wedge_close(session)
# wait_for is the assertion: an unbounded close never returns here
await asyncio.wait_for(session.close(), timeout=5)
assert started.is_set()
await _really_close(session, original_close)
with caplog.at_level(logging.ERROR, logger="monitor_utils.sessions"):
asyncio.run(run())
assert any(
record.message == "session close exceeded budget, abandoning session"
for record in caplog.records
)
def test_async_with_exit_is_bounded(monkeypatch, caplog, tmp_path):
"""The bound applies through `async with`, which is how monitors close."""
monkeypatch.setattr(sessions, "SESSION_CLOSE_TIMEOUT_SECONDS", 0.05)
proxies = _proxies(tmp_path)
async def run():
session = get_default_session(proxies, timeout=8)
original_close = session.backend.close
started = await _wedge_close(session)
async with session:
pass
assert started.is_set()
await _really_close(session, original_close)
with caplog.at_level(logging.ERROR, logger="monitor_utils.sessions"):
asyncio.run(asyncio.wait_for(run(), timeout=5))
assert any(
record.message == "session close exceeded budget, abandoning session"
for record in caplog.records
)
def test_healthy_close_completes_and_is_not_logged(caplog, tmp_path):
"""The normal path closes fully and reports nothing."""
proxies = _proxies(tmp_path)
async def run():
session = get_default_session(proxies, timeout=8)
session.session # force lazy construction so close has work to do
await session.close()
return session
with caplog.at_level(logging.ERROR, logger="monitor_utils.sessions"):
session = asyncio.run(run())
assert session._is_closed()
assert not caplog.records
def test_get_default_session_returns_a_bounded_session(tmp_path):
"""The factory hands out the bounded type, not a bare TLSSession."""
proxies = _proxies(tmp_path)
async def run():
session = get_default_session(proxies, timeout=8)
try:
return isinstance(session, BoundedCloseSession)
finally:
await session.close()
assert asyncio.run(run())