5d60d33db1
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
212 lines
7.5 KiB
Python
212 lines
7.5 KiB
Python
"""Fetch live supermarket folders from reclamefolder.nl — all chains."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import date, datetime, timezone
|
|
from typing import Any, Optional
|
|
from urllib.parse import unquote
|
|
from urllib.request import HTTPCookieProcessor, Request, build_opener
|
|
|
|
from app.db import execute, fetch_all, fetch_one
|
|
|
|
BASE = "https://www.reclamefolder.nl"
|
|
SUPERMARKT_URL = f"{BASE}/categorieen/supermarkt/"
|
|
SITEMAP_RETAILERS = f"{BASE}/sitemap/retailers.xml"
|
|
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
|
|
|
SUPERMARKT_KEYWORDS = (
|
|
"supermarkt", "markt", "ah", "jumbo", "lidl", "aldi", "plus", "dirk",
|
|
"coop", "boni", "vomar", "deka", "ekoplaza", "mitra", "spar", "hoogvliet",
|
|
"food", "gall", "poiesz", "nettorama",
|
|
)
|
|
|
|
_opener = None
|
|
|
|
|
|
def _get_opener():
|
|
global _opener
|
|
if _opener is None:
|
|
_opener = build_opener(HTTPCookieProcessor())
|
|
return _opener
|
|
|
|
|
|
def _fetch_html(url: str) -> str:
|
|
headers = {"User-Agent": USER_AGENT, "Accept-Language": "nl-NL,nl;q=0.9"}
|
|
html = _get_opener().open(Request(url, headers=headers), timeout=35).read().decode("utf-8", "ignore")
|
|
if "__NEXT_DATA__" not in html:
|
|
cb = re.search(r"decodeURIComponent\('([^']+)'\)", html)
|
|
if cb:
|
|
html = _get_opener().open(Request(unquote(cb.group(1)), headers=headers), timeout=35).read().decode("utf-8", "ignore")
|
|
return html
|
|
|
|
|
|
def _fetch_page_props(url: str) -> dict[str, Any]:
|
|
html = _fetch_html(url)
|
|
match = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.+?)</script>', html)
|
|
if not match:
|
|
return {}
|
|
data = json.loads(match.group(1))
|
|
return data.get("props", {}).get("pageProps", {}) or {}
|
|
|
|
|
|
def _parse_day(raw: Optional[str]) -> Optional[date]:
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(raw.replace("Z", "+00:00")).date()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _folder_item(row: dict[str, Any], source: str = "category") -> Optional[dict[str, Any]]:
|
|
retailer = row.get("retailer") or {}
|
|
if source == "retailer_page":
|
|
chain = retailer.get("name") or row.get("name", "")
|
|
edition_id = row.get("id")
|
|
valid_label = ""
|
|
cover = row.get("cover") or {}
|
|
folder_name = row.get("name") or "Folder"
|
|
else:
|
|
chain = retailer.get("name")
|
|
edition_id = row.get("editionId") or row.get("id")
|
|
valid_label = row.get("validLabel") or ""
|
|
cover = row.get("cover") or {}
|
|
folder_name = valid_label or "Folder"
|
|
|
|
if not edition_id or not chain:
|
|
return None
|
|
|
|
valid_to = _parse_day(row.get("validThru"))
|
|
today = datetime.now(timezone.utc).date()
|
|
if valid_to and valid_to < today:
|
|
return None
|
|
|
|
permaname = retailer.get("permaname") or ""
|
|
url = f"{BASE}/f/folders/{edition_id}/"
|
|
title = f"{chain} — {folder_name}" if folder_name != chain else f"{chain} folder ({valid_label})".strip(" ()")
|
|
|
|
return {
|
|
"chain": chain,
|
|
"title": title,
|
|
"folder_path": url,
|
|
"folder_label": valid_label or folder_name or chain,
|
|
"description": f"Reclamefolder.nl · {valid_label or folder_name}".strip(" ·"),
|
|
"valid_from": _parse_day(row.get("validFrom")),
|
|
"valid_to": valid_to,
|
|
"image_url": cover.get("imageUrl") if isinstance(cover, dict) else None,
|
|
"status": "active",
|
|
"promo_type": row.get("name") or "folder",
|
|
"source": "reclamefolder.nl",
|
|
"metadata": {
|
|
"edition_id": str(edition_id),
|
|
"version_id": str(row.get("versionId") or row.get("id") or ""),
|
|
"retailer_permaname": permaname,
|
|
"retailer_url": f"{BASE}/winkels/{permaname}/" if permaname else "",
|
|
"folder_type": row.get("name") or "folder",
|
|
},
|
|
}
|
|
|
|
|
|
def fetch_retailer_slugs() -> list[str]:
|
|
try:
|
|
req = Request(SITEMAP_RETAILERS, headers={"User-Agent": USER_AGENT})
|
|
data = _get_opener().open(req, timeout=45).read()
|
|
text = data.decode("utf-8", "ignore")
|
|
slugs = set()
|
|
for m in re.finditer(r"https://www\.reclamefolder\.nl/winkels/([a-z0-9-]+)/", text):
|
|
slug = m.group(1)
|
|
if "vestiging" in slug:
|
|
continue
|
|
if any(k in slug for k in SUPERMARKET_KEYWORDS):
|
|
slugs.add(slug)
|
|
return sorted(slugs)
|
|
except Exception:
|
|
return [
|
|
"albert-heijn", "jumbo", "lidl", "plus", "dirk", "aldi", "dekamarkt",
|
|
"ekoplaza", "vomar", "coop-supermarkten", "boni-supermarkt", "mitra",
|
|
]
|
|
|
|
|
|
def fetch_supermarkt_folders(include_all_retailers: bool = True) -> list[dict[str, Any]]:
|
|
seen: set[str] = set()
|
|
items: list[dict[str, Any]] = []
|
|
|
|
props = _fetch_page_props(SUPERMARKT_URL)
|
|
for row in props.get("foldersFromProps") or []:
|
|
item = _folder_item(row, "category")
|
|
if item and item["metadata"]["edition_id"] not in seen:
|
|
seen.add(item["metadata"]["edition_id"])
|
|
items.append(item)
|
|
|
|
if include_all_retailers:
|
|
for slug in fetch_retailer_slugs():
|
|
try:
|
|
rprops = _fetch_page_props(f"{BASE}/winkels/{slug}/")
|
|
retailer = rprops.get("retailer") or {}
|
|
for folder in retailer.get("folders") or []:
|
|
folder = dict(folder)
|
|
folder["retailer"] = retailer
|
|
item = _folder_item(folder, "retailer_page")
|
|
if item and item["metadata"]["edition_id"] not in seen:
|
|
seen.add(item["metadata"]["edition_id"])
|
|
items.append(item)
|
|
except Exception:
|
|
continue
|
|
|
|
items.sort(key=lambda x: (x.get("chain") or "", x.get("valid_to") or date.max))
|
|
return items
|
|
|
|
|
|
def sync_to_db() -> dict[str, Any]:
|
|
folders = fetch_supermarkt_folders()
|
|
execute(
|
|
"UPDATE promo_campaigns SET status = 'expired', updated_at = NOW() WHERE source = 'reclamefolder.nl'"
|
|
)
|
|
inserted = 0
|
|
chains: set[str] = set()
|
|
for f in folders:
|
|
fetch_one(
|
|
"""INSERT INTO promo_campaigns
|
|
(chain, title, folder_path, folder_label, description, valid_from, valid_to,
|
|
status, promo_type, image_url, source, metadata)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
|
RETURNING id""",
|
|
(
|
|
f["chain"], f["title"], f["folder_path"], f["folder_label"],
|
|
f["description"], f["valid_from"], f["valid_to"],
|
|
f["status"], f["promo_type"], f.get("image_url"),
|
|
f["source"], json.dumps(f["metadata"]),
|
|
),
|
|
)
|
|
inserted += 1
|
|
chains.add(f["chain"])
|
|
return {
|
|
"ok": True,
|
|
"source": "reclamefolder.nl",
|
|
"synced": inserted,
|
|
"chains": len(chains),
|
|
"items": folders,
|
|
}
|
|
|
|
|
|
def list_cached(limit: int = 200) -> list[dict[str, Any]]:
|
|
rows = fetch_all(
|
|
"""SELECT * FROM promo_campaigns
|
|
WHERE source = 'reclamefolder.nl' AND status = 'active'
|
|
ORDER BY valid_to ASC NULLS LAST, chain ASC
|
|
LIMIT %s""",
|
|
(limit,),
|
|
)
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def list_chains() -> list[str]:
|
|
rows = fetch_all(
|
|
"""SELECT DISTINCT chain FROM promo_campaigns
|
|
WHERE source = 'reclamefolder.nl' AND status = 'active' AND chain IS NOT NULL
|
|
ORDER BY chain"""
|
|
)
|
|
return [r["chain"] for r in rows if r.get("chain")]
|