feat: crawler finds a page with two H1 headings and stores the issue (tracer)

- golem15_seo_crawls, crawl pages, links, issues and ignores (P33-37)
- classes/crawl: fetchguard client on the site host and its www twin,
  sitemap index and robots.txt seeds, two workers with a delay, limits
- h1_missing and h1_multiple with the P33-38 fix route
- POST /_seo/api/v1/crawls queues the golem15.seo.crawl job; GET /issues
  answers the stored issues with id, severity, source, fix, found_at,
  ignored and crawl_id
This commit is contained in:
Jakub Zych
2026-11-04 15:05:45 +01:00
parent d38049a956
commit c2b61bf8e9
23 changed files with 2967 additions and 13 deletions

151
classes/crawl/checks.go Normal file
View File

@@ -0,0 +1,151 @@
package crawl
import (
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
)
// The crawl kinds of UI-SPEC C23.
const (
KindPageStatus = "page_status"
KindSitemapError = "sitemap_error"
KindSitemapNoindex = "sitemap_noindex"
KindSitemapBlocked = "sitemap_blocked"
KindSitemapMissing = "sitemap_missing"
KindBrokenLink = "broken_link"
KindRedirectedLink = "redirected_link"
KindCanonicalMissing = "canonical_missing"
KindCanonicalError = "canonical_error"
KindMissingTitle = "missing_title"
KindDuplicateTitle = "duplicate_title"
KindMissingDesc = "missing_description"
KindDuplicateDesc = "duplicate_description"
KindH1Missing = "h1_missing"
KindH1Multiple = "h1_multiple"
KindHeadingSkipped = "heading_skipped"
KindHeadingEmpty = "heading_empty"
KindH1TitleMismatch = "h1_title_mismatch"
KindJSONLDInvalid = "jsonld_invalid"
KindJSONLDMultiple = "jsonld_multiple"
KindJSONLDRequired = "jsonld_required"
KindJSONLDRecommended = "jsonld_recommended"
KindHreflangRecip = "hreflang_reciprocal"
KindHreflangError = "hreflang_error"
KindGenericAnchor = "generic_anchor"
KindImageAlt = "image_alt"
KindImageSize = "image_size"
KindFeaturedLazy = "featured_lazy"
KindContentJS = "content_js"
KindOrphanPage = "orphan_page"
// KindH1InContent is the CMS-data check of a record whose body holds
// an H1 although its page renders the title as the H1.
KindH1InContent = "h1_in_content"
)
// severities are the default severities of the kinds table (UI-SPEC C23),
// the Phase 32 CMS kinds included.
var severities = map[string]string{
KindPageStatus: models.SeverityError,
KindSitemapError: models.SeverityError,
KindSitemapNoindex: models.SeverityError,
KindSitemapBlocked: models.SeverityError,
KindSitemapMissing: models.SeverityNotice,
KindBrokenLink: models.SeverityError,
KindRedirectedLink: models.SeverityNotice,
KindCanonicalMissing: models.SeverityWarning,
KindCanonicalError: models.SeverityError,
"canonical_noindex": models.SeverityError,
"canonical_redirected": models.SeverityWarning,
KindMissingTitle: models.SeverityWarning,
KindDuplicateTitle: models.SeverityWarning,
KindMissingDesc: models.SeverityNotice,
KindDuplicateDesc: models.SeverityNotice,
"rule_overrides_record": models.SeverityNotice,
"redirect_chain": models.SeverityWarning,
"noindex_blocked": models.SeverityError,
KindH1Missing: models.SeverityError,
KindH1Multiple: models.SeverityError,
KindH1InContent: models.SeverityWarning,
KindHeadingSkipped: models.SeverityWarning,
KindHeadingEmpty: models.SeverityWarning,
KindH1TitleMismatch: models.SeverityNotice,
KindJSONLDInvalid: models.SeverityError,
KindJSONLDMultiple: models.SeverityWarning,
KindJSONLDRequired: models.SeverityError,
KindJSONLDRecommended: models.SeverityNotice,
KindHreflangRecip: models.SeverityWarning,
KindHreflangError: models.SeverityError,
KindGenericAnchor: models.SeverityWarning,
KindImageAlt: models.SeverityWarning,
KindImageSize: models.SeverityNotice,
KindFeaturedLazy: models.SeverityNotice,
KindContentJS: models.SeverityWarning,
KindOrphanPage: models.SeverityWarning,
}
// Severity returns a kind's severity (warning for an unknown kind).
func Severity(kind string) string {
if s, ok := severities[kind]; ok {
return s
}
return models.SeverityWarning
}
// Kinds lists every kind the audit stores, in the kinds table order.
func Kinds() []string {
return []string{
KindPageStatus, KindSitemapError, KindSitemapNoindex, KindSitemapBlocked,
KindSitemapMissing, KindBrokenLink, KindRedirectedLink, KindCanonicalMissing,
KindCanonicalError, "canonical_noindex", "canonical_redirected", KindMissingTitle,
KindDuplicateTitle, KindMissingDesc, KindDuplicateDesc, "rule_overrides_record",
"redirect_chain", "noindex_blocked", KindH1Missing, KindH1Multiple, KindH1InContent,
KindHeadingSkipped, KindHeadingEmpty, KindH1TitleMismatch, KindJSONLDInvalid,
KindJSONLDMultiple, KindJSONLDRequired, KindJSONLDRecommended, KindHreflangRecip,
KindHreflangError, KindGenericAnchor, KindImageAlt, KindImageSize, KindFeaturedLazy,
KindContentJS, KindOrphanPage,
}
}
// Env is what the checks know besides the crawl: the record behind an
// address.
type Env struct {
// Lookup returns the CMS record of an address, nil when none.
Lookup func(key string) *Record
}
func (e *Env) record(key string) *Record {
if e == nil || e.Lookup == nil {
return nil
}
return e.Lookup(key)
}
// Check runs the per-page checks over a crawl's pages.
func Check(res *Result, env *Env) []Finding {
var out []Finding
for _, key := range res.Order {
p := res.Pages[key]
if !p.OK() {
continue
}
out = append(out, checkHeadings(p, env.record(key))...)
}
return out
}
// checkHeadings reports h1_missing and h1_multiple. An H1 inside the
// record's own content is fixed through the record's headings tool.
func checkHeadings(p *Page, rec *Record) []Finding {
h1 := p.Facts.H1()
switch {
case len(h1) == 0:
return []Finding{{Kind: KindH1Missing, URL: p.Key, Record: rec}}
case len(h1) > 1:
f := Finding{Kind: KindH1Multiple, URL: p.Key, Record: rec, Details: map[string]any{"count": len(h1), "h1": h1}}
if rec.hasHeading(1, "") {
f.Content = []string{pact.ContentToolHeadings, pact.ContentToolEdit}
}
return []Finding{f}
}
return nil
}

425
classes/crawl/crawler.go Normal file
View File

