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.
740 lines
24 KiB
Go
740 lines
24 KiB
Go
package crawl
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"github.com/jimsmart/grobotstxt"
|
|
)
|
|
|
|
// 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, whether the site runs outside production (the staging lock's
|
|
// noindex is never reported, RESEARCH Pitfall 12) and the generic link
|
|
// texts.
|
|
type Env struct {
|
|
// Lookup returns the CMS record of an address, nil when none.
|
|
Lookup func(key string) *Record
|
|
// Staging is true outside production.
|
|
Staging bool
|
|
// GenericAnchors are the generic link texts (any case).
|
|
GenericAnchors []string
|
|
// Keyphrases maps an address to its SEO entry's focus keyphrase.
|
|
Keyphrases map[string]string
|
|
// SitemapTool is the tool that edits the sitemap's Additional
|
|
// addresses (ToolSitemapSettings while the sitemap plugin is mounted,
|
|
// else ""): a listed noindex address without a CMS record is one of
|
|
// them.
|
|
SitemapTool string
|
|
}
|
|
|
|
func (e *Env) record(key string) *Record {
|
|
if e == nil || e.Lookup == nil {
|
|
return nil
|
|
}
|
|
return e.Lookup(key)
|
|
}
|
|
|
|
// locale is the issue locale of a page: its record's.
|
|
func locale(rec *Record) string {
|
|
if rec == nil {
|
|
return ""
|
|
}
|
|
return rec.Locale
|
|
}
|
|
|
|
// noindex reports whether a page asks not to be indexed (meta robots or
|
|
// X-Robots-Tag). On a non-production environment every page carries the
|
|
// staging lock's noindex, so it is never counted there.
|
|
func (e *Env) noindex(p *Page) bool {
|
|
if e != nil && e.Staging {
|
|
return false
|
|
}
|
|
if p.Facts != nil && p.Facts.Noindex() {
|
|
return true
|
|
}
|
|
return p.Resp != nil && hasNoindex(p.Resp.XRobots)
|
|
}
|
|
|
|
// selfCanonical reports whether a page's canonical is missing or names
|
|
// the page itself.
|
|
func selfCanonical(res *Result, p *Page) bool {
|
|
if p.Facts == nil || p.Facts.Canonical == "" || res.fetcher == nil {
|
|
return true
|
|
}
|
|
u, err := url.Parse(p.Facts.Canonical)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return res.fetcher.Key(u) == p.Key
|
|
}
|
|
|
|
// indexable reports whether a page answers 200 with HTML, may be indexed
|
|
// and is its own canonical.
|
|
func (e *Env) indexable(res *Result, p *Page) bool {
|
|
return p.OK() && !e.noindex(p) && selfCanonical(res, p)
|
|
}
|
|
|
|
// Check runs every check over a crawl's pages: the per-page checks of
|
|
// UI-SPEC C23 and the site-wide ones (sitemaps, statuses, duplicates).
|
|
func Check(res *Result, env *Env) []Finding {
|
|
if env == nil {
|
|
env = &Env{}
|
|
}
|
|
var out []Finding
|
|
for _, key := range res.Order {
|
|
p := res.Pages[key]
|
|
rec := env.record(key)
|
|
out = append(out, checkStatus(res, p, rec)...)
|
|
if !p.OK() {
|
|
continue
|
|
}
|
|
out = append(out, checkHead(res, p, rec)...)
|
|
out = append(out, checkHeadings(p, rec)...)
|
|
out = append(out, checkJSONLD(p, rec)...)
|
|
out = append(out, checkHreflang(res, p, rec)...)
|
|
out = append(out, checkGenericAnchors(res, p, rec, env.GenericAnchors)...)
|
|
out = append(out, checkImages(p, rec)...)
|
|
out = append(out, checkContentCMS(res, p, rec)...)
|
|
out = append(out, checkRenderedPage(res, p, rec)...)
|
|
}
|
|
out = append(out, checkLinks(res, env)...)
|
|
out = append(out, checkOrphans(res, env)...)
|
|
out = append(out, checkSitemaps(res, env)...)
|
|
out = append(out, checkDuplicates(res, env)...)
|
|
return out
|
|
}
|
|
|
|
// checkStatus reports page_status for an address that answers an error
|
|
// (other than 404 and 410, which are broken links when linked) or cannot
|
|
// be fetched.
|
|
func checkStatus(res *Result, p *Page, rec *Record) []Finding {
|
|
r := p.Resp
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
switch {
|
|
case r.Err != "" && r.Err != "canceled":
|
|
return []Finding{{Kind: KindPageStatus, URL: p.Key, Locale: locale(rec), Record: rec, Tool: ToolCreateRedirect, Details: map[string]any{"status": 0, "error": r.Err}}}
|
|
case r.Status >= 400 && r.Status != 404 && r.Status != 410:
|
|
return []Finding{{Kind: KindPageStatus, URL: p.Key, Locale: locale(rec), Record: rec, Tool: ToolCreateRedirect, Details: map[string]any{"status": r.Status}}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkHead reports a missing title, description or canonical and a
|
|
// canonical pointing to an error page.
|
|
func checkHead(res *Result, p *Page, rec *Record) []Finding {
|
|
var out []Finding
|
|
f := p.Facts
|
|
loc := locale(rec)
|
|
if strings.TrimSpace(f.Title) == "" {
|
|
out = append(out, Finding{Kind: KindMissingTitle, URL: p.Key, Locale: loc, Record: rec, Tool: ToolSetURLSEO})
|
|
}
|
|
if strings.TrimSpace(f.Description) == "" {
|
|
out = append(out, Finding{Kind: KindMissingDesc, URL: p.Key, Locale: loc, Record: rec, Tool: ToolSetURLSEO})
|
|
}
|
|
if f.Canonical == "" {
|
|
out = append(out, Finding{Kind: KindCanonicalMissing, URL: p.Key, Locale: loc, Record: rec})
|
|
} else if u, err := url.Parse(f.Canonical); err == nil && res.fetcher != nil {
|
|
if key := res.fetcher.Key(u); key != "" && key != p.Key {
|
|
if t := res.Page(key); t != nil && t.Resp != nil && (t.Resp.Err != "" || t.Resp.Status >= 400) {
|
|
out = append(out, Finding{Kind: KindCanonicalError, URL: p.Key, Locale: loc, Record: rec, Tool: ToolSetURLSEO, Details: map[string]any{"url": key, "status": t.Resp.Status}})
|
|
}
|
|
}
|
|
}
|
|
if h1 := f.H1(); len(h1) > 0 && strings.TrimSpace(f.Title) != "" && strings.TrimSpace(h1[0]) != "" {
|
|
a, b := normal(h1[0]), normal(f.Title)
|
|
if !strings.Contains(b, a) && !strings.Contains(a, b) {
|
|
out = append(out, Finding{Kind: KindH1TitleMismatch, URL: p.Key, Locale: loc, Record: rec, Tool: ToolSetURLSEO, Details: map[string]any{"h1": h1[0], "title": f.Title}})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// checkHeadings reports h1_missing, h1_multiple, heading_skipped and
|
|
// heading_empty. A heading inside the record's own content is fixed
|
|
// through the record's tool: H1 findings prefer Tools[headings], the
|
|
// others use Tools[edit] (P33-38).
|
|
func checkHeadings(p *Page, rec *Record) []Finding {
|
|
var out []Finding
|
|
loc := locale(rec)
|
|
h1 := p.Facts.H1()
|
|
switch {
|
|
case len(h1) == 0:
|
|
out = append(out, Finding{Kind: KindH1Missing, URL: p.Key, Locale: loc, Record: rec})
|
|
case len(h1) > 1:
|
|
f := Finding{Kind: KindH1Multiple, URL: p.Key, Locale: loc, Record: rec, Details: map[string]any{"count": len(h1), "h1": h1}}
|
|
if rec.hasHeading(1, "") {
|
|
f.Content = []string{pact.ContentToolHeadings, pact.ContentToolEdit}
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
var skips []map[string]any
|
|
var first *Finding
|
|
prev := 0
|
|
for _, h := range p.Facts.Headings {
|
|
if prev > 0 && h.Level > prev+1 {
|
|
skips = append(skips, map[string]any{"from": prev, "to": h.Level, "text": h.Text})
|
|
if first == nil {
|
|
first = &Finding{Kind: KindHeadingSkipped, URL: p.Key, Locale: loc, Record: rec, Details: map[string]any{"from": prev, "to": h.Level, "text": h.Text}}
|
|
if rec.hasHeading(h.Level, h.Text) {
|
|
first.Content = []string{pact.ContentToolEdit}
|
|
}
|
|
}
|
|
}
|
|
prev = h.Level
|
|
}
|
|
if first != nil {
|
|
first.Details["skips"] = skips
|
|
first.Details["count"] = len(skips)
|
|
out = append(out, *first)
|
|
}
|
|
empty := 0
|
|
var firstEmpty *Finding
|
|
for _, h := range p.Facts.Headings {
|
|
if strings.TrimSpace(h.Text) != "" {
|
|
continue
|
|
}
|
|
empty++
|
|
if firstEmpty == nil {
|
|
firstEmpty = &Finding{Kind: KindHeadingEmpty, URL: p.Key, Locale: loc, Record: rec, Details: map[string]any{"level": h.Level}}
|
|
if hasEmptyHeading(rec, h.Level) {
|
|
if h.Level == 1 {
|
|
firstEmpty.Content = []string{pact.ContentToolHeadings, pact.ContentToolEdit}
|
|
} else {
|
|
firstEmpty.Content = []string{pact.ContentToolEdit}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if firstEmpty != nil {
|
|
firstEmpty.Details["count"] = empty
|
|
out = append(out, *firstEmpty)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hasEmptyHeading reports whether the record's own headings hold an empty
|
|
// one of level.
|
|
func hasEmptyHeading(rec *Record, level int) bool {
|
|
if rec == nil {
|
|
return false
|
|
}
|
|
for _, h := range rec.Headings {
|
|
if h.Level == level && strings.TrimSpace(h.Text) == "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// sitemapNoindex is the finding of a listed noindex page. A CMS record's
|
|
// page is fixed with seo_set_url_seo; an address without a record is an
|
|
// Additional address of the sitemap (or another listed address the SEO
|
|
// plugin cannot change), fixed by removing it from the sitemap's address
|
|
// list: env.SitemapTool, else fix code naming that list.
|
|
func sitemapNoindex(key, file string, rec *Record, env *Env) Finding {
|
|
f := Finding{Kind: KindSitemapNoindex, URL: key, Locale: locale(rec), Record: rec, Details: map[string]any{"file": file}}
|
|
if rec != nil {
|
|
f.Tool = ToolSetURLSEO
|
|
return f
|
|
}
|
|
f.Details["extra"] = true
|
|
if env != nil {
|
|
f.Tool = env.SitemapTool
|
|
}
|
|
return f
|
|
}
|
|
|
|
// checkSitemaps compares the sitemaps with what the crawl found:
|
|
// sitemap_error for a listed address that does not answer 200,
|
|
// sitemap_noindex for a listed noindex page, sitemap_blocked for a listed
|
|
// address robots.txt keeps Googlebot from, and sitemap_missing for an
|
|
// indexable page found only through links.
|
|
func checkSitemaps(res *Result, env *Env) []Finding {
|
|
var out []Finding
|
|
var keys []string
|
|
for key := range res.Listed {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
robots := res.Robots
|
|
for _, key := range keys {
|
|
file := res.Listed[key]
|
|
rec := env.record(key)
|
|
if p := res.Page(key); p != nil && p.Resp != nil {
|
|
switch {
|
|
case p.Resp.Err == "canceled":
|
|
case p.Resp.Err != "" || p.Resp.Status != 200:
|
|
out = append(out, Finding{Kind: KindSitemapError, URL: key, Locale: locale(rec), Record: rec, Tool: ToolSetURLSEO, Details: map[string]any{"status": p.Resp.Status, "file": file}})
|
|
case env.noindex(p):
|
|
out = append(out, sitemapNoindex(key, file, rec, env))
|
|
}
|
|
}
|
|
if robots != "" && res.fetcher != nil {
|
|
m := grobotstxt.NewRobotsMatcher()
|
|
if !m.AgentAllowed(robots, "Googlebot", res.fetcher.Absolute(key)) {
|
|
line := m.MatchingLine()
|
|
rule := ""
|
|
if lines := strings.Split(robots, "\n"); line > 0 && line <= len(lines) {
|
|
rule = strings.TrimSpace(lines[line-1])
|
|
}
|
|
out = append(out, Finding{Kind: KindSitemapBlocked, URL: key, Locale: locale(rec), Record: rec, Tool: ToolUpdateRobots, Details: map[string]any{"line": line, "rule": rule, "file": file}})
|
|
}
|
|
}
|
|
}
|
|
links := inbound(res)
|
|
for _, key := range res.Order {
|
|
p := res.Pages[key]
|
|
if _, listed := res.Listed[key]; listed || !env.indexable(res, p) {
|
|
continue
|
|
}
|
|
rec := env.record(key)
|
|
f := Finding{Kind: KindSitemapMissing, URL: key, Locale: locale(rec), Record: rec, Tool: ToolCreatePage, Details: map[string]any{"count": len(links[key])}}
|
|
if rec != nil {
|
|
f.Tool = ToolSetURLSEO
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// inbound maps each address to the pages linking to it (each once, a page
|
|
// linking to itself left out), in crawl order.
|
|
func inbound(res *Result) map[string][]string {
|
|
out := map[string][]string{}
|
|
if res.fetcher == nil {
|
|
return out
|
|
}
|
|
for _, from := range res.Order {
|
|
p := res.Pages[from]
|
|
if p.Facts == nil {
|
|
continue
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, l := range p.Facts.Links {
|
|
to := res.fetcher.Key(l.URL)
|
|
if to == "" || to == from || seen[to] {
|
|
continue
|
|
}
|
|
seen[to] = true
|
|
out[to] = append(out[to], from)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// checkDuplicates reports titles and descriptions shared by indexable
|
|
// pages of the crawl (compared without case and extra spaces).
|
|
func checkDuplicates(res *Result, env *Env) []Finding {
|
|
titles := map[string][]string{}
|
|
descs := map[string][]string{}
|
|
for _, key := range res.Order {
|
|
p := res.Pages[key]
|
|
if !env.indexable(res, p) {
|
|
continue
|
|
}
|
|
if t := normal(p.Facts.Title); t != "" {
|
|
titles[t] = append(titles[t], key)
|
|
}
|
|
if d := normal(p.Facts.Description); d != "" {
|
|
descs[d] = append(descs[d], key)
|
|
}
|
|
}
|
|
var out []Finding
|
|
emit := func(groups map[string][]string, kind string) {
|
|
var values []string
|
|
for v := range groups {
|
|
values = append(values, v)
|
|
}
|
|
sort.Strings(values)
|
|
for _, v := range values {
|
|
list := groups[v]
|
|
if len(list) < 2 {
|
|
continue
|
|
}
|
|
for _, key := range list {
|
|
others := make([]string, 0, len(list)-1)
|
|
for _, o := range list {
|
|
if o != key {
|
|
others = append(others, o)
|
|
}
|
|
}
|
|
rec := env.record(key)
|
|
out = append(out, Finding{Kind: kind, URL: key, Locale: locale(rec), Record: rec, Tool: ToolSetURLSEO, Details: map[string]any{"value": v, "count": len(others), "also": others}})
|
|
}
|
|
}
|
|
}
|
|
emit(titles, KindDuplicateTitle)
|
|
emit(descs, KindDuplicateDesc)
|
|
return out
|
|
}
|
|
|
|
// publisherTypes are the node types the SEO settings generate (publisher,
|
|
// local business, the site): their missing properties are fixed through
|
|
// seo_update_settings.
|
|
var publisherTypes = map[string]bool{
|
|
"Organization": true, "Person": true, "WebSite": true, "LocalBusiness": true,
|
|
"ProfessionalService": true, "Store": true, "FoodEstablishment": true,
|
|
"HealthAndBeautyBusiness": true, "MedicalBusiness": true, "LegalService": true,
|
|
"HomeAndConstructionBusiness": true, "AutomotiveBusiness": true, "FinancialService": true,
|
|
}
|
|
|
|
// checkJSONLD reads every JSON-LD script of a page: jsonld_invalid for one
|
|
// that is not JSON, jsonld_multiple for more than one script, and the
|
|
// graph validated with the rules of the admin preview (schema.Validate,
|
|
// D-46): jsonld_required and jsonld_recommended. Types without Google
|
|
// rules (FAQPage and the like) find nothing.
|
|
func checkJSONLD(p *Page, rec *Record) []Finding {
|
|
scripts := p.Facts.JSONLD
|
|
if len(scripts) == 0 {
|
|
return nil
|
|
}
|
|
loc := locale(rec)
|
|
var out []Finding
|
|
var errs []string
|
|
var nodes []any
|
|
for _, raw := range scripts {
|
|
var v any
|
|
dec := json.NewDecoder(strings.NewReader(raw))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&v); err != nil {
|
|
errs = append(errs, err.Error())
|
|
continue
|
|
}
|
|
switch x := v.(type) {
|
|
case map[string]any:
|
|
if g, ok := x["@graph"].([]any); ok {
|
|
nodes = append(nodes, g...)
|
|
} else {
|
|
nodes = append(nodes, x)
|
|
}
|
|
case []any:
|
|
nodes = append(nodes, x...)
|
|
}
|
|
}
|
|
if len(errs) > 0 {
|
|
f := Finding{Kind: KindJSONLDInvalid, URL: p.Key, Locale: loc, Record: rec, Details: map[string]any{"error": errs[0], "count": len(errs)}}
|
|
if rec != nil && rec.CustomJSONLD {
|
|
f.Tool = ToolSetURLSEO
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
if len(scripts) > 1 {
|
|
out = append(out, Finding{Kind: KindJSONLDMultiple, URL: p.Key, Locale: loc, Record: rec, Details: map[string]any{"count": len(scripts)}})
|
|
}
|
|
if len(nodes) == 0 {
|
|
return out
|
|
}
|
|
type miss struct{ typ, prop string }
|
|
var required, recommended []miss
|
|
for _, rep := range schema.Validate(map[string]any{"@graph": nodes}) {
|
|
for _, f := range rep.Findings {
|
|
m := miss{rep.Type, f.Property}
|
|
if f.Level == schema.LevelRequired {
|
|
required = append(required, m)
|
|
} else {
|
|
recommended = append(recommended, m)
|
|
}
|
|
}
|
|
}
|
|
emit := func(kind string, list []miss) {
|
|
if len(list) == 0 {
|
|
return
|
|
}
|
|
all := make([]map[string]any, 0, len(list))
|
|
for _, m := range list {
|
|
all = append(all, map[string]any{"type": m.typ, "property": m.prop})
|
|
}
|
|
tool := ToolSetURLSEO
|
|
if publisherTypes[list[0].typ] {
|
|
tool = ToolUpdateSettings
|
|
}
|
|
out = append(out, Finding{Kind: kind, URL: p.Key, Locale: loc, Record: rec, Tool: tool, Details: map[string]any{
|
|
"type": list[0].typ, "property": list[0].prop, "count": len(list), "missing": all,
|
|
}})
|
|
}
|
|
emit(KindJSONLDRequired, required)
|
|
emit(KindJSONLDRecommended, recommended)
|
|
return out
|
|
}
|
|
|
|
// checkHreflang follows a page's hreflang alternates: hreflang_error for
|
|
// an alternate that does not answer 200, hreflang_reciprocal for one that
|
|
// answers but does not link back.
|
|
func checkHreflang(res *Result, p *Page, rec *Record) []Finding {
|
|
if res.fetcher == nil {
|
|
return nil
|
|
}
|
|
loc := locale(rec)
|
|
var broken, oneWay []map[string]any
|
|
for _, a := range p.Facts.Alternates {
|
|
key := res.fetcher.Key(a.URL)
|
|
if key == "" || key == p.Key || strings.EqualFold(a.Lang, "x-default") {
|
|
continue
|
|
}
|
|
t := res.Page(key)
|
|
if t == nil || t.Resp == nil || t.Resp.Err == "canceled" {
|
|
continue
|
|
}
|
|
if !t.OK() {
|
|
broken = append(broken, map[string]any{"locale": a.Lang, "url": key, "status": t.Resp.Status})
|
|
continue
|
|
}
|
|
back := false
|
|
for _, b := range t.Facts.Alternates {
|
|
if res.fetcher.Key(b.URL) == p.Key {
|
|
back = true
|
|
break
|
|
}
|
|
}
|
|
if !back {
|
|
oneWay = append(oneWay, map[string]any{"locale": a.Lang, "url": key})
|
|
}
|
|
}
|
|
var out []Finding
|
|
if len(broken) > 0 {
|
|
d := map[string]any{"count": len(broken), "alternates": broken}
|
|
for k, v := range broken[0] {
|
|
d[k] = v
|
|
}
|
|
out = append(out, Finding{Kind: KindHreflangError, URL: p.Key, Locale: loc, Record: rec, Tool: ToolCreateRedirect, Details: d})
|
|
}
|
|
if len(oneWay) > 0 {
|
|
d := map[string]any{"count": len(oneWay), "alternates": oneWay}
|
|
for k, v := range oneWay[0] {
|
|
d[k] = v
|
|
}
|
|
f := Finding{Kind: KindHreflangRecip, URL: p.Key, Locale: loc, Record: rec, Details: d}
|
|
if rec != nil {
|
|
f.Content = []string{pact.ContentToolEdit}
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// maxListed caps the images a finding names.
|
|
const maxListed = 10
|
|
|
|
// checkImages reports image_alt (an <img> without an alt attribute; an
|
|
// empty alt is a decorative image), image_size (no width or height) and
|
|
// featured_lazy (the og:image loads lazily). An image the record lists is
|
|
// fixed through the record's image_alt tool.
|
|
func checkImages(p *Page, rec *Record) []Finding {
|
|
loc := locale(rec)
|
|
var noAlt, noSize []string
|
|
var lazy string
|
|
recordImage := false
|
|
for _, img := range p.Facts.Images {
|
|
if !img.HasAlt {
|
|
noAlt = append(noAlt, img.Src)
|
|
if rec != nil {
|
|
for _, ri := range rec.Images {
|
|
if ri.URL != "" && (strings.HasSuffix(img.Src, ri.URL) || ri.URL == img.Src) {
|
|
recordImage = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if strings.TrimSpace(img.Width) == "" || strings.TrimSpace(img.Height) == "" {
|
|
noSize = append(noSize, img.Src)
|
|
}
|
|
if p.Facts.OGImage != "" && img.Src == p.Facts.OGImage && img.Loading == "lazy" {
|
|
lazy = img.Src
|
|
}
|
|
}
|
|
cut := func(list []string) []string {
|
|
if len(list) > maxListed {
|
|
return list[:maxListed]
|
|
}
|
|
return list
|
|
}
|
|
var out []Finding
|
|
if len(noAlt) > 0 {
|
|
f := Finding{Kind: KindImageAlt, URL: p.Key, Locale: loc, Record: rec, Details: map[string]any{"count": len(noAlt), "images": cut(noAlt)}}
|
|
if recordImage {
|
|
f.Content = []string{pact.ContentToolImageAlt, pact.ContentToolEdit}
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
if len(noSize) > 0 {
|
|
out = append(out, Finding{Kind: KindImageSize, URL: p.Key, Locale: loc, Record: rec, Details: map[string]any{"count": len(noSize), "images": cut(noSize)}})
|
|
}
|
|
if lazy != "" {
|
|
out = append(out, Finding{Kind: KindFeaturedLazy, URL: p.Key, Locale: loc, Record: rec, Details: map[string]any{"image": lazy}})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// checkContentCMS compares the server-rendered HTML with what the CMS
|
|
// record says (D-70, D-71; check cms): the record title must appear in an
|
|
// H1, the text must reach half of the record's word count, and the
|
|
// record's CMS-known links must be present. A miss is content_js.
|
|
func checkContentCMS(res *Result, p *Page, rec *Record) []Finding {
|
|
if rec == nil {
|
|
return nil
|
|
}
|
|
var what []string
|
|
details := map[string]any{}
|
|
if title := normal(rec.Title); title != "" {
|
|
found := false
|
|
for _, h := range p.Facts.H1() {
|
|
if strings.Contains(normal(h), title) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
details["title"] = rec.Title
|
|
what = append(what, "the title “"+rec.Title+"” in the H1")
|
|
}
|
|
}
|
|
if rec.Words != nil && *rec.Words > 0 && p.Facts.Words*2 < *rec.Words {
|
|
details["words_raw"] = p.Facts.Words
|
|
details["words_cms"] = *rec.Words
|
|
what = append(what, strconv.Itoa(p.Facts.Words)+" of "+strconv.Itoa(*rec.Words)+" words")
|
|
}
|
|
if len(rec.Links) > 0 && res.fetcher != nil {
|
|
have := map[string]bool{}
|
|
for _, l := range p.Facts.Links {
|
|
if k := res.fetcher.Key(l.URL); k != "" {
|
|
have[k] = true
|
|
}
|
|
}
|
|
var missing []string
|
|
for _, l := range rec.Links {
|
|
if !have[l] {
|
|
missing = append(missing, l)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
details["links"] = missing
|
|
what = append(what, "links "+strings.Join(missing, ", "))
|
|
}
|
|
}
|
|
if len(what) == 0 {
|
|
return nil
|
|
}
|
|
return []Finding{{Kind: KindContentJS, URL: p.Key, Locale: locale(rec), Record: rec, Check: checkCMS, Details: details, What: strings.Join(what, "; ")}}
|
|
}
|
|
|
|
// checkRenderedPage runs the raw versus rendered comparison of a page the
|
|
// renderer rendered.
|
|
func checkRenderedPage(res *Result, p *Page, rec *Record) []Finding {
|
|
if p.Rendered == nil || res.fetcher == nil {
|
|
return nil
|
|
}
|
|
out := compareRendered(p.Facts, p.Rendered, res.fetcher.Hosts())
|
|
for i := range out {
|
|
out[i].URL, out[i].Locale, out[i].Record = p.Key, locale(rec), rec
|
|
}
|
|
return out
|
|
}
|