from __future__ import annotations import hashlib import json import re from typing import Any from urllib.parse import urljoin, urlparse import httpx from bs4 import BeautifulSoup from app.db import execute, fetch_all, fetch_one, get_connection 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 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, } 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() 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 []) try: with get_connection() as conn: with conn.cursor() as cur: cur.execute( """ 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()) ON CONFLICT (url) DO UPDATE SET final_url=EXCLUDED.final_url, title=EXCLUDED.title, content=EXCLUDED.content, content_html=EXCLUDED.content_html, site_id=EXCLUDED.site_id, metadata=EXCLUDED.metadata, links=EXCLUDED.links, crawled_at=NOW() RETURNING id """, ( url, final_url, title, text[:50000], html[:100000], site_id, json.dumps(metadata), links_json, ), ) 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: parse_info = _extract_parse_info(html, text, title, final_url, url) _save_snapshot(site_id, url, final_url, title, text, html, parse_info) 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,)) 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 def trigger_crawl(site_id: int | None = None) -> dict[str, Any]: if site_id: row = fetch_one( "SELECT id, url, name, last_hash, last_title FROM monitored_sites WHERE id = %s AND is_active = TRUE", (site_id,), ) sites = [dict(row)] if row else [] else: 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.", } results: list[dict[str, Any]] = [] changed = 0 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, }