← back to Elitis Price 2026

exec/elitis_feed.py

136 lines

#!/usr/bin/env python3
"""Elitis feed-first scraper (D). $0 - local curl + node-eval of the elitis.fr
__NUXT__ hydration payload. NO Shopify writes, NO Browserbase, NO paid vision.

Pipeline:
  1. sitemap-en.xml -> wall/panoramic/HP-contract collection URLs (~217).
  2. fetch each (polite delay), extract the window.__NUXT__ IIFE blob.
  3. node extract_nuxt.js -> [{name, slug, n, images:[originals]}] per page.
  4. aggregate pattern -> {collection, images} ; write elitis_feed.json.
  5. coverage: accent-normalized match vs our Needs-Image drafts + missing patterns.

Real brand domain is elitis.fr (NOT elitis.com = an IT consultancy).
Images come from backoffice.elitis.fr/media/cache/resolve/original/... (public)."""
import sys
import os
import re
import json
import subprocess
import time
import urllib.request
import unicodedata

HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..")
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
SITEMAP = "https://elitis.fr/sitemap-en.xml"
WALL_CATS = ("walls", "panoramic", "high-performance-contract")
FEED_OUT = os.path.join(ROOT, "elitis_feed.json")
NUXT_JS = os.path.join(HERE, "extract_nuxt.js")


def norm(s):
    s = unicodedata.normalize("NFKD", s or "")
    s = "".join(c for c in s if not unicodedata.combining(c))
    return " ".join(s.casefold().split())


def fetch(url, timeout=30):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return r.read().decode("utf-8", "replace")


def collection_urls():
    xml = fetch(SITEMAP)
    urls = re.findall(r"<loc>(https://elitis\.fr/en/collections/[^<]+)</loc>", xml)
    out = [u for u in urls if u.split("/collections/", 1)[1].split("/")[0] in WALL_CATS
           and u.count("/collections/") and len(u.split("/collections/")[1].split("/")) == 2]
    return sorted(set(out))


def extract_page(html):
    m = re.search(r"window\.__NUXT__\s*=\s*", html)
    if not m:
        return []
    end = html.find("</script>", m.end())
    blob = html[m.end():end].strip().rstrip(";")
    p = subprocess.run(["node", NUXT_JS], input=blob, capture_output=True, text=True, timeout=60)
    try:
        data = json.loads(p.stdout)
    except Exception:
        return []
    return data if isinstance(data, list) else []


NAV = {"walls", "fabric", "accessoire", "panoramic", "outdoor", "in-outdoor",
       "high-performance-contract", "high performance contract"}


def run_scrape():
    urls = collection_urls()
    print(f"wall/panoramic/HP collection pages: {len(urls)}")
    feed = {}   # norm(pattern) -> {name, collection, images:set}
    errs = 0
    for i, u in enumerate(urls):
        coll = u.rsplit("/", 1)[1]
        try:
            pats = extract_page(fetch(u))
        except Exception as e:
            errs += 1
            print(f"  [err] {coll}: {str(e)[:70]}")
            continue
        for p in pats:
            nm = (p.get("name") or "").strip()
            if not nm or norm(nm) in {norm(x) for x in NAV}:
                continue
            key = norm(nm)
            slot = feed.setdefault(key, {"name": nm, "collection": coll, "images": []})
            for img in p.get("images", []):
                if img not in slot["images"]:
                    slot["images"].append(img)
        if (i + 1) % 25 == 0:
            print(f"  ...{i+1}/{len(urls)} patterns={len(feed)} errs={errs}")
        time.sleep(0.3)
    out = {k: {"name": v["name"], "collection": v["collection"],
               "n_images": len(v["images"]), "images": v["images"]}
           for k, v in feed.items()}
    json.dump(out, open(FEED_OUT, "w"), ensure_ascii=False, indent=1)
    print(f"\nWROTE {FEED_OUT}: {len(out)} patterns, "
          f"{sum(x['n_images'] for x in out.values())} image URLs, errs={errs}")
    return out


def coverage(feed=None):
    if feed is None:
        feed = json.load(open(FEED_OUT))
    feed_norm = set(feed.keys())
    # Needs-Image draft patterns: derive from staging join + the 47 missing list is implicit
    sj = os.path.join(ROOT, "OUT_577_staging_join.csv")
    ndc = os.path.join(ROOT, "OUT_577_no_catalog_data.csv")
    import csv
    want = {}
    for path, tag in [(sj, "staging"), (ndc, "no-data")]:
        if not os.path.exists(path):
            continue
        for r in csv.DictReader(open(path)):
            pat = r.get("pattern") or ""
            if pat:
                want.setdefault(norm(pat), (pat, tag))
    hit = {k: v for k, v in want.items() if k in feed_norm}
    miss = {k: v for k, v in want.items() if k not in feed_norm}
    print(f"\nCOVERAGE vs list patterns needing data: {len(want)} distinct")
    print(f"  COVERED by feed (>=1 image): {len(hit)}")
    print(f"  still missing               : {len(miss)}")
    print("  -- still-missing patterns --")
    for k, (pat, tag) in sorted(miss.items()):
        print(f"    {pat!r} [{tag}]")


if __name__ == "__main__":
    if "--coverage" in sys.argv:
        coverage()
    else:
        feed = run_scrape()
        coverage(feed)