Platform bundle: marketing publish, IT ops, packaging, agents mesh.

Volledige Foodlinkk Command Center uitbreiding met social automatisering,
reclamefolder filters, Proxmox monitoring en documentatie.
This commit is contained in:
Aissa
2026-06-09 00:41:27 +00:00
commit 5d60d33db1
212 changed files with 30044 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
FROM mcr.microsoft.com/playwright/python:v1.49.1-noble
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr tesseract-ocr-nld \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && playwright install chromium
COPY app ./app
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7790"]
View File
+49
View File
@@ -0,0 +1,49 @@
import os
import psycopg2
from psycopg2.extras import RealDictCursor
_pool = None
def init_pool() -> None:
global _pool
if _pool is None:
_pool = {
"host": os.getenv("DB_HOST", "10.4.7.18"),
"user": os.getenv("DB_USER", "aissa"),
"password": os.getenv("DB_PASSWORD", "Foodlinkk#2026"),
"dbname": os.getenv("DB_NAME", "foodlinkk"),
}
def _conn():
init_pool()
return psycopg2.connect(**_pool, cursor_factory=RealDictCursor)
def fetch_one(sql: str, params: tuple = ()) -> dict | None:
with _conn() as conn:
with conn.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
conn.commit()
return dict(row) if row else None
def fetch_all(sql: str, params: tuple = ()) -> list:
with _conn() as conn:
with conn.cursor() as cur:
cur.execute(sql, params)
rows = cur.fetchall()
conn.commit()
return [dict(r) for r in rows]
def execute_returning(sql: str, params: tuple = ()) -> dict:
with _conn() as conn:
with conn.cursor() as cur:
cur.execute(sql, params)
row = cur.fetchone()
conn.commit()
return dict(row) if row else {}
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import base64
import json
import os
import re
from typing import Any
from playwright.sync_api import sync_playwright
VNC_CDP_URL = os.getenv("VNC_CDP_URL", "http://10.4.7.18:9222")
WEIGHT_RE = re.compile(
r"(\d+[.,]?\d*\s*(?:g|gr|gram|kg|ml|cl|l|liter|st|stuks?|x\d+|pack|zak|doos))",
re.I,
)
PRICE_RE = re.compile(r"(€\s?\d+[.,]\d{2}|EUR\s?\d+[.,]\d{2})", re.I)
def extract_items_from_text(text: str, source: str = "ocr") -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
seen: set[str] = set()
for line in re.split(r"[\n\r]+", text):
line = re.sub(r"\s+", " ", line).strip()
if len(line) < 8:
continue
weight = WEIGHT_RE.search(line)
price = PRICE_RE.search(line)
if not weight and not price:
continue
key = line[:80].lower()
if key in seen:
continue
seen.add(key)
items.append({
"raw": line[:300],
"weight": weight.group(1) if weight else None,
"price": price.group(1) if price else None,
"source": source,
})
return items[:200]
def ocr_with_boxes(image_bytes: bytes) -> tuple[str, list[dict[str, Any]]]:
try:
import io
import pytesseract
from PIL import Image
img = Image.open(io.BytesIO(image_bytes))
text = pytesseract.image_to_string(img, lang="nld+eng").strip()
data = pytesseract.image_to_data(img, lang="nld+eng", output_type=pytesseract.Output.DICT)
boxes: list[dict[str, Any]] = []
for i, word in enumerate(data.get("text", [])):
word = (word or "").strip()
conf_raw = data["conf"][i]
conf = int(conf_raw) if str(conf_raw).isdigit() else -1
if not word or conf < 40:
continue
boxes.append({
"text": word,
"confidence": conf,
"x": data["left"][i],
"y": data["top"][i],
"w": data["width"][i],
"h": data["height"][i],
})
return text, boxes[:500]
except Exception as exc:
return f"OCR niet beschikbaar: {exc}", []
def vnc_screenshot_sync() -> bytes:
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(VNC_CDP_URL)
context = browser.contexts[0] if browser.contexts else browser.new_context()
page = context.pages[0] if context.pages else context.new_page()
return page.screenshot(type="jpeg", quality=72, full_page=False)
def vnc_navigate_sync(url: str, instruction: str | None, wait_seconds: float, helpers: dict) -> dict[str, Any]:
accept_cookies = helpers["accept_cookies"]
run_instructions = helpers["run_instructions"]
log: list[str] = []
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(VNC_CDP_URL)
context = browser.contexts[0] if browser.contexts else browser.new_context()
page = context.pages[0] if context.pages else context.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(int(wait_seconds * 1000))
cr = accept_cookies(page)
if cr:
log.append(cr)
if instruction:
log.extend(run_instructions(page, instruction))
shot = page.screenshot(type="jpeg", quality=72, full_page=False)
title = page.title()
final_url = page.url
return {
"ok": True,
"url": url,
"final_url": final_url,
"title": title,
"steps": log,
"screenshot_b64": base64.b64encode(shot).decode("ascii"),
}
def extract_full_sync(
url: str,
instruction: str | None,
wait_seconds: float,
scroll_pages: int,
site_id: int | None,
helpers: dict,
) -> dict:
from bs4 import BeautifulSoup
execute_returning = helpers["execute_returning"]
serialize = helpers["serialize"]
accept_cookies = helpers["accept_cookies"]
run_instructions = helpers["run_instructions"]
extract_links = helpers["extract_links"]
session_row = execute_returning(
"INSERT INTO browser_sessions (url, task, status, site_id) VALUES (%s, %s, 'running', %s) RETURNING id",
(url, instruction or "full-extract", site_id),
)
session_id = session_row["id"]
all_items: list[dict] = []
all_ocr: list[str] = []
steps_log: list[str] = []
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1440, "height": 900},
user_agent="Mozilla/5.0 FoodlinkkBot/2.0 FullExtract",
locale="nl-NL",
)
page = context.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(int(wait_seconds * 1000))
cr = accept_cookies(page)
if cr:
steps_log.append(cr)
if instruction:
steps_log.extend(run_instructions(page, instruction))
page.evaluate("window.scrollTo(0, 0)")
page.wait_for_timeout(400)
for i in range(max(1, min(scroll_pages, 12))):
shot = page.screenshot(type="jpeg", quality=70, full_page=False)
text, _ = ocr_with_boxes(shot)
all_ocr.append(text)
all_items.extend(extract_items_from_text(text, f"ocr-scroll-{i + 1}"))
page.evaluate("window.scrollBy(0, Math.min(window.innerHeight * 0.85, 800))")
page.wait_for_timeout(500)
steps_log.append(f"Scroll+OCR {i + 1}")
full_shot = page.screenshot(type="jpeg", quality=65, full_page=True)
full_b64 = base64.b64encode(full_shot).decode("ascii")
full_text, full_boxes = ocr_with_boxes(full_shot)
all_items.extend(extract_items_from_text(full_text, "ocr-fullpage"))
title = page.title()
final_url = page.url
html = page.content()
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "noscript"]):
tag.decompose()
text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))
all_items.extend(extract_items_from_text(text, "html-text"))
links = extract_links(html, final_url)
seen: set[str] = set()
deduped: list[dict] = []
for it in all_items:
k = (it.get("raw") or "")[:100]
if k in seen:
continue
seen.add(k)
deduped.append(it)
meta = {
"task": "full-extract",
"steps": steps_log,
"scroll_pages": scroll_pages,
"extracted_items": deduped[:300],
"ocr_combined": "\n\n".join(all_ocr)[:50000],
"detection_boxes": full_boxes[:200],
}
row = execute_returning(
"""
UPDATE browser_sessions SET
final_url=%s, title=%s, status='completed',
content_text=%s, content_html=%s, screenshot_b64=%s,
links=%s::jsonb, completed_at=NOW(), metadata=%s::jsonb
WHERE id=%s RETURNING *
""",
(
final_url,
title,
text[:120000],
html[:250000],
full_b64,
json.dumps(links),
json.dumps(meta),
session_id,
),
)
browser.close()
out = serialize(row)
out["extracted_items"] = deduped[:300]
out["detection_boxes"] = meta["detection_boxes"]
out["content_preview"] = text[:2000]
return out
except Exception as exc:
execute_returning(
"UPDATE browser_sessions SET status='failed', error_message=%s, completed_at=NOW() WHERE id=%s RETURNING id",
(str(exc)[:500], session_id),
)
raise
def analyze_photo_sync(
image_b64: str,
source: str,
filename: str | None,
storage_path: str | None,
session_id: int | None,
execute_returning,
) -> dict:
image_bytes = base64.b64decode(image_b64)
text, boxes = ocr_with_boxes(image_bytes)
items = extract_items_from_text(text, "photo-ocr")
row = execute_returning(
"""
INSERT INTO photo_imports (source, filename, storage_path, image_b64, ocr_text, detections, extracted_items, session_id, metadata)
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s::jsonb)
RETURNING *
""",
(
source,
filename,
storage_path,
image_b64,
text,
json.dumps(boxes),
json.dumps(items),
session_id,
json.dumps({"box_count": len(boxes), "item_count": len(items)}),
),
)
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
out.pop("image_b64", None)
return out
+660
View File
@@ -0,0 +1,660 @@
from __future__ import annotations
import asyncio
import base64
import json
import re
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import Any, Optional
from bs4 import BeautifulSoup
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field
from playwright.sync_api import sync_playwright
from app.db import execute_returning, fetch_all, fetch_one, init_pool
from app import pa_live
app = FastAPI(title="Foodlinkk Browser Agent", version="1.0.0")
_executor = ThreadPoolExecutor(max_workers=4)
_active_session: dict[str, Any] = {"id": None, "status": "idle", "screenshot_b64": None, "url": None}
class BrowseRequest(BaseModel):
url: str
task: Optional[str] = Field(default=None, description="Optional task description for logging")
site_id: Optional[int] = None
wait_seconds: float = Field(default=3.0, ge=0, le=30)
instruction: Optional[str] = Field(default=None, description="Klik/scroll instructies in NL")
pa_label: Optional[str] = None
pa_job_id: Optional[str] = None
pa_index: Optional[int] = None
pa_total: Optional[int] = None
pa_query: Optional[str] = None
pa_chat_id: Optional[int] = None
pa_user_name: Optional[str] = None
def _serialize(row: dict | None) -> dict | None:
if not row:
return None
out = dict(row)
for k, v in list(out.items()):
if hasattr(v, "isoformat"):
out[k] = v.isoformat()
return out
def _accept_cookies(page) -> str | None:
for sel in (
'button:has-text("Accepteren")',
'button:has-text("Alles accepteren")',
'button:has-text("Akkoord")',
'button:has-text("Accept")',
"#onetrust-accept-btn-handler",
'[data-testid="accept-all"]',
):
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=1200):
btn.click(timeout=3000)
page.wait_for_timeout(500)
return f"Clicked: {sel}"
except Exception:
pass
return None
def _run_instructions(page, instruction: str) -> list[str]:
"""Parse Dutch/English instructions into Playwright actions."""
log: list[str] = []
if not instruction or not instruction.strip():
return log
lower = instruction.lower()
if any(w in lower for w in ("cookie", "accepteren", "privacy", "akkoord", "consent")):
r = _accept_cookies(page)
if r:
log.append(r)
for m in re.finditer(r"klik(?:ken)?(?:\s+op)?\s+['\"]?([^'\".\n]+)", instruction, re.I):
label = m.group(1).strip()
if len(label) < 2:
continue
try:
page.get_by_role("button", name=re.compile(re.escape(label[:40]), re.I)).first.click(timeout=4000)
log.append(f"Klik: button '{label[:40]}'")
page.wait_for_timeout(800)
continue
except Exception:
pass
try:
page.get_by_text(label[:60], exact=False).first.click(timeout=4000)
log.append(f"Klik: tekst '{label[:40]}'")
page.wait_for_timeout(800)
except Exception as exc:
log.append(f"Mislukt klik '{label[:30]}': {exc}")
scroll_n = lower.count("scroll") + lower.count("naar beneden") + lower.count("verder")
scroll_n = max(scroll_n, 1 if ("meer" in lower and "product" in lower) else 0)
for i in range(min(scroll_n, 5)):
page.evaluate("window.scrollBy(0, Math.min(window.innerHeight, 700))")
page.wait_for_timeout(400)
log.append(f"Scroll {i + 1}")
if "filter" in lower or "categorie" in lower or "subcategorie" in lower:
for word in re.findall(r"[a-zA-Z]{4,}", instruction):
if word.lower() in ("filter", "categorie", "subcategorie", "klik", "scroll"):
continue
try:
page.get_by_text(word, exact=False).first.click(timeout=2000)
log.append(f"Filter/klik: {word}")
page.wait_for_timeout(600)
break
except Exception:
pass
page.wait_for_timeout(1000)
return log
def _ocr_bytes(image_bytes: bytes) -> str:
try:
import io
import pytesseract
from PIL import Image
img = Image.open(io.BytesIO(image_bytes))
return pytesseract.image_to_string(img, lang="nld+eng").strip()
except Exception as exc:
return f"OCR niet beschikbaar: {exc}"
def _extract_links(html: str, base_url: str) -> list[dict[str, str]]:
soup = BeautifulSoup(html, "html.parser")
links: list[dict[str, str]] = []
seen: set[str] = set()
for a in soup.find_all("a", href=True):
href = a["href"].strip()
text = (a.get_text() or "").strip()[:120]
if not href or href.startswith("#") or href in seen:
continue
seen.add(href)
links.append({"href": href, "text": text})
if len(links) >= 40:
break
return links
def _browse_sync(url: str, task: Optional[str], site_id: Optional[int], wait_seconds: float, instruction: Optional[str] = None) -> dict:
global _active_session
session_row = execute_returning(
"""
INSERT INTO browser_sessions (url, task, status, site_id)
VALUES (%s, %s, 'running', %s)
RETURNING id
""",
(url, task or instruction, site_id),
)
session_id = session_row["id"]
_active_session = {"id": session_id, "status": "running", "screenshot_b64": None, "url": url}
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1440, "height": 900},
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 FoodlinkkBot/2.0",
locale="nl-NL",
)
page = context.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=90000)
page.wait_for_timeout(int(wait_seconds * 1000))
try:
page.wait_for_load_state("networkidle", timeout=15000)
except Exception:
pass
steps_log: list[str] = []
cr = _accept_cookies(page)
if cr:
steps_log.append(cr)
if instruction:
steps_log.extend(_run_instructions(page, instruction))
elif task:
steps_log.extend(_run_instructions(page, task))
shot = page.screenshot(type="jpeg", quality=72, full_page=False)
b64 = base64.b64encode(shot).decode("ascii")
_active_session["screenshot_b64"] = b64
title = page.title()
final_url = page.url
html = page.content()
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "noscript"]):
tag.decompose()
text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))[:80000]
links = _extract_links(html, final_url)
row = execute_returning(
"""
UPDATE browser_sessions SET
final_url=%s, title=%s, status='completed',
content_text=%s, content_html=%s, screenshot_b64=%s,
links=%s::jsonb, completed_at=NOW(),
metadata=%s::jsonb
WHERE id=%s
RETURNING *
""",
(
final_url,
title,
text,
html[:200000],
b64,
json.dumps(links),
json.dumps({"task": task or instruction or "browse", "link_count": len(links), "steps": steps_log}),
session_id,
),
)
execute_returning(
"""
INSERT INTO crawled_pages (url, final_url, title, content, content_html, screenshot_b64, links, site_id, metadata, crawled_at)
VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s::jsonb,NOW())
ON CONFLICT (url) DO UPDATE SET
final_url=EXCLUDED.final_url,
title=EXCLUDED.title,
content=EXCLUDED.content,
content_html=EXCLUDED.content_html,
screenshot_b64=EXCLUDED.screenshot_b64,
links=EXCLUDED.links,
site_id=EXCLUDED.site_id,
metadata=EXCLUDED.metadata,
crawled_at=NOW()
RETURNING id
""",
(
url,
final_url,
title,
text[:50000],
html[:100000],
b64,
json.dumps(links),
site_id,
json.dumps({"session_id": session_id, "source": "browser-agent"}),
),
)
browser.close()
_active_session["status"] = "completed"
out = _serialize(row)
out["content_preview"] = text[:2000]
out["screenshot_b64"] = b64
return out
except Exception as exc:
execute_returning(
"""
UPDATE browser_sessions SET status='failed', error_message=%s, completed_at=NOW()
WHERE id=%s RETURNING id
""",
(str(exc)[:500], session_id),
)
_active_session["status"] = "failed"
raise
@app.on_event("startup")
def startup() -> None:
init_pool()
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok", "service": "browser-agent"}
@app.get("/live")
def live_view() -> dict[str, Any]:
return {
"active": _active_session,
"novnc_url": "http://10.4.7.18:6080/vnc.html?autoconnect=true&resize=scale&password=Foodlinkk2026&path=websockify",
"gradio_url": "http://10.4.7.18:7788",
}
@app.get("/sessions")
def list_sessions(limit: int = Query(default=20, ge=1, le=100)) -> dict[str, Any]:
rows = fetch_all(
"""
SELECT id, url, final_url, title, task, status, created_at, completed_at,
LEFT(content_text, 400) AS content_preview,
(screenshot_b64 IS NOT NULL) AS has_screenshot,
site_id
FROM browser_sessions ORDER BY created_at DESC LIMIT %s
""",
(limit,),
)
return {"sessions": [_serialize(r) for r in rows]}
@app.get("/sessions/{session_id}")
def get_session(session_id: int, include_screenshot: bool = Query(default=True)) -> dict[str, Any]:
row = fetch_one("SELECT * FROM browser_sessions WHERE id = %s", (session_id,))
if not row:
raise HTTPException(status_code=404, detail="Session not found")
out = _serialize(row)
if not include_screenshot:
out.pop("screenshot_b64", None)
out.pop("content_html", None)
else:
out["content_preview"] = (out.get("content_text") or "")[:3000]
return {"session": out}
@app.get("/sessions/{session_id}/screenshot.jpg")
def session_screenshot_jpg(session_id: int) -> Response:
row = fetch_one("SELECT screenshot_b64 FROM browser_sessions WHERE id = %s", (session_id,))
if not row or not row.get("screenshot_b64"):
raise HTTPException(status_code=404, detail="No screenshot")
data = base64.b64decode(row["screenshot_b64"])
return Response(content=data, media_type="image/jpeg")
@app.get("/live/screenshot.jpg")
def live_screenshot_jpg() -> Response:
b64 = _active_session.get("screenshot_b64")
if not b64:
row = fetch_one(
"SELECT screenshot_b64 FROM browser_sessions WHERE screenshot_b64 IS NOT NULL ORDER BY id DESC LIMIT 1"
)
b64 = row.get("screenshot_b64") if row else None
if not b64:
raise HTTPException(status_code=404, detail="No active screenshot")
return Response(content=base64.b64decode(b64), media_type="image/jpeg")
@app.post("/browse")
async def browse(req: BrowseRequest) -> dict[str, Any]:
url = req.url.strip()
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="URL must start with http:// or https://")
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
_executor,
_browse_sync,
url,
req.task,
req.site_id,
req.wait_seconds,
req.instruction,
)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"ok": True, "session": result}
class TaskRequest(BaseModel):
url: str
task: str = "open and summarize visible content"
metadata: dict[str, Any] = Field(default_factory=dict)
@app.post("/task")
async def browser_task(req: TaskRequest) -> dict[str, Any]:
"""Compatibility endpoint for tools-api."""
browse_req = BrowseRequest(url=req.url, task=req.task, wait_seconds=4.0)
return await browse(browse_req)
class InstructRequest(BaseModel):
url: str
instruction: str
wait_seconds: float = 5.0
@app.post("/browse/instruct")
async def browse_instruct(req: InstructRequest) -> dict[str, Any]:
browse_req = BrowseRequest(url=req.url, task=req.instruction, instruction=req.instruction, wait_seconds=req.wait_seconds)
return await browse(browse_req)
@app.get("/sessions/{session_id}/ocr")
def session_ocr(session_id: int) -> dict[str, Any]:
row = fetch_one("SELECT screenshot_b64, title, url FROM browser_sessions WHERE id = %s", (session_id,))
if not row or not row.get("screenshot_b64"):
raise HTTPException(status_code=404, detail="Geen screenshot voor OCR")
text = _ocr_bytes(base64.b64decode(row["screenshot_b64"]))
return {"ok": True, "session_id": session_id, "ocr_text": text, "title": row.get("title"), "url": row.get("url")}
@app.post("/ocr")
def ocr_upload(body: dict[str, Any]) -> dict[str, Any]:
b64 = body.get("image_b64") or ""
if not b64:
raise HTTPException(status_code=400, detail="image_b64 required")
text = _ocr_bytes(base64.b64decode(b64))
return {"ok": True, "ocr_text": text}
# --- VNC + full extract + photo analysis (added by patch) ---
from app import extract as extract_mod
def _browser_helpers() -> dict:
return {
"execute_returning": execute_returning,
"serialize": _serialize,
"accept_cookies": _accept_cookies,
"run_instructions": _run_instructions,
"extract_links": _extract_links,
}
class VncNavigateBody(BaseModel):
url: str
instruction: Optional[str] = None
wait_seconds: float = 4.0
class ExtractFullBody(BaseModel):
url: str
instruction: Optional[str] = None
wait_seconds: float = 4.0
scroll_pages: int = Field(default=6, ge=1, le=12)
site_id: Optional[int] = None
also_vnc: bool = False
pa_label: Optional[str] = None
pa_job_id: Optional[str] = None
pa_index: Optional[int] = None
pa_total: Optional[int] = None
pa_query: Optional[str] = None
pa_chat_id: Optional[int] = None
pa_user_name: Optional[str] = None
class PhotoAnalyzeBody(BaseModel):
image_b64: str
source: str = "upload"
filename: Optional[str] = None
storage_path: Optional[str] = None
session_id: Optional[int] = None
@app.post("/vnc/navigate")
async def vnc_navigate(req: VncNavigateBody) -> dict[str, Any]:
url = req.url.strip()
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="URL must start with http:// or https://")
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
_executor,
extract_mod.vnc_navigate_sync,
url,
req.instruction,
req.wait_seconds,
_browser_helpers(),
)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"VNC navigate failed: {exc}") from exc
return result
@app.post("/browse/extract-full")
async def browse_extract_full(req: ExtractFullBody) -> dict[str, Any]:
url = req.url.strip()
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="URL must start with http:// or https://")
if req.pa_label and req.pa_job_id:
if req.pa_index == 1 or not pa_live.get_live().get("job_id"):
pa_live.start_job(
req.pa_job_id,
req.pa_query or "",
chat_id=req.pa_chat_id,
user_name=req.pa_user_name or "",
)
pa_live.update_slot(
req.pa_label,
status="loading",
url=url,
index=req.pa_index,
total=req.pa_total,
)
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
_executor,
extract_mod.extract_full_sync,
url,
req.instruction,
req.wait_seconds,
req.scroll_pages,
req.site_id,
_browser_helpers(),
)
if req.pa_label:
pa_live.update_slot(
req.pa_label,
status="completed",
url=result.get("final_url") or url,
title=result.get("title"),
screenshot_b64=result.get("screenshot_b64"),
index=req.pa_index,
total=req.pa_total,
)
if req.also_vnc:
try:
await loop.run_in_executor(
_executor,
extract_mod.vnc_navigate_sync,
url,
req.instruction,
req.wait_seconds,
_browser_helpers(),
)
except Exception:
pass
except Exception as exc:
if req.pa_label:
pa_live.update_slot(req.pa_label, status="failed", url=url, error=str(exc)[:300])
raise HTTPException(status_code=502, detail=str(exc)) from exc
global _active_session
_active_session = {
"id": result.get("id"),
"status": "completed",
"screenshot_b64": result.get("screenshot_b64"),
"url": url,
}
return {"ok": True, "session": result}
@app.post("/photos/analyze")
async def photos_analyze(req: PhotoAnalyzeBody) -> dict[str, Any]:
if not req.image_b64.strip():
raise HTTPException(status_code=400, detail="image_b64 required")
loop = asyncio.get_event_loop()
try:
row = await loop.run_in_executor(
_executor,
extract_mod.analyze_photo_sync,
req.image_b64,
req.source,
req.filename,
req.storage_path,
req.session_id,
execute_returning,
)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return {"ok": True, "photo": row}
@app.get("/photos")
def photos_list(limit: int = Query(default=30, ge=1, le=100)) -> dict[str, Any]:
rows = fetch_all(
"""
SELECT id, source, filename, storage_path, LEFT(ocr_text, 400) AS ocr_preview,
jsonb_array_length(COALESCE(detections, '[]'::jsonb)) AS box_count,
jsonb_array_length(COALESCE(extracted_items, '[]'::jsonb)) AS item_count,
session_id, created_at
FROM photo_imports ORDER BY created_at DESC LIMIT %s
""",
(limit,),
)
return {"photos": [_serialize(r) for r in rows]}
@app.get("/photos/{photo_id}")
def photos_get(photo_id: int) -> dict[str, Any]:
row = fetch_one("SELECT * FROM photo_imports WHERE id = %s", (photo_id,))
if not row:
raise HTTPException(status_code=404, detail="Photo not found")
out = _serialize(row)
out.pop("image_b64", None)
return {"photo": out}
@app.get("/photos/{photo_id}/image.jpg")
def photos_image(photo_id: int) -> Response:
row = fetch_one("SELECT image_b64 FROM photo_imports WHERE id = %s", (photo_id,))
if not row or not row.get("image_b64"):
raise HTTPException(status_code=404, detail="No image")
return Response(content=base64.b64decode(row["image_b64"]), media_type="image/jpeg")
@app.get("/photos/{photo_id}/detections")
def photos_detections(photo_id: int) -> dict[str, Any]:
row = fetch_one(
"SELECT id, detections, extracted_items, ocr_text FROM photo_imports WHERE id = %s",
(photo_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Photo not found")
return {
"ok": True,
"photo_id": photo_id,
"detections": row.get("detections") or [],
"extracted_items": row.get("extracted_items") or [],
"ocr_text": row.get("ocr_text") or "",
}
class PaJobStartBody(BaseModel):
job_id: str
query: str
chat_id: Optional[int] = None
user_name: Optional[str] = None
sites: Optional[list[str]] = None
@app.post("/pa/job/start")
def pa_job_start(body: PaJobStartBody) -> dict[str, Any]:
pa_live.start_job(
body.job_id,
body.query,
chat_id=body.chat_id,
user_name=body.user_name or "",
sites=body.sites,
)
return {"ok": True, "live": pa_live.get_live()}
@app.post("/pa/job/comparing")
def pa_job_comparing() -> dict[str, Any]:
pa_live.set_comparing()
return {"ok": True, "live": pa_live.get_live()}
@app.post("/pa/job/done")
def pa_job_done() -> dict[str, Any]:
pa_live.finish_job()
return {"ok": True, "live": pa_live.get_live()}
@app.get("/pa/live")
def pa_live_view() -> dict[str, Any]:
return pa_live.get_live()
@app.get("/pa/live/{label}/screenshot.jpg")
def pa_slot_screenshot(label: str) -> Response:
data = pa_live.get_slot_screenshot(label)
if not data:
raise HTTPException(status_code=404, detail="No screenshot for slot")
return Response(content=data, media_type="image/jpeg")
@app.get("/vnc/screenshot.jpg")
async def vnc_screenshot_jpg() -> Response:
loop = asyncio.get_event_loop()
try:
data = await loop.run_in_executor(_executor, extract_mod.vnc_screenshot_sync)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return Response(content=data, media_type="image/jpeg")
+157
View File
@@ -0,0 +1,157 @@
"""PA multi-site live status — 4 browser slots for Cockpit."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
PA_SITES = ("Airbnb", "Booking.com", "DuckDuckGo", "HolidayCheck")
_state: dict[str, Any] = {
"job_id": None,
"query": "",
"chat_id": None,
"user_name": "",
"status": "idle",
"updated_at": None,
"slots": {
site: {
"label": site,
"status": "idle",
"url": None,
"title": None,
"error": None,
"index": i + 1,
"total": len(PA_SITES),
"screenshot_b64": None,
"updated_at": None,
}
for i, site in enumerate(PA_SITES)
},
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _slot(label: str) -> dict[str, Any]:
slots = _state["slots"]
if label not in slots:
slots[label] = {
"label": label,
"status": "idle",
"url": None,
"title": None,
"error": None,
"index": 0,
"total": len(PA_SITES),
"screenshot_b64": None,
"updated_at": _now(),
}
return slots[label]
def start_job(
job_id: str,
query: str,
*,
chat_id: int | None = None,
user_name: str = "",
sites: list[str] | None = None,
) -> None:
site_list = sites or list(PA_SITES)
_state["job_id"] = job_id
_state["query"] = query[:200]
_state["chat_id"] = chat_id
_state["user_name"] = user_name
_state["status"] = "running"
_state["updated_at"] = _now()
_state["slots"] = {
site: {
"label": site,
"status": "waiting",
"url": None,
"title": None,
"error": None,
"index": i + 1,
"total": len(site_list),
"screenshot_b64": None,
"updated_at": _now(),
}
for i, site in enumerate(site_list)
}
def set_comparing() -> None:
_state["status"] = "comparing"
_state["updated_at"] = _now()
def finish_job() -> None:
_state["status"] = "done"
_state["updated_at"] = _now()
def fail_job(error: str) -> None:
_state["status"] = "failed"
_state["updated_at"] = _now()
_state["error"] = error[:200]
def update_slot(
label: str,
*,
status: str,
url: str | None = None,
title: str | None = None,
error: str | None = None,
index: int | None = None,
total: int | None = None,
screenshot_b64: str | None = None,
) -> None:
slot = _slot(label)
slot["status"] = status
slot["updated_at"] = _now()
if url is not None:
slot["url"] = url
if title is not None:
slot["title"] = title
if error is not None:
slot["error"] = error[:300]
if index is not None:
slot["index"] = index
if total is not None:
slot["total"] = total
if screenshot_b64 is not None:
slot["screenshot_b64"] = screenshot_b64
_state["updated_at"] = _now()
def get_live() -> dict[str, Any]:
out = {
"job_id": _state.get("job_id"),
"query": _state.get("query"),
"chat_id": _state.get("chat_id"),
"user_name": _state.get("user_name"),
"status": _state.get("status", "idle"),
"updated_at": _state.get("updated_at"),
"sites": list(PA_SITES),
"slots": [],
}
for site in PA_SITES:
slot = dict(_state["slots"].get(site) or _slot(site))
slot.pop("screenshot_b64", None)
slot["has_screenshot"] = bool(
(_state["slots"].get(site) or {}).get("screenshot_b64")
)
out["slots"].append(slot)
return out
def get_slot_screenshot(label: str) -> bytes | None:
slot = _state["slots"].get(label)
if not slot or not slot.get("screenshot_b64"):
return None
import base64
return base64.b64decode(slot["screenshot_b64"])
+8
View File
@@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
psycopg2-binary==2.9.10
httpx==0.28.1
beautifulsoup4==4.12.3
playwright==1.49.1
pytesseract==0.3.13
Pillow==11.0.0