Files
sm-seo-plugin/classes/crawl/fetch.go
Jakub Zych e2af5b44c8 fix: one decoded path form for crawl keys and CMS lookups
Crawl keys, the rendered comparison and the link graph use the decoded
path (classes.PathKey), the form the resolver, the descriptors and the
SEO entries use; the request URL is percent-encoded again
(classes.EscapeKeyPath). A non-ASCII address now matches its CMS record
and its stored links. A path that does not decode to text keeps its
encoded form.
2026-11-04 22:59:17 +01:00

225 lines
6.4 KiB
Go

// Package crawl is the site audit's crawler (D-14): it fetches the
// rendered site like a search engine, checks every page (UI-SPEC C23),
// stores the findings with the CMS-data issues and names how each one is
// fixed (P33-38).
package crawl
import (
"context"
"net/http"
"net/url"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/fetchguard"
)
// The config keys of the crawler.
const (
// TrustedConfigKey lets the crawler fetch plain http and private
// addresses (staging, local sites). Only Go config can set it.
TrustedConfigKey = "golem15.seo.crawl_trusted"
)
// The crawler's limits (D-14, RESEARCH Pattern 7).
const (
// MaxBody caps one answer's body.
MaxBody = 5 << 20
// FetchTimeout is one request's timeout.
FetchTimeout = 10 * time.Second
// Workers is the number of requests at a time.
Workers = 2
// MaxLinksPerPage caps the links stored and followed per page.
MaxLinksPerPage = 1000
)
// UserAgent is the crawler's identity (P33-41).
func UserAgent(site string) string {
return "SummerCMS-SEO-Crawler/1 (+" + site + ")"
}
// Response is one fetched address: its status, Location, content type,
// X-Robots-Tag and body, or the fetch error.
type Response struct {
Status int
Location string
ContentType string
XRobots string
Body []byte
Err string
}
// HTML reports whether the answer is an HTML page.
func (r *Response) HTML() bool {
ct := strings.ToLower(r.ContentType)
return ct == "" || strings.Contains(ct, "text/html") || strings.Contains(ct, "application/xhtml")
}
// Fetcher fetches the site's own addresses through a fetchguard client
// limited to the site host and its www twin: HTTPS only, private
// addresses refused at dial time and redirects never followed. Trusted
// mode (golem15.seo.crawl_trusted, Go config only) allows plain http and
// private addresses, still only on those hosts.
type Fetcher struct {
client *fetchguard.Client
site *url.URL
hosts []string
port string
trusted bool
ua string
}
// effectivePort is u's port: the explicit one, else 443 for https and 80
// for http ("" for another scheme without a port).
func effectivePort(u *url.URL) string {
if u == nil {
return ""
}
if p := u.Port(); p != "" {
return p
}
switch strings.ToLower(u.Scheme) {
case "https":
return "443"
case "http":
return "80"
}
return ""
}
// SiteHosts returns the host of site and its www twin.
func SiteHosts(host string) []string {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return nil
}
if bare, ok := strings.CutPrefix(host, "www."); ok {
return []string{host, bare}
}
return []string{host, "www." + host}
}
// Trusted reports whether config turns trusted mode on.
func Trusted(cfg *compass.Config) bool {
return cfg != nil && cfg.Bool(TrustedConfigKey)
}
// NewFetcher builds the fetcher of site (an absolute origin).
func NewFetcher(site string, cfg *compass.Config) (*Fetcher, error) {
u, err := url.Parse(strings.TrimRight(site, "/"))
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
return nil, ErrNoSiteURL
}
f := &Fetcher{site: u, hosts: SiteHosts(u.Hostname()), port: effectivePort(u), trusted: Trusted(cfg), ua: UserAgent(u.Scheme + "://" + u.Host)}
policy := fetchguard.Policy{Mode: fetchguard.AllowHostsMode, AllowHosts: f.hosts, MaxBytes: MaxBody, Timeout: FetchTimeout}
if f.trusted {
policy.Mode = fetchguard.TrustedMode
}
f.client, err = fetchguard.NewClient(policy, cfg)
if err != nil {
return nil, err
}
return f, nil
}
// Hosts returns the site host and its www twin.
func (f *Fetcher) Hosts() []string { return f.hosts }
// IsTrusted reports whether the fetcher runs in trusted mode.
func (f *Fetcher) IsTrusted() bool { return f.trusted }
// Site returns the site origin.
func (f *Fetcher) Site() *url.URL { return f.site }
// UA returns the crawler's User-Agent.
func (f *Fetcher) UA() string { return f.ua }
// Port returns the site's effective port (explicit, else 443 for https
// and 80 for http).
func (f *Fetcher) Port() string { return f.port }
// Allowed reports whether u may be fetched: https (http only in trusted
// mode) on the site host or its www twin and on the site's effective port,
// without credentials. In trusted mode the host list is checked here,
// since fetchguard checks none then.
func (f *Fetcher) Allowed(u *url.URL) bool {
if u == nil || u.User != nil {
return false
}
if effectivePort(u) != f.port {
return false
}
switch strings.ToLower(u.Scheme) {
case "https":
case "http":
if !f.trusted {
return false
}
default:
return false
}
host := strings.ToLower(u.Hostname())
for _, h := range f.hosts {
if host == h {
return true
}
}
return false
}
// Key is the address of u on the site: its path in the decoded form of
// classes.PathKey with the raw query, or "" when u is not on the site
// (another host, scheme or port). The crawl's keys, the rendered
// comparison, the CMS lookups and the link graph use this one form.
func (f *Fetcher) Key(u *url.URL) string {
if !f.Allowed(u) {
return ""
}
p := classes.PathKey(u)
if u.RawQuery != "" {
p += "?" + u.RawQuery
}
return p
}
// Absolute returns the site URL of an address key, its path
// percent-encoded for the request (classes.EscapeKeyPath).
func (f *Fetcher) Absolute(key string) string {
p, q, hasQuery := strings.Cut(key, "?")
out := f.site.Scheme + "://" + f.site.Host + classes.EscapeKeyPath(p)
if hasQuery {
out += "?" + q
}
return out
}
// Get fetches one address key of the site.
func (f *Fetcher) Get(ctx context.Context, key string) *Response {
header := http.Header{}
header.Set("User-Agent", f.ua)
header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.5")
res, err := f.client.Get(ctx, f.Absolute(key), header)
if err != nil {
reason := "network_error"
if fe, ok := err.(*fetchguard.Error); ok {
reason = string(fe.Reason)
}
return &Response{Err: reason}
}
out := &Response{
Status: res.StatusCode,
ContentType: res.Header.Get("Content-Type"),
XRobots: strings.Join(res.Header.Values("X-Robots-Tag"), ", "),
Body: res.Body,
}
if loc := res.Header.Get("Location"); loc != "" {
base, _ := url.Parse(f.Absolute(key))
if ref, err := url.Parse(loc); err == nil && base != nil {
out.Location = base.ResolveReference(ref).String()
}
}
return out
}