@@ -0,0 +1,425 @@
package crawl
import (
"bytes"
"context"
"encoding/xml"
"errors"
"io"
"net/url"
"strings"
"sync"
"time"
)
// ErrNoSiteURL is a crawl started without a site address
// (golem15.seo.site_url, else app.url).
var ErrNoSiteURL = errors.New("golem15.seo: the site address for crawls is not configured")
// The paths the crawler seeds from (RESEARCH Pattern 7).
const (
HomePath = "/"
SitemapPath = "/sitemap.xml"
RobotsPath = "/robots.txt"
)
// maxSitemapFiles caps the sitemap files read from an index.
const maxSitemapFiles = 500
// How a page entered the frontier.
const (
viaHome = "home"
viaSitemap = "sitemap"
viaLink = "link"
viaRedirect = "redirect"
viaCanonical = "canonical"
viaAlternate = "alternate"
)
// Page is one fetched address of a crawl: the answer, the facts of its raw
// HTML, the facts of its JavaScript-rendered DOM when the comparison ran,
// its depth from the home page and the sitemap file that lists it.
type Page struct {
Key string
Depth int
Via string
Resp *Response
Facts *PageFacts
Rendered *PageFacts
InSitemap bool
SitemapFile string
FetchedAt time.Time
}
// OK reports whether the page answered 200 with HTML.
func (p *Page) OK() bool {
return p != nil && p.Resp != nil && p.Resp.Err == "" && p.Resp.Status == 200 && p.Facts != nil
}
// SitemapFile is one sitemap the crawl read: its address, status and the
// page addresses it lists.
type SitemapFile struct {
Key string
Status int
URLs []string
}
// RenderState is the outcome of the rendered comparison (P33-55).
type RenderState struct {
Status string
Pages int
Note string
}
// Result is what one crawl fetched, in frontier order.
type Result struct {
Pages map[string]*Page
Order []string
Sitemaps []SitemapFile
// Listed maps a page address to the sitemap file listing it.
Listed map[string]string
Robots string
RobotsStatus int
LimitHit bool
Canceled bool
Render RenderState
fetcher *Fetcher
}
// Page returns the fetched page at key, nil when none.
func (r *Result) Page(key string) *Page {
if r == nil {
return nil
}
return r.Pages[key]
}
// Options are the limits and seams of one crawl.
type Options struct {
// MaxPages caps the fetched addresses (D-14, default 5000).
MaxPages int
// Delay is the pause between two requests (default 250 ms).
Delay time.Duration
// Renderer renders pages for the comparison; nil skips it.
Renderer Renderer
// RenderMax caps the rendered pages (default 200, crawl order).
RenderMax int
// Canceled is asked between requests; true stops the crawl and keeps
// what was fetched.
Canceled func(ctx context.Context) bool
// Progress is told the number of fetched pages.
Progress func(ctx context.Context, fetched int)
}
// item is one frontier entry.
type item struct {
key string
depth int
via string
}
// pacer spaces requests at least delay apart across the workers.
type pacer struct {
mu sync.Mutex
next time.Time
delay time.Duration
}
func (p *pacer) wait(ctx context.Context) error {
p.mu.Lock()
now := time.Now()
at := p.next
if at.Before(now) {
at = now
}
p.next = at.Add(p.delay)
p.mu.Unlock()
if d := time.Until(at); d > 0 {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
return nil
}
// Crawl fetches the site through f, a fetchguard client limited to the
// site host and its www twin (fetchguard.AllowHostsMode, HTTPS, no
// redirects followed; TrustedMode only from Go config): robots.txt, the
// sitemap index and its files, then breadth-first from the home page and
// the sitemap addresses over same-host links, redirect targets, canonicals
// and hreflang alternates, two requests at a time with the delay between
// requests, up to MaxPages. robots.txt rules are read for the report, not
// obeyed (P33-41). Cancelling keeps what was fetched. After each HTML page
// answering 200, a configured Renderer renders it (one tab at a time, up
// to RenderMax pages) for the raw versus rendered comparison.
func Crawl(ctx context.Context, f *Fetcher, opt Options) (*Result, error) {
if opt.MaxPages <= 0 {
opt.MaxPages = 5000
}
if opt.RenderMax <= 0 {
opt.RenderMax = DefaultRenderMax
}
res := &Result{Pages: map[string]*Page{}, Listed: map[string]string{}, Render: RenderState{Status: renderStatusSkipped}, fetcher: f}
if opt.Renderer != nil {
res.Render.Status = renderStatusDone
}
pace := &pacer{delay: opt.Delay}
canceled := func() bool {
if ctx.Err() != nil {
return true
}
return opt.Canceled != nil && opt.Canceled(ctx)
}
// robots.txt, for the sitemap_blocked check.
if err := pace.wait(ctx); err != nil {
return res, err
}
if r := f.Get(ctx, RobotsPath); r.Err == "" {
res.RobotsStatus = r.Status
if r.Status == 200 {
res.Robots = string(r.Body)
}
}
// The sitemap index and its files.
if err := readSitemaps(ctx, f, pace, res); err != nil {
return res, err
}
seen := map[string]bool{}
var queue []item
push := func(key string, depth int, via string) {
if key == "" || seen[key] {
return
}
seen[key] = true
queue = append(queue, item{key: key, depth: depth, via: via})
}
push(HomePath, 0, viaHome)
for _, sm := range res.Sitemaps {
for _, key := range sm.URLs {
push(key, 1, viaSitemap)
}
}
var renderMu sync.Mutex
rendered := 0
renderOff := opt.Renderer == nil
for len(queue) > 0 {
if canceled() {
res.Canceled = true
break
}
if len(res.Order) >= opt.MaxPages {
res.LimitHit = true
break
}
n := Workers
if room := opt.MaxPages - len(res.Order); n > room {
n = room
}
if n > len(queue) {
n = len(queue)
}
batch := queue[:n]
queue = queue[n:]
pages := make([]*Page, len(batch))
var wg sync.WaitGroup
for i, it := range batch {
wg.Add(1)
go func(i int, it item) {
defer wg.Done()
p := &Page{Key: it.key, Depth: it.depth, Via: it.via}
if err := pace.wait(ctx); err != nil {
p.Resp = &Response{Err: "canceled"}
pages[i] = p
return
}
p.Resp = f.Get(ctx, it.key)
p.FetchedAt = time.Now().UTC()
if p.Resp.Err == "" && p.Resp.Status == 200 && p.Resp.HTML() {
base, _ := url.Parse(f.Absolute(it.key))
p.Facts = Parse(base, p.Resp.Body)
renderMu.Lock()
if !renderOff && rendered < opt.RenderMax && ctx.Err() == nil {
rendered++
dom, err := opt.Renderer.Render(ctx, f.Absolute(it.key))
if err != nil {
renderOff = true
rendered--
res.Render = RenderState{Status: renderStatusFailed, Note: err.Error()}
} else {
p.Rendered = Parse(base, []byte(dom))
}
}
renderMu.Unlock()
}
pages[i] = p
}(i, it)
}
wg.Wait()
for _, p := range pages {
if file, ok := res.Listed[p.Key]; ok {
p.InSitemap, p.SitemapFile = true, file
}
res.Pages[p.Key] = p
res.Order = append(res.Order, p.Key)
p.Resp.Body = nil
for _, next := range discovered(f, p) {
push(next.key, p.Depth+1, next.via)
}
}
if opt.Progress != nil {
opt.Progress(ctx, len(res.Order))
}
}
if len(queue) > 0 && !res.Canceled && len(res.Order) >= opt.MaxPages {
res.LimitHit = true
}
res.Render.Pages = rendered
return res, nil
}
// discovered lists the same-host addresses a fetched page leads to: its
// redirect target, its links, canonical and hreflang alternates.
func discovered(f *Fetcher, p *Page) []item {
var out []item
add := func(raw string, via string) {
u, err := url.Parse(raw)
if err != nil {
return
}
u.Fragment = ""
if key := f.Key(u); key != "" {
out = append(out, item{key: key, via: via})
}
}
if p.Resp != nil && p.Resp.Location != "" {
add(p.Resp.Location, viaRedirect)
}
if p.Facts == nil {
return out
}
for _, l := range p.Facts.Links {
if l.URL != nil {
add(l.URL.String(), viaLink)
}
}
if p.Facts.Canonical != "" {
add(p.Facts.Canonical, viaCanonical)
}
for _, a := range p.Facts.Alternates {
if a.URL != nil {
add(a.URL.String(), viaAlternate)
}
}
return out
}
// readSitemaps reads /sitemap.xml: an index's files (same host only) or a
// single urlset.
func readSitemaps(ctx context.Context, f *Fetcher, pace *pacer, res *Result) error {
if err := pace.wait(ctx); err != nil {
return err
}
r := f.Get(ctx, SitemapPath)
if r.Err != "" || r.Status != 200 {
res.Sitemaps = append(res.Sitemaps, SitemapFile{Key: SitemapPath, Status: r.Status})
return nil
}
index, locs := parseSitemap(r.Body)
if !index {
res.addSitemap(f, SitemapFile{Key: SitemapPath, Status: 200}, locs)
return nil
}
for i, loc := range locs {
if i >= maxSitemapFiles {
break
}
u, err := url.Parse(loc)
if err != nil {
continue
}
key := f.Key(u)
if key == "" {
continue
}
if err := pace.wait(ctx); err != nil {
return err
}
sr := f.Get(ctx, key)
file := SitemapFile{Key: key, Status: sr.Status}
if sr.Err != "" || sr.Status != 200 {
res.Sitemaps = append(res.Sitemaps, file)
continue
}
_, urls := parseSitemap(sr.Body)
res.addSitemap(f, file, urls)
}
return nil
}
func (res *Result) addSitemap(f *Fetcher, file SitemapFile, locs []string) {
for _, loc := range locs {
u, err := url.Parse(loc)
if err != nil {
continue
}
key := f.Key(u)
if key == "" {
continue
}
if _, dup := res.Listed[key]; !dup {
res.Listed[key] = file.Key
}
file.URLs = append(file.URLs, key)
}
res.Sitemaps = append(res.Sitemaps, file)
}
// parseSitemap reads the <loc> values of a sitemap document; index is true
// for a <sitemapindex>.
func parseSitemap(body []byte) (index bool, locs []string) {
dec := xml.NewDecoder(io.LimitReader(bytes.NewReader(body), MaxBody))
depth := 0
inLoc := false
var parent string
var stack []string
for {
tok, err := dec.Token()
if err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
depth++
name := strings.ToLower(t.Name.Local)
if depth == 1 && name == "sitemapindex" {
index = true
}
stack = append(stack, name)
if name == "loc" && len(stack) >= 2 {
parent = stack[len(stack)-2]
inLoc = parent == "url" || parent == "sitemap"
}
case xml.EndElement:
depth--
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
inLoc = false
case xml.CharData:
if inLoc {
if s := strings.TrimSpace(string(t)); s != "" {
locs = append(locs, s)
}
}
}
}
return index, locs
}

191
classes/crawl/fetch.go Normal file
View File

@@ -0,0 +1,191 @@
// 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/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
trusted bool
ua string
}
// 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()), 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 }
// Allowed reports whether u may be fetched: https (http only in trusted
// mode) on the site host or its www twin, 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
}
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 with the query, or ""
// when u is not on the site.
func (f *Fetcher) Key(u *url.URL) string {
if !f.Allowed(u) {
return ""
}
p := u.EscapedPath()
if p == "" {
p = "/"
}
if u.RawQuery != "" {
p += "?" + u.RawQuery
}
return p
}
// Absolute returns the site URL of an address key.
func (f *Fetcher) Absolute(key string) string {
return f.site.Scheme + "://" + f.site.Host + key
}
// 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
}

145
classes/crawl/fix.go Normal file
View File

@@ -0,0 +1,145 @@
package crawl
import (
"crypto/sha256"
"encoding/hex"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
)
// The SEO tools an issue may name (pier prefixes them with seo_).
const (
ToolSetURLSEO = "seo_set_url_seo"
ToolUpdateRobots = "seo_update_robots"
ToolCreateRedirect = "seo_create_redirect"
ToolUpdatePage = "seo_update_page"
ToolCreatePage = "seo_create_page"
ToolUpdateSettings = "seo_update_settings"
)
// Record is the CMS record behind a crawled page: its content type, id
// and locale, the descriptor facts the checks compare the page with
// (title, word count, headings, images, the highest heading level) and the
// fixing tools its type names (pact.ContentDescriptor.Tools).
type Record struct {
Type string
ID string
Locale string
Title string
Words *int
Headings []pact.ContentHeading
Images []pact.ContentImage
TopHeading int
// Links are the record's CMS-known links: the address of its primary
// parent, which the page's breadcrumb trail links to.
Links []string
Tools map[string]string
// CustomJSONLD is true when the record's SEO tab stores custom JSON-LD.
CustomJSONLD bool
}
// tool returns the record's tool for the first operation of ops it
// names, "" when it names none.
func (r *Record) tool(ops ...string) string {
if r == nil {
return ""
}
for _, op := range ops {
if t := strings.TrimSpace(r.Tools[op]); t != "" {
return t
}
}
return ""
}
// hasHeading reports whether the record's own headings hold one of level
// (any level when level is 0) whose text is text (any text when empty).
func (r *Record) hasHeading(level int, text string) bool {
if r == nil {
return false
}
for _, h := range r.Headings {
if level != 0 && h.Level != level {
continue
}
if text == "" || normal(h.Text) == normal(text) {
return true
}
}
return false
}
// Finding is one problem a check found on a page, before it is stored:
// the kind, the address and locale, the details of the kind, and how it
// is fixed (P33-38). Tool is a fixed CMS tool (seo_*); Content lists the
// record operations (pact.ContentTool*) that fix a finding inside the
// record's own content, tried in order; What names the template element
// when it is fixed in code.
type Finding struct {
Kind string
URL string
Locale string
Details map[string]any
Tool string
Content []string
Record *Record
What string
// Check tells the two content_js findings apart (cms, rendered).
Check string
}
// Route applies P33-38: a finding inside the record's own content names
// the record type's fitting tool (H1 findings prefer Tools[headings],
// other content findings Tools[edit]); else a fixed CMS tool; else fix
// code with the template element in details.what. Every finding gets one
// of the two.
func Route(f Finding) (fix, tool, what string) {
if len(f.Content) > 0 {
if t := f.Record.tool(f.Content...); t != "" {
return models.FixTool, t, ""
}
}
if f.Tool != "" {
return models.FixTool, f.Tool, ""
}
what = f.What
if what == "" {
what = defaultWhat[f.Kind]
}
if what == "" {
what = "the page template"
}
return models.FixCode, "", what
}
// defaultWhat names the template element of a kind fixed in code.
var defaultWhat = map[string]string{
KindCanonicalMissing: `<link rel="canonical"> in the page head`,
KindH1Missing: "the <h1> of the page template",
KindH1Multiple: "the <h1> headings of the page template",
KindHeadingSkipped: "the heading levels of the page template",
KindHeadingEmpty: "the empty heading of the page template",
KindJSONLDInvalid: `the <script type="application/ld+json"> of the page template`,
KindJSONLDMultiple: `the <script type="application/ld+json"> blocks of the page template`,
KindHreflangRecip: `the <link rel="alternate" hreflang> links of the page template`,
KindImageAlt: "the alt attribute of the template's <img>",
KindImageSize: "the width and height attributes of the template's <img>",
KindFeaturedLazy: `loading="lazy" on the main image`,
KindContentJS: "server-side rendering of the page content",
KindGenericAnchor: "the link text in the page template",
KindRedirectedLink: "the link in the page template",
KindOrphanPage: "a link to this page from the site navigation or another page",
}
// Fingerprint identifies a finding across crawls: the kind, the address,
// the locale and the check (content_js), so an ignore survives later
// crawls (P33-37).
func Fingerprint(kind, url, locale, check string) string {
sum := sha256.Sum256([]byte(kind + "\x00" + url + "\x00" + locale + "\x00" + check))
return hex.EncodeToString(sum[:16])
}
// normal is text compared without case and extra whitespace.
func normal(s string) string { return strings.ToLower(strings.Join(strings.Fields(s), " ")) }

