Files
sm-seo-plugin/classes/crawl/fix.go
Jakub Zych 9de6b2ec83 feat: noindex paths from the route report leave the sitemaps (tracer)
The route report takes an optional noindex list of static paths. When
the key is present the stored set (golem15_seo_noindex_paths) is
replaced, and the sitemap filter leaves those paths out with the reason
noindex; a changed set asks the sitemap for a debounced rebuild through
contract.Rebuilder. A listed noindex address without a CMS record now
names sitemap_update_settings, or fix: code without the sitemap plugin.
2026-11-04 22:44:26 +01:00

151 lines
5.1 KiB
Go

package crawl
import (
"crypto/sha256"
"encoding/hex"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"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"
// ToolSitemapSettings is the sitemap plugin's settings tool, which
// edits its Additional addresses.
ToolSitemapSettings = "sitemap_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",
KindSitemapNoindex: "the address list of the sitemap that names this 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 classes.NormalText(s) }