Compare commits

..
12 Commits
Author SHA1 Message Date
TPV 4dd4494435 use get_response_json in get_category_products
Adds the body guard this site lacked; a non-dict body now logs and
returns before the fan-out instead of raising on searchTotalCount.
2026-09-26 10:30:02 -07:00
TPV c6c606b504 drop EXPECTED_TASK_ERRORS wrappers in category fetch; finish monitor kwarg rename
The add_products_to_database call still passed monitor_name after the
previous commit because its hunk sat inside this larger edit. It now
passes monitor, matching the monitor_utils signature.
2026-09-25 19:09:05 -07:00
TPV f5b3ce997f rename monitor_name kwarg to monitor
Matches the monitor_utils signature change.
2026-09-25 19:02:09 -07:00
TPV e89f5d8682 added bounded semphore 2026-08-31 12:52:45 -07:00
TPV 28eaa1d1df add motor to dependencies 2026-08-16 12:43:29 -07:00
TPV 5beba44389 add dev/docker uv sources split; normalize monitor-utils URL to rethinkstudios.io 2026-08-14 11:06:12 -07:00
TPV 74f38484ad added bounded cycles for curl_cfii session wedges 2026-07-30 18:57:17 -07:00
TPV c5fd3efdc5 added semphores/raised category concurrency to 10 2026-07-21 14:00:23 -07:00
TPV 4d72d08eb4 added semphores/raised category concurrency to 10 2026-07-21 13:45:44 -07:00
TPV 902edbac30 added aioweb/refactor 2026-07-19 08:25:04 -07:00
TPV a2821301e1 small cleanup 2026-07-15 21:13:42 -07:00
TPV b01e6a675b added edu_price into pricing_info so pricing would show up as edu under samsung channels 2026-07-15 21:13:29 -07:00
3 changed files with 57 additions and 42 deletions

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY pyproject.toml ./ COPY pyproject.toml ./
RUN uv pip install --system --no-cache -r pyproject.toml RUN uv pip install --system --no-cache --no-sources -r pyproject.toml
COPY samsung_shop/ ./samsung_shop/ COPY samsung_shop/ ./samsung_shop/
COPY configs/proxies.txt ./proxies.txt COPY configs/proxies.txt ./proxies.txt
RUN mkdir -p logs && chmod -R a+rwX /app RUN mkdir -p logs && chmod -R a+rwX /app
+8 -1
View File
@@ -5,9 +5,11 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"aiohttp>=3.14.1", "aiohttp>=3.14.1",
"aioproxies @ git+https://git.rethinkstudios.io/rethink-public/aioproxies.git", "aioproxies @ git+https://git.rethinkstudios.io/rethink-public/aioproxies.git",
"aioweb-tls[curl] @ git+https://git.rethinkstudios.io/rethink-public/aioweb_tls.git",
"curl-cffi>=0.15.0", "curl-cffi>=0.15.0",
"log-setup @ git+https://git.rethinkstudios.io/rethink-public/log_setup.git", "log-setup @ git+https://git.rethinkstudios.io/rethink-public/log_setup.git",
"monitor-utils @ git+https://git.profitlounge.us/pl_devs/monitor-shared-utils.git", "monitor-utils @ git+https://git.rethinkstudios.io/pl_devs/monitor-shared-utils.git",
"motor>=3.7.1",
"tenacity>=9.1.4", "tenacity>=9.1.4",
] ]
@@ -27,3 +29,8 @@ dev = [
"pytest>=9.1.1", "pytest>=9.1.1",
"pytest-asyncio>=1.4.0", "pytest-asyncio>=1.4.0",
] ]
# Local dev only: uv sync/run use the sibling checkout; the Dockerfile
# passes --no-sources so images resolve the git URL above instead.
[tool.uv.sources]
monitor-utils = { path = "../monitor-shared-utils", editable = true }
+48 -40
View File
@@ -1,53 +1,64 @@
import asyncio import asyncio
import logging import logging
from urllib.parse import urlencode
from curl_cffi import AsyncSession from aioweb_tls import TLSSession
from monitor_utils.config import DevelopmentMode from monitor_utils.config import DevelopmentMode
from monitor_utils.constants import EXPECTED_TASK_ERRORS from monitor_utils.constants import CYCLE_DELAY
from monitor_utils.database import add_products_to_database from monitor_utils.database import add_products_to_database
from monitor_utils.models import AvailabilityInfo, PricingInfo, Product, ProductMetadata from monitor_utils.models import AvailabilityInfo, PricingInfo, Product, ProductMetadata
from monitor_utils.runner import ( from monitor_utils.runner import (
execute_monitor_logic,
execute_pagination, execute_pagination,
fan_out_group,
init_monitor, init_monitor,
run_bounded_cycle,
setup_monitor_logging, setup_monitor_logging,
) )
from monitor_utils.sessions import send_request from monitor_utils.sessions import get_response_json, send_request
from motor.motor_asyncio import AsyncIOMotorCollection from motor.motor_asyncio import AsyncIOMotorCollection
setup_monitor_logging(monitor_name="samsung_shop") setup_monitor_logging(monitor="samsung_shop")
DEVELOPMENT_MODE = DevelopmentMode.PRODUCTION DEVELOPMENT_MODE = DevelopmentMode.PRODUCTION
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
PRODUCTS_PER_PAGE = 16 PRODUCTS_PER_PAGE = 16
CATEGORY_CONCURRENCY = 10
CATEGORY_SEMAPHORE = asyncio.Semaphore(CATEGORY_CONCURRENCY)
async def main(): async def main():
"""Run the samsung shop monitor loop over all category ids using a base session.""" """Run the samsung shop monitor loop over all category ids on bounded cycles."""
category_paths, collection, proxies = await init_monitor( category_paths, collection, proxies = await init_monitor(
monitor_name="samsung_shop", development_mode=DEVELOPMENT_MODE monitor="samsung_shop", development_mode=DEVELOPMENT_MODE
) )
await execute_monitor_logic( while True:
proxies=proxies, await run_bounded_cycle(
category_identifiers=category_paths, category_paths,
task_func=get_category_products, get_category_products,
collection=collection, proxies,
collection,
headers={
"origin": "https://www.samsung.com",
"referer": "https://www.samsung.com/us/shop/offer-program/education/",
},
) )
LOGGER.info("tasks completed sleeping", extra={"delay": CYCLE_DELAY})
await asyncio.sleep(CYCLE_DELAY)
async def get_category_products( async def get_category_products(
category_id: str, category_id: str,
session: AsyncSession, session: TLSSession,
collection: AsyncIOMotorCollection, collection: AsyncIOMotorCollection,
offset=0, offset=0,
): ):
LOGGER.debug("getting products", extra={"category": category_id, "offset": offset}) LOGGER.debug("getting products", extra={"category": category_id, "offset": offset})
try:
response = await send_request( response = await send_request(
session, session,
url="https://sribsrch.ecom.samsung.com/estoresearch-api/v1/scom/us/pf_search", url="https://sribsrch.ecom.samsung.com/estoresearch-api/v1/scom/us/pf_search",
method="POST", method="POST",
data={ data=urlencode(
{
"clientCode": "b2c", "clientCode": "b2c",
"clientName": "scom_pf", "clientName": "scom_pf",
"firstSearchYN": "true", "firstSearchYN": "true",
@@ -57,24 +68,26 @@ async def get_category_products(
"requestCount": str(PRODUCTS_PER_PAGE), "requestCount": str(PRODUCTS_PER_PAGE),
"category_code": category_id, "category_code": category_id,
"sort": "newest", "sort": "newest",
}, }
),
headers={"content-type": "application/x-www-form-urlencoded"},
) )
except EXPECTED_TASK_ERRORS:
LOGGER.warning(
"failed to get products",
extra={"category": category_id, "offset": offset},
)
raise
if offset != 0: data = get_response_json(
await process_page(response, category_id, offset, collection) response, logger=LOGGER, category=category_id, offset=offset
)
if data is None:
return return
page_task = asyncio.create_task( if offset != 0:
process_page(response, category_id, offset, collection) await process_page(data, category_id, offset, collection)
) return
try:
total_products = response.get("searchTotalCount") # offset zero owns the fan-out: the group binds this page's processing to the
# pagination it spawns, so a cancelled cycle takes both down together.
async with fan_out_group() as group:
group.create_task(process_page(data, category_id, offset, collection))
total_products = data.get("searchTotalCount")
if total_products is None: if total_products is None:
LOGGER.warning("missing searchTotalCount", extra={"category": category_id}) LOGGER.warning("missing searchTotalCount", extra={"category": category_id})
return return
@@ -85,11 +98,10 @@ async def get_category_products(
products_per_page=PRODUCTS_PER_PAGE, products_per_page=PRODUCTS_PER_PAGE,
func=get_category_products, func=get_category_products,
session=session, session=session,
semaphore=CATEGORY_SEMAPHORE,
category_id=category_id, category_id=category_id,
collection=collection, collection=collection,
) )
finally:
await page_task
async def process_page( async def process_page(
@@ -100,14 +112,13 @@ async def process_page(
): ):
"""Extract and persist one page of category search results.""" """Extract and persist one page of category search results."""
try:
products = extract_products(response) products = extract_products(response)
if products: if products:
await add_products_to_database( await add_products_to_database(
products, products,
collection, collection,
category_identifier=category_id, category_identifier=category_id,
monitor_name="samsung_shop", monitor="samsung_shop",
) )
LOGGER.info( LOGGER.info(
"added products", "added products",
@@ -122,17 +133,12 @@ async def process_page(
"no products found", "no products found",
extra={"category": category_id, "offset": offset}, extra={"category": category_id, "offset": offset},
) )
except EXPECTED_TASK_ERRORS:
LOGGER.warning(
"failed to build products",
extra={"category": category_id, "offset": offset},
)
raise
def extract_products(response: dict) -> list[Product]: def extract_products(response: dict) -> list[Product]:
products = [] products = []
for current_product in response["searchResults"]: for current_product in response["searchResults"]:
current_price = current_product["sale_price"]
product = Product( product = Product(
product_id=current_product["modelCode"], product_id=current_product["modelCode"],
title=current_product["name"], title=current_product["name"],
@@ -142,7 +148,8 @@ def extract_products(response: dict) -> list[Product]:
), ),
pricing=PricingInfo( pricing=PricingInfo(
original_price=current_product["msrp_price"], original_price=current_product["msrp_price"],
current_price=current_product["sale_price"], current_price=current_price,
edu_price=current_price,
), ),
availability=AvailabilityInfo( availability=AvailabilityInfo(
available=(current_product["stockFlag"] == "Y") available=(current_product["stockFlag"] == "Y")
@@ -153,4 +160,5 @@ def extract_products(response: dict) -> list[Product]:
return products return products
if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())