# -*- coding: utf-8 -*- """Wisersel track-lookup worker. Wraps the existing Playwright bot (bot_core.py, copied verbatim from the "etgb bot" project) in a small FastAPI server so the ETGB Next.js app can call it over HTTP. Contract (matches lib/wisersel.ts in the ETGB app): POST /track-lookup Authorization: Bearer $WISERSEL_API_KEY (required if the env is set) {"invoices": [{"id": "uuid", "invoiceNo": "CO123"}, ...]} -> {"results": [{"id": "uuid", "invoiceNo": "CO123", "trackNo": "774483..." | null, "error": "..."?}]} Designed to run headless in a Docker container (HuggingFace Space) or locally with a visible Chrome window for debugging (WORKER_HEADFUL=1). """ from __future__ import annotations import os import secrets import threading from pathlib import Path from typing import Any, Optional from fastapi import FastAPI, Header, HTTPException from pydantic import BaseModel from bot_core import ( BotConfig, BrowserSession, WisersellClient, credentials_status, load_credentials_from_env_file, ) BASE_DIR = Path(__file__).parent.resolve() # Writable state (browser profile, debug snapshots). On HF Spaces with # persistent storage mount it at /data to keep the Wisersell session cookies # across restarts; otherwise it lives in the container and resets on rebuild. DATA_DIR = Path(os.environ.get("WORKER_DATA_DIR", str(BASE_DIR / "data"))) ENV_PATH = BASE_DIR / ".env.local" MAX_INVOICES = int(os.environ.get("WORKER_MAX_INVOICES", "50")) app = FastAPI(title="Wisersel Track Lookup Worker", version="1.0.0") # One Chromium profile == one session: serialize browser work so concurrent # requests queue instead of fighting over the profile (a second launch would # fall back to a cookie-less temp profile and fail headless login). _browser_lock = threading.Lock() class InvoiceIn(BaseModel): id: Optional[str] = None invoiceNo: str class LookupRequest(BaseModel): invoices: list[InvoiceIn] def make_config() -> BotConfig: headful = os.environ.get("WORKER_HEADFUL", "").strip().lower() in {"1", "true", "yes"} config = BotConfig( excel_path=BASE_DIR / "dummy.xlsx", # not used: invoices arrive as JSON output_dir=DATA_DIR / "pdfs", logs_dir=DATA_DIR / "logs", backups_dir=DATA_DIR / "backups", diagnostics_dir=DATA_DIR / "debug", user_data_dir=DATA_DIR / "browser_profile", downloads_dir=DATA_DIR / "downloads", batch_size=1, headless=not headful, tracking_only=True, ).resolve_paths(BASE_DIR) # Reads WISESELL_EMAIL / WISESELL_PASSWORD from .env.local or the process # environment (HF Space secrets). Headless mode REQUIRES these, since no # human can complete a manual login in the container. return load_credentials_from_env_file(config, ENV_PATH) class WorkerBrowserSession(BrowserSession): """BrowserSession with container-safe Chromium flags added.""" def _launch_context(self, launch_options: dict[str, Any]) -> Any: args = list(launch_options.get("args") or []) for extra in ("--no-sandbox", "--disable-dev-shm-usage"): if extra not in args: args.append(extra) launch_options["args"] = args return super()._launch_context(launch_options) def worker_api_key() -> str: return ( os.environ.get("WISERSEL_API_KEY", "").strip() or os.environ.get("WISESELL_API_KEY", "").strip() ) def env_presence() -> dict[str, bool]: keys = [ "WISESELL_EMAIL", "WISESELL_PASSWORD", "WISERSEL_EMAIL", "WISERSEL_PASSWORD", "WISERSEL_API_KEY", "WISESELL_API_KEY", ] return {key: bool(os.environ.get(key, "").strip()) for key in keys} def check_auth(authorization: str) -> None: expected = worker_api_key() if not expected: return # auth disabled (local/dev use) if not secrets.compare_digest(authorization.strip(), f"Bearer {expected}"): raise HTTPException(status_code=401, detail="Geçersiz API anahtarı.") @app.get("/health") def health() -> dict[str, Any]: config = make_config() status = credentials_status(config) return { "ok": True, "wisersellCredentials": status["wisersell"], "headless": config.headless, "authEnabled": bool(worker_api_key()), "maxInvoices": MAX_INVOICES, "env": env_presence(), } @app.post("/track-lookup") def track_lookup( payload: LookupRequest, authorization: str = Header(default=""), ) -> dict[str, Any]: check_auth(authorization) if not payload.invoices: return {"results": []} if len(payload.invoices) > MAX_INVOICES: raise HTTPException( status_code=400, detail=f"En fazla {MAX_INVOICES} fatura numarası gönderilebilir.", ) # Dedupe by invoice number, remember the first queue id for each. id_by_invoice: dict[str, Optional[str]] = {} order: list[str] = [] for item in payload.invoices: invoice_no = item.invoiceNo.strip() if not invoice_no or invoice_no in id_by_invoice: continue id_by_invoice[invoice_no] = item.id order.append(invoice_no) if not order: return {"results": []} config = make_config() if config.headless and not (config.wisersell_username and config.wisersell_password): raise HTTPException( status_code=503, detail=( "WISESELL_EMAIL / WISESELL_PASSWORD tanımlı değil. " "Alternatif olarak WISERSEL_EMAIL / WISERSEL_PASSWORD da kabul edilir. " "Headless çalışmada otomatik giriş için zorunludur." ), ) with _browser_lock: try: with WorkerBrowserSession(config) as session: client = WisersellClient(session.page, config, session.timeout_error) lookups = client.find_tracking_numbers(order) except Exception as exc: # login failure, page structure change, etc. raise HTTPException( status_code=503, detail=f"Wisersell araması başarısız: {exc}", ) from exc results: list[dict[str, Any]] = [] for invoice_no in order: lookup = lookups.get(invoice_no) track_no = (lookup.tracking_no or "").strip() if lookup else "" entry: dict[str, Any] = { "invoiceNo": invoice_no, "trackNo": track_no or None, } # Omit id entirely when the caller sent none (the ETGB client's schema # accepts a missing id, and null would previously fail its parse). row_id = id_by_invoice[invoice_no] if row_id is not None: entry["id"] = row_id if not track_no and lookup and lookup.message: entry["error"] = lookup.message results.append(entry) return {"results": results}