feat: rendered comparison through headless Chrome

- github.com/chromedp/chromedp v0.21.0 (decision note
  2026-10-28-chromedp-rendered-crawl.md): with golem15.seo.crawl_chrome_path
  the crawl renders up to golem15.seo.crawl_render_max pages (200) and
  compares them with the raw HTML; without a path it records skipped
- guard: site host and www twin resolved and checked with
  fetchguard.IsPrivateAddr, pinned through --host-resolver-rules with a
  final MAP * ~NOTFOUND; Fetch-domain interception fails every request
  outside the site (third-party scripts never load); fresh temporary
  profile removed on Close; downloads denied; one tab, 20 s, 5 MiB DOM
- a relative or non-executable path, a private address or a Chrome that
  does not start records render_status failed; the crawl still finishes
- config keys documented; README Rendered comparison section
- TestChromeRenderer (runs with a local Chromium) and TestRendererGuards
This commit is contained in:
Jakub Zych
2026-11-04 15:58:36 +01:00
parent 96963c3067
commit b553fc2937
10 changed files with 638 additions and 9 deletions

View File

@@ -104,6 +104,9 @@ type Options struct {
Renderer Renderer
// RenderMax caps the rendered pages (default 200, crawl order).
RenderMax int
// RenderErr is why the renderer could not be built: the comparison is
// recorded as failed with it, and the raw checks run.
RenderErr error
// Canceled is asked between requests; true stops the crawl and keeps
// what was fetched.
Canceled func(ctx context.Context) bool
@@ -164,8 +167,11 @@ func Crawl(ctx context.Context, f *Fetcher, opt Options) (*Result, error) {
opt.RenderMax = DefaultRenderMax
}
res := &Result{Pages: map[string]*Page{}, Listed: map[string]string{}, Render: RenderState{Status: renderStatusSkipped}, fetcher: f}
if opt.Renderer != nil {
switch {
case opt.Renderer != nil:
res.Render.Status = renderStatusDone
case opt.RenderErr != nil:
res.Render = RenderState{Status: renderStatusFailed, Note: opt.RenderErr.Error()}
}
pace := &pacer{delay: opt.Delay}
canceled := func() bool {

View File

@@ -196,10 +196,22 @@ func run(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.Craw
if app := svc.App(); app != nil {
m, _ = conga.From(app)
}
// The rendered comparison: the test seam's renderer, else Chrome from
// golem15.seo.crawl_chrome_path (none: skipped). Chrome is closed when
// the crawl finishes, fails or is cancelled.
renderer, renderErr := rendererFrom(ctx), error(nil)
if renderer == nil {
renderer, renderErr = NewRenderer(ctx, svc.Config(), f.Site(), f.IsTrusted(), f.UA())
if renderer != nil {
defer func() { _ = renderer.Close() }()
}
}
opt := Options{
MaxPages: limits.MaxPages,
Delay: limits.Delay,
Renderer: rendererFrom(ctx),
MaxPages: limits.MaxPages,
Delay: limits.Delay,
Renderer: renderer,
RenderErr: renderErr,
RenderMax: RenderMax(svc.Config()),
Canceled: func(ctx context.Context) bool {
var status string
if err := db.Raw(`SELECT status FROM golem15_seo_crawls WHERE id = ?`, row.ID).Scan(&status).Error; err == nil && status == models.CrawlCancelled {

View File

@@ -1,6 +1,18 @@
package crawl
import "context"
import (
"context"
"fmt"
"net/netip"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/compass"
)
// Renderer renders a page of the site in a browser for the raw versus
// rendered comparison (D-70, P33-55): Render answers the page's DOM after
@@ -41,3 +53,140 @@ func rendererFrom(ctx context.Context) Renderer {
r, _ := ctx.Value(rendererKey{}).(Renderer)
return r
}
// The config keys of the rendered comparison (P33-55). The executable
// path is Go config only (environment-overridable like every key), never
// an admin setting or MCP value: an editable path would let any holder of
// golem15.seo.manage make the server run an arbitrary binary.
const (
// ChromePathConfigKey names an operator-installed Chrome or Chromium
// binary; empty skips the comparison.
ChromePathConfigKey = "golem15.seo.crawl_chrome_path"
// RenderMaxConfigKey caps the pages rendered per crawl.
RenderMaxConfigKey = "golem15.seo.crawl_render_max"
)
// The limits of one rendered page.
const (
RenderTimeout = 20 * time.Second
MaxDOM = 5 << 20
)
// RenderMax is the configured page cap of the comparison (default 200).
func RenderMax(cfg *compass.Config) int {
if cfg != nil {
if n := cfg.Int(RenderMaxConfigKey); n > 0 {
return n
}
}
return DefaultRenderMax
}
// NewRenderer builds the renderer of a crawl of site from config: nil
// without error when golem15.seo.crawl_chrome_path is empty (the
// comparison is skipped); an error, recorded as the comparison's failure
// note, when the path is not absolute, not an executable regular file, the
// site host resolves to a private address outside trusted mode, or Chrome
// does not start. ua is the crawler's User-Agent.
func NewRenderer(ctx context.Context, cfg *compass.Config, site *url.URL, trusted bool, ua string) (Renderer, error) {
if cfg == nil || site == nil {
return nil, nil
}
path := strings.TrimSpace(cfg.String(ChromePathConfigKey))
if path == "" {
return nil, nil
}
if err := checkExecutable(path); err != nil {
return nil, err
}
hosts, err := resolveHosts(ctx, SiteHosts(site.Hostname()), trusted)
if err != nil {
return nil, err
}
r, err := newChromeRenderer(path, hosts, trusted, ua)
if err != nil {
return nil, err
}
return r, nil
}
// checkExecutable refuses a Chrome path that is not absolute or not an
// executable regular file.
func checkExecutable(path string) error {
if !filepath.IsAbs(path) {
return fmt.Errorf("the Chrome path %q is not absolute", path)
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("the Chrome path %q cannot be read", path)
}
if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 {
return fmt.Errorf("the Chrome path %q is not an executable file", path)
}
return nil
}
// resolverRules is Chrome's --host-resolver-rules value: one MAP rule per
// allowed host to one of its checked addresses (IPv4 first), then
// MAP * ~NOTFOUND for every other host. Chrome applies the first rule
// that matches a host, so the catch-all comes last.
func resolverRules(hosts map[string][]netip.Addr) string {
names := make([]string, 0, len(hosts))
for h := range hosts {
names = append(names, h)
}
sort.Strings(names)
var rules []string
for _, h := range names {
addrs := hosts[h]
if len(addrs) == 0 {
continue
}
// One address per host: the first IPv4 one, else the first.
a := addrs[0]
for _, c := range addrs {
if c.Is4() {
a = c
break
}
}
target := a.String()
if a.Is6() {
target = "[" + target + "]"
}
rules = append(rules, "MAP "+h+" "+target)
}
rules = append(rules, "MAP * ~NOTFOUND")
return strings.Join(rules, ", ")
}
// allowRequest reports whether the headless browser may load rawURL: https
// (http only in trusted mode) to one of hosts, or a data: or blob: URL.
// Every other request, third-party scripts included, is failed.
func allowRequest(rawURL string, hosts []string, trusted bool) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
switch strings.ToLower(u.Scheme) {
case "data", "blob":
return true
case "https":
case "http":
if !trusted {
return false
}
default:
return false
}
if u.User != nil {
return false
}
host := strings.ToLower(u.Hostname())
for _, h := range hosts {
if host == strings.ToLower(strings.Trim(h, "[]")) {
return true
}
}
return false
}

View File

@@ -0,0 +1,224 @@
package crawl
import (
"context"
"fmt"
"net"
"net/netip"
"os"
"sort"
"strings"
"sync"
"git.golem15.com/golem15/summercms/modules/fetchguard"
"github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/fetch"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
)
// chromeRenderer renders pages in an operator-installed headless Chrome
// or Chromium through github.com/chromedp/chromedp (D-70, P33-55), held to
// the crawler's fetchguard limits:
//
// - Chrome resolves only the site host and its www twin, pinned through
// --host-resolver-rules to the addresses resolveHosts checked with
// fetchguard.IsPrivateAddr; every other host resolves to NOTFOUND;
// - every request is paused through the Fetch domain and continued only
// when allowRequest passes (https, http only in trusted mode, to an
// allowed host, or data: and blob:); the others fail with
// BlockedByClient, so third-party scripts are never loaded;
// - a fresh temporary profile per crawl, removed by Close; no cookies,
// credentials or extensions; downloads denied;
// - one tab at a time (the crawler serialises Render), 20 s per page, the
// DOM capped at 5 MiB; Close stops Chrome.
//
// chromedp's default allocator flags are used as they are: nothing is
// added that weakens Chrome's sandbox.
type chromeRenderer struct {
allowed []string
trusted bool
profile string
allocCancel context.CancelFunc
browserCtx context.Context
browserCancel context.CancelFunc
mu sync.Mutex
blocked []string
closed bool
}
// newChromeRenderer starts Chrome at path for the hosts and their checked
// addresses.
func newChromeRenderer(path string, hosts map[string][]netip.Addr, trusted bool, ua string) (*chromeRenderer, error) {
profile, err := os.MkdirTemp("", "summercms-seo-chrome-")
if err != nil {
return nil, fmt.Errorf("the Chrome profile cannot be created: %w", err)
}
r := &chromeRenderer{trusted: trusted, profile: profile}
for h := range hosts {
r.allowed = append(r.allowed, h)
}
sort.Strings(r.allowed)
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.ExecPath(path),
chromedp.UserDataDir(profile),
chromedp.Flag("host-resolver-rules", resolverRules(hosts)),
chromedp.UserAgent(ua),
)
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
r.allocCancel = allocCancel
r.browserCtx, r.browserCancel = chromedp.NewContext(allocCtx)
// The first Run starts the browser; it runs without a timeout, since a
// timeout on this context would stop the whole browser.
if err := chromedp.Do(r.browserCtx); err != nil {
_ = r.Close()
return nil, fmt.Errorf("Chrome did not start: %w", err)
}
if _, err := chromedp.CallBrowser(r.browserCtx, browser.SetDownloadBehavior, browser.SetDownloadBehaviorParams{Behavior: browser.SetDownloadBehaviorBehaviorDeny}); err != nil {
_ = r.Close()
return nil, fmt.Errorf("Chrome downloads cannot be denied: %w", err)
}
return r, nil
}
// Render loads pageURL in a new tab and answers the DOM after JavaScript
// ran (the outer HTML of the document, capped at 5 MiB).
func (r *chromeRenderer) Render(ctx context.Context, pageURL string) (string, error) {
r.mu.Lock()
closed := r.closed
r.mu.Unlock()
if closed {
return "", fmt.Errorf("Chrome is closed")
}
if !allowRequest(pageURL, r.allowed, r.trusted) {
return "", fmt.Errorf("the address %s is outside the site", pageURL)
}
tab, closeTab := chromedp.NewContext(r.browserCtx)
defer closeTab()
// The tab ends with the crawl's context (a cancelled crawl) or after
// the page's timeout.
stop := context.AfterFunc(ctx, closeTab)
defer stop()
if err := chromedp.Do(tab); err != nil {
return "", fmt.Errorf("Chrome cannot open a tab: %w", err)
}
page, cancel := context.WithTimeout(tab, RenderTimeout)
defer cancel()
target := chromedp.FromContext(tab).Target
paused := chromedp.Events(page, fetch.RequestPaused)
if _, err := chromedp.Call(page, fetch.Enable, fetch.EnableParams{Patterns: []*fetch.RequestPattern{{URLPattern: "*"}}}); err != nil {
return "", fmt.Errorf("Chrome cannot intercept requests: %w", err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for ev, err := range paused {
if err != nil {
return
}
r.decide(page, target, ev)
}
}()
html, err := chromedp.Run(page, func(ctx context.Context, t *chromedp.Target) (string, error) {
if _, err := chromedp.Navigate(pageURL)(ctx, t); err != nil {
return "", err
}
if _, err := chromedp.WaitReady(chromedp.CSS("body"))(ctx, t); err != nil {
return "", err
}
return chromedp.OuterHTML(chromedp.CSS("html"))(ctx, t)
})
cancel()
closeTab()
wg.Wait()
if err != nil {
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("Chrome could not render %s: %w", pageURL, err)
}
if len(html) > MaxDOM {
html = truncateUTF8(html, MaxDOM)
}
return html, nil
}
// decide continues a paused request that allowRequest passes and fails
// every other one with BlockedByClient, keeping its address.
func (r *chromeRenderer) decide(ctx context.Context, target cdp.Session, ev fetch.EventRequestPaused) {
u := ""
if ev.Request != nil {
u = ev.Request.URL
}
if allowRequest(u, r.allowed, r.trusted) {
_, _ = cdp.Call(ctx, target, fetch.ContinueRequest, fetch.ContinueRequestParams{RequestID: ev.RequestID})
return
}
r.mu.Lock()
r.blocked = append(r.blocked, u)
r.mu.Unlock()
_, _ = cdp.Call(ctx, target, fetch.FailRequest, fetch.FailRequestParams{RequestID: ev.RequestID, ErrorReason: network.ErrorReasonBlockedByClient})
}
// Blocked lists the addresses the browser was kept from loading.
func (r *chromeRenderer) Blocked() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.blocked...)
}
// ProfileDir is the temporary profile of this crawl's browser.
func (r *chromeRenderer) ProfileDir() string { return r.profile }
// Close stops Chrome and removes the temporary profile.
func (r *chromeRenderer) Close() error {
r.mu.Lock()
if r.closed {
r.mu.Unlock()
return nil
}
r.closed = true
r.mu.Unlock()
if r.browserCancel != nil {
r.browserCancel()
}
if r.allocCancel != nil {
r.allocCancel()
}
return os.RemoveAll(r.profile)
}
// resolveHosts resolves the site host and its www twin. A private or
// reserved address (fetchguard.IsPrivateAddr) refuses outside trusted
// mode; a twin that does not resolve is left out.
func resolveHosts(ctx context.Context, hosts []string, trusted bool) (map[string][]netip.Addr, error) {
out := map[string][]netip.Addr{}
for i, host := range hosts {
if addr, err := netip.ParseAddr(strings.Trim(host, "[]")); err == nil {
if !trusted && fetchguard.IsPrivateAddr(addr) {
return nil, fmt.Errorf("the site host %s is a private address", addr)
}
out[host] = []netip.Addr{addr.Unmap()}
continue
}
addrs, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil || len(addrs) == 0 {
if i == 0 {
return nil, fmt.Errorf("the site host %s does not resolve", host)
}
continue
}
for _, a := range addrs {
a = a.Unmap()
if !trusted && fetchguard.IsPrivateAddr(a) {
return nil, fmt.Errorf("the site host %s resolves to the private address %s", host, a)
}
out[host] = append(out[host], a)
}
}
return out, nil
}