Spaces:
Sleeping
Sleeping
Download server.py from restasismed/wisersel-worker: direct link, hf CLI and curl.
- Browser
- Download file 6.98 kB
-
https://huggingface.co/spaces/restasismed/wisersel-worker/resolve/main/server.py
- Command line
-
hf download hf://spaces/restasismed/wisersel-worker/server.py
-
curl -L -o server.py https://huggingface.co/spaces/restasismed/wisersel-worker/resolve/main/server.py
6.98 kB
| # -*- 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ı.") | |
| 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(), | |
| } | |
| 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} | |