Compare commits
4
Commits
0be6fa5d13
...
6b3bd33739
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3bd33739 | ||
|
|
b0319f6ada | ||
|
|
54881acaee | ||
|
|
136a67c16f |
No files matched your search
@@ -17,11 +17,13 @@ class DevelopmentMode(str, Enum):
|
|||||||
|
|
||||||
def is_production(development_mode: DevelopmentMode) -> bool:
|
def is_production(development_mode: DevelopmentMode) -> bool:
|
||||||
"""Return True when the mode is PRODUCTION."""
|
"""Return True when the mode is PRODUCTION."""
|
||||||
|
|
||||||
return development_mode == DevelopmentMode.PRODUCTION
|
return development_mode == DevelopmentMode.PRODUCTION
|
||||||
|
|
||||||
|
|
||||||
def get_bot_token(is_production: bool) -> str:
|
def get_bot_token(is_production: bool) -> str:
|
||||||
"""Return the production or development bot token for the mode."""
|
"""Return the production or development bot token for the mode."""
|
||||||
|
|
||||||
if is_production:
|
if is_production:
|
||||||
return PRODUCTION_BOT_TOKEN
|
return PRODUCTION_BOT_TOKEN
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ def get_config(
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of the parsed config dict and the resolved config path.
|
Tuple of the parsed config dict and the resolved config path.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
monitor_name = monitor_name.replace(".json", "")
|
monitor_name = monitor_name.replace(".json", "")
|
||||||
base = config_dir if use_production_path else test_config_dir
|
base = config_dir if use_production_path else test_config_dir
|
||||||
config_path = pathlib.Path(base) / f"{monitor_name}.json"
|
config_path = pathlib.Path(base) / f"{monitor_name}.json"
|
||||||
@@ -54,5 +57,6 @@ def get_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):
|
||||||
"""Set the given loggers to the given level to quiet noisy libraries."""
|
"""Set the given loggers to the given level to quiet noisy libraries."""
|
||||||
|
|
||||||
for name in loggers:
|
for name in loggers:
|
||||||
logging.getLogger(name).setLevel(level)
|
logging.getLogger(name).setLevel(level)
|
||||||
@@ -6,6 +6,7 @@ and the expected error tuple used for known-failure handling.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from enum import Enum
|
||||||
from json import JSONDecodeError
|
from json import JSONDecodeError
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -58,10 +59,6 @@ BUCKET_WEBHOOK_MAP = {
|
|||||||
90: "90-100",
|
90: "90-100",
|
||||||
"price_drop": "40-49",
|
"price_drop": "40-49",
|
||||||
}
|
}
|
||||||
STATUS_PENDING = "pending"
|
|
||||||
STATUS_SENT = "sent"
|
|
||||||
STATUS_SKIPPED = "skipped"
|
|
||||||
STATUS_FAILED = "failed"
|
|
||||||
|
|
||||||
EXPECTED_TASK_ERRORS = (
|
EXPECTED_TASK_ERRORS = (
|
||||||
RequestsError,
|
RequestsError,
|
||||||
@@ -72,3 +69,12 @@ EXPECTED_TASK_ERRORS = (
|
|||||||
PyMongoError,
|
PyMongoError,
|
||||||
JSONDecodeError,
|
JSONDecodeError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductStatus(str, Enum):
|
||||||
|
"""Lifecycle status for a monitored product document."""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
SENT = "sent"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
FAILED = "failed"
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
"""Database helpers for upserting monitored products and tracking price history.
|
||||||
|
|
||||||
|
Provides the shared Motor client, collection access, and the upsert path that
|
||||||
|
rewrites current product fields while preserving prior prices in a pastPrices
|
||||||
|
map and stamping lastUpdated on every write.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -7,18 +14,33 @@ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
|
|||||||
from pymongo import UpdateOne
|
from pymongo import UpdateOne
|
||||||
from pymongo.errors import PyMongoError
|
from pymongo.errors import PyMongoError
|
||||||
|
|
||||||
from monitor_utils.constants import MONGO_DATABASE_NAME, MONGO_URL, STATUS_PENDING
|
from monitor_utils.constants import ProductStatus, 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__)
|
||||||
|
|
||||||
|
|
||||||
def get_collection(collection_name: str) -> AsyncIOMotorCollection:
|
def get_collection(collection_name: str) -> AsyncIOMotorCollection:
|
||||||
|
"""Return the named collection from the shared client's database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection_name: Name of the collection to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The requested collection.
|
||||||
|
"""
|
||||||
|
|
||||||
return get_mongo_client()[MONGO_DATABASE_NAME][collection_name]
|
return get_mongo_client()[MONGO_DATABASE_NAME][collection_name]
|
||||||
|
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
def get_mongo_client() -> AsyncIOMotorClient:
|
def get_mongo_client() -> AsyncIOMotorClient:
|
||||||
|
"""Return a cached Motor client configured for the shared cluster.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The pooled, timezone-aware Motor client.
|
||||||
|
"""
|
||||||
|
|
||||||
return AsyncIOMotorClient(
|
return AsyncIOMotorClient(
|
||||||
MONGO_URL,
|
MONGO_URL,
|
||||||
maxPoolSize=200,
|
maxPoolSize=200,
|
||||||
@@ -34,6 +56,8 @@ async def get_product_from_database(
|
|||||||
collection: AsyncIOMotorCollection,
|
collection: AsyncIOMotorCollection,
|
||||||
product_id: str,
|
product_id: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""Fetch a single product document by its _id."""
|
||||||
|
|
||||||
return await collection.find_one({"_id": product_id})
|
return await collection.find_one({"_id": product_id})
|
||||||
|
|
||||||
|
|
||||||
@@ -41,18 +65,38 @@ async def add_products_to_database(
|
|||||||
products: list[Product],
|
products: list[Product],
|
||||||
collection: AsyncIOMotorCollection,
|
collection: AsyncIOMotorCollection,
|
||||||
category_identifier: str,
|
category_identifier: str,
|
||||||
|
use_composite: bool = False,
|
||||||
):
|
):
|
||||||
|
"""Upsert products, tracking price history and last-updated time.
|
||||||
|
|
||||||
|
Rewrites current fields on each upsert while preserving prior prices in a
|
||||||
|
pastPrices map keyed by epoch-millisecond timestamps, appending only when
|
||||||
|
the price differs from the stored value. Flags dropped prices as pending
|
||||||
|
and records the drop's prior price.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
products: Products to upsert.
|
||||||
|
collection: Target MongoDB collection.
|
||||||
|
category_identifier: Category suffix appended to composite ids.
|
||||||
|
use_composite: Build composite ids so multi-variant monitors (Woot)
|
||||||
|
keep one doc per variant; other monitors collapse to one doc per
|
||||||
|
product_id.
|
||||||
|
"""
|
||||||
if not products:
|
if not products:
|
||||||
return
|
return
|
||||||
|
|
||||||
composite_ids = [f"{p.product_id}_{category_identifier}" for p in products]
|
doc_ids = [
|
||||||
cursor = collection.find({"_id": {"$in": composite_ids}}, {"currentPrice": 1})
|
build_doc_id(p.product_id, category_identifier, use_composite) for p in products
|
||||||
|
]
|
||||||
|
cursor = collection.find({"_id": {"$in": doc_ids}}, {"currentPrice": 1})
|
||||||
existing_prices = {str(doc["_id"]): doc.get("currentPrice") async for doc in cursor}
|
existing_prices = {str(doc["_id"]): doc.get("currentPrice") async for doc in cursor}
|
||||||
|
|
||||||
ops = []
|
ops = []
|
||||||
for product in products:
|
now = datetime.now(timezone.utc)
|
||||||
|
price_key = str(int(now.timestamp() * 1000))
|
||||||
|
for product, doc_id in zip(products, doc_ids):
|
||||||
document = convert_product_to_mongo(product, category_identifier)
|
document = convert_product_to_mongo(product, category_identifier)
|
||||||
doc_id = document["_id"]
|
document["_id"] = doc_id
|
||||||
old_price = existing_prices.get(doc_id)
|
old_price = existing_prices.get(doc_id)
|
||||||
new_price = document.get("currentPrice")
|
new_price = document.get("currentPrice")
|
||||||
|
|
||||||
@@ -60,20 +104,18 @@ async def add_products_to_database(
|
|||||||
new_price is not None and new_price < old_price
|
new_price is not None and new_price < old_price
|
||||||
)
|
)
|
||||||
|
|
||||||
set_on_insert = {"discoveredDate": datetime.now(timezone.utc)}
|
set_on_insert = {"discoveredDate": now}
|
||||||
if price_dropped:
|
if price_dropped:
|
||||||
document["status"] = STATUS_PENDING
|
document["status"] = ProductStatus.PENDING
|
||||||
document["priceBeforeDrop"] = old_price
|
document["priceBeforeDrop"] = old_price
|
||||||
else:
|
else:
|
||||||
set_on_insert["status"] = STATUS_PENDING
|
set_on_insert["status"] = ProductStatus.PENDING
|
||||||
|
|
||||||
ops.append(
|
update = {"$set": document, "$setOnInsert": set_on_insert}
|
||||||
UpdateOne(
|
if new_price is not None and new_price != old_price:
|
||||||
{"_id": doc_id},
|
update["$set"][f"pastPrices.{price_key}"] = new_price
|
||||||
{"$set": document, "$setOnInsert": set_on_insert},
|
|
||||||
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)
|
||||||
@@ -85,7 +127,35 @@ async def add_products_to_database(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def convert_product_to_mongo(product: Product, category_identifier: str) -> dict:
|
def build_doc_id(
|
||||||
|
product_id: str, category_identifier: str, use_composite: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Return the composite id when use_composite is set, else the bare product id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
product_id: Retailer-specific product identifier.
|
||||||
|
category_identifier: Category suffix for multi-variant monitors.
|
||||||
|
use_composite: Append category_identifier to disambiguate variants.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The composite id when use_composite is set, otherwise product_id.
|
||||||
|
"""
|
||||||
|
return f"{product_id}_{category_identifier}" if use_composite else product_id
|
||||||
|
|
||||||
|
|
||||||
|
def convert_product_to_mongo(product: Product) -> dict:
|
||||||
|
"""Flatten a Product into its MongoDB document representation.
|
||||||
|
|
||||||
|
Coerces string prices to floats and stamps lastUpdated with the current
|
||||||
|
UTC time. The _id is assigned by the caller via build_doc_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
product: Product to convert.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The MongoDB document dict for the product.
|
||||||
|
"""
|
||||||
|
|
||||||
data = asdict(product)
|
data = asdict(product)
|
||||||
metadata = data["metadata"]
|
metadata = data["metadata"]
|
||||||
timing = data["timing"]
|
timing = data["timing"]
|
||||||
@@ -98,7 +168,6 @@ def convert_product_to_mongo(product: Product, category_identifier: str) -> dict
|
|||||||
original_price = float(original_price.replace(",", ""))
|
original_price = float(original_price.replace(",", ""))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"_id": f"{data['product_id']}_{category_identifier}",
|
|
||||||
"title": data["title"],
|
"title": data["title"],
|
||||||
"url": data["url"],
|
"url": data["url"],
|
||||||
"currentPrice": current_price,
|
"currentPrice": current_price,
|
||||||
@@ -117,4 +186,5 @@ def convert_product_to_mongo(product: Product, category_identifier: str) -> dict
|
|||||||
"appExclusive": availability["app_exclusive"],
|
"appExclusive": availability["app_exclusive"],
|
||||||
"memberPick": availability["member_pick"],
|
"memberPick": availability["member_pick"],
|
||||||
"memberDeal": availability["member_deal"],
|
"memberDeal": availability["member_deal"],
|
||||||
|
"lastUpdated": datetime.now(timezone.utc),
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,7 @@ def init_monitor(
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of category names, Mongo collection, and loaded proxies.
|
Tuple of category names, Mongo collection, and loaded proxies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use_production = is_production(development_mode)
|
use_production = is_production(development_mode)
|
||||||
setup_logging(
|
setup_logging(
|
||||||
name="logs/latest.log",
|
name="logs/latest.log",
|
||||||
@@ -90,6 +91,7 @@ def setup_config(
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of category names, channel id mapping, and Mongo collection.
|
Tuple of category names, channel id mapping, and Mongo collection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use_production = is_production(development_mode)
|
use_production = is_production(development_mode)
|
||||||
config, _ = get_config(
|
config, _ = get_config(
|
||||||
monitor_name=monitor_name,
|
monitor_name=monitor_name,
|
||||||
@@ -122,6 +124,7 @@ def get_channel_id(
|
|||||||
Returns:
|
Returns:
|
||||||
The resolved channel id.
|
The resolved channel id.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if product.availability.app_exclusive:
|
if product.availability.app_exclusive:
|
||||||
return get_single_channel_id(channel_ids["appExclusive"])
|
return get_single_channel_id(channel_ids["appExclusive"])
|
||||||
|
|
||||||
@@ -147,6 +150,7 @@ def get_channel_id(
|
|||||||
|
|
||||||
def get_single_channel_id(value: str | list[str]) -> str:
|
def get_single_channel_id(value: str | list[str]) -> str:
|
||||||
"""Return the first channel id when given a list, else the value itself."""
|
"""Return the first channel id when given a list, else the value itself."""
|
||||||
|
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return value[0]
|
return value[0]
|
||||||
|
|
||||||
@@ -174,6 +178,7 @@ def build_product_details(
|
|||||||
Returns:
|
Returns:
|
||||||
The built Product, or None when skipped or below thresholds.
|
The built Product, or None when skipped or below thresholds.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
product_id = product["_id"]
|
product_id = product["_id"]
|
||||||
current_price = product["currentPrice"]
|
current_price = product["currentPrice"]
|
||||||
original_price = product["originalPrice"]
|
original_price = product["originalPrice"]
|
||||||
@@ -259,6 +264,7 @@ async def execute_monitor_logic(
|
|||||||
use_tls: Use the TLS fingerprint session instead of the base session.
|
use_tls: Use the TLS fingerprint session instead of the base session.
|
||||||
**kwargs: Forwarded verbatim to pre_task_func and every task_func call.
|
**kwargs: Forwarded verbatim to pre_task_func and every task_func call.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
session_ctx = (
|
session_ctx = (
|
||||||
get_tls_session(
|
get_tls_session(
|
||||||
@@ -344,6 +350,7 @@ async def execute_pagination(
|
|||||||
query_category_id) must be supplied here, since naming varies
|
query_category_id) must be supplied here, since naming varies
|
||||||
per monitor.
|
per monitor.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
LOGGER.info("executing pagination", extra={"category": category_identifier})
|
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)
|
semaphore = asyncio.Semaphore(max_concurrent_tasks)
|
||||||
@@ -410,6 +417,7 @@ async def fetch_all_pages(
|
|||||||
Returns:
|
Returns:
|
||||||
Page numbers whose fetch raised an expected error.
|
Page numbers whose fetch raised an expected error.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
tasks = [fetch_func(page) for page in page_range]
|
tasks = [fetch_func(page) for page in page_range]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
return handle_page_results(
|
return handle_page_results(
|
||||||
@@ -440,8 +448,8 @@ def handle_page_results(
|
|||||||
Returns:
|
Returns:
|
||||||
Page numbers whose fetch raised an expected error.
|
Page numbers whose fetch raised an expected error.
|
||||||
"""
|
"""
|
||||||
failed_pages = []
|
|
||||||
|
|
||||||
|
failed_pages = []
|
||||||
for page_number, result in zip(page_numbers, results):
|
for page_number, result in zip(page_numbers, results):
|
||||||
if isinstance(result, EXPECTED_TASK_ERRORS):
|
if isinstance(result, EXPECTED_TASK_ERRORS):
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
@@ -503,6 +511,7 @@ async def retry_failed_pages(
|
|||||||
Returns:
|
Returns:
|
||||||
Page numbers still failing after all retries.
|
Page numbers still failing after all retries.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
retry_attempt = 1
|
retry_attempt = 1
|
||||||
while failed_pages and retry_attempt <= max_retries:
|
while failed_pages and retry_attempt <= max_retries:
|
||||||
await asyncio.sleep(2**retry_attempt)
|
await asyncio.sleep(2**retry_attempt)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ async def get_base_session(
|
|||||||
Yields:
|
Yields:
|
||||||
The configured aiohttp session.
|
The configured aiohttp session.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
headers = headers if headers else {}
|
headers = headers if headers else {}
|
||||||
headers.setdefault("User-Agent", UA.random)
|
headers.setdefault("User-Agent", UA.random)
|
||||||
session = aiohttp.ClientSession(
|
session = aiohttp.ClientSession(
|
||||||
@@ -95,6 +96,7 @@ async def get_tls_session(
|
|||||||
Yields:
|
Yields:
|
||||||
The configured curl_cffi session.
|
The configured curl_cffi session.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
headers = headers if headers else {}
|
headers = headers if headers else {}
|
||||||
headers.setdefault("User-Agent", UA.chrome)
|
headers.setdefault("User-Agent", UA.chrome)
|
||||||
async with AsyncSession(
|
async with AsyncSession(
|
||||||
@@ -115,6 +117,7 @@ def build_session_details(
|
|||||||
session: Session whose headers are updated in place.
|
session: Session whose headers are updated in place.
|
||||||
headers: Headers to merge; User-Agent is defaulted if absent.
|
headers: Headers to merge; User-Agent is defaulted if absent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
headers = headers or {}
|
headers = headers or {}
|
||||||
headers.setdefault("User-Agent", UA.random)
|
headers.setdefault("User-Agent", UA.random)
|
||||||
session.headers.update(headers)
|
session.headers.update(headers)
|
||||||
@@ -174,6 +177,7 @@ async def send_request(
|
|||||||
json.JSONDecodeError: When parsing fails and reraise_json_error is
|
json.JSONDecodeError: When parsing fails and reraise_json_error is
|
||||||
True.
|
True.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
verify = True
|
verify = True
|
||||||
if development_proxy:
|
if development_proxy:
|
||||||
proxy = development_proxy
|
proxy = development_proxy
|
||||||
|
|||||||
Reference in new issue
Block a user