wisersel-worker / server.py
restasismed's picture
Accept Wisersel env aliases
ecb7ddc verified
Raw History Blame Contribute Delete
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ı.")
@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}