Files
kage/sanitize/sanitize.go
Duc-Tam Nguyen e6afa91e09 Add the clone engine, CLI, tests, CI, and docs
kage renders every page in headless Chrome, snapshots the final
DOM, strips all JavaScript, and localises CSS, images, and fonts
so a site can be browsed offline as a plain folder of files.

The engine is split into small packages:

  urlx      deterministic URL to local-path mapping and scope rules
  sanitize  remove scripts, on* handlers, and javascript: URLs
  asset     rewrite HTML and CSS references, download assets
  browser   headless Chrome pool over the DevTools protocol
  robots    robots.txt matcher
  clone     the orchestrator: a polite resumable breadth-first crawl

The cli package wires a cobra and fang command surface with two
commands, clone and serve. Every pure package has table tests; the
browser and clone packages add Chrome-driven end-to-end tests that
skip when no browser is present or under -short.

CI runs gofmt, vet, build, race tests, golangci-lint, govulncheck,
and a tidy check on Linux and macOS. A goreleaser config fans one
tag out to archives, deb/rpm/apk, a Chromium-bundled GHCR image,
and the package managers. A tago docs site builds to Pages and
Cloudflare.
2026-06-14 18:22:25 +07:00

221 lines
6.2 KiB
Go

// Package sanitize removes every trace of JavaScript from an HTML document so
// the saved page is inert: a photograph, not a program.
//
// It parses with golang.org/x/net/html, walks the tree, and deletes scripts,
// event handlers, javascript: URLs, and the dead preconnect/preload hints that
// mean nothing offline — while leaving styles, images, fonts, forms, and all
// semantic markup untouched so the layout survives intact.
package sanitize
import (
"bytes"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// Options tune a few edge behaviours; the zero value is the safe default
// (scripts and noscript removed, meta-refresh removed).
type Options struct {
// KeepNoscript unwraps <noscript> content into the document instead of
// deleting it, for sites whose real content hides behind a JS check.
KeepNoscript bool
// KeepMetaRefresh preserves a plain timed <meta http-equiv="refresh">
// (a JS-target refresh is always removed).
KeepMetaRefresh bool
// Banner, when non-empty, is inserted as an HTML comment at the top of the
// document.
Banner string
}
// Report counts what was removed, for the run summary and for tests.
type Report struct {
ScriptsRemoved int
HandlersRemoved int
NoscriptRemoved int
NoscriptUnwrapped int
JSURLsNeutralized int
MetaRefreshRemoved int
DeadLinksRemoved int
}
// jsURLAttrs are attributes whose value may be a javascript: URL.
var jsURLAttrs = map[string]bool{
"href": true, "src": true, "action": true, "formaction": true,
"poster": true, "data": true, "background": true, "xlink:href": true,
}
// Strip parses doc, removes all JavaScript, and returns the rewritten HTML plus
// a Report. A parse error is returned unchanged to the caller.
func Strip(doc []byte, opts Options) ([]byte, Report, error) {
root, err := html.Parse(bytes.NewReader(doc))
if err != nil {
return nil, Report{}, err
}
rep := CleanTree(root, opts)
var buf bytes.Buffer
if err := html.Render(&buf, root); err != nil {
return nil, rep, err
}
return buf.Bytes(), rep, nil
}
// CleanTree removes all JavaScript from an already-parsed document in place and
// returns the Report. The cloner uses this so the HTML is parsed only once and
// shared with the asset rewriter.
func CleanTree(root *html.Node, opts Options) Report {
var rep Report
clean(root, opts, &rep)
if opts.Banner != "" {
insertBanner(root, opts.Banner)
}
return rep
}
// clean walks n's children, removing or scrubbing each before recursing.
func clean(n *html.Node, opts Options, rep *Report) {
var next *html.Node
for c := n.FirstChild; c != nil; c = next {
next = c.NextSibling
if c.Type == html.ElementNode {
switch c.DataAtom {
case atom.Script:
n.RemoveChild(c)
rep.ScriptsRemoved++
continue
case atom.Noscript:
if opts.KeepNoscript {
unwrapNoscript(n, c)
rep.NoscriptUnwrapped++
} else {
n.RemoveChild(c)
rep.NoscriptRemoved++
}
continue
case atom.Meta:
if isMetaRefresh(c) && (!opts.KeepMetaRefresh || isJSRefresh(c)) {
n.RemoveChild(c)
rep.MetaRefreshRemoved++
continue
}
case atom.Link:
if isDeadLink(c) {
n.RemoveChild(c)
rep.DeadLinksRemoved++
continue
}
}
stripHandlers(c, rep)
neutralizeJSURLs(c, rep)
}
clean(c, opts, rep)
}
}
// stripHandlers removes every on* event-handler attribute from n.
func stripHandlers(n *html.Node, rep *Report) {
kept := n.Attr[:0]
for _, a := range n.Attr {
if len(a.Key) > 2 && strings.HasPrefix(strings.ToLower(a.Key), "on") {
rep.HandlersRemoved++
continue
}
kept = append(kept, a)
}
n.Attr = kept
}
// neutralizeJSURLs replaces javascript: URLs: links become "#", other carriers
// lose the attribute entirely.
func neutralizeJSURLs(n *html.Node, rep *Report) {
kept := n.Attr[:0]
for _, a := range n.Attr {
key := strings.ToLower(a.Key)
if jsURLAttrs[key] && strings.HasPrefix(strings.ToLower(strings.TrimSpace(a.Val)), "javascript:") {
rep.JSURLsNeutralized++
if key == "href" {
a.Val = "#"
kept = append(kept, a)
}
// non-href carriers: drop the attribute.
continue
}
kept = append(kept, a)
}
n.Attr = kept
}
// isMetaRefresh reports whether n is a <meta http-equiv="refresh">.
func isMetaRefresh(n *html.Node) bool {
return strings.EqualFold(attr(n, "http-equiv"), "refresh")
}
// isJSRefresh reports whether a meta-refresh target is a javascript: URL.
func isJSRefresh(n *html.Node) bool {
return strings.Contains(strings.ToLower(attr(n, "content")), "javascript:")
}
// isDeadLink reports whether a <link> is a resource hint that is useless or
// script-bound offline: preconnect, dns-prefetch, modulepreload, or a
// preload/prefetch that targets a script.
func isDeadLink(n *html.Node) bool {
for r := range strings.FieldsSeq(strings.ToLower(attr(n, "rel"))) {
switch r {
case "preconnect", "dns-prefetch", "modulepreload":
return true
case "preload", "prefetch":
as := strings.ToLower(attr(n, "as"))
href := strings.ToLower(attr(n, "href"))
if as == "script" || strings.HasSuffix(href, ".js") {
return true
}
}
}
return false
}
// unwrapNoscript replaces a <noscript> with its content. Because x/net/html
// parses noscript content as raw text (scripting enabled), the text is
// re-parsed as a fragment in the parent's context and spliced in before the
// noscript node, which is then removed.
func unwrapNoscript(parent, ns *html.Node) {
var raw strings.Builder
for c := ns.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
raw.WriteString(c.Data)
}
}
frag, err := html.ParseFragment(strings.NewReader(raw.String()), &html.Node{
Type: html.ElementNode,
Data: "body",
DataAtom: atom.Body,
})
if err == nil {
for _, fn := range frag {
parent.InsertBefore(fn, ns)
}
}
parent.RemoveChild(ns)
}
// insertBanner prepends an HTML comment to the document.
func insertBanner(root *html.Node, text string) {
c := &html.Node{Type: html.CommentNode, Data: " " + text + " "}
if root.FirstChild != nil {
root.InsertBefore(c, root.FirstChild)
} else {
root.AppendChild(c)
}
}
// attr returns the value of n's attribute key (case-insensitive), or "".
func attr(n *html.Node, key string) string {
for _, a := range n.Attr {
if strings.EqualFold(a.Key, key) {
return a.Val
}
}
return ""
}