HARvestGym / server /tools /html_distiller.py
kdcyberdude's picture
Upload folder using huggingface_hub
9eebce3 verified
Raw
History Blame
16.9 kB
"""
html_distiller — technology-agnostic HTML distillation for the RL agent.
Converts an HTML response body into a compact, structured dict that the agent
and the embedding index can work with. Raw HTML is never returned as-is —
it is expensive (200 KB+) and mostly noise (CSS classes, JS bundles, nav chrome).
What is extracted (in priority order):
1. Embedded JSON data blobs — server-injected structured data that is the
*actual payload* for SSR pages:
• <script type="application/json"> (Next.js, generic)
• <script type="text/x-magento-init"> (Magento 2)
• window.__INITIAL_STATE__ = {...} (Redux-style SSR)
• window.__NEXT_DATA__ = {...} (Next.js legacy)
• window.__nuxt__ = {...} / window.__NUXT__ = {} (Nuxt.js)
• <script id="__NEXT_DATA__"> (Next.js)
• Any <script> tag containing only valid JSON
These are technology-specific patterns, but the extraction logic is written
generically — it looks for the common conventions rather than hardcoding
Magento. A React/Next.js app will be handled by the same code path.
2. HTML forms — discovers new POST endpoints (form.action) and captures
auth-critical fields (CSRF tokens, hidden inputs).
3. Visible text content — the human-readable body after stripping all
scripts, styles, and nav/header/footer chrome. Capped at MAX_TEXT_CHARS.
Output schema (always a dict with the same keys — absent items are None/[]):
{
"page_type": str, # "data_page" | "form_page" | "text_page"
"title": str | None, # <title> text
"description": str | None, # <meta name="description">
"data_blobs": [ # extracted JSON payloads
{"source": str, "data": any, "keys": [str]} # keys = top-level keys
],
"forms": [
{
"action": str, # endpoint URL (relative or absolute)
"method": str, # GET | POST
"fields": { # name → value (includes hidden inputs)
"field_name": "field_value_or_type"
}
}
],
"text": str | None, # stripped visible text (capped)
"raw_truncated": str, # first RAW_PREVIEW_CHARS of raw HTML (fallback)
}
Usage:
from server.tools.html_distiller import distill_html
result = distill_html(html_string, base_url="http://example.com/page")
# result["data_blobs"] — structured data, e.g. product listings
# result["forms"] — form actions + CSRF tokens
# result["text"] — stripped readable text
"""
from __future__ import annotations
import json
import re
from typing import Any
from urllib.parse import urljoin
try:
from bs4 import BeautifulSoup
_BS4_AVAILABLE = True
except ImportError:
_BS4_AVAILABLE = False
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MAX_TEXT_CHARS = 20000 # max chars of stripped visible text to keep
MAX_BLOB_KEYS = 40 # max top-level keys to surface from a JSON blob
MAX_BLOB_DEPTH_PREVIEW = 2 # how many levels of nesting to summarise
RAW_PREVIEW_CHARS = 1000 # fallback raw HTML preview if BS4 unavailable
MAX_BLOBS = 10 # max embedded JSON blobs to extract
MAX_FORMS = 5 # max forms to extract
MAX_ITEMS_IN_ARRAY = 3 # preview items for large arrays in blobs
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def distill_html(html: str, base_url: str = "") -> dict:
"""
Distil an HTML page into a structured, compact representation.
Args:
html: Raw HTML string (may be very large).
base_url: The URL this page was fetched from, used to resolve
relative URLs in form actions.
Returns:
Distilled dict (see module docstring for schema).
"""
if not html:
return _empty_result()
if not _BS4_AVAILABLE:
return {
**_empty_result(),
"raw_truncated": html[:RAW_PREVIEW_CHARS],
"_note": "beautifulsoup4 not installed; only raw preview returned.",
}
try:
# lxml is faster and more forgiving than html.parser for large pages
soup = BeautifulSoup(html, "lxml")
except Exception:
soup = BeautifulSoup(html, "html.parser")
title = _extract_title(soup)
description = _extract_meta_description(soup)
data_blobs = _extract_data_blobs(soup)
forms = _extract_forms(soup, base_url)
text = _extract_visible_text(soup)
# Determine page_type based on what we found
if data_blobs:
page_type = "data_page"
elif forms:
page_type = "form_page"
else:
page_type = "text_page"
return {
"page_type": page_type,
"title": title,
"description": description,
"data_blobs": data_blobs,
"forms": forms,
"text": text,
"raw_truncated": html[:RAW_PREVIEW_CHARS],
}
def distill_html_compact(html: str, base_url: str = "") -> str:
"""
Return a compact text representation of the distilled HTML,
suitable for returning to the agent in curl_exec responses.
Aims for < 3000 chars while preserving all actionable information.
"""
d = distill_html(html, base_url)
parts: list[str] = []
if d["title"]:
parts.append(f"[Page: {d['title']}]")
if d["description"]:
parts.append(f"[Description: {d['description']}]")
if d["data_blobs"]:
parts.append(f"[Embedded data — {len(d['data_blobs'])} block(s)]")
for i, blob in enumerate(d["data_blobs"]):
src = blob.get("source", "?")
data = blob.get("data")
preview = _compact_blob_preview(data)
parts.append(f" blob[{i}] from <{src}>: {preview}")
if d["forms"]:
parts.append(f"[Forms — {len(d['forms'])} found]")
for form in d["forms"]:
action = form["action"] or "(current page)"
method = form["method"]
fields = form["fields"]
# Strip noisy base64-encoded redirect fields; keep actionable fields only
_SKIP_FIELDS = {"uenc"}
clean_fields = {k: v for k, v in fields.items() if k not in _SKIP_FIELDS}
csrf = {k: v for k, v in clean_fields.items()
if "csrf" in k.lower() or "token" in k.lower()
or k.startswith("_") or clean_fields.get(k, "") == "hidden"}
field_summary = ", ".join(f"{k}={repr(v)}" for k, v in list(clean_fields.items())[:6])
parts.append(f" {method} {action}")
parts.append(f" fields: {field_summary}")
if csrf:
parts.append(f" csrf/hidden: {csrf}")
if d["text"]:
parts.append(f"[Text content]\n{d['text'][:800]}")
result = "\n".join(parts)
if not result:
# Absolute fallback: raw preview
return html[:RAW_PREVIEW_CHARS]
return result
# ---------------------------------------------------------------------------
# Extraction helpers
# ---------------------------------------------------------------------------
def _extract_title(soup) -> str | None:
tag = soup.find("title")
if tag:
return tag.get_text(strip=True) or None
return None
def _extract_meta_description(soup) -> str | None:
tag = soup.find("meta", attrs={"name": "description"})
if tag and tag.get("content"):
return tag["content"].strip() or None
return None
# Patterns for window.X = {...} assignments in inline scripts
_WINDOW_ASSIGN_RE = re.compile(
r'window\.__?([A-Za-z0-9_]+)__?\s*=\s*(\{.*?\}|\[.*?\])',
re.DOTALL,
)
# Known SSR data script types
_DATA_SCRIPT_TYPES = {
"application/json",
"text/x-magento-init",
"application/ld+json", # structured data / schema.org
}
# Known SSR script IDs
_DATA_SCRIPT_IDS = {
"__next_data__",
"__nuxt__",
"initial-state",
"redux-state",
"app-state",
"page-data",
"server-data",
"bootstrap-data",
}
def _try_parse_json(text: str) -> tuple[bool, Any]:
"""Returns (success, parsed_value)."""
text = text.strip()
if not text:
return False, None
try:
return True, json.loads(text)
except (json.JSONDecodeError, ValueError):
return False, None
def _summarise_json_keys(obj: Any, depth: int = 0) -> list[str]:
"""Return top-level keys (and one level of nested keys) for a JSON object."""
if not isinstance(obj, dict):
if isinstance(obj, list) and obj:
return _summarise_json_keys(obj[0], depth)
return []
keys = list(obj.keys())
if depth < 1:
nested = []
for k in keys[:5]:
v = obj[k]
if isinstance(v, dict):
sub = list(v.keys())[:5]
nested.append(f"{k}.{{{','.join(sub)}}}")
elif isinstance(v, list) and v and isinstance(v[0], dict):
sub = list(v[0].keys())[:4]
nested.append(f"{k}[].{{{','.join(sub)}}}")
return keys + nested
return keys
def _extract_data_blobs(soup) -> list[dict]:
"""
Extract all embedded JSON data blobs from <script> tags and window.X = {...} patterns.
"""
blobs: list[dict] = []
seen_sources: set[str] = set()
# 1. <script type="..."> tags with known data types
for script in soup.find_all("script"):
if len(blobs) >= MAX_BLOBS:
break
script_type = (script.get("type") or "").lower().strip()
script_id = (script.get("id") or "").lower().strip()
text = script.string or ""
source = None
if script_type in _DATA_SCRIPT_TYPES:
source = script_type
elif script_id in _DATA_SCRIPT_IDS:
source = f"id={script.get('id')}"
elif script_type in ("", "text/javascript", "module"):
# Check for window.X = {...} patterns
for m in _WINDOW_ASSIGN_RE.finditer(text):
var_name = f"window.__{m.group(1)}__"
ok, data = _try_parse_json(m.group(2))
if ok and isinstance(data, (dict, list)):
source_key = var_name
if source_key not in seen_sources:
seen_sources.add(source_key)
blobs.append({
"source": var_name,
"data": _preview_blob(data),
"keys": _summarise_json_keys(data)[:MAX_BLOB_KEYS],
})
continue # already handled window patterns above
else:
continue
if not text.strip():
continue
ok, data = _try_parse_json(text)
if not ok:
continue
# Skip tiny or trivially small blobs (no useful data)
if isinstance(data, dict) and len(data) <= 1 and not any(
isinstance(v, (dict, list)) for v in data.values()
):
continue
source_key = f"{source}:{script_id or 'anon'}"
if source_key in seen_sources:
continue
seen_sources.add(source_key)
blobs.append({
"source": source,
"data": _preview_blob(data),
"keys": _summarise_json_keys(data)[:MAX_BLOB_KEYS],
})
return blobs
def _preview_blob(data: Any) -> Any:
"""
Return a compact preview of a JSON blob — large arrays are trimmed,
deeply nested objects are summarised.
"""
if isinstance(data, list):
if len(data) > MAX_ITEMS_IN_ARRAY:
return {
"sample": [_preview_blob(item) for item in data[:MAX_ITEMS_IN_ARRAY]],
"total": len(data),
"_note": f"{len(data)} items total. Use search_episode_data() for specifics.",
}
return [_preview_blob(item) for item in data]
if isinstance(data, dict):
result = {}
for k, v in list(data.items())[:MAX_BLOB_KEYS]:
if isinstance(v, list) and len(v) > MAX_ITEMS_IN_ARRAY:
result[k] = {
"sample": [_preview_blob(i) for i in v[:MAX_ITEMS_IN_ARRAY]],
"total": len(v),
"_note": f"{len(v)} items. Use search_episode_data() for specifics.",
}
elif isinstance(v, dict) and len(v) > 30:
# Only collapse very large dicts — preserve small-to-medium ones fully
# since they often contain critical IDs (e.g. product option configs)
result[k] = {
"_keys": list(v.keys())[:20],
"_note": "large nested object — call search_episode_data() for full content",
}
else:
result[k] = v
return result
return data
def _extract_forms(soup, base_url: str) -> list[dict]:
"""
Extract all forms: action URL, method, and all named fields with their values.
Hidden inputs (CSRF tokens, form_key, etc.) are included.
"""
forms = []
for form in soup.find_all("form")[:MAX_FORMS]:
action = form.get("action", "") or ""
if base_url and action and not action.startswith("http"):
action = urljoin(base_url, action)
method = (form.get("method") or "GET").upper()
fields: dict[str, str] = {}
for inp in form.find_all(["input", "select", "textarea"]):
name = inp.get("name")
if not name:
continue
inp_type = (inp.get("type") or "text").lower()
value = inp.get("value", "")
if inp_type == "hidden":
# Hidden inputs: store actual value (CSRF tokens etc.)
fields[name] = value
elif inp_type in ("submit", "button", "reset"):
continue
elif inp_type == "checkbox":
fields[name] = "checkbox"
elif inp_type == "radio":
if name not in fields:
fields[name] = "radio"
else:
# text, email, password, number, etc.
fields[name] = inp_type if not value else value
forms.append({
"action": action,
"method": method,
"fields": fields,
})
return forms
# Tags whose text content is irrelevant noise
_NOISE_TAGS = {
"script", "style", "noscript", "head", "meta", "link",
"header", "footer", "nav", "aside",
"svg", "path", "symbol",
"[document]",
}
def _extract_visible_text(soup) -> str | None:
"""
Extract visible text content from the page.
Strips scripts, styles, navigation, and other noise.
Returns plain text, capped at MAX_TEXT_CHARS.
"""
# Remove noise tags in-place
for tag in soup.find_all(_NOISE_TAGS):
tag.decompose()
# Get text from what's left — use separator so words don't jam together
text = soup.get_text(separator=" ", strip=True)
# Collapse whitespace
text = re.sub(r"\s{2,}", " ", text).strip()
if not text:
return None
return text[:MAX_TEXT_CHARS]
def _compact_blob_preview(data: Any) -> str:
"""One-line preview of a JSON blob for the compact text representation."""
if data is None:
return "null"
if isinstance(data, bool):
return str(data).lower()
if isinstance(data, (int, float)):
return str(data)
if isinstance(data, str):
return repr(data[:80])
if isinstance(data, list):
total = data.get("total") if isinstance(data, dict) else len(data)
sample = data.get("sample") if isinstance(data, dict) else data[:1]
if sample:
first_keys = list(sample[0].keys())[:4] if isinstance(sample[0], dict) else []
return f"array({total} items), first keys: {first_keys}"
return f"array({len(data)} items)"
if isinstance(data, dict):
# If it has a "total" note it's our preview wrapper
if "_note" in data and "total" in data:
sample = data.get("sample", [])
keys = list(sample[0].keys())[:4] if sample and isinstance(sample[0], dict) else []
return f"array({data['total']} items), first item keys: {keys}"
keys = list(data.keys())[:8]
return f"object({len(data)} keys): {keys}"
return str(data)[:100]
def _empty_result() -> dict:
return {
"page_type": "text_page",
"title": None,
"description": None,
"data_blobs": [],
"forms": [],
"text": None,
"raw_truncated": "",
}