2026-06-09 00:41:27 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
2026-06-23 10:04:23 +00:00
|
|
|
import json
|
2026-06-09 00:41:27 +00:00
|
|
|
import re
|
2026-06-23 10:04:23 +00:00
|
|
|
from typing import Any
|
|
|
|
|
from urllib.parse import urljoin, urlparse
|
2026-06-09 00:41:27 +00:00
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
from app.db import execute, fetch_all, fetch_one, get_connection
|
2026-06-09 00:41:27 +00:00
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_url(url: str) -> str:
|
|
|
|
|
url = (url or "").strip()
|
|
|
|
|
if not url.startswith(("http://", "https://")):
|
|
|
|
|
url = "https://" + url.lstrip("/")
|
|
|
|
|
parsed = urlparse(url)
|
|
|
|
|
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
|
|
|
raise ValueError("URL must start with http:// or https://")
|
|
|
|
|
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_page(url: str) -> tuple[str, str, str, str] | None:
|
|
|
|
|
"""Returns final_url, title, normalized_text, raw_html."""
|
|
|
|
|
try:
|
|
|
|
|
resp = httpx.get(
|
|
|
|
|
url,
|
|
|
|
|
timeout=25.0,
|
|
|
|
|
follow_redirects=True,
|
|
|
|
|
headers={"User-Agent": USER_AGENT, "Accept-Language": "nl-NL,nl;q=0.9"},
|
|
|
|
|
)
|
|
|
|
|
if resp.status_code >= 400:
|
|
|
|
|
return None
|
|
|
|
|
html = resp.text
|
|
|
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
|
|
|
title = (soup.title.string or "").strip() if soup.title else ""
|
|
|
|
|
for tag in soup(["script", "style", "noscript", "svg", "iframe"]):
|
|
|
|
|
tag.decompose()
|
|
|
|
|
text = re.sub(r"\s+", " ", soup.get_text(" ", strip=True))
|
|
|
|
|
return str(resp.url), title, text, html
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
def _extract_parse_info(
|
|
|
|
|
html: str,
|
|
|
|
|
text: str,
|
|
|
|
|
title: str,
|
|
|
|
|
final_url: str,
|
|
|
|
|
base_url: str,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
soup = BeautifulSoup(html or "", "html.parser")
|
|
|
|
|
headings: list[str] = []
|
|
|
|
|
for tag in soup.find_all(["h1", "h2", "h3"])[:12]:
|
|
|
|
|
t = re.sub(r"\s+", " ", (tag.get_text() or "").strip())
|
|
|
|
|
if t:
|
|
|
|
|
headings.append(t[:140])
|
|
|
|
|
|
|
|
|
|
links_sample: list[dict[str, str]] = []
|
|
|
|
|
seen_hrefs: set[str] = set()
|
|
|
|
|
for a in soup.find_all("a", href=True):
|
|
|
|
|
href = (a.get("href") or "").strip()
|
|
|
|
|
if not href or href.startswith("#") or href.lower().startswith("javascript:"):
|
|
|
|
|
continue
|
|
|
|
|
if not href.startswith("http"):
|
|
|
|
|
href = urljoin(base_url or final_url, href)
|
|
|
|
|
if href in seen_hrefs:
|
|
|
|
|
continue
|
|
|
|
|
seen_hrefs.add(href)
|
|
|
|
|
label = re.sub(r"\s+", " ", (a.get_text() or "").strip())[:90]
|
|
|
|
|
links_sample.append({"href": href, "label": label or href})
|
|
|
|
|
if len(links_sample) >= 10:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
words = len(text.split()) if text else 0
|
|
|
|
|
excerpt = ""
|
|
|
|
|
if text:
|
|
|
|
|
excerpt = text[:320] + ("…" if len(text) > 320 else "")
|
|
|
|
|
|
|
|
|
|
meta_desc = ""
|
|
|
|
|
md = soup.find("meta", attrs={"name": "description"})
|
|
|
|
|
if md and md.get("content"):
|
|
|
|
|
meta_desc = str(md["content"]).strip()[:240]
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"title": title or "(geen titel)",
|
|
|
|
|
"final_url": final_url,
|
|
|
|
|
"word_count": words,
|
|
|
|
|
"char_count": len(text or ""),
|
|
|
|
|
"excerpt": excerpt,
|
|
|
|
|
"meta_description": meta_desc,
|
|
|
|
|
"headings": headings,
|
|
|
|
|
"links_count": len(seen_hrefs) if seen_hrefs else len(soup.find_all("a", href=True)),
|
|
|
|
|
"links_sample": links_sample,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 00:41:27 +00:00
|
|
|
def get_page_hash(url: str) -> str | None:
|
|
|
|
|
fetched = _fetch_page(url)
|
|
|
|
|
if not fetched:
|
|
|
|
|
return None
|
|
|
|
|
_, _, text, _ = fetched
|
|
|
|
|
return hashlib.md5(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
def _save_snapshot(
|
|
|
|
|
site_id: int,
|
|
|
|
|
url: str,
|
|
|
|
|
final_url: str,
|
|
|
|
|
title: str,
|
|
|
|
|
text: str,
|
|
|
|
|
html: str,
|
|
|
|
|
parse_info: dict[str, Any] | None = None,
|
|
|
|
|
) -> int | None:
|
|
|
|
|
parse_info = parse_info or _extract_parse_info(html, text, title, final_url, url)
|
|
|
|
|
metadata = {"source": "monitor", "parse": parse_info}
|
|
|
|
|
links_json = json.dumps(parse_info.get("links_sample") or [])
|
2026-06-09 00:41:27 +00:00
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with get_connection() as conn:
|
|
|
|
|
with conn.cursor() as cur:
|
|
|
|
|
cur.execute(
|
|
|
|
|
"""
|
2026-06-23 10:04:23 +00:00
|
|
|
INSERT INTO crawled_pages (url, final_url, title, content, content_html, site_id, metadata, links, crawled_at)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, NOW())
|
2026-06-09 00:41:27 +00:00
|
|
|
ON CONFLICT (url) DO UPDATE SET
|
|
|
|
|
final_url=EXCLUDED.final_url, title=EXCLUDED.title,
|
|
|
|
|
content=EXCLUDED.content, content_html=EXCLUDED.content_html,
|
2026-06-23 10:04:23 +00:00
|
|
|
site_id=EXCLUDED.site_id, metadata=EXCLUDED.metadata,
|
|
|
|
|
links=EXCLUDED.links, crawled_at=NOW()
|
2026-06-09 00:41:27 +00:00
|
|
|
RETURNING id
|
|
|
|
|
""",
|
|
|
|
|
(
|
|
|
|
|
url,
|
|
|
|
|
final_url,
|
|
|
|
|
title,
|
|
|
|
|
text[:50000],
|
|
|
|
|
html[:100000],
|
|
|
|
|
site_id,
|
2026-06-23 10:04:23 +00:00
|
|
|
json.dumps(metadata),
|
|
|
|
|
links_json,
|
2026-06-09 00:41:27 +00:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
page_id = cur.fetchone()[0]
|
|
|
|
|
cur.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO browser_sessions (url, final_url, title, status, content_text, site_id, completed_at)
|
|
|
|
|
VALUES (%s,%s,%s,'completed',%s,%s,NOW()) RETURNING id
|
|
|
|
|
""",
|
|
|
|
|
(url, final_url, title, text[:80000], site_id),
|
|
|
|
|
)
|
|
|
|
|
session_id = cur.fetchone()[0]
|
|
|
|
|
cur.execute(
|
|
|
|
|
"UPDATE monitored_sites SET last_title=%s, last_snapshot_id=%s WHERE id=%s",
|
|
|
|
|
(title, session_id, site_id),
|
|
|
|
|
)
|
|
|
|
|
return session_id
|
|
|
|
|
except Exception:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def add_site(url: str, name: str) -> dict:
|
|
|
|
|
url = _validate_url(url)
|
|
|
|
|
name = (name or url).strip()
|
|
|
|
|
fetched = _fetch_page(url)
|
|
|
|
|
if fetched:
|
|
|
|
|
final_url, title, text, html = fetched
|
|
|
|
|
h = hashlib.md5(text.encode("utf-8")).hexdigest()
|
|
|
|
|
else:
|
|
|
|
|
final_url, title, text, html = url, name, "", ""
|
|
|
|
|
h = hashlib.md5(url.encode("utf-8")).hexdigest()
|
|
|
|
|
with get_connection() as conn:
|
|
|
|
|
with conn.cursor() as cur:
|
|
|
|
|
cur.execute(
|
|
|
|
|
"""
|
|
|
|
|
INSERT INTO monitored_sites (url, name, last_hash, last_crawled, last_title, is_active)
|
|
|
|
|
VALUES (%s, %s, %s, NOW(), %s, TRUE)
|
|
|
|
|
ON CONFLICT (url) DO UPDATE SET
|
|
|
|
|
name = EXCLUDED.name,
|
|
|
|
|
last_hash = EXCLUDED.last_hash,
|
|
|
|
|
last_crawled = NOW(),
|
|
|
|
|
last_title = EXCLUDED.last_title,
|
|
|
|
|
is_active = TRUE
|
|
|
|
|
RETURNING id
|
|
|
|
|
""",
|
|
|
|
|
(url, name, h, title),
|
|
|
|
|
)
|
|
|
|
|
site_id = cur.fetchone()[0]
|
|
|
|
|
if fetched:
|
2026-06-23 10:04:23 +00:00
|
|
|
parse_info = _extract_parse_info(html, text, title, final_url, url)
|
|
|
|
|
_save_snapshot(site_id, url, final_url, title, text, html, parse_info)
|
2026-06-09 00:41:27 +00:00
|
|
|
row = fetch_one(
|
|
|
|
|
"SELECT id, url, name, last_hash, last_crawled, last_title, is_active, last_snapshot_id FROM monitored_sites WHERE id = %s",
|
|
|
|
|
(site_id,),
|
|
|
|
|
)
|
|
|
|
|
return dict(row) if row else {"id": site_id, "url": url, "name": name}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def remove_site(site_id: int, soft: bool = True) -> None:
|
|
|
|
|
if soft:
|
|
|
|
|
execute("UPDATE monitored_sites SET is_active = FALSE WHERE id = %s", (site_id,))
|
|
|
|
|
else:
|
|
|
|
|
execute("DELETE FROM crawl_logs WHERE site_id = %s", (site_id,))
|
|
|
|
|
execute("DELETE FROM page_changes WHERE site_id = %s", (site_id,))
|
|
|
|
|
execute("DELETE FROM monitored_sites WHERE id = %s", (site_id,))
|
|
|
|
|
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
def _crawl_one_site(site: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
site_id = int(site["id"])
|
|
|
|
|
url = site["url"]
|
|
|
|
|
name = site.get("name") or url
|
|
|
|
|
result: dict[str, Any] = {
|
|
|
|
|
"site_id": site_id,
|
|
|
|
|
"url": url,
|
|
|
|
|
"name": name,
|
|
|
|
|
"status": "ERROR",
|
|
|
|
|
"changed": False,
|
|
|
|
|
"error": None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fetched = _fetch_page(url)
|
|
|
|
|
if not fetched:
|
|
|
|
|
msg = f"Kan {url} niet bereiken"
|
|
|
|
|
execute(
|
|
|
|
|
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
|
|
|
|
|
(site_id, "ERROR", msg),
|
|
|
|
|
)
|
|
|
|
|
result["error"] = msg
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
final_url, title, text, html = fetched
|
|
|
|
|
parse_info = _extract_parse_info(html, text, title, final_url, url)
|
|
|
|
|
new_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
|
|
|
|
|
old_hash = site.get("last_hash")
|
|
|
|
|
changed = bool(old_hash and old_hash != new_hash)
|
|
|
|
|
|
|
|
|
|
if changed:
|
|
|
|
|
execute(
|
|
|
|
|
"INSERT INTO page_changes (site_id, old_hash, new_hash) VALUES (%s, %s, %s)",
|
|
|
|
|
(site_id, old_hash, new_hash),
|
|
|
|
|
)
|
|
|
|
|
execute(
|
|
|
|
|
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
|
|
|
|
|
(site_id, "CHANGE", f"Wijziging op {url} — {title}"),
|
|
|
|
|
)
|
|
|
|
|
result["status"] = "CHANGE"
|
|
|
|
|
else:
|
|
|
|
|
execute(
|
|
|
|
|
"INSERT INTO crawl_logs (site_id, status, message) VALUES (%s, %s, %s)",
|
|
|
|
|
(site_id, "OK", f"Crawl OK — {title} ({parse_info['word_count']} woorden)"),
|
|
|
|
|
)
|
|
|
|
|
result["status"] = "OK"
|
|
|
|
|
|
|
|
|
|
execute(
|
|
|
|
|
"""
|
|
|
|
|
UPDATE monitored_sites SET last_hash=%s, last_crawled=NOW(), last_title=%s WHERE id=%s
|
|
|
|
|
""",
|
|
|
|
|
(new_hash, title, site_id),
|
|
|
|
|
)
|
|
|
|
|
snapshot_id = _save_snapshot(site_id, url, final_url, title, text, html, parse_info)
|
|
|
|
|
|
|
|
|
|
result.update(parse_info)
|
|
|
|
|
result["changed"] = changed
|
|
|
|
|
result["snapshot_id"] = snapshot_id
|
|
|
|
|
return result
|
2026-06-09 00:41:27 +00:00
|
|
|
|
|
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
def trigger_crawl(site_id: int | None = None) -> dict[str, Any]:
|
2026-06-09 00:41:27 +00:00
|
|
|
if site_id:
|
|
|
|
|
row = fetch_one(
|
2026-06-23 10:04:23 +00:00
|
|
|
"SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE",
|
2026-06-09 00:41:27 +00:00
|
|
|
(site_id,),
|
|
|
|
|
)
|
2026-06-23 10:04:23 +00:00
|
|
|
sites = [dict(row)] if row else []
|
2026-06-09 00:41:27 +00:00
|
|
|
else:
|
2026-06-23 10:04:23 +00:00
|
|
|
sites = [
|
|
|
|
|
dict(r)
|
|
|
|
|
for r in fetch_all(
|
|
|
|
|
"SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE is_active = TRUE ORDER BY id"
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if not sites:
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"method": "inline",
|
|
|
|
|
"sites": 0,
|
|
|
|
|
"changed": 0,
|
|
|
|
|
"errors": 0,
|
|
|
|
|
"results": [],
|
|
|
|
|
"message": "Geen actieve monitor-sites — voeg eerst een URL toe.",
|
|
|
|
|
}
|
2026-06-09 00:41:27 +00:00
|
|
|
|
2026-06-23 10:04:23 +00:00
|
|
|
results: list[dict[str, Any]] = []
|
2026-06-09 00:41:27 +00:00
|
|
|
changed = 0
|
2026-06-23 10:04:23 +00:00
|
|
|
errors = 0
|
|
|
|
|
for site in sites:
|
|
|
|
|
row = _crawl_one_site(site)
|
|
|
|
|
results.append(row)
|
|
|
|
|
if row.get("status") == "ERROR":
|
|
|
|
|
errors += 1
|
|
|
|
|
if row.get("changed"):
|
|
|
|
|
changed += 1
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"ok": errors < len(sites),
|
|
|
|
|
"method": "inline",
|
|
|
|
|
"sites": len(sites),
|
|
|
|
|
"changed": changed,
|
|
|
|
|
"errors": errors,
|
|
|
|
|
"results": results,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_parse_results(site_id: int | None = None, limit: int = 20) -> list[dict[str, Any]]:
|
|
|
|
|
limit = max(1, min(limit, 50))
|
|
|
|
|
if site_id:
|
|
|
|
|
rows = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
|
|
|
|
cp.crawled_at, cp.site_id, ms.name AS site_name
|
|
|
|
|
FROM crawled_pages cp
|
|
|
|
|
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
|
|
|
|
WHERE cp.site_id = %s
|
|
|
|
|
ORDER BY cp.crawled_at DESC NULLS LAST
|
|
|
|
|
LIMIT %s
|
|
|
|
|
""",
|
|
|
|
|
(site_id, limit),
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
rows = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
|
|
|
|
cp.crawled_at, cp.site_id, ms.name AS site_name
|
|
|
|
|
FROM crawled_pages cp
|
|
|
|
|
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
|
|
|
|
ORDER BY cp.crawled_at DESC NULLS LAST
|
|
|
|
|
LIMIT %s
|
|
|
|
|
""",
|
|
|
|
|
(limit,),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
out: list[dict[str, Any]] = []
|
|
|
|
|
for row in rows:
|
|
|
|
|
item = dict(row)
|
|
|
|
|
meta = item.get("metadata") or {}
|
|
|
|
|
if isinstance(meta, str):
|
|
|
|
|
try:
|
|
|
|
|
meta = json.loads(meta)
|
|
|
|
|
except Exception:
|
|
|
|
|
meta = {}
|
|
|
|
|
parse = (meta or {}).get("parse") or {}
|
|
|
|
|
content = item.get("content") or ""
|
|
|
|
|
if not parse.get("excerpt") and content:
|
|
|
|
|
parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
|
|
|
|
|
if not parse.get("word_count") and content:
|
|
|
|
|
parse["word_count"] = len(str(content).split())
|
|
|
|
|
links = item.get("links") or []
|
|
|
|
|
if isinstance(links, str):
|
|
|
|
|
try:
|
|
|
|
|
links = json.loads(links)
|
|
|
|
|
except Exception:
|
|
|
|
|
links = []
|
|
|
|
|
if not parse.get("links_sample") and links:
|
|
|
|
|
parse["links_sample"] = links
|
|
|
|
|
crawled = item.get("crawled_at")
|
|
|
|
|
if crawled is not None and hasattr(crawled, "isoformat"):
|
|
|
|
|
item["crawled_at"] = crawled.isoformat()
|
|
|
|
|
out.append(
|
|
|
|
|
{
|
|
|
|
|
"id": item.get("id"),
|
|
|
|
|
"site_id": item.get("site_id"),
|
|
|
|
|
"site_name": item.get("site_name"),
|
|
|
|
|
"url": item.get("url"),
|
|
|
|
|
"final_url": item.get("final_url"),
|
|
|
|
|
"title": item.get("title") or parse.get("title"),
|
|
|
|
|
"crawled_at": item.get("crawled_at"),
|
|
|
|
|
"word_count": parse.get("word_count", 0),
|
|
|
|
|
"excerpt": parse.get("excerpt", ""),
|
|
|
|
|
"meta_description": parse.get("meta_description", ""),
|
|
|
|
|
"headings": parse.get("headings") or [],
|
|
|
|
|
"links_count": parse.get("links_count", len(links)),
|
|
|
|
|
"links_sample": parse.get("links_sample") or links[:10],
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
NL_STOPWORDS = frozenset(
|
|
|
|
|
"""
|
|
|
|
|
de het een en van in op te dat die dit voor met als zij ze er maar om ook al naar dan wel
|
|
|
|
|
kan zo nog uit over bij tot door na ons uw u uw je jij mij hem haar hun was zijn worden wordt
|
|
|
|
|
heb hebt heeft hebben had deed doen done the and or is are was were be been being a an to of in
|
|
|
|
|
for on at by from with about into through during before after above below between under again
|
|
|
|
|
further then once here there when where why how all each few more most other some such no nor
|
|
|
|
|
not only own same so than too very just don should now naar website home pagina menu contact
|
|
|
|
|
service cookie cookies privacy login inloggen registreren meer lees read click klik
|
|
|
|
|
""".split()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _tokens(text: str, min_len: int = 4) -> list[str]:
|
|
|
|
|
if not text:
|
|
|
|
|
return []
|
|
|
|
|
raw = re.findall(r"[a-zA-Zà-üÀ-Ü0-9][a-zA-Zà-üÀ-Ü0-9\-]{2,}", text.lower())
|
|
|
|
|
return [t for t in raw if len(t) >= min_len and t not in NL_STOPWORDS and not t.isdigit()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_page_row(row: dict[str, Any], *, content_limit: int = 8000) -> dict[str, Any]:
|
|
|
|
|
item = dict(row)
|
|
|
|
|
meta = item.get("metadata") or {}
|
|
|
|
|
if isinstance(meta, str):
|
|
|
|
|
try:
|
|
|
|
|
meta = json.loads(meta)
|
|
|
|
|
except Exception:
|
|
|
|
|
meta = {}
|
|
|
|
|
parse = (meta or {}).get("parse") or {}
|
|
|
|
|
content = str(item.get("content") or "")
|
|
|
|
|
if not parse.get("excerpt") and content:
|
|
|
|
|
parse["excerpt"] = content[:320] + ("…" if len(content) > 320 else "")
|
|
|
|
|
if not parse.get("word_count") and content:
|
|
|
|
|
parse["word_count"] = len(content.split())
|
|
|
|
|
links = item.get("links") or []
|
|
|
|
|
if isinstance(links, str):
|
|
|
|
|
try:
|
|
|
|
|
links = json.loads(links)
|
|
|
|
|
except Exception:
|
|
|
|
|
links = []
|
|
|
|
|
if not parse.get("links_sample") and links:
|
|
|
|
|
parse["links_sample"] = links
|
|
|
|
|
crawled = item.get("crawled_at")
|
|
|
|
|
if crawled is not None and hasattr(crawled, "isoformat"):
|
|
|
|
|
crawled = crawled.isoformat()
|
|
|
|
|
content_read = content[:content_limit]
|
|
|
|
|
if len(content) > content_limit:
|
|
|
|
|
content_read += "\n\n[… tekst ingekort — open volledige pagina voor alles …]"
|
|
|
|
|
return {
|
|
|
|
|
"id": item.get("id"),
|
|
|
|
|
"site_id": item.get("site_id"),
|
|
|
|
|
"site_name": item.get("site_name"),
|
|
|
|
|
"url": item.get("url"),
|
|
|
|
|
"final_url": item.get("final_url"),
|
|
|
|
|
"title": item.get("title") or parse.get("title"),
|
|
|
|
|
"crawled_at": crawled,
|
|
|
|
|
"word_count": parse.get("word_count", 0),
|
|
|
|
|
"char_count": parse.get("char_count", len(content)),
|
|
|
|
|
"excerpt": parse.get("excerpt", ""),
|
|
|
|
|
"meta_description": parse.get("meta_description", ""),
|
|
|
|
|
"headings": parse.get("headings") or [],
|
|
|
|
|
"links_count": parse.get("links_count", len(links)),
|
|
|
|
|
"links_sample": parse.get("links_sample") or links[:15],
|
|
|
|
|
"content_read": content_read,
|
|
|
|
|
"content_length": len(content),
|
|
|
|
|
"has_full_content": len(content) > 0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_parse_page(page_id: int) -> dict[str, Any] | None:
|
|
|
|
|
row = fetch_one(
|
|
|
|
|
"""
|
|
|
|
|
SELECT cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
|
|
|
|
cp.crawled_at, cp.site_id, ms.name AS site_name
|
|
|
|
|
FROM crawled_pages cp
|
|
|
|
|
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
|
|
|
|
WHERE cp.id = %s
|
|
|
|
|
""",
|
|
|
|
|
(page_id,),
|
|
|
|
|
)
|
|
|
|
|
if not row:
|
|
|
|
|
return None
|
|
|
|
|
page = _normalize_page_row(dict(row), content_limit=50000)
|
|
|
|
|
page["content_full"] = str(dict(row).get("content") or "")
|
|
|
|
|
return page
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_parse_intelligence(
|
|
|
|
|
site_id: int | None = None,
|
|
|
|
|
query: str | None = None,
|
|
|
|
|
limit: int = 30,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Aggregate parsed pages for analysis — hype terms, trends, readable content."""
|
|
|
|
|
limit = max(1, min(limit, 100))
|
|
|
|
|
if site_id:
|
|
|
|
|
rows = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT DISTINCT ON (cp.site_id)
|
|
|
|
|
cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
|
|
|
|
cp.crawled_at, cp.site_id, ms.name AS site_name
|
|
|
|
|
FROM crawled_pages cp
|
|
|
|
|
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
|
|
|
|
WHERE cp.site_id = %s
|
|
|
|
|
ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
|
|
|
|
|
""",
|
|
|
|
|
(site_id,),
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
rows = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT DISTINCT ON (cp.site_id)
|
|
|
|
|
cp.id, cp.url, cp.final_url, cp.title, cp.content, cp.metadata, cp.links,
|
|
|
|
|
cp.crawled_at, cp.site_id, ms.name AS site_name
|
|
|
|
|
FROM crawled_pages cp
|
|
|
|
|
LEFT JOIN monitored_sites ms ON ms.id = cp.site_id
|
|
|
|
|
WHERE cp.site_id IS NOT NULL
|
|
|
|
|
ORDER BY cp.site_id, cp.crawled_at DESC NULLS LAST
|
|
|
|
|
LIMIT %s
|
|
|
|
|
""",
|
|
|
|
|
(limit,),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
pages = [_normalize_page_row(dict(r)) for r in rows]
|
|
|
|
|
q = (query or "").strip().lower()
|
|
|
|
|
if q:
|
|
|
|
|
pages = [
|
|
|
|
|
p
|
|
|
|
|
for p in pages
|
|
|
|
|
if q in (p.get("title") or "").lower()
|
|
|
|
|
or q in (p.get("content_read") or "").lower()
|
|
|
|
|
or q in (p.get("excerpt") or "").lower()
|
|
|
|
|
or any(q in h.lower() for h in p.get("headings") or [])
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
changed_site_ids: set[int] = set()
|
|
|
|
|
recent_changes: list[dict[str, Any]] = []
|
|
|
|
|
try:
|
|
|
|
|
change_rows = fetch_all(
|
|
|
|
|
"""
|
|
|
|
|
SELECT pc.site_id, pc.changed_at, ms.name, ms.url
|
|
|
|
|
FROM page_changes pc
|
|
|
|
|
JOIN monitored_sites ms ON ms.id = pc.site_id
|
|
|
|
|
WHERE pc.changed_at >= NOW() - INTERVAL '7 days'
|
|
|
|
|
ORDER BY pc.changed_at DESC
|
|
|
|
|
LIMIT 30
|
|
|
|
|
"""
|
|
|
|
|
)
|
|
|
|
|
for cr in change_rows:
|
|
|
|
|
sid = int(cr["site_id"])
|
|
|
|
|
changed_site_ids.add(sid)
|
|
|
|
|
ts = cr.get("changed_at")
|
|
|
|
|
if ts is not None and hasattr(ts, "isoformat"):
|
|
|
|
|
ts = ts.isoformat()
|
|
|
|
|
recent_changes.append(
|
|
|
|
|
{
|
|
|
|
|
"site_id": sid,
|
|
|
|
|
"site_name": cr.get("name"),
|
|
|
|
|
"url": cr.get("url"),
|
|
|
|
|
"changed_at": ts,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
term_scores: dict[str, dict[str, Any]] = {}
|
|
|
|
|
heading_counts: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
|
|
|
|
def bump_term(term: str, site_name: str, weight: int = 1) -> None:
|
|
|
|
|
if len(term) < 3:
|
|
|
|
|
return
|
|
|
|
|
bucket = term_scores.setdefault(term, {"term": term, "score": 0, "sites": set()})
|
|
|
|
|
bucket["score"] += weight
|
|
|
|
|
if site_name:
|
|
|
|
|
bucket["sites"].add(site_name)
|
|
|
|
|
|
|
|
|
|
for page in pages:
|
|
|
|
|
site_name = page.get("site_name") or str(page.get("site_id") or "")
|
|
|
|
|
for tok in _tokens(page.get("title") or "", min_len=3):
|
|
|
|
|
bump_term(tok, site_name, 3)
|
|
|
|
|
for h in page.get("headings") or []:
|
|
|
|
|
hnorm = re.sub(r"\s+", " ", h.strip())[:80]
|
|
|
|
|
if len(hnorm) < 3:
|
|
|
|
|
continue
|
|
|
|
|
hc = heading_counts.setdefault(hnorm.lower(), {"label": hnorm, "count": 0, "sites": set()})
|
|
|
|
|
hc["count"] += 1
|
|
|
|
|
hc["sites"].add(site_name)
|
|
|
|
|
for tok in _tokens(h, min_len=3):
|
|
|
|
|
bump_term(tok, site_name, 4)
|
|
|
|
|
for tok in _tokens(page.get("content_read") or ""):
|
|
|
|
|
bump_term(tok, site_name, 1)
|
|
|
|
|
for link in page.get("links_sample") or []:
|
|
|
|
|
for tok in _tokens(link.get("label") or "", min_len=3):
|
|
|
|
|
bump_term(tok, site_name, 2)
|
|
|
|
|
|
|
|
|
|
hype_terms: list[dict[str, Any]] = []
|
|
|
|
|
for term, data in term_scores.items():
|
|
|
|
|
if data["score"] < 4:
|
|
|
|
|
continue
|
|
|
|
|
sites_list = sorted(data["sites"])
|
|
|
|
|
hype_terms.append(
|
|
|
|
|
{
|
|
|
|
|
"term": term,
|
|
|
|
|
"score": data["score"],
|
|
|
|
|
"site_count": len(sites_list),
|
|
|
|
|
"sites": sites_list[:5],
|
|
|
|
|
"cross_site": len(sites_list) >= 2,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
hype_terms.sort(key=lambda x: (-x["score"], -x["site_count"], x["term"]))
|
|
|
|
|
hype_terms = hype_terms[:40]
|
|
|
|
|
|
|
|
|
|
heading_trends = []
|
|
|
|
|
for _key, data in heading_counts.items():
|
|
|
|
|
if data["count"] < 1:
|
|
|
|
|
continue
|
|
|
|
|
heading_trends.append(
|
|
|
|
|
{
|
|
|
|
|
"label": data["label"],
|
|
|
|
|
"count": data["count"],
|
|
|
|
|
"sites": sorted(data["sites"])[:6],
|
|
|
|
|
"cross_site": len(data["sites"]) >= 2,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
heading_trends.sort(key=lambda x: (-x["count"], -len(x["sites"]), x["label"]))
|
|
|
|
|
heading_trends = heading_trends[:25]
|
|
|
|
|
|
|
|
|
|
top_term_set = {t["term"] for t in hype_terms[:15]}
|
|
|
|
|
food_signals = frozenset(
|
|
|
|
|
"halal vegan plantaardig biologisch bio trend nieuw actie aanbieding kip rund vlees vis "
|
|
|
|
|
"groente fruit snack curry kebab burger protein eiwit alternatief duurzaam premium "
|
|
|
|
|
"supermarkt retail assortiment prijs private label merk".split()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for page in pages:
|
|
|
|
|
signals: list[str] = []
|
|
|
|
|
wc = int(page.get("word_count") or 0)
|
|
|
|
|
sid = page.get("site_id")
|
|
|
|
|
if wc >= 1500:
|
|
|
|
|
signals.append("Rijke pagina — veel te analyseren")
|
|
|
|
|
elif wc >= 400:
|
|
|
|
|
signals.append("Normale pagina-dichtheid")
|
|
|
|
|
if sid in changed_site_ids:
|
|
|
|
|
signals.append("Recent gewijzigd — mogelijke hype/shift")
|
|
|
|
|
|
|
|
|
|
page_terms = set(_tokens((page.get("content_read") or "") + " " + " ".join(page.get("headings") or [])))
|
|
|
|
|
matched_hype = [t for t in top_term_set if t in page_terms]
|
|
|
|
|
food_hits = [t for t in page_terms if t in food_signals]
|
|
|
|
|
for t in matched_hype[:4]:
|
|
|
|
|
signals.append(f"Trend-term: {t}")
|
|
|
|
|
for t in food_hits[:3]:
|
|
|
|
|
if f"Trend-term: {t}" not in signals:
|
|
|
|
|
signals.append(f"Food-signaal: {t}")
|
|
|
|
|
|
|
|
|
|
if page.get("meta_description"):
|
|
|
|
|
signals.append("SEO meta beschikbaar")
|
|
|
|
|
|
|
|
|
|
page["signals"] = signals[:8]
|
|
|
|
|
page["hype_score"] = len(matched_hype) * 10 + len(food_hits) * 5 + (20 if sid in changed_site_ids else 0) + min(wc // 200, 15)
|
|
|
|
|
page["recently_changed"] = sid in changed_site_ids
|
|
|
|
|
|
|
|
|
|
pages.sort(key=lambda p: (-(p.get("hype_score") or 0), -(p.get("word_count") or 0)))
|
|
|
|
|
|
|
|
|
|
total_words = sum(int(p.get("word_count") or 0) for p in pages)
|
|
|
|
|
return {
|
|
|
|
|
"summary": {
|
|
|
|
|
"pages": len(pages),
|
|
|
|
|
"total_words": total_words,
|
|
|
|
|
"themes_detected": len(hype_terms),
|
|
|
|
|
"headings_unique": len(heading_trends),
|
|
|
|
|
"changes_7d": len(recent_changes),
|
|
|
|
|
"query": q or None,
|
|
|
|
|
},
|
|
|
|
|
"hype_terms": hype_terms,
|
|
|
|
|
"heading_trends": heading_trends,
|
|
|
|
|
"recent_changes": recent_changes[:12],
|
|
|
|
|
"pages": pages,
|
|
|
|
|
}
|