Files
sm-seo-plugin/classes/crawl/render.go
Jakub Zych b553fc2937 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
2026-11-04 15:58:36 +01:00

193 lines
5.4 KiB
Go

package crawl
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
// JavaScript ran, Close releases the browser. A crawl without a Renderer
// records the comparison as skipped; a Render error records it as failed
// with the error and never fails the crawl.
type Renderer interface {
Render(ctx context.Context, pageURL string) (string, error)
Close() error
}
// DefaultRenderMax is the number of pages rendered per crawl when
// golem15.seo.crawl_render_max is unset.
const DefaultRenderMax = 200
// The render states of a crawl (models.RenderSkipped and the others).
const (
renderStatusSkipped = "skipped"
renderStatusDone = "done"
renderStatusFailed = "failed"
)
type rendererKey struct{}
// WithRenderer returns a context whose crawls use r instead of the
// renderer built from config. It is the test seam of the comparison,
// settable only from Go code (like fetchguard.WithTransport): no config
// key, setting, request or MCP call reaches it.
func WithRenderer(ctx context.Context, r Renderer) context.Context {
return context.WithValue(ctx, rendererKey{}, r)
}
// rendererFrom returns the renderer of ctx, nil when none.
func rendererFrom(ctx context.Context) Renderer {
if ctx == nil {
return nil
}
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
}