added .env.local support

This commit is contained in:
tpv committed 2026-06-16 18:53:49 -07:00
commit 25632e1938
10 files changed
+831

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
.venv
__pycache__
*.pyc
.vscode
.idea
*.md
.env.local
View File
Whitespace-only changes.
+49
View File
@@ -0,0 +1,49 @@
import json
from enum import Enum
from functools import cache
from pydantic_settings import BaseSettings, SettingsConfigDict
from monitor_utils.constants import PROXIES_PATH, PROJECT_ROOT
class DevelopmentMode(str, Enum):
PRODUCTION = "PRODUCTION"
DEVELOPMENT = "DEVELOPMENT"
class Settings(BaseSettings):
mongo_url: str
mongo_database_name: str
production_bot_token: str
profit_api_key: str
development_bot_token: str | None = None
logging_level: str = "INFO"
development_mode: DevelopmentMode = DevelopmentMode.PRODUCTION
model_config = SettingsConfigDict(env_file=(".env", ".env.local"), extra="ignore")
@cache
def get_settings() -> Settings:
return Settings()
@cache
def get_proxies() -> list[str]:
with PROXIES_PATH.open() as f:
proxies = [f"http://{p.strip()}" for p in f if p.strip()]
if not proxies:
raise ValueError("Proxy list cannot be empty")
return proxies
def get_config(config_name: str, use_production_path: bool = False) -> dict:
config_name = config_name.replace(".json", "")
if use_production_path:
config_path = PROJECT_ROOT / "configs" / f"{config_name}.json"
else:
config_path = PROJECT_ROOT / "tests" / "configs" / f"{config_name}.json"
with open(config_path) as config:
return json.load(config)
+43
View File
@@ -0,0 +1,43 @@
import asyncio
import pathlib
import aiohttp
import requests
from curl_cffi.requests.errors import RequestsError
from pymongo.errors import PyMongoError
PROJECT_ROOT = pathlib.Path(".")
PROXIES_PATH = pathlib.Path("proxies.txt")
RETRY_LIMIT = 5
RETRY_WAIT = 3
TIMEOUT_SECONDS = 30
CLIENT_IDENTIFIERS = ["chrome", "chrome_android", "safari", "safari_ios"]
CLIENT_TIMEOUT = aiohttp.ClientTimeout(
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",
}
MONITOR_NAMES = ["bestbuy", "woot", "lg", "hp", "samsung"]
EXPECTED_TASK_ERRORS = (
RequestsError,
aiohttp.ClientError,
asyncio.TimeoutError,
requests.Timeout,
requests.HTTPError,
PyMongoError,
)
+121
View File
@@ -0,0 +1,121 @@
import logging
from dataclasses import asdict
from datetime import datetime, timezone
from functools import cache
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
from pymongo import UpdateOne
from pymongo.errors import PyMongoError
from monitor_utils.config import get_settings
from monitor_utils.models import Product
LOGGER = logging.getLogger(__name__)
def get_collection(collection_name: str) -> AsyncIOMotorCollection:
settings = get_settings()
return get_mongo_client()[settings.mongo_database_name][collection_name]
@cache
def get_mongo_client() -> AsyncIOMotorClient:
settings = get_settings()
return AsyncIOMotorClient(
settings.mongo_url,
maxPoolSize=200,
minPoolSize=10,
retryWrites=True,
retryReads=True,
tz_aware=True,
tzinfo=timezone.utc,
)
async def get_product_from_database(
collection: AsyncIOMotorCollection,
product_id: str,
) -> dict | None:
return await collection.find_one({"_id": product_id})
async def add_products_to_database(
products: list[Product],
collection: AsyncIOMotorCollection,
category_identifier: str,
) -> None:
if not products:
return
composite_ids = [f"{p.product_id}_{category_identifier}" for p in products]
cursor = collection.find({"_id": {"$in": composite_ids}}, {"currentPrice": 1})
existing_prices = {str(doc["_id"]): doc.get("currentPrice") async for doc in cursor}
ops = []
for product in products:
document = convert_product_to_mongo(product, category_identifier)
doc_id = document["_id"]
old_price = existing_prices.get(doc_id)
new_price = document.get("currentPrice")
price_dropped = old_price is None or (
new_price is not None and new_price < old_price
)
if price_dropped:
document["webhookSent"] = False
document["priceBeforeDrop"] = old_price
set_on_insert = {"discoveredDate": datetime.now(timezone.utc)}
if not price_dropped:
set_on_insert["webhookSent"] = False
ops.append(
UpdateOne(
{"_id": doc_id},
{"$set": document, "$setOnInsert": set_on_insert},
upsert=True,
)
)
try:
await collection.bulk_write(ops, ordered=False)
except PyMongoError:
LOGGER.exception(f"{category_identifier} - Failed to add products to database!")
raise
def convert_product_to_mongo(product: Product, category_identifier: str) -> dict:
data = asdict(product)
metadata = data["metadata"]
timing = data["timing"]
pricing = data["pricing"]
availability = data["availability"]
current_price = pricing["current_price"]
original_price = pricing["original_price"]
if isinstance(current_price, str):
current_price = float(current_price.replace(",", ""))
original_price = float(original_price.replace(",", ""))
return {
"_id": f"{data['product_id']}_{category_identifier}",
"title": data["title"],
"url": data["url"],
"currentPrice": current_price,
"originalPrice": original_price,
"percentage": metadata["percentage"],
"imageUrl": metadata["image_url"],
"customizationIndex": metadata["customization_index"],
"memberNonMemberComparison": metadata["member_non_member_comparison"],
"overstockText": metadata["overstock_text"],
"startDate": timing["start_date"],
"endDate": timing["end_date"],
"priceBeforeDrop": pricing["price_before_drop"],
"eduPrice": pricing["edu_price"],
"available": availability["available"],
"condition": availability["condition"],
"appExclusive": availability["app_exclusive"],
"memberPick": availability["member_pick"],
"memberDeal": availability["member_deal"],
}
+97
View File
@@ -0,0 +1,97 @@
import asyncio
from typing import Dict
import httpx
"""
Minimal usage:
from dsql_shortlinks import generate_short_link_sync
result = generate_short_link_sync(
"https://example.com/product",
api_key="YOUR_DSQL_KEY",
)
print(result["short_url"])
"""
DEFAULT_BASE_URL = "https://api.profitlounge.us/link/affiliate"
class DsqlShortlinks:
def __init__(
self,
api_key: str,
*,
base_url: str = DEFAULT_BASE_URL,
timeout: int = 60,
) -> None:
self.api_key = api_key
self.base_url = base_url
self.timeout = timeout
async def create_short_link(self, url: str) -> Dict[str, str]:
headers = {"x-api-key": self.api_key}
payload = {"url": url}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
self.base_url,
headers=headers,
json=payload,
timeout=self.timeout,
)
response.raise_for_status()
data = response.json()
if isinstance(data, dict) and data.get("short_url"):
return data
except Exception:
pass
return {"short_url": url, "long_url": url}
def create_short_link_sync(self, url: str) -> Dict[str, str]:
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
if running_loop and running_loop.is_running():
raise RuntimeError(
"create_short_link_sync() cannot run inside an active event loop. "
"Use await create_short_link(...) instead."
)
return asyncio.run(self.create_short_link(url))
async def generate_short_link(
url: str, api_key: str, timeout: int = 60
) -> Dict[str, str]:
client = DsqlShortlinks(api_key, timeout=timeout)
return await client.create_short_link(url)
def generate_short_link_sync(
url: str, api_key: str, timeout: int = 60
) -> Dict[str, str]:
client = DsqlShortlinks(api_key, timeout=timeout)
return client.create_short_link_sync(url)
if __name__ == "__main__":
EXAMPLE_URL = "https://woot.com/"
EXAMPLE_API_KEY = "YOUR_DSQL_KEY"
print("Example:")
print("from dsql_shortlinks import generate_short_link_sync")
print()
print("result = generate_short_link_sync(")
print(f' "{EXAMPLE_URL}",')
print(f' api_key="{EXAMPLE_API_KEY}",')
print(")")
print()
print('print(result["short_url"])')
+49
View File
@@ -0,0 +1,49 @@
from datetime import datetime
from dataclasses import field
from pydantic.dataclasses import dataclass
@dataclass
class PricingInfo:
original_price: float
current_price: float
price_before_drop: float | None = None
edu_price: float | None = None
@dataclass
class AvailabilityInfo:
available: bool | None = None
condition: str | None = None
app_exclusive: bool | None = None
member_pick: bool | None = None
member_deal: bool | None = None
@dataclass
class ProductMetadata:
image_url: str | None = None
channel_id: str | None = None
customization_index: str | None = None
member_non_member_comparison: str | None = None
overstock_text: str | None = None
percentage: int | None = None
affiliate_url: str | None = None
@dataclass
class ProductTiming:
discovered_date: datetime | None = None
start_date: datetime | None = None
end_date: datetime | None = None
@dataclass
class Product:
product_id: str
title: str
url: str
metadata: ProductMetadata = field(default_factory=ProductMetadata)
timing: ProductTiming = field(default_factory=ProductTiming)
pricing: PricingInfo = field(default_factory=PricingInfo)
availability: AvailabilityInfo = field(default_factory=AvailabilityInfo)
+320
View File
@@ -0,0 +1,320 @@
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable, Iterable
import aiohttp
from curl_cffi.requests import AsyncSession
from motor.motor_asyncio import AsyncIOMotorCollection
from monitor_utils.constants import (
EXPECTED_TASK_ERRORS,
FLAG_WEBHOOK_MAP,
BUCKET_WEBHOOK_MAP,
)
from monitor_utils.config import get_settings, get_config
from monitor_utils.models import (
Product,
ProductMetadata,
ProductTiming,
PricingInfo,
AvailabilityInfo,
)
from monitor_utils.sessions import get_base_session, get_tls_session
from monitor_utils.database import get_collection
LOGGER = logging.getLogger(__name__)
def configure_logging():
logging.basicConfig(
level=get_settings().logging_level,
format="%(asctime)s - %(levelname)s - %(module)s - %(funcName)s - %(message)s",
)
def setup_config(config_name: str) -> tuple[list[str], dict, AsyncIOMotorCollection]:
is_production = get_settings().development_mode == "PRODUCTION"
config = get_config(
config_name=config_name,
use_production_path=is_production,
)
category_names = config["categories"]
collection = get_collection(
config_name if is_production else f"{config_name}_testing"
)
return category_names, config, collection
def get_channel_id(
product: Product, channel_ids: dict, use_price_drop_bucket: bool = False
) -> str:
# KeyError is not a issue here bc the percentage filtering per monitor handles it
if product.availability.app_exclusive:
return get_single_channel_id(channel_ids["appExclusive"])
if product.availability.available is False:
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:
if isinstance(value, list):
return value[0]
return value
def build_product_details(
product: dict,
channel_ids: dict,
min_percentage: int,
min_price_drop: float | None = None,
) -> Product | None:
product_id = product["_id"]
current_price = product["currentPrice"]
original_price = product["originalPrice"]
if not original_price:
LOGGER.warning(f"{product_id} - Missing or zero original price, skipping")
return
percentage = int(((original_price - current_price) / original_price) * 100)
price_drop = original_price - current_price
passes_percentage = 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(
product_id=product_id,
title=product["title"],
url=product["url"],
metadata=ProductMetadata(
image_url=product["imageUrl"],
customization_index=product["customizationIndex"],
member_non_member_comparison=product["memberNonMemberComparison"],
overstock_text=product["overstockText"],
percentage=percentage,
affiliate_url=product.get("affiliateUrl"),
),
timing=ProductTiming(
discovered_date=product["discoveredDate"],
start_date=product["startDate"],
end_date=product["endDate"],
),
pricing=PricingInfo(
current_price=current_price,
original_price=original_price,
price_before_drop=product["priceBeforeDrop"],
edu_price=product["eduPrice"],
),
availability=AvailabilityInfo(
available=product["available"],
condition=product["condition"],
app_exclusive=product["appExclusive"],
member_pick=product["memberPick"],
member_deal=product["memberDeal"],
),
)
built.metadata.channel_id = get_channel_id(
built, channel_ids, passes_price_drop and not passes_percentage
)
return built
async def execute_monitor_logic(
category_identifiers: list[str] | dict[str, str],
task_func: Callable[..., Awaitable[None]],
headers: dict | None = None,
cookies: dict | None = None,
pre_task_func: Callable[..., Awaitable[None]] | None = None,
use_tls: bool = False,
**kwargs,
):
while True:
session_ctx = (
get_tls_session(headers=headers)
if use_tls
else get_base_session(headers=headers, cookies=cookies)
)
async with session_ctx as session:
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):
tasks = [
task_func(category_id, session, query_category_id=query, **kwargs)
for category_id, query in category_identifiers.items()
]
category_keys = list(category_identifiers.keys())
else:
tasks = [
task_func(category_identifier, session, **kwargs)
for category_identifier in category_identifiers
]
category_keys = category_identifiers
results = await asyncio.gather(*tasks, return_exceptions=True)
for category_identifier, result in zip(category_keys, results):
if isinstance(result, EXPECTED_TASK_ERRORS):
LOGGER.warning(
f"{category_identifier} - Failed to get products",
exc_info=result,
)
elif isinstance(result, Exception):
LOGGER.error(
f"{category_identifier} - Unexpected error",
exc_info=result,
)
raise result
LOGGER.info(
"Sleeping 0.30 seconds before next tasks"
) # Might add option to increase/decrease this delay
await asyncio.sleep(0.30)
async def execute_pagination(
category_identifier: str,
total_products: int,
products_per_page: int,
func: Callable[..., Awaitable[None]],
session: aiohttp.ClientSession | AsyncSession,
max_concurrent_tasks: int = 5,
start_offset: int = 24,
pagination_mode: str = "offset",
**kwargs,
):
LOGGER.info(f"{category_identifier} - executing pagination")
total_pages = (int(total_products) + products_per_page - 1) // products_per_page
semaphore = asyncio.Semaphore(max_concurrent_tasks)
stop_event = asyncio.Event()
pagination_label = "offset" if pagination_mode == "offset" else "page"
page_range = range(2, total_pages + 1)
async def fetch_page(page_index: int):
async with semaphore:
if stop_event.is_set():
return
if pagination_mode == "offset":
current_offset = start_offset + page_index * products_per_page
await func(
session=session,
offset=current_offset,
**kwargs,
)
else:
await func(
session=session,
page_number=page_index,
**kwargs,
)
failed_pages = await fetch_all_pages(
page_range, fetch_page, category_identifier, pagination_label
)
if failed_pages and not stop_event.is_set():
failed_pages = await retry_failed_pages(
failed_pages, fetch_page, category_identifier, pagination_label
)
if failed_pages and not stop_event.is_set():
LOGGER.error(
f"{category_identifier} - failed to get products after retries: "
f"{pagination_label}s {failed_pages}"
)
if stop_event.is_set():
LOGGER.info(f"{category_identifier} - pagination stopped early")
async def fetch_all_pages(
page_range: Iterable[int],
fetch_func: Callable[[int], Awaitable[None]],
category_identifier: str,
pagination_label: str,
) -> list[int]:
tasks = [fetch_func(page) for page in page_range]
results = await asyncio.gather(*tasks, return_exceptions=True)
return handle_page_results(
list(page_range), results, category_identifier, pagination_label
)
def handle_page_results(
page_numbers: list[int],
results: list,
category_identifier: str,
pagination_label: str,
retry_attempt: int | None = None,
) -> list[int]:
failed_pages = []
retry_suffix = f" Retry {retry_attempt} " if retry_attempt else " "
for page_number, result in zip(page_numbers, results):
if isinstance(result, EXPECTED_TASK_ERRORS):
LOGGER.warning(
f"{category_identifier} - {pagination_label} {page_number} -{retry_suffix} failed",
exc_info=result,
)
failed_pages.append(page_number)
elif isinstance(result, Exception):
LOGGER.error(
f"{category_identifier} - {pagination_label} {page_number} -{retry_suffix} unexpected error",
exc_info=result,
)
raise result
elif retry_attempt:
LOGGER.info(
f"{category_identifier} - {pagination_label} {page_number} - retry {retry_attempt} succeeded"
)
return failed_pages
async def retry_failed_pages(
failed_pages: list[int],
fetch_func: Callable[[int], Awaitable[None]],
category_identifier: str,
pagination_label: str,
max_retries: int = 3,
) -> list[int]:
retry_attempt = 1
while failed_pages and retry_attempt <= max_retries:
await asyncio.sleep(2**retry_attempt)
LOGGER.info(
f"{category_identifier} - retrying {len(failed_pages)} failed {pagination_label}s "
f"(attempt {retry_attempt}/{max_retries})"
)
tasks = [fetch_func(page_number) for page_number in failed_pages]
results = await asyncio.gather(*tasks, return_exceptions=True)
failed_pages = handle_page_results(
failed_pages, results, category_identifier, pagination_label, retry_attempt
)
retry_attempt += 1
return failed_pages
+133
View File
@@ -0,0 +1,133 @@
# ik ua/tls doesn't match headers/header order but targeted antibots don't seem to care might fix later
import asyncio
import random
import json
import logging
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import aiohttp
import requests
from curl_cffi.requests import AsyncSession
from curl_cffi.requests.errors import RequestsError
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.config import get_proxies
from monitor_utils.constants import (
CLIENT_TIMEOUT,
TIMEOUT_SECONDS,
CLIENT_IDENTIFIERS,
RETRY_LIMIT,
RETRY_WAIT,
)
LOGGER = logging.getLogger(__name__)
UA = FakeUserAgent()
@asynccontextmanager
async def get_base_session(
headers: dict | None = None,
cookies: dict | None = None,
connector: aiohttp.TCPConnector | None = None,
timeout: aiohttp.ClientTimeout = CLIENT_TIMEOUT,
) -> AsyncGenerator[aiohttp.ClientSession, None]:
headers = headers if headers else {}
headers.setdefault("User-Agent", UA.random)
session = aiohttp.ClientSession(
headers=headers, cookies=cookies, connector=connector, timeout=timeout
)
try:
yield session
finally:
await session.close()
@asynccontextmanager
async def get_tls_session(
headers: dict | None = None, timeout: int = TIMEOUT_SECONDS
) -> AsyncGenerator[AsyncSession, None]:
headers = headers if headers else {}
headers.setdefault("User-Agent", UA.chrome)
async with AsyncSession(
impersonate=random.choice(CLIENT_IDENTIFIERS),
timeout=timeout,
headers=headers,
) as session:
yield session
def build_session_details(
session: aiohttp.ClientSession | AsyncSession, headers: dict = None
):
# ik headers doesn't match tls i'm lazy and the antibots used don't care
headers = headers or {}
headers.setdefault("User-Agent", FakeUserAgent().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(
session: aiohttp.ClientSession | AsyncSession,
url: str,
method: str,
params: dict | None = None,
json_data: dict | None = None,
data: dict | None = None,
reraise_json_error: bool = False,
) -> dict | str:
proxy = random.choice(get_proxies())
if isinstance(session, aiohttp.ClientSession):
async with session.request(
method=method,
url=url,
params=params,
json=json_data,
data=data,
proxy=proxy,
) as response:
response.raise_for_status()
text = await response.text()
else:
response = await session.request(
method=method.upper(),
url=url,
params=params,
json=json_data,
data=data,
proxy=proxy,
)
response.raise_for_status()
text = response.text
try:
return json.loads(text)
except json.JSONDecodeError:
if reraise_json_error:
raise
return text
+12
View File
@@ -0,0 +1,12 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "monitor-utils"
version = "0.1.0"
requires-python = ">=3.10"
[tool.setuptools.packages.find]
where = ["."]
include = ["monitor_utils*"]