Files
foodlinkk-command-center/browser-agent/app/main.py
T
Aissa 5d60d33db1 Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering,
reclamefolder filters, Proxmox monitoring en documentatie.
2026-06-09 00:41:27 +00:00

661 lines
22 KiB
Python

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")