import asyncio import logging from pathlib import Path from typing import List, Tuple from datetime import datetime, timedelta import pandas as pd import yaml from fetcher import Fetcher, FetchError from sink import write_outputs, write_master_excel from models import Product # ---------- конфиг/логи ---------- def setup_logging(): Path("out/logs").mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", handlers=[ logging.FileHandler("out/logs/run.log", encoding="utf-8"), logging.StreamHandler(), ], ) def load_config() -> dict: with open("config.yaml", "r", encoding="utf-8") as f: return yaml.safe_load(f) # ---------- загрузка категорий из первой колонки A ---------- def load_categories() -> List[Tuple[str, str]]: """ Читает categories.xlsx без заголовка. Берёт только первую колонку (A) — ссылки на категории. Имя категории вычисляет из последнего сегмента URL. """ from urllib.parse import urlparse xlsx = Path("categories.xlsx") if not xlsx.exists(): # демо, если файл не создан return [ ("bathroom-accessories", "https://www.next.pl/en/shop/home/bathroom/bathroom-accessories"), ] df = pd.read_excel(xlsx, header=None) if df.shape[1] == 0: return [] urls: List[str] = [] for val in df.iloc[:, 0].tolist(): if isinstance(val, str): u = val.strip() elif pd.notna(val): u = str(val).strip() else: continue if not u or not u.lower().startswith(("http://", "https://")): continue urls.append(u) def name_from_url(u: str) -> str: p = urlparse(u) parts = [s for s in p.path.split("/") if s] return parts[-1] if parts else p.netloc return [(name_from_url(u), u) for u in urls] # ---------- адаптер: dict -> Product ---------- def normalize_to_models(collected: List[dict]) -> List[Product]: """ Вход: элементы от fetcher.collect_products(): { id, title, url, price(float|None), currency('PLN'|...), color, description } Выход: список Product (минимально необходимые поля) """ out: List[Product] = [] for d in collected: price_val = d.get("price") price_str = f"{float(price_val):.2f}" if isinstance(price_val, (int, float)) else None out.append(Product( product_id=str(d.get("id")) if d.get("id") is not None else None, url=str(d.get("url")) if d.get("url") else None, name=d.get("title"), price=price_str, currency=(d.get("currency") or "PLN").upper(), color=d.get("color"), description=d.get("description"), image_urls=[], size_variants=[] )) return out # ---------- основной сценарий ---------- async def run_category(fetcher: Fetcher, cfg: dict, name: str, url: str): logging.info(f"Category start: {name} — {url}") try: await fetcher.load_category(url) # доскроллить до полного количества (считает из шапки "(N)") await fetcher.auto_scroll_until_total() # собрать товары (SSR + DOM) и обогатить с PDP collected = await fetcher.collect_products() collected = await fetcher.enrich_with_pdp_details( collected, max_concurrency=cfg.get("pdp", {}).get("max_concurrency", 3) ) products = normalize_to_models(collected) # сохранить в xlsx/csv/jsonl path, n, rows = write_outputs( category_name=name, category_url=url, products=products, out_folder=cfg["output"]["folder"], excel_prefix=cfg["output"]["excel_prefix"], csv_also=cfg["output"].get("csv_also", True), jsonl_also=cfg["output"].get("jsonl_also", True), ) logging.info(f"✔ {name}: {n} товаров → {path}") return rows except FetchError as e: logging.error(f"Category failed: {name} — {e}") return [] except Exception as e: logging.exception(f"Category crashed: {name} — {e}") return [] async def main_async(): setup_logging() cfg = load_config() categories = load_categories() if not categories: logging.warning("categories.xlsx пуст — добавьте ссылки в первую колонку (без заголовков).") return # Аккумулятор для общего XLSX master_rows: List[dict] = [] # Имя общего файла: all_YYYYMMDD_HHMMSS_UTC+3.xlsx ts_utc_plus3 = (datetime.utcnow() + timedelta(hours=3)).strftime("%Y%m%d_%H%M%S") all_filename = f"all_{ts_utc_plus3}_UTC+3.xlsx" all_path = str(Path(cfg["output"]["folder"]) / all_filename) async with Fetcher(cfg) as fetcher: for name, url in categories: rows = await run_category(fetcher, cfg, name, url) master_rows.extend(rows) # По завершении всех категорий — пишем общий XLSX all_written_path, total = write_master_excel(all_path, master_rows) logging.info(f"◎ ALL: {total} товаров → {all_written_path}") def main(): asyncio.run(main_async()) if __name__ == "__main__": main()