Files
sm-seo-plugin/classes/crawl/checks.go
Jakub Zych c2b61bf8e9 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
2026-11-04 15:05:45 +01:00

152 lines
5.6 KiB
Go

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
}