361
classes/crawl/job.go Normal file
View File

@@ -0,0 +1,361 @@
package crawl
import (
"context"
"errors"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/diary"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// CrawlKind is the River kind of the crawl job.
const CrawlKind = "golem15.seo.crawl"
// CrawlArgs are the arguments of the crawl job: the crawl row it runs.
type CrawlArgs struct {
CrawlID uint `json:"crawl_id"`
}
// Kind is CrawlKind.
func (CrawlArgs) Kind() string { return CrawlKind }
// The crawl limits used until the Site audit settings say otherwise.
const (
DefaultMaxPages = 5000
DefaultDelay = 250 * time.Millisecond
)
// AlreadyRunningError refuses a start while a crawl is queued or running
// (P33-39): one crawl runs at a time.
type AlreadyRunningError struct {
Crawl models.Crawl
}
func (e *AlreadyRunningError) Error() string { return "golem15.seo: a crawl is already running" }
// Since is the time the running crawl started, else its creation time.
func (e *AlreadyRunningError) Since() time.Time {
if e.Crawl.StartedAt != nil {
return *e.Crawl.StartedAt
}
if e.Crawl.CreatedAt != nil {
return *e.Crawl.CreatedAt
}
return time.Time{}
}
// Start queues a crawl (D-14, P33-39) inside tx: the settings row is
// locked so two starts cannot both pass the check that no crawl is queued
// or running, then the crawl row is written and the conga job dispatched
// in the same transaction. A missing site address refuses with
// ErrNoSiteURL, a running crawl with *AlreadyRunningError.
func Start(ctx context.Context, svc *classes.Service, tx *gorm.DB, trigger, name string) (models.Crawl, error) {
site := svc.SiteURL()
if site == "" {
return models.Crawl{}, ErrNoSiteURL
}
if _, err := classes.SettingsRow(tx); err != nil {
return models.Crawl{}, err
}
if err := tx.Exec(`SELECT id FROM golem15_seo_settings WHERE id = 1 FOR UPDATE`).Error; err != nil {
return models.Crawl{}, err
}
active, err := ActiveCrawl(tx)
if err != nil {
return models.Crawl{}, err
}
if active != nil {
return models.Crawl{}, &AlreadyRunningError{Crawl: *active}
}
limits, err := LoadLimits(tx)
if err != nil {
return models.Crawl{}, err
}
now := time.Now().UTC()
row := models.Crawl{
Status: models.CrawlQueued, Trigger: trigger, TriggerName: name, Site: site,
MaxPages: limits.MaxPages, RenderStatus: models.RenderSkipped, CreatedAt: &now,
}
if err := tx.Create(&row).Error; err != nil {
return models.Crawl{}, err
}
m, err := conga.From(svc.App())
if err != nil {
return models.Crawl{}, err
}
id, err := m.Dispatch(ctx, tx, CrawlArgs{CrawlID: row.ID}, conga.DispatchOpts{Label: svc.Phrase("golem15.seo::lang.jobs.crawl", nil)})
if err != nil {
return models.Crawl{}, err
}
row.JobID = &id
if err := tx.Model(&models.Crawl{}).Where("id = ?", row.ID).Update("job_id", id).Error; err != nil {
return models.Crawl{}, err
}
return row, nil
}
// Limits are a crawl's politeness limits.
type Limits struct {
MaxPages int
Delay time.Duration
}
// LoadLimits reads the crawl limits.
func LoadLimits(db *gorm.DB) (Limits, error) {
return Limits{MaxPages: DefaultMaxPages, Delay: DefaultDelay}, nil
}
// LoadCrawl reads one crawl.
func LoadCrawl(db *gorm.DB, id uint) (models.Crawl, error) {
var rows []models.Crawl
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
return models.Crawl{}, err
}
if len(rows) == 0 {
return models.Crawl{}, gorm.ErrRecordNotFound
}
return rows[0], nil
}
// Run runs one queued crawl: it fetches the site, checks every page,
// stores the pages, links and findings with the CMS-data issues, and marks
// the crawl finished, cancelled (pages and issues fetched so far are kept)
// or failed (with the error; what was fetched before the failure is kept).
// A crawl that is no longer queued is left alone.
func Run(ctx context.Context, svc *classes.Service, id uint) error {
gdb, err := svc.GormDB()
if err != nil {
return err
}
db := gdb.WithContext(ctx)
row, err := LoadCrawl(db, id)
if err != nil {
return err
}
if row.Status != models.CrawlQueued {
return nil
}
now := time.Now().UTC()
moved := db.Model(&models.Crawl{}).Where("id = ? AND status = ?", id, models.CrawlQueued).
Updates(map[string]any{"status": models.CrawlRunning, "started_at": now})
if moved.Error != nil {
return moved.Error
}
if moved.RowsAffected == 0 {
return nil
}
res, runErr := run(ctx, svc, db, row)
return finish(ctx, svc, db, row, res, runErr)
}
// run fetches and checks the site of one crawl.
func run(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.Crawl) (*Result, error) {
f, err := NewFetcher(row.Site, svc.Config())
if err != nil {
return nil, err
}
limits, err := LoadLimits(db)
if err != nil {
return nil, err
}
if row.MaxPages > 0 {
limits.MaxPages = row.MaxPages
}
var m *conga.Manager
if app := svc.App(); app != nil {
m, _ = conga.From(app)
}
opt := Options{
MaxPages: limits.MaxPages,
Delay: limits.Delay,
Renderer: rendererFrom(ctx),
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 {
return true
}
if m != nil && row.JobID != nil {
if c, err := m.CheckIfCanceled(ctx, *row.JobID); err == nil && c {
return true
}
}
return false
},
Progress: func(ctx context.Context, n int) {
_ = db.Model(&models.Crawl{}).Where("id = ?", row.ID).Update("pages", n).Error
if m != nil && row.JobID != nil {
_ = m.UpdateJobState(ctx, *row.JobID, n, nil)
}
},
}
if m != nil && row.JobID != nil {
_ = m.StartJob(ctx, *row.JobID, limits.MaxPages)
}
return Crawl(ctx, f, opt)
}
// finish stores what a crawl found and writes its final state.
func finish(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.Crawl, res *Result, runErr error) error {
status := models.CrawlFinished
errText := ""
if runErr != nil {
status, errText = models.CrawlFailed, runErr.Error()
}
var current string
_ = db.Raw(`SELECT status FROM golem15_seo_crawls WHERE id = ?`, row.ID).Scan(&current).Error
if current == models.CrawlCancelled || (res != nil && res.Canceled) {
status = models.CrawlCancelled
}
store := func(ctx context.Context, tx *gorm.DB) error {
if res == nil {
return nil
}
if err := SavePages(tx, row.ID, res); err != nil {
return err
}
findings := Check(res, &Env{Lookup: Lookup(ctx, svc, tx)})
cms, err := svc.Issues(classes.WithTx(ctx, tx))
if err != nil {
return err
}
id := row.ID
return SaveIssues(tx, &id, findings, cms)
}
if err := lagoon.Transaction(ctx, db, store); err != nil {
status, errText = models.CrawlFailed, err.Error()
}
updates := map[string]any{"status": status, "error": errText, "finished_at": time.Now().UTC()}
if res != nil {
updates["pages"] = len(res.Order)
updates["limit_hit"] = res.LimitHit
updates["render_status"] = res.Render.Status
updates["render_pages"] = res.Render.Pages
updates["render_note"] = res.Render.Note
}
if err := db.Model(&models.Crawl{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil {
return err
}
if m, err := conga.From(svc.App()); err == nil && row.JobID != nil {
meta := map[string]any{"crawl_id": row.ID, "status": status}
switch status {
case models.CrawlFailed:
_ = m.FailJob(ctx, *row.JobID, meta)
case models.CrawlCancelled:
_ = m.StopJob(ctx, *row.JobID, meta)
default:
_ = m.CompleteJob(ctx, *row.JobID, meta)
}
}
if status == models.CrawlFailed {
pluginLog.Warn("golem15.seo: crawl failed", "crawl", row.ID, "error", errText)
}
return nil
}
// Lookup returns the CMS record behind a crawled address (cached per
// address): the content type whose Match answers the path exactly, its
// descriptor facts in the matched locale, its primary parent's address and
// whether its SEO tab stores custom JSON-LD.
func Lookup(ctx context.Context, svc *classes.Service, db *gorm.DB) func(key string) *Record {
cache := map[string]*Record{}
reg := svc.Registry()
unprefixed := svc.UnprefixedLocale()
return func(key string) *Record {
if reg == nil {
return nil
}
path, _, _ := strings.Cut(key, "?")
if r, ok := cache[path]; ok {
return r
}
cache[path] = nil
typeKey, match, err := reg.MatchPath(ctx, path, unprefixed)
if err != nil || match == nil || !match.Exact {
return nil
}
typ, ok := reg.ContentType(typeKey)
if !ok || typ.Content == nil {
return nil
}
rec, err := reg.Describe(ctx, typeKey, match.ID, unprefixed)
if err != nil || rec == nil {
return nil
}
out := &Record{Type: typeKey, ID: match.ID, Locale: match.Locale, TopHeading: rec.TopHeading, Tools: typ.Content.Tools}
var loc *pact.ContentLocale
for i := range rec.Locales {
if rec.Locales[i].Locale == match.Locale {
loc = &rec.Locales[i]
break
}
}
if loc == nil && len(rec.Locales) > 0 {
loc = &rec.Locales[0]
}
if loc != nil {
out.Locale = loc.Locale
out.Title, out.Words, out.Headings, out.Images = loc.Title, loc.Words, loc.Headings, loc.Images
}
if len(rec.Parents) > 0 {
if parent, err := reg.Describe(ctx, rec.Parents[0].Type, rec.Parents[0].ID, unprefixed); err == nil && parent != nil {
for _, pl := range parent.Locales {
if pl.Locale == out.Locale && pl.URL != "" {
out.Links = append(out.Links, pl.URL)
}
}
}
}
if entry, found, err := classes.LoadEntry(db, typ.Content.Model, match.ID, out.Locale); err == nil && found && entry.Data != nil {
if raw, _ := entry.Data["schema"].(string); raw != "" {
if v, err := schema.ParseValue(raw); err == nil && strings.TrimSpace(v.Custom) != "" {
out.CustomJSONLD = true
}
}
}
cache[path] = out
return out
}
}
// errCancelledCrawl refuses cancelling a crawl that is not queued or
// running.
var errCancelledCrawl = errors.New("golem15.seo: the crawl is not running")
// Cancel stops a queued or running crawl inside tx: the row is marked
// cancelled by name (the crawl keeps what it fetched) and its conga job
// cancelled.
func Cancel(ctx context.Context, svc *classes.Service, tx *gorm.DB, row models.Crawl, name string) error {
if !row.Active() {
return errCancelledCrawl
}
now := time.Now().UTC()
updates := map[string]any{"status": models.CrawlCancelled, "cancelled_by": name}
if row.Status == models.CrawlQueued {
updates["finished_at"] = now
}
if err := tx.Model(&models.Crawl{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil {
return err
}
if row.JobID != nil {
if m, err := conga.From(svc.App()); err == nil {
if err := m.CancelJob(ctx, *row.JobID); err != nil {
pluginLog.Warn("golem15.seo: cancel crawl job", "crawl", row.ID, "error", err)
}
}
}
return nil
}
// ErrNotRunning is a cancel of a crawl that is not queued or running.
var ErrNotRunning = errCancelledCrawl
// pluginLog is the crawler's logger (component seo).
var pluginLog = diary.For("seo")

293
classes/crawl/parse.go Normal file
View File

@@ -0,0 +1,293 @@
package crawl
import (
"bytes"
"net/url"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// Heading is one heading of a page in document order.
type Heading struct {
Level int `json:"level"`
Text string `json:"text"`
}
// Link is one <a href> of a page, resolved against the page's address.
type Link struct {
URL *url.URL
Href string
Text string
Rel string
}
// Image is one <img> of a page.
type Image struct {
Src string
Alt string
HasAlt bool
Width string
Height string
Loading string
}
// Alternate is one <link rel="alternate" hreflang>.
type Alternate struct {
Lang string
URL *url.URL
Href string
}
// PageFacts is what one HTML document says, parsed with
// golang.org/x/net/html (no regular expressions over HTML): the language,
// title, meta description, canonical, meta robots, og:image, headings,
// links, images, JSON-LD scripts, hreflang alternates and the main text
// with its word count. The rendered comparison parses the JavaScript
// rendered DOM into the same facts.
type PageFacts struct {
Lang string
Title string
HasTitle bool
Description string
HasDescription bool
Canonical string
Robots string
OGImage string
Headings []Heading
Links []Link
Images []Image
JSONLD []string
Alternates []Alternate
Text string
Words int
}
// H1 returns the texts of the page's level-1 headings.
func (p *PageFacts) H1() []string {
var out []string
for _, h := range p.Headings {
if h.Level == 1 {
out = append(out, h.Text)
}
}
return out
}
// Noindex reports whether the meta robots value says noindex.
func (p *PageFacts) Noindex() bool { return hasNoindex(p.Robots) }
// hasNoindex reports whether a robots value contains noindex or none.
func hasNoindex(v string) bool {
for _, part := range strings.FieldsFunc(strings.ToLower(v), func(r rune) bool { return r == ',' || r == ' ' || r == ';' }) {
if part == "noindex" || part == "none" {
return true
}
}
return false
}
// Parse reads the facts of an HTML document fetched from base.
func Parse(base *url.URL, body []byte) *PageFacts {
out := &PageFacts{}
doc, err := html.Parse(bytes.NewReader(body))
if err != nil {
return out
}
resolve := func(href string) *url.URL {
href = strings.TrimSpace(href)
if href == "" {
return nil
}
ref, err := url.Parse(href)
if err != nil {
return nil
}
if base == nil {
return ref
}
u := base.ResolveReference(ref)
u.Fragment = ""
return u
}
var main *html.Node
var bodyNode *html.Node
var walk func(n *html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.DataAtom {
case atom.Html:
out.Lang = strings.TrimSpace(attr(n, "lang"))
case atom.Title:
if !out.HasTitle {
out.HasTitle = true
out.Title = collapse(textOf(n))
}
case atom.Meta:
name := strings.ToLower(attr(n, "name"))
prop := strings.ToLower(attr(n, "property"))
switch {
case name == "description" && !out.HasDescription:
out.HasDescription = true
out.Description = collapse(attr(n, "content"))
case name == "robots" || name == "googlebot":
if v := strings.TrimSpace(attr(n, "content")); v != "" {
if out.Robots != "" {
out.Robots += ", "
}
out.Robots += v
}
case prop == "og:image" && out.OGImage == "":
if u := resolve(attr(n, "content")); u != nil {
out.OGImage = u.String()
}
}
case atom.Link:
rel := strings.ToLower(attr(n, "rel"))
switch {
case relHas(rel, "canonical") && out.Canonical == "":
if u := resolve(attr(n, "href")); u != nil {
out.Canonical = u.String()
}
case relHas(rel, "alternate") && hasAttr(n, "hreflang"):
href := attr(n, "href")
out.Alternates = append(out.Alternates, Alternate{Lang: strings.TrimSpace(attr(n, "hreflang")), URL: resolve(href), Href: href})
}
case atom.H1, atom.H2, atom.H3, atom.H4, atom.H5, atom.H6:
out.Headings = append(out.Headings, Heading{Level: int(n.Data[1] - '0'), Text: collapse(textOf(n))})
case atom.A:
if hasAttr(n, "href") && len(out.Links) < MaxLinksPerPage {
href := attr(n, "href")
low := strings.ToLower(strings.TrimSpace(href))
if !strings.HasPrefix(low, "javascript:") && !strings.HasPrefix(low, "mailto:") && !strings.HasPrefix(low, "tel:") && !strings.HasPrefix(low, "#") {
if u := resolve(href); u != nil {
out.Links = append(out.Links, Link{URL: u, Href: href, Text: collapse(textOf(n)), Rel: strings.ToLower(attr(n, "rel"))})
}
}
}
case atom.Img:
alt, hasAlt := attrOK(n, "alt")
src := attr(n, "src")
if u := resolve(src); u != nil {
src = u.String()
}
out.Images = append(out.Images, Image{Src: src, Alt: alt, HasAlt: hasAlt, Width: attr(n, "width"), Height: attr(n, "height"), Loading: strings.ToLower(attr(n, "loading"))})
case atom.Script:
if strings.EqualFold(strings.TrimSpace(attr(n, "type")), "application/ld+json") {
out.JSONLD = append(out.JSONLD, textOf(n))
}
return
case atom.Main:
if main == nil {
main = n
}
case atom.Body:
bodyNode = n
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
root := main
if root == nil {
root = bodyNode
}
if root != nil {
var b strings.Builder
visibleText(root, &b)
out.Text = collapse(b.String())
if len(out.Text) > maxText {
out.Text = truncateUTF8(out.Text, maxText)
}
out.Words = len(strings.Fields(out.Text))
}
return out
}
// maxText caps the main text kept per page (characters of UTF-8, bytes
// bound).
const maxText = 100_000
func truncateUTF8(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && n < len(s) && (s[n]&0xC0) == 0x80 {
n--
}
return s[:n]
}
// visibleText writes the text of n without script, style, noscript and
// template content, one space between block parts.
func visibleText(n *html.Node, b *strings.Builder) {
if n.Type == html.ElementNode {
switch n.DataAtom {
case atom.Script, atom.Style, atom.Noscript, atom.Template, atom.Svg:
return
}
}
if n.Type == html.TextNode {
b.WriteString(n.Data)
b.WriteByte(' ')
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
visibleText(c, b)
}
}
func textOf(n *html.Node) string {
var b strings.Builder
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
b.WriteString(n.Data)
b.WriteByte(' ')
}
if n.Type == html.ElementNode && n.DataAtom == atom.Img {
if alt := attr(n, "alt"); alt != "" {
b.WriteString(alt)
b.WriteByte(' ')
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return strings.TrimSpace(b.String())
}
func collapse(s string) string { return strings.Join(strings.Fields(s), " ") }
func attrOK(n *html.Node, key string) (string, bool) {
for _, a := range n.Attr {
if strings.EqualFold(a.Key, key) {
return a.Val, true
}
}
return "", false
}
func attr(n *html.Node, key string) string {
v, _ := attrOK(n, key)
return v
}
func hasAttr(n *html.Node, key string) bool {
_, ok := attrOK(n, key)
return ok
}
func relHas(rel, want string) bool {
for _, part := range strings.Fields(rel) {
if part == want {
return true
}
}
return false
}

43
classes/crawl/render.go Normal file
View File

@@ -0,0 +1,43 @@
package crawl
import "context"
// 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
}

248
classes/crawl/store.go Normal file
View File

@@ -0,0 +1,248 @@
package crawl
import (
"context"
"sort"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// insertBatch is the rows written per INSERT.
const insertBatch = 200
// SavePages writes the pages and links one crawl fetched. The crawler
// writes only its own tables (D-14).
func SavePages(tx *gorm.DB, crawlID uint, res *Result) error {
if res == nil {
return nil
}
in := map[string]int{}
var links []models.CrawlLink
for _, key := range res.Order {
p := res.Pages[key]
if p.Facts == nil {
continue
}
seen := map[string]bool{}
for _, l := range p.Facts.Links {
to := ""
internal := false
if res.fetcher != nil {
to = res.fetcher.Key(l.URL)
internal = to != ""
}
if !internal {
to = l.URL.String()
}
links = append(links, models.CrawlLink{CrawlID: crawlID, FromURL: key, ToURL: to, Text: truncateUTF8(l.Text, 500), Rel: l.Rel, Internal: internal})
if internal && to != key && !seen[to] {
seen[to] = true
in[to]++
}
}
}
pages := make([]models.CrawlPage, 0, len(res.Order))
for _, key := range res.Order {
p := res.Pages[key]
row := models.CrawlPage{CrawlID: crawlID, URL: key, Depth: p.Depth, InSitemap: p.InSitemap, SitemapFile: p.SitemapFile, LinksIn: in[key]}
if !p.FetchedAt.IsZero() {
t := p.FetchedAt
row.FetchedAt = &t
}
if r := p.Resp; r != nil {
row.Status, row.Location, row.ContentType, row.XRobots = r.Status, r.Location, truncateUTF8(r.ContentType, 200), truncateUTF8(r.XRobots, 500)
}
if f := p.Facts; f != nil {
row.Title, row.Description, row.Canonical, row.Robots = truncateUTF8(f.Title, 1000), truncateUTF8(f.Description, 2000), truncateUTF8(f.Canonical, 2048), truncateUTF8(f.Robots, 500)
row.H1 = lagoon.Jsonable[[]string]{Data: f.H1(), Valid: true}
hs := make([]models.CrawlHeading, 0, len(f.Headings))
for _, h := range f.Headings {
hs = append(hs, models.CrawlHeading{Level: h.Level, Text: truncateUTF8(h.Text, 500)})
}
row.Headings = lagoon.Jsonable[[]models.CrawlHeading]{Data: hs, Valid: true}
row.Words, row.Text = f.Words, truncateUTF8(f.Text, models.MaxPageText)
row.LinksOut = len(f.Links)
}
pages = append(pages, row)
}
if len(pages) > 0 {
if err := tx.CreateInBatches(pages, insertBatch).Error; err != nil {
return err
}
}
if len(links) > 0 {
if err := tx.CreateInBatches(links, insertBatch).Error; err != nil {
return err
}
}
return nil
}
// issueRow turns a finding into its stored row.
func issueRow(crawlID *uint, source string, f Finding, now time.Time) models.Issue {
fix, tool, what := Route(f)
details := map[string]any{}
for k, v := range f.Details {
details[k] = v
}
if what != "" {
details["what"] = what
}
if f.Check != "" {
details["check"] = f.Check
}
sev := Severity(f.Kind)
t := now
return models.Issue{
CrawlID: crawlID,
Source: source,
Kind: f.Kind,
Severity: sev,
Rank: models.SeverityRank(sev),
URL: truncateUTF8(f.URL, 2048),
Locale: f.Locale,
Details: lagoon.Jsonable[map[string]any]{Data: details, Valid: true},
Tool: tool,
Fix: fix,
Fingerprint: Fingerprint(f.Kind, f.URL, f.Locale, f.Check),
FoundAt: &t,
}
}
// cmsFinding turns a CMS-data issue of the classes report into a finding:
// its tool is the one the report names; a CMS issue without one is fixed
// in the record's admin form.
func cmsFinding(issue classes.Issue) Finding {
f := Finding{Kind: issue.Kind, URL: issue.URL, Locale: issue.Locale, Details: issue.Details, Tool: issue.Tool}
if f.Tool == "" {
f.What = "the record in its admin form"
}
return f
}
// SaveIssues stores a crawl's findings and the CMS-data issues with it
// (crawlID nil: the CMS issues of a site not crawled yet). A CMS issue
// with the fingerprint of a crawl finding is the same problem and is not
// stored twice.
func SaveIssues(tx *gorm.DB, crawlID *uint, findings []Finding, cms []classes.Issue) error {
now := time.Now().UTC()
seen := map[string]bool{}
var rows []models.Issue
for _, f := range findings {
row := issueRow(crawlID, models.SourceCrawl, f, now)
if seen[row.Fingerprint] {
continue
}
seen[row.Fingerprint] = true
rows = append(rows, row)
}
for _, issue := range cms {
row := issueRow(crawlID, models.SourceCMS, cmsFinding(issue), now)
if seen[row.Fingerprint] {
continue
}
seen[row.Fingerprint] = true
rows = append(rows, row)
}
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].Rank != rows[j].Rank {
return rows[i].Rank < rows[j].Rank
}
if rows[i].Kind != rows[j].Kind {
return rows[i].Kind < rows[j].Kind
}
return rows[i].URL < rows[j].URL
})
if len(rows) == 0 {
return nil
}
return tx.CreateInBatches(rows, insertBatch).Error
}
// LatestCrawl returns the newest crawl that is no longer queued or
// running, nil when there is none: its issues are the audit's current
// ones.
func LatestCrawl(db *gorm.DB) (*models.Crawl, error) {
var rows []models.Crawl
if err := db.Where("status NOT IN ?", []string{models.CrawlQueued, models.CrawlRunning}).Order("id DESC").Limit(1).Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
return &rows[0], nil
}
// ActiveCrawl returns the queued or running crawl, nil when none.
func ActiveCrawl(db *gorm.DB) (*models.Crawl, error) {
var rows []models.Crawl
if err := db.Where("status IN ?", []string{models.CrawlQueued, models.CrawlRunning}).Order("id DESC").Limit(1).Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
return &rows[0], nil
}
// CurrentScope narrows an issues query to the audit's current issues: the
// newest finished, failed or cancelled crawl's, or before any crawl the
// stored CMS-data issues.
func CurrentScope(db *gorm.DB, latest *models.Crawl) *gorm.DB {
if latest == nil {
return db.Where("golem15_seo_issues.crawl_id IS NULL")
}
return db.Where("golem15_seo_issues.crawl_id = ?", latest.ID)
}
// IgnoredSet returns which of the fingerprints are ignored.
func IgnoredSet(db *gorm.DB, fingerprints []string) (map[string]bool, error) {
out := map[string]bool{}
if len(fingerprints) == 0 {
return out, nil
}
var rows []models.IssueIgnore
for start := 0; start < len(fingerprints); start += 1000 {
end := min(start+1000, len(fingerprints))
var part []models.IssueIgnore
if err := db.Where("fingerprint IN ?", fingerprints[start:end]).Find(&part).Error; err != nil {
return nil, err
}
rows = append(rows, part...)
}
for _, r := range rows {
out[r.Fingerprint] = true
}
return out, nil
}
// Current returns the crawl whose issues are the audit's current ones (nil
// before any crawl). Before any crawl, the CMS-data checks run now and
// their issues replace the stored ones, so the list shows the CMS data as
// it is (UI-SPEC C23: when the list is opened with no crawl yet).
func Current(ctx context.Context, svc *classes.Service, db *gorm.DB) (*models.Crawl, error) {
latest, err := LatestCrawl(db)
if err != nil || latest != nil {
return latest, err
}
return nil, RefreshCMS(ctx, svc, db)
}
// RefreshCMS replaces the stored CMS-data issues of a site not crawled
// yet with the report's current ones, in one transaction.
func RefreshCMS(ctx context.Context, svc *classes.Service, db *gorm.DB) error {
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
cms, err := svc.Issues(classes.WithTx(ctx, tx))
if err != nil {
return err
}
if err := tx.Where("crawl_id IS NULL").Delete(&models.Issue{}).Error; err != nil {
return err
}
return SaveIssues(tx, nil, nil, cms)
})
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// The kinds of the CMS-data issue report (D-13, D-14 CMS part). The
@@ -309,3 +310,11 @@ func (s *Service) canonicalProblem(ctx context.Context, canonical string) (kind,
}
return IssueCanonicalNoindex, "", nil
}
// Actor names who runs an audit operation (a crawl start, a cancel, an
// ignore): the backend administrator's id and name, and whether the call
// came through MCP.
func (s *Service) Actor(ctx context.Context, db *gorm.DB) (id *uint, name string, mcp bool) {
id, name = s.author(ctx, db, models.ViaAdmin)
return id, name, ViaOf(ctx) == models.ViaMCP
}

View File

@@ -44,3 +44,10 @@ route_report_token: ""
# WebSite node of the JSON-LD graph carries a SearchAction. Empty means no
# SearchAction:
# search_url: "/szukaj?q={search_term_string}"
#
# crawl_trusted lets the site audit's crawler fetch plain http addresses
# and addresses inside the network (a staging or local site). Without it
# the crawler fetches only https addresses of the site host and its www
# twin, and refuses private addresses. Only config (or the environment,
# SUMMER_GOLEM15__SEO__CRAWL_TRUSTED) sets it, never a setting or MCP.
crawl_trusted: false

90
controllers/api/crawls.go Normal file
View File

@@ -0,0 +1,90 @@
package api
import (
"context"
"errors"
"net/http"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// StartCrawlInput is the body of POST /_seo/api/v1/crawls: nothing.
type StartCrawlInput struct{}
// CrawlView is a crawl in an answer.
type CrawlView struct {
ID uint `json:"id"`
Status string `json:"status"`
Trigger string `json:"trigger"`
TriggerName string `json:"trigger_name,omitempty"`
Site string `json:"site"`
MaxPages int `json:"max_pages"`
Pages int `json:"pages"`
LimitHit bool `json:"limit_hit"`
Error string `json:"error,omitempty"`
StartedAt *time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at"`
CreatedAt *time.Time `json:"created_at"`
}
// crawlView is the answer form of a crawl.
func crawlView(c models.Crawl) CrawlView {
return CrawlView{
ID: c.ID, Status: c.Status, Trigger: c.Trigger, TriggerName: c.TriggerName, Site: c.Site,
MaxPages: c.MaxPages, Pages: c.Pages, LimitHit: c.LimitHit, Error: c.Error,
StartedAt: c.StartedAt, FinishedAt: c.FinishedAt, CreatedAt: c.CreatedAt,
}
}
// StartCrawl serves POST /_seo/api/v1/crawls (D-14, P33-39): it queues a
// crawl started by the administrator (trigger admin, or mcp through MCP)
// and answers 202 with it. A crawl already queued or running refuses with
// 409 crawls.already_running, a missing site address with 422
// crawls.no_site_url.
func StartCrawl(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var row models.Crawl
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
_, name, viaMCP := svc.Actor(ctx, tx)
trigger := models.TriggerAdmin
if viaMCP {
trigger = models.TriggerMCP
}
var err error
row, err = crawl.Start(ctx, svc, tx, trigger, name)
return err
})
if err != nil {
return 0, nil, StartRefusal(ctx, svc, err)
}
return http.StatusAccepted, map[string]any{"data": crawlView(row), "message": svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.started", nil)}, nil
})
}
// StartRefusal maps a refused start to its answer.
func StartRefusal(ctx context.Context, svc *classes.Service, err error) error {
var running *crawl.AlreadyRunningError
switch {
case errors.As(err, &running):
return &apiError{Status: http.StatusConflict, Body: map[string]any{
"error": "crawls.already_running",
"crawl_id": running.Crawl.ID,
"message": RunningMessage(ctx, svc, running),
}}
case errors.Is(err, crawl.ErrNoSiteURL):
return &apiError{Status: http.StatusUnprocessableEntity, Body: map[string]any{
"error": "crawls.no_site_url",
"message": svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.no_site_url", nil),
}}
}
return err
}
// RunningMessage is crawls.already_running with the running crawl's start.
func RunningMessage(ctx context.Context, svc *classes.Service, running *crawl.AlreadyRunningError) string {
return svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.already_running", map[string]string{"time": running.Since().UTC().Format("2006-01-02 15:04 UTC")})
}

View File

@@ -3,31 +3,96 @@ package api
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
)
// ListIssuesInput is the query of GET /_seo/api/v1/issues.
type ListIssuesInput struct {
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only: missing_title, missing_description, duplicate_title, duplicate_description, rule_overrides_record, redirect_chain, canonical_noindex, canonical_redirected, noindex_blocked or sitemap_blocked"`
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only, e.g. h1_multiple, broken_link, missing_title, redirect_chain or sitemap_blocked"`
}
// ListIssues serves GET /_seo/api/v1/issues (D-13, D-14 CMS part): the
// CMS-data issue report, each issue with its URL, kind, details and the
// tool that fixes it, and a count per kind.
// IssueView is a stored issue in an answer: the Phase 32 keys (kind, url,
// locale, details, tool) and the audit's id, severity, source, fix,
// found_at, ignored and crawl_id. tool is the fixing MCP tool when fix is
// tool; for fix code, details.what names the template element.
type IssueView struct {
ID uint `json:"id"`
Kind string `json:"kind"`
URL string `json:"url"`
Locale string `json:"locale,omitempty"`
Details map[string]any `json:"details,omitempty"`
Tool string `json:"tool"`
Severity string `json:"severity"`
Source string `json:"source"`
Fix string `json:"fix"`
FoundAt *time.Time `json:"found_at"`
Ignored bool `json:"ignored"`
CrawlID *uint `json:"crawl_id"`
}
// issueViews turns stored issues into their answer form.
func issueViews(db *gorm.DB, rows []models.Issue) ([]IssueView, error) {
fps := make([]string, 0, len(rows))
for _, r := range rows {
fps = append(fps, r.Fingerprint)
}
ignored, err := crawl.IgnoredSet(db, fps)
if err != nil {
return nil, err
}
out := make([]IssueView, 0, len(rows))
for _, r := range rows {
out = append(out, IssueView{
ID: r.ID, Kind: r.Kind, URL: r.URL, Locale: r.Locale, Details: r.DetailsMap(), Tool: r.Tool,
Severity: r.Severity, Source: r.Source, Fix: r.Fix, FoundAt: r.FoundAt,
Ignored: ignored[r.Fingerprint], CrawlID: r.CrawlID,
})
}
return out, nil
}
// ListIssues serves GET /_seo/api/v1/issues (D-13, D-14): the audit's
// current issues, errors first: the newest crawl's findings with the
// CMS-data issues stored with it, or before any crawl the CMS-data issues
// as they are now. Each issue names the tool that fixes it, or fix code.
// counts holds the number per kind.
func ListIssues(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
issues, err := svc.Issues(ctx)
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
kind := r.URL.Query().Get("kind")
latest, err := crawl.Current(ctx, svc, db)
if err != nil {
return 0, nil, err
}
q := r.URL.Query()
base := crawl.CurrentScope(db.Model(&models.Issue{}), latest)
if id, err := strconv.ParseUint(strings.TrimSpace(q.Get("crawl")), 10, 64); err == nil && id > 0 {
base = db.Model(&models.Issue{}).Where("golem15_seo_issues.crawl_id = ?", id)
}
var all []models.Issue
if err := base.Order("rank ASC, kind ASC, url ASC, id ASC").Find(&all).Error; err != nil {
return 0, nil, err
}
views, err := issueViews(db, all)
if err != nil {
return 0, nil, err
}
kind := strings.TrimSpace(q.Get("kind"))
counts := map[string]int{}
out := make([]classes.Issue, 0, len(issues))
for _, issue := range issues {
counts[issue.Kind]++
if kind == "" || issue.Kind == kind {
out = append(out, issue)
out := make([]IssueView, 0, len(views))
for _, v := range views {
counts[v.Kind]++
if kind == "" || v.Kind == kind {
out = append(out, v)
}
}
return http.StatusOK, map[string]any{"data": out, "counts": counts, "total": len(out)}, nil

417
crawl_site_test.go Normal file
View File

@@ -0,0 +1,417 @@
package seo_test
import (
"context"
"encoding/json"
"html"
"io"
"net/http"
"slices"
"sort"
"strings"
"sync"
"testing"
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/fetchguard"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/signpost"
)
// The test site the crawler fetches: a fake http.RoundTripper handed to
// the crawler through fetchguard.WithTransport, so the crawler's real
// requests (URL checks, User-Agent, no redirects followed) are asserted
// offline. Paths it does not know answer 404.
// sitePage is one answer of the test site.
type sitePage struct {
status int
body string
header map[string]string
}
// fakeSite serves the test site and records every request URL.
type fakeSite struct {
mu sync.Mutex
pages map[string]sitePage
requests []string
agents []string
}
func newFakeSite() *fakeSite { return &fakeSite{pages: map[string]sitePage{}} }
// set serves body with status at path (path with query).
func (s *fakeSite) set(path string, status int, body string, header ...string) {
s.mu.Lock()
defer s.mu.Unlock()
h := map[string]string{"Content-Type": "text/html; charset=utf-8"}
for i := 0; i+1 < len(header); i += 2 {
h[header[i]] = header[i+1]
}
s.pages[path] = sitePage{status: status, body: body, header: h}
}
// html serves a 200 HTML page.
func (s *fakeSite) html(path, body string) { s.set(path, http.StatusOK, body) }
func (s *fakeSite) RoundTrip(r *http.Request) (*http.Response, error) {
s.mu.Lock()
s.requests = append(s.requests, r.URL.String())
s.agents = append(s.agents, r.Header.Get("User-Agent"))
key := r.URL.EscapedPath()
if r.URL.RawQuery != "" {
key += "?" + r.URL.RawQuery
}
p, ok := s.pages[key]
s.mu.Unlock()
if !ok || !strings.EqualFold(r.URL.Hostname(), "www.example.test") {
p = sitePage{status: http.StatusNotFound, body: "<html><body>Not found</body></html>", header: map[string]string{"Content-Type": "text/html"}}
}
header := http.Header{}
for k, v := range p.header {
header.Set(k, v)
}
return &http.Response{StatusCode: p.status, Header: header, Body: io.NopCloser(strings.NewReader(p.body)), Request: r}, nil
}
// requested reports whether any request went to an URL containing part.
func (s *fakeSite) requested(part string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, u := range s.requests {
if strings.Contains(u, part) {
return true
}
}
return false
}
// sitemap serves /sitemap.xml as an index of one file listing paths.
func (s *fakeSite) sitemap(paths ...string) {
s.set("/sitemap.xml", http.StatusOK, `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>`+siteURL+`/sitemap-pages-1.xml</loc></sitemap></sitemapindex>`, "Content-Type", "application/xml")
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
for _, p := range paths {
b.WriteString("<url><loc>" + siteURL + html.EscapeString(p) + "</loc></url>")
}
b.WriteString("</urlset>")
s.set("/sitemap-pages-1.xml", http.StatusOK, b.String(), "Content-Type", "application/xml")
}
// doc is one planted page: a complete, clean document (unique title and
// description, a self canonical, one H1 matching the title) that a test
// mutates for the kind it plants.
type doc struct {
path string
lang string
title string
noTitle bool
description string
noDesc bool
canonical string
noCanonical bool
robots string
head string
h1 []string
body string
links []string
}
// render writes the document's HTML.
func (d doc) render() string {
var b strings.Builder
lang := d.lang
if lang == "" {
lang = "en"
}
b.WriteString(`<!doctype html><html lang="` + lang + `"><head><meta charset="utf-8">`)
title := d.title
if title == "" {
title = "Page " + d.path
}
if !d.noTitle {
b.WriteString("<title>" + html.EscapeString(title) + "</title>")
}
if !d.noDesc {
desc := d.description
if desc == "" {
desc = "About " + d.path
}
b.WriteString(`<meta name="description" content="` + html.EscapeString(desc) + `">`)
}
if !d.noCanonical {
c := d.canonical
if c == "" {
c = siteURL + d.path
}
b.WriteString(`<link rel="canonical" href="` + html.EscapeString(c) + `">`)
}
if d.robots != "" {
b.WriteString(`<meta name="robots" content="` + d.robots + `">`)
}
b.WriteString(d.head + "</head><body><main>")
h1 := d.h1
if h1 == nil {
h1 = []string{title}
}
for _, h := range h1 {
b.WriteString("<h1>" + html.EscapeString(h) + "</h1>")
}
b.WriteString(d.body)
for _, l := range d.links {
b.WriteString(`<p><a href="` + l + `">Read about ` + html.EscapeString(strings.Trim(l, "/")) + `</a></p>`)
}
b.WriteString("</main></body></html>")
return b.String()
}
// plant serves the documents on the site.
func (s *fakeSite) plant(docs ...doc) {
for _, d := range docs {
s.html(d.path, d.render())
}
}
// The fake content plugin acme.crawl: in-memory records behind two types
// with descriptor facts and fixing tools, so the audit's content routes
// (P33-38) are exercised. acme-crawl-post (/c/:slug) names edit, headings
// and image_alt tools; acme-crawl-category (/cat/:slug) names only a
// headings tool.
const (
crawlPostType = "acme-crawl-post"
crawlCategoryType = "acme-crawl-category"
)
// crawlRecord is one in-memory record.
type crawlRecord struct {
id string
path string
title string
words int
headings []pact.ContentHeading
images []pact.ContentImage
topHeading int
parents []pact.ContentRef
}
var (
crawlRecordsMu sync.Mutex
crawlRecords = map[string][]crawlRecord{}
)
// setCrawlRecords replaces the records of the fake types for one test.
func setCrawlRecords(t *testing.T, byType map[string][]crawlRecord) {
t.Helper()
crawlRecordsMu.Lock()
crawlRecords = byType
crawlRecordsMu.Unlock()
t.Cleanup(func() {
crawlRecordsMu.Lock()
crawlRecords = map[string][]crawlRecord{}
crawlRecordsMu.Unlock()
})
}
func crawlRecordsOf(typeKey string) []crawlRecord {
crawlRecordsMu.Lock()
defer crawlRecordsMu.Unlock()
return slices.Clone(crawlRecords[typeKey])
}
func (r crawlRecord) content() pact.ContentRecord {
words := r.words
var wp *int
if words > 0 {
wp = &words
}
return pact.ContentRecord{
ID: r.id, Published: true, TopHeading: r.topHeading, Parents: r.parents,
Locales: []pact.ContentLocale{{Locale: "en", URL: r.path, Title: r.title, Words: wp, Headings: r.headings, Images: r.images}},
}
}
type crawlContentPlugin struct{}
func (crawlContentPlugin) ID() string { return "acme.crawl" }
func (crawlContentPlugin) Requires() []string { return nil }
func (crawlContentPlugin) Register(*backpack.App) error { return nil }
func (crawlContentPlugin) Boot(*backpack.App) error { return nil }
func (crawlContentPlugin) MenuItemTypes() []pact.MenuItemType {
return []pact.MenuItemType{
crawlType(crawlPostType, "/c/:slug", map[string]string{
pact.ContentToolEdit: "acme_edit_post", pact.ContentToolHeadings: "acme_fix_headings", pact.ContentToolImageAlt: "acme_set_alt",
}),
crawlType(crawlCategoryType, "/cat/:slug", map[string]string{pact.ContentToolHeadings: "acme_category_headings"}),
}
}
// crawlType is one fake type over the in-memory records.
func crawlType(key, pattern string, tools map[string]string) pact.MenuItemType {
find := func(match func(crawlRecord) bool) *crawlRecord {
for _, r := range crawlRecordsOf(key) {
if match(r) {
return &r
}
}
return nil
}
return pact.MenuItemType{
Key: key, Label: key, DefaultPattern: pattern, Placeholders: []string{"slug"},
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) { return nil, nil },
Content: &pact.ContentDescriptor{
Model: "acme_crawl_" + strings.ReplaceAll(key, "-", "_"),
Tools: tools,
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
r := find(func(r crawlRecord) bool { return r.id == in.ID })
if r == nil {
return nil, nil
}
rec := r.content()
return &rec, nil
},
Records: func(_ context.Context, in pact.ContentList) (pact.ContentPage, error) {
var out pact.ContentPage
if in.After != "" {
return out, nil
}
for _, r := range crawlRecordsOf(key) {
out.Records = append(out.Records, r.content())
}
return out, nil
},
Match: func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
if _, ok := signpost.MatchPattern(in.Pattern, in.Path); !ok {
return nil, nil
}
r := find(func(r crawlRecord) bool { return r.path == in.Path })
if r == nil {
return nil, nil
}
return &pact.ContentMatchResult{ID: r.id, Locale: "en", Exact: true}, nil
},
},
}
}
var _ pact.HasMenuItemTypes = crawlContentPlugin{}
func init() {
party.Register(crawlContentPlugin{})
}
// bootCrawlEnv boots acme.pages, acme.crawl and golem15.seo.
func bootCrawlEnv(t *testing.T, extraEnv ...string) *testEnv {
t.Helper()
return bootPlugins(t, []string{"acme.pages", "acme.crawl", "golem15.seo"}, extraEnv...)
}
// crawlJob returns the SEO plugin's crawl job.
func (e *testEnv) crawlJob(t *testing.T) pact.Job {
t.Helper()
for _, j := range e.seoPlugin(t).Jobs() {
if info, ok := conga.Describe(j); ok && info.Kind == crawl.CrawlKind {
return j
}
}
t.Fatal("the SEO plugin registers no crawl job")
return nil
}
// siteCtx is a context whose crawler requests go to site.
func siteCtx(t *testing.T, site *fakeSite) context.Context {
return fetchguard.WithTransport(t.Context(), site)
}
// startCrawl starts a crawl through the API and returns its id.
func (e *testEnv) startCrawl(t *testing.T, token string) uint {
t.Helper()
rec := e.expect(t, http.StatusAccepted, http.MethodPost, "/_seo/api/v1/crawls", token, map[string]any{})
var out struct {
Data struct {
ID uint `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.Data.ID == 0 {
t.Fatalf("start crawl: %s (%v)", rec.Body.String(), err)
}
return out.Data.ID
}
// runCrawl runs the crawl job of id against the site.
func (e *testEnv) runCrawl(t *testing.T, ctx context.Context, id uint) {
t.Helper()
if err := e.crawlJob(t).Work(ctx, crawl.CrawlArgs{CrawlID: id}); err != nil {
t.Fatalf("crawl job: %v", err)
}
}
// crawlOnce starts a crawl and runs it against the site.
func (e *testEnv) crawlOnce(t *testing.T, token string, ctx context.Context) uint {
t.Helper()
id := e.startCrawl(t, token)
e.runCrawl(t, ctx, id)
return id
}
// apiIssue is one issue of GET /_seo/api/v1/issues.
type apiIssue struct {
ID uint `json:"id"`
Kind string `json:"kind"`
URL string `json:"url"`
Locale string `json:"locale"`
Details map[string]any `json:"details"`
Tool string `json:"tool"`
Severity string `json:"severity"`
Source string `json:"source"`
Fix string `json:"fix"`
Ignored bool `json:"ignored"`
CrawlID *uint `json:"crawl_id"`
}
// issues lists the audit's issues through the API (query appended).
func (e *testEnv) issues(t *testing.T, token, query string) []apiIssue {
t.Helper()
path := "/_seo/api/v1/issues"
if query != "" {
path += "?" + query
}
rec := e.expect(t, http.StatusOK, http.MethodGet, path, token, nil)
var out struct {
Data []apiIssue `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out.Data
}
// issueSet is "kind url" for every issue of source, sorted.
func issueSet(list []apiIssue, source string) []string {
var out []string
for _, i := range list {
if source == "" || i.Source == source {
out = append(out, i.Kind+" "+i.URL)
}
}
sort.Strings(out)
return out
}
// findIssue returns the one issue of kind at url, failing when there is
// not exactly one.
func findIssue(t *testing.T, list []apiIssue, kind, url string) apiIssue {
t.Helper()
var found []apiIssue
for _, i := range list {
if i.Kind == kind && i.URL == url {
found = append(found, i)
}
}
if len(found) != 1 {
t.Fatalf("%d %s issues for %s, want 1; all: %v", len(found), kind, url, issueSet(list, ""))
}
return found[0]
}

48
crawler_test.go Normal file
View File

@@ -0,0 +1,48 @@
package seo_test
import (
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
)
// TestCrawlerTracer is the end-to-end path (D-14): an administrator
// starts a crawl through the API, the crawl job fetches the site with the
// crawler's User-Agent, and GET /issues lists the page with two H1
// headings as an error with how to fix it: code for H1 headings of the
// template, the record type's headings tool for an H1 inside the record's
// own content.
func TestCrawlerTracer(t *testing.T) {
env := bootCrawlEnv(t)
token := env.admin(t, true)
setCrawlRecords(t, map[string][]crawlRecord{
crawlPostType: {{id: "1", path: "/c/two-h1", title: "Post title", headings: []pact.ContentHeading{{Level: 1, Text: "Inside the body"}}}},
})
site := newFakeSite()
site.sitemap("/", "/p/two-h1", "/c/two-h1")
site.plant(
doc{path: "/", title: "Home", links: []string{"/p/two-h1", "/c/two-h1"}},
doc{path: "/p/two-h1", title: "Template page", h1: []string{"Template page", "Another H1"}},
doc{path: "/c/two-h1", title: "Post title", h1: []string{"Post title", "Inside the body"}},
)
env.crawlOnce(t, token, siteCtx(t, site))
list := env.issues(t, token, "")
tpl := findIssue(t, list, "h1_multiple", "/p/two-h1")
if tpl.Severity != "error" || tpl.Fix != "code" || tpl.Tool != "" || tpl.Source != "crawl" || tpl.Details["what"] == nil || tpl.Details["count"] != float64(2) {
t.Fatalf("template h1_multiple = %+v", tpl)
}
content := findIssue(t, list, "h1_multiple", "/c/two-h1")
if content.Fix != "tool" || content.Tool != "acme_fix_headings" || content.CrawlID == nil || content.ID == 0 {
t.Fatalf("content h1_multiple = %+v", content)
}
if !site.requested("/sitemap-pages-1.xml") || !site.requested("/c/two-h1") {
t.Fatalf("crawler requests = %v", site.requests)
}
for _, ua := range site.agents {
if ua != "SummerCMS-SEO-Crawler/1 (+https://www.example.test)" {
t.Fatalf("User-Agent = %q", ua)
}
}
}

2
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/testcontainers/testcontainers-go v0.44.0
github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0
gocloud.dev v0.46.0
golang.org/x/net v0.58.0
gorm.io/driver/postgres v1.6.3
gorm.io/gorm v1.31.2
)
@@ -162,7 +163,6 @@ require (
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/image v0.46.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect

16
jobs.go
View File

@@ -2,19 +2,33 @@ package seo
import (
"context"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Jobs registers the entry generation job (D-94) on conga.
// Jobs registers the entry generation job (D-94) and the crawl job
// (golem15.seo.crawl, D-14) on conga. A crawl is not retried: a failed run
// records its error and keeps what it fetched.
func (p *Plugin) Jobs() []pact.Job {
return []pact.Job{
conga.Job(p.generateEntries, conga.MaxAttempts(3)),
conga.Job(p.crawl, conga.MaxAttempts(1), conga.Timeout(6*time.Hour)),
}
}
// crawl runs one crawl job (golem15.seo.crawl).
func (p *Plugin) crawl(ctx context.Context, args crawl.CrawlArgs) error {
svc := p.Service()
if svc == nil {
return classes.ErrNotBooted
}
return crawl.Run(ctx, svc, args.CrawlID)
}
// generateEntries runs one generation job.
func (p *Plugin) generateEntries(ctx context.Context, args classes.GenerateEntriesArgs) error {
svc := p.Service()

View File

@@ -311,6 +311,7 @@ history:
kind_notfound: 404 log entry
kind_page: registry page
jobs:
crawl: 'SEO: crawl the site'
generate_entries: 'SEO: generate entries'
effective:
property: Property
@@ -520,3 +521,7 @@ schema:
price_invalid: "Enter a price such as 150 or 149.99."
currency_invalid: "Use a three-letter currency code, e.g. PLN."
merged: "Saved. Your custom JSON-LD replaces :props in the generated :node node."
crawls:
started: Crawl started. Follow it in SEO › Crawls; the issues appear in Site audit when it finishes.
already_running: A crawl is already running (started :time). Wait for it, or cancel it in SEO › Crawls.
no_site_url: The site address for crawls is not configured (golem15.seo.site_url). Ask the administrator to set it.

View File

@@ -317,6 +317,7 @@ history:
kind_notfound: Wpis dziennika 404
kind_page: strona rejestru
jobs:
crawl: 'SEO: skanowanie witryny'
generate_entries: 'SEO: generowanie wpisów'
effective:
property: Właściwość
@@ -534,3 +535,7 @@ schema:
price_invalid: "Wpisz cenę, np. 150 lub 149,99."
currency_invalid: "Użyj trzyliterowego kodu waluty, np. PLN."
merged: "Zapisano. Twój JSON-LD zastępuje :props w wygenerowanym węźle :node."
crawls:
started: Rozpoczęto skanowanie. Postęp znajdziesz w SEO › Skanowania; problemy pojawią się w Audycie witryny po jego zakończeniu.
already_running: Skanowanie już trwa (rozpoczęte :time). Poczekaj na nie lub anuluj je w SEO › Skanowania.
no_site_url: Adres witryny do skanowania nie jest skonfigurowany (golem15.seo.site_url). Poproś administratora o jego ustawienie.

103
models/crawl.go Normal file
View File

@@ -0,0 +1,103 @@
package models
import (
"time"
"gorm.io/gorm"
)
// The states of a crawl (UI-SPEC C24).
const (
CrawlQueued = "queued"
CrawlRunning = "running"
CrawlFinished = "finished"
CrawlFailed = "failed"
CrawlCancelled = "cancelled"
)
// CrawlStatuses lists the states in filter order.
var CrawlStatuses = []string{CrawlQueued, CrawlRunning, CrawlFinished, CrawlFailed, CrawlCancelled}
// What started a crawl.
const (
TriggerAdmin = "admin"
TriggerSchedule = "schedule"
TriggerMCP = "mcp"
)
// The states of the rendered comparison of a crawl (P33-55).
const (
RenderSkipped = "skipped"
RenderDone = "done"
RenderFailed = "failed"
)
// Crawl is one run of the site audit's crawler on golem15_seo_crawls
// (D-14, P33-37): its state, what started it and who, the site address,
// the page limit, the pages fetched and whether the limit stopped it, the
// error of a failed run, the state of the rendered comparison, the conga
// job and its times.
type Crawl struct {
ID uint `gorm:"column:id;primaryKey"`
Status string `gorm:"column:status"`
Trigger string `gorm:"column:trigger"`
TriggerName string `gorm:"column:trigger_name"`
Site string `gorm:"column:site"`
MaxPages int `gorm:"column:max_pages"`
Pages int `gorm:"column:pages"`
LimitHit bool `gorm:"column:limit_hit"`
Error string `gorm:"column:error"`
RenderStatus string `gorm:"column:render_status"`
RenderPages int `gorm:"column:render_pages"`
RenderNote string `gorm:"column:render_note"`
CancelledBy string `gorm:"column:cancelled_by"`
JobID *uint `gorm:"column:job_id"`
StartedAt *time.Time `gorm:"column:started_at"`
FinishedAt *time.Time `gorm:"column:finished_at"`
CreatedAt *time.Time `gorm:"column:created_at"`
// The list's values, filled by the Crawls controller's
// ListExtendRecords and never saved.
IssuesCell string `gorm:"-:all;column:issues"`
PagesCell string `gorm:"-:all;column:pages_cell"`
DurationCell string `gorm:"-:all;column:duration"`
TriggerCell string `gorm:"-:all;column:trigger_cell"`
}
func (Crawl) TableName() string { return "golem15_seo_crawls" }
// MorphName names a crawl in the admin.
func (Crawl) MorphName() string { return `Golem15\SEO\Models\Crawl` }
// Fillable is empty: crawls are written by the crawler only.
func (Crawl) Fillable() []string { return []string{} }
// Rules is empty: nothing is edited.
func (Crawl) Rules() map[string]string { return map[string]string{} }
// Active reports whether the crawl is queued or running.
func (c Crawl) Active() bool { return c.Status == CrawlQueued || c.Status == CrawlRunning }
// The Crawls list's filter scope.
const FilterCrawlStatus = "FilterCrawlStatus"
// FilterScopes lists the Crawls list's filter scopes.
func (Crawl) FilterScopes() []string { return []string{FilterCrawlStatus} }
// FilterScope narrows the Crawls list to one state; an unknown value
// matches nothing.
func (Crawl) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil || name != FilterCrawlStatus {
return db
}
s, _ := value.(string)
for _, st := range CrawlStatuses {
if s == st {
return db.Where("golem15_seo_crawls.status = ?", s)
}
}
return db.Where("1 = 0")
}
func init() {
Register(Crawl{})
}

66
models/crawl_page.go Normal file
View File

@@ -0,0 +1,66 @@
package models
import (
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// CrawlHeading is one heading of a crawled page in document order.
type CrawlHeading struct {
Level int `json:"level"`
Text string `json:"text"`
}
// MaxPageText caps the stored main text of a crawled page, in characters.
const MaxPageText = 100_000
// CrawlPage is one address a crawl fetched, on golem15_seo_crawl_pages
// (P33-37): the answer (status, Location, content type) and what the raw
// HTML says (title, description, canonical, robots, headings, word count,
// main text), its depth from the home page, whether a sitemap lists it and
// which file, and its outgoing and incoming internal links.
type CrawlPage struct {
ID uint `gorm:"column:id;primaryKey"`
CrawlID uint `gorm:"column:crawl_id"`
URL string `gorm:"column:url"`
Status int `gorm:"column:status"`
Location string `gorm:"column:location"`
ContentType string `gorm:"column:content_type"`
Title string `gorm:"column:title"`
Description string `gorm:"column:description"`
Canonical string `gorm:"column:canonical"`
Robots string `gorm:"column:robots"`
XRobots string `gorm:"column:x_robots"`
H1 lagoon.Jsonable[[]string] `gorm:"column:h1"`
Headings lagoon.Jsonable[[]CrawlHeading] `gorm:"column:headings"`
Words int `gorm:"column:words"`
Text string `gorm:"column:text"`
Depth int `gorm:"column:depth"`
InSitemap bool `gorm:"column:in_sitemap"`
SitemapFile string `gorm:"column:sitemap_file"`
LinksOut int `gorm:"column:links_out"`
LinksIn int `gorm:"column:links_in"`
FetchedAt *time.Time `gorm:"column:fetched_at"`
}
func (CrawlPage) TableName() string { return "golem15_seo_crawl_pages" }
// CrawlLink is one link between two addresses of a crawl, on
// golem15_seo_crawl_links: the linking page, the target, its text and rel,
// and whether the target is on the site.
type CrawlLink struct {
ID uint `gorm:"column:id;primaryKey"`
CrawlID uint `gorm:"column:crawl_id"`
FromURL string `gorm:"column:from_url"`
ToURL string `gorm:"column:to_url"`
Text string `gorm:"column:text"`
Rel string `gorm:"column:rel"`
Internal bool `gorm:"column:internal"`
}
func (CrawlLink) TableName() string { return "golem15_seo_crawl_links" }
func init() {
Register(CrawlPage{}, CrawlLink{})
}

156
models/issue.go Normal file
View File

@@ -0,0 +1,156 @@
package models
import (
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// Where a stored issue was found (UI-SPEC C23).
const (
SourceCrawl = "crawl"
SourceCMS = "cms"
)
// How an issue is fixed (P33-38): through an MCP tool that changes CMS
// data, or in the site's template code.
const (
FixTool = "tool"
FixCode = "code"
)
// The severities of an issue and their sort rank (errors first).
const (
SeverityError = "error"
SeverityWarning = "warning"
SeverityNotice = "notice"
)
// SeverityRank orders the severities: error 1, warning 2, notice 3.
func SeverityRank(severity string) int {
switch severity {
case SeverityError:
return 1
case SeverityWarning:
return 2
}
return 3
}
// Issue is one stored finding of the site audit on golem15_seo_issues
// (P33-37): the crawl it belongs to (nil for CMS-data issues stored before
// any crawl), where it was found, its kind and severity, the address and
// locale, the kind's details, the fixing MCP tool or fix code, and the
// fingerprint an ignore is keyed by.
type Issue struct {
ID uint `gorm:"column:id;primaryKey"`
CrawlID *uint `gorm:"column:crawl_id"`
Source string `gorm:"column:source"`
Kind string `gorm:"column:kind"`
Severity string `gorm:"column:severity"`
Rank int `gorm:"column:rank"`
URL string `gorm:"column:url"`
Locale string `gorm:"column:locale"`
Details lagoon.Jsonable[map[string]any] `gorm:"column:details"`
Tool string `gorm:"column:tool"`
Fix string `gorm:"column:fix"`
Fingerprint string `gorm:"column:fingerprint"`
FoundAt *time.Time `gorm:"column:found_at"`
// The list's values, filled by the Site audit controller's
// ListExtendRecords and never saved.
Summary string `gorm:"-:all;column:summary"`
FixCell string `gorm:"-:all;column:fix_cell"`
KindCell string `gorm:"-:all;column:kind_cell"`
}
func (Issue) TableName() string { return "golem15_seo_issues" }
// MorphName names an issue in the admin.
func (Issue) MorphName() string { return `Golem15\SEO\Models\Issue` }
// Fillable is empty: issues are written by the audit only.
func (Issue) Fillable() []string { return []string{} }
// Rules is empty: nothing is edited.
func (Issue) Rules() map[string]string { return map[string]string{} }
// DetailsMap returns the issue's details, never nil.
func (i Issue) DetailsMap() map[string]any {
if d := i.Details.Get(); d != nil {
return d
}
return map[string]any{}
}
// IssueIgnore is one ignored fingerprint on golem15_seo_issue_ignores: an
// issue with this fingerprint stays hidden in later crawls until it is no
// longer ignored (UI-SPEC C23).
type IssueIgnore struct {
ID uint `gorm:"column:id;primaryKey"`
Fingerprint string `gorm:"column:fingerprint"`
AdminID *uint `gorm:"column:admin_id"`
IgnoredAt *time.Time `gorm:"column:ignored_at"`
}
func (IssueIgnore) TableName() string { return "golem15_seo_issue_ignores" }
// The Site audit list's filter scopes and the status and fix values.
const (
FilterIssueSeverity = "FilterIssueSeverity"
FilterIssueKind = "FilterIssueKind"
FilterIssueSource = "FilterIssueSource"
FilterIssueFix = "FilterIssueFix"
FilterIssueStatus = "FilterIssueStatus"
IssueOpen = "open"
IssueIgnored = "ignored"
)
// IgnoredSQL is true for an issue whose fingerprint is ignored.
const IgnoredSQL = `EXISTS (SELECT 1 FROM golem15_seo_issue_ignores AS ig WHERE ig.fingerprint = golem15_seo_issues.fingerprint)`
// FilterScopes lists the Site audit list's filter scopes.
func (Issue) FilterScopes() []string {
return []string{FilterIssueSeverity, FilterIssueKind, FilterIssueSource, FilterIssueFix, FilterIssueStatus}
}
// FilterScope narrows the Site audit list; an unknown value matches
// nothing.
func (Issue) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil {
return db
}
s, _ := value.(string)
switch name {
case FilterIssueSeverity:
if s == SeverityError || s == SeverityWarning || s == SeverityNotice {
return db.Where("golem15_seo_issues.severity = ?", s)
}
case FilterIssueKind:
if s != "" {
return db.Where("golem15_seo_issues.kind = ?", s)
}
case FilterIssueSource:
if s == SourceCrawl || s == SourceCMS {
return db.Where("golem15_seo_issues.source = ?", s)
}
case FilterIssueFix:
if s == FixTool || s == FixCode {
return db.Where("golem15_seo_issues.fix = ?", s)
}
case FilterIssueStatus:
switch s {
case IssueOpen:
return db.Where("NOT " + IgnoredSQL)
case IssueIgnored:
return db.Where(IgnoredSQL)
}
default:
return db
}
return db.Where("1 = 0")
}
func init() {
Register(Issue{}, IssueIgnore{})
}

View File

@@ -81,6 +81,7 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Post("/import", apicontrollers.ImportCSV(p.Service), api)
g.Post("/batch", apicontrollers.ApplyBatch(p.Service), api)
g.Get("/issues", apicontrollers.ListIssues(p.Service), api)
g.Post("/crawls", apicontrollers.StartCrawl(p.Service), api)
})
return nil
}

View File

@@ -0,0 +1,111 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// createCrawls creates the site audit's tables (D-14, P33-37): the crawls,
// the pages and links each crawl fetched, the stored issues (crawl and
// CMS-data findings, with the fixing tool or fix code and the fingerprint)
// and the ignored fingerprints. Deleting a crawl deletes its pages, links
// and issues.
var createCrawls = []*gormigrate.Migration{
{
ID: "202610280005_create_golem15_seo_crawls",
Migrate: func(tx *gorm.DB) error {
for _, stmt := range []string{
`CREATE TABLE golem15_seo_crawls (
id BIGSERIAL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'queued',
trigger TEXT NOT NULL DEFAULT 'admin',
trigger_name TEXT NOT NULL DEFAULT '',
site TEXT NOT NULL DEFAULT '',
max_pages INTEGER NOT NULL DEFAULT 0,
pages INTEGER NOT NULL DEFAULT 0,
limit_hit BOOLEAN NOT NULL DEFAULT FALSE,
error TEXT NOT NULL DEFAULT '',
render_status TEXT NOT NULL DEFAULT 'skipped',
render_pages INTEGER NOT NULL DEFAULT 0,
render_note TEXT NOT NULL DEFAULT '',
cancelled_by TEXT NOT NULL DEFAULT '',
job_id BIGINT NULL,
started_at TIMESTAMPTZ NULL,
finished_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NULL
)`,
`CREATE INDEX golem15_seo_crawls_status ON golem15_seo_crawls (status)`,
`CREATE TABLE golem15_seo_crawl_pages (
id BIGSERIAL PRIMARY KEY,
crawl_id BIGINT NOT NULL REFERENCES golem15_seo_crawls (id) ON DELETE CASCADE,
url TEXT NOT NULL,
status INTEGER NOT NULL DEFAULT 0,
location TEXT NOT NULL DEFAULT '',
content_type TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
canonical TEXT NOT NULL DEFAULT '',
robots TEXT NOT NULL DEFAULT '',
x_robots TEXT NOT NULL DEFAULT '',
h1 JSONB NULL,
headings JSONB NULL,
words INTEGER NOT NULL DEFAULT 0,
text TEXT NOT NULL DEFAULT '',
depth INTEGER NOT NULL DEFAULT 0,
in_sitemap BOOLEAN NOT NULL DEFAULT FALSE,
sitemap_file TEXT NOT NULL DEFAULT '',
links_out INTEGER NOT NULL DEFAULT 0,
links_in INTEGER NOT NULL DEFAULT 0,
fetched_at TIMESTAMPTZ NULL
)`,
`CREATE UNIQUE INDEX golem15_seo_crawl_pages_url ON golem15_seo_crawl_pages (crawl_id, url)`,
`CREATE TABLE golem15_seo_crawl_links (
id BIGSERIAL PRIMARY KEY,
crawl_id BIGINT NOT NULL REFERENCES golem15_seo_crawls (id) ON DELETE CASCADE,
from_url TEXT NOT NULL,
to_url TEXT NOT NULL,
text TEXT NOT NULL DEFAULT '',
rel TEXT NOT NULL DEFAULT '',
internal BOOLEAN NOT NULL DEFAULT TRUE
)`,
`CREATE INDEX golem15_seo_crawl_links_to ON golem15_seo_crawl_links (crawl_id, to_url)`,
`CREATE TABLE golem15_seo_issues (
id BIGSERIAL PRIMARY KEY,
crawl_id BIGINT NULL REFERENCES golem15_seo_crawls (id) ON DELETE CASCADE,
source TEXT NOT NULL DEFAULT 'crawl',
kind TEXT NOT NULL,
severity TEXT NOT NULL DEFAULT 'warning',
rank SMALLINT NOT NULL DEFAULT 2,
url TEXT NOT NULL DEFAULT '',
locale TEXT NOT NULL DEFAULT '',
details JSONB NULL,
tool TEXT NOT NULL DEFAULT '',
fix TEXT NOT NULL DEFAULT 'tool',
fingerprint TEXT NOT NULL,
found_at TIMESTAMPTZ NULL
)`,
`CREATE INDEX golem15_seo_issues_crawl ON golem15_seo_issues (crawl_id, rank, kind)`,
`CREATE INDEX golem15_seo_issues_fingerprint ON golem15_seo_issues (fingerprint)`,
`CREATE TABLE golem15_seo_issue_ignores (
id BIGSERIAL PRIMARY KEY,
fingerprint TEXT NOT NULL,
admin_id BIGINT NULL,
ignored_at TIMESTAMPTZ NULL
)`,
`CREATE UNIQUE INDEX golem15_seo_issue_ignores_fingerprint ON golem15_seo_issue_ignores (fingerprint)`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec(`DROP TABLE IF EXISTS golem15_seo_issue_ignores, golem15_seo_issues, golem15_seo_crawl_links, golem15_seo_crawl_pages, golem15_seo_crawls`).Error
},
},
}
func init() {
Register(createCrawls...)
}