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:
@@ -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"]
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user