diff --git a/classes/crawl/checks.go b/classes/crawl/checks.go index 6150f30..da6b4be 100644 --- a/classes/crawl/checks.go +++ b/classes/crawl/checks.go @@ -1,8 +1,13 @@ package crawl import ( + "net/url" + "sort" + "strings" + "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. @@ -107,10 +112,16 @@ func Kinds() []string { } // Env is what the checks know besides the crawl: the record behind an -// address. +// 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 } func (e *Env) record(key string) *Record { @@ -120,32 +131,312 @@ func (e *Env) record(key string) *Record { return e.Lookup(key) } -// Check runs the per-page checks over a crawl's pages. +// 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, checkHeadings(p, env.record(key))...) + out = append(out, checkHead(res, p, rec)...) + out = append(out, checkHeadings(p, rec)...) + } + 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 and h1_multiple. An H1 inside the -// record's own content is fixed through the record's headings tool. +// 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: - return []Finding{{Kind: KindH1Missing, URL: p.Key, Record: rec}} + out = append(out, Finding{Kind: KindH1Missing, URL: p.Key, Locale: loc, Record: rec}) case len(h1) > 1: - f := Finding{Kind: KindH1Multiple, URL: p.Key, Record: rec, Details: map[string]any{"count": len(h1), "h1": h1}} + 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} } - return []Finding{f} + out = append(out, f) } - return nil + 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 +} + +// 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, Finding{Kind: KindSitemapNoindex, URL: key, Locale: locale(rec), Record: rec, Tool: ToolSetURLSEO, Details: map[string]any{"file": file}}) + } + } + 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 } diff --git a/classes/crawl/job.go b/classes/crawl/job.go index 4eb01cb..e61d23f 100644 --- a/classes/crawl/job.go +++ b/classes/crawl/job.go @@ -221,7 +221,7 @@ func finish(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.C if err := SavePages(tx, row.ID, res); err != nil { return err } - findings := Check(res, &Env{Lookup: Lookup(ctx, svc, tx)}) + findings := Check(res, &Env{Lookup: Lookup(ctx, svc, tx), Staging: svc.Staging()}) cms, err := svc.Issues(classes.WithTx(ctx, tx)) if err != nil { return err diff --git a/crawl_site_test.go b/crawl_site_test.go index be2c434..3e5d68a 100644 --- a/crawl_site_test.go +++ b/crawl_site_test.go @@ -6,6 +6,7 @@ import ( "html" "io" "net/http" + "net/url" "slices" "sort" "strings" @@ -415,3 +416,168 @@ func findIssue(t *testing.T, list []apiIssue, kind, url string) apiIssue { } return found[0] } + +// plantedPage is one page of the audit site: the document (or a raw +// answer), whether a sitemap lists it, whether the home page links to it +// and the issues it plants ("kind" each, at its own address). +type plantedPage struct { + doc doc + status int + header []string + raw string + listed bool + linked bool + kinds []string +} + +// auditPages is the planted audit site: one page per kind of UI-SPEC C23 +// and the clean control page /clean. +func auditPages() []plantedPage { + jsonld := func(v string) string { return `` } + alt := func(lang, href string) string { + return `` + } + page := func(d doc, listed, linked bool, kinds ...string) plantedPage { + return plantedPage{doc: d, listed: listed, linked: linked, kinds: kinds} + } + return []plantedPage{ + page(doc{path: "/clean", head: jsonld(`{"@context":"https://schema.org","@graph":[{"@type":"FAQPage","@id":"#faq"}]}`), + body: `
Plain text of the control page.
Text
"}, true, true, "heading_empty"), + page(doc{path: "/p/mismatch", title: "Alpha page", h1: []string{"Completely different"}}, true, true, "h1_title_mismatch"), + // JSON-LD. + page(doc{path: "/p/jsonld-bad", head: jsonld(`{"@context": "https://schema.org", bad`)}, true, true, "jsonld_invalid"), + page(doc{path: "/p/jsonld-two", head: jsonld(`{"@context":"https://schema.org","@type":"WebPage","name":"A"}`) + jsonld(`{"@context":"https://schema.org","@type":"WebPage","name":"B"}`)}, true, true, "jsonld_multiple"), + page(doc{path: "/p/jsonld-req", head: jsonld(`{"@context":"https://schema.org","@graph":[{"@type":"BreadcrumbList","@id":"#crumbs"}]}`)}, true, true, "jsonld_required"), + page(doc{path: "/p/jsonld-rec", head: jsonld(`{"@context":"https://schema.org","@type":"Organization","name":"Acme","url":"https://www.example.test/"}`)}, true, true, "jsonld_recommended"), + // hreflang. + page(doc{path: "/en/x", head: alt("en", "/en/x") + alt("pl", "/pl/x")}, true, true, "hreflang_reciprocal"), + page(doc{path: "/pl/x", lang: "pl", head: alt("pl", "/pl/x")}, true, true), + page(doc{path: "/p/hreflang-err", head: alt("en", "/p/hreflang-err") + alt("de", "/de/missing")}, true, true, "hreflang_error"), + // Links. + page(doc{path: "/p/generic", body: ``}, true, true, "generic_anchor"), + {doc: doc{path: "/p/gone"}, status: 404, linked: true, kinds: []string{"broken_link"}}, + {doc: doc{path: "/p/moved"}, status: 301, header: []string{"Location", "/clean"}, linked: true, kinds: []string{"redirected_link"}}, + // Images. + page(doc{path: "/p/img-noalt", body: `
`}, true, true, "image_size"),
+ page(doc{path: "/p/lazy", head: ``, body: `Just a few words here.
"}, true, true, "content_js"), + page(doc{path: "/c/js-links", title: "Linked post", links: []string{"/clean"}}, true, true, "content_js"), + page(doc{path: "/c/parent", title: "Parent post"}, true, true), + // Raw against rendered (content_js, check rendered). + page(doc{path: "/p/render", body: "Short raw text.
"}, true, true, "content_js"), + page(doc{path: "/p/render-h1", h1: []string{}}, true, true, "h1_missing", "content_js"), + // Orphans. + page(doc{path: "/p/orphan", title: "Orphan topic"}, true, false, "orphan_page"), + page(doc{path: "/p/mentions", body: "We wrote about the orphan topic before.
"}, true, true), + } +} + +// auditRecords are the CMS records behind the /c/ pages. +func auditRecords() map[string][]crawlRecord { + return map[string][]crawlRecord{crawlPostType: { + {id: "1", path: "/c/js-h1", title: "Record heading"}, + {id: "2", path: "/c/js-words", title: "Words page", words: 400}, + {id: "3", path: "/c/js-links", title: "Linked post", parents: []pact.ContentRef{{Type: crawlPostType, ID: "4"}}}, + {id: "4", path: "/c/parent", title: "Parent post"}, + }} +} + +// plantAudit serves the audit site and returns the expected crawl issues +// ("kind url", sorted). +func plantAudit(site *fakeSite) []string { + var listed, linked, want []string + listed = append(listed, "/") + for _, p := range auditPages() { + switch { + case p.raw != "": + site.set(p.doc.path, p.status, p.raw, p.header...) + case p.status != 0: + site.set(p.doc.path, p.status, p.doc.render(), p.header...) + default: + site.plant(p.doc) + } + if p.listed { + listed = append(listed, p.doc.path) + } + if p.linked { + linked = append(linked, p.doc.path) + } + for _, k := range p.kinds { + want = append(want, k+" "+p.doc.path) + } + } + home := doc{path: "/", title: "Home", links: linked, body: ``} + site.plant(home) + site.sitemap(listed...) + site.set("/robots.txt", http.StatusOK, "User-agent: *\nDisallow: /p/blocked\n", "Content-Type", "text/plain") + sort.Strings(want) + return want +} + +// fakeRenderer is the test renderer of the comparison: the DOM planted +// for a path, else the raw page (an identical rendering). +type fakeRenderer struct { + site *fakeSite + dom map[string]string + fail error + mu sync.Mutex + calls int + closed bool +} + +func (r *fakeRenderer) Render(_ context.Context, pageURL string) (string, error) { + r.mu.Lock() + r.calls++ + r.mu.Unlock() + if r.fail != nil { + return "", r.fail + } + u, err := url.Parse(pageURL) + if err != nil { + return "", err + } + if d, ok := r.dom[u.Path]; ok { + return d, nil + } + r.site.mu.Lock() + defer r.site.mu.Unlock() + return r.site.pages[u.Path].body, nil +} + +func (r *fakeRenderer) Close() error { + r.closed = true + return nil +} + +// auditRenderer renders /p/render with a link and 80 more words, and +// /p/render-h1 with an H1. +func auditRenderer(site *fakeSite) *fakeRenderer { + words := strings.Repeat("rendered word ", 40) + return &fakeRenderer{site: site, dom: map[string]string{ + "/p/render": doc{path: "/p/render", body: `Short raw text.
` + words + `
Only after JavaScript`}.render(), + "/p/render-h1": doc{path: "/p/render-h1"}.render(), + }} +} diff --git a/crawler_test.go b/crawler_test.go index 931d277..f3ce668 100644 --- a/crawler_test.go +++ b/crawler_test.go @@ -1,8 +1,11 @@ package seo_test import ( + "fmt" + "strings" "testing" + "git.golem15.com/golem15/sm-seo-plugin/classes/crawl" "git.golem15.com/golem15/summercms/modules/pact" ) @@ -46,3 +49,95 @@ func TestCrawlerTracer(t *testing.T) { } } } + +// checkedKinds are the crawl kinds TestCrawlerChecks compares. +var checkedKinds = map[string]bool{ + "page_status": true, "sitemap_error": true, "sitemap_noindex": true, "sitemap_blocked": true, + "sitemap_missing": true, "canonical_missing": true, "canonical_error": true, "missing_title": true, + "duplicate_title": true, "missing_description": true, "duplicate_description": true, + "h1_missing": true, "h1_multiple": true, "heading_skipped": true, "heading_empty": true, + "h1_title_mismatch": true, +} + +// onlyChecked keeps the "kind url" entries of checkedKinds. +func onlyChecked(list []string) []string { + out := []string{} + for _, e := range list { + kind, _, _ := strings.Cut(e, " ") + if checkedKinds[kind] { + out = append(out, e) + } + } + return out +} + +// TestCrawlerChecks crawls the planted site (one page per kind of UI-SPEC +// C23 and the clean control page /clean): every planted issue is found +// exactly once with its severity and details, nothing is found on /clean, +// and the crawler never requests another host, a private address or plain +// http (T-33-43). +func TestCrawlerChecks(t *testing.T) { + env := bootCrawlEnv(t) + token := env.admin(t, true) + setCrawlRecords(t, auditRecords()) + site := newFakeSite() + want := plantAudit(site) + + ctx := crawl.WithRenderer(siteCtx(t, site), auditRenderer(site)) + env.crawlOnce(t, token, ctx) + list := env.issues(t, token, "source=crawl") + + got := onlyChecked(issueSet(list, "crawl")) + if strings.Join(got, "\n") != strings.Join(onlyChecked(want), "\n") { + t.Fatalf("crawl issues:\n%s\n\nwant:\n%s", strings.Join(got, "\n"), strings.Join(onlyChecked(want), "\n")) + } + for _, i := range list { + if i.URL == "/clean" { + t.Fatalf("issue on the control page: %+v", i) + } + if i.Fix != "tool" && i.Fix != "code" || (i.Fix == "tool") != (i.Tool != "") { + t.Fatalf("issue without a fix route: %+v", i) + } + } + for _, part := range []string{"evil.example.com", "169.254.169.254", "http://www.example.test"} { + if site.requested(part) { + t.Fatalf("the crawler requested %s: %v", part, site.requests) + } + } + + detail := func(kind, url, key string, value any) { + t.Helper() + i := findIssue(t, list, kind, url) + if fmt.Sprint(i.Details[key]) != fmt.Sprint(value) { + t.Fatalf("%s %s details.%s = %v, want %v (%v)", kind, url, key, i.Details[key], value, i.Details) + } + } + severity := func(kind, url, want string) { + t.Helper() + if i := findIssue(t, list, kind, url); i.Severity != want { + t.Fatalf("%s %s severity = %s, want %s", kind, url, i.Severity, want) + } + } + detail("page_status", "/p/status-500", "status", 500) + severity("page_status", "/p/status-500", "error") + detail("sitemap_error", "/p/sitemap-404", "status", 404) + detail("sitemap_error", "/p/sitemap-404", "file", "/sitemap-pages-1.xml") + detail("sitemap_noindex", "/p/noindex", "file", "/sitemap-pages-1.xml") + detail("sitemap_blocked", "/p/blocked", "line", 2) + detail("sitemap_missing", "/p/unlisted", "count", 1) + severity("sitemap_missing", "/p/unlisted", "notice") + detail("canonical_error", "/p/bad-canonical", "url", "/p/canonical-404") + detail("canonical_error", "/p/bad-canonical", "status", 404) + detail("duplicate_title", "/p/dup-title-a", "count", 1) + detail("h1_multiple", "/p/two-h1", "count", 2) + detail("heading_skipped", "/p/skip", "from", 1) + detail("heading_skipped", "/p/skip", "to", 3) + detail("heading_empty", "/p/empty-heading", "level", 2) + detail("h1_title_mismatch", "/p/mismatch", "h1", "Completely different") + if i := findIssue(t, list, "canonical_missing", "/p/no-canonical"); i.Fix != "code" || i.Severity != "warning" { + t.Fatalf("canonical_missing = %+v", i) + } + if i := findIssue(t, list, "missing_title", "/p/no-title"); i.Tool != "seo_set_url_seo" { + t.Fatalf("missing_title = %+v", i) + } +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index ce00aa2..5b02dfe 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -525,3 +525,46 @@ 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. +issues: + kind_page_status: "Page returns an error" + sum_page_status: "Status :status" + kind_sitemap_error: "Sitemap lists a page that does not answer 200" + sum_sitemap_error: "Status :status in :file" + kind_sitemap_noindex: "Sitemap lists a noindex page" + sum_sitemap_noindex: "In :file" + kind_sitemap_blocked: "Sitemap lists an address blocked in robots.txt" + sum_sitemap_blocked: "Blocked by line :line" + kind_sitemap_missing: "Indexable page missing from the sitemaps" + sum_sitemap_missing: "Found through :count links" + kind_canonical_missing: "No canonical link" + sum_canonical_missing: "—" + kind_canonical_error: "Canonical points to an error page" + sum_canonical_error: ":url answers :status" + kind_canonical_noindex: "Canonical points to a noindex page" + sum_canonical_noindex: ":url" + kind_canonical_redirected: "Canonical points to a redirect" + sum_canonical_redirected: ":url → :target" + kind_missing_title: "Missing title" + sum_missing_title: "—" + kind_duplicate_title: "Duplicate title" + sum_duplicate_title: "Same as :count other pages" + kind_missing_description: "Missing meta description" + sum_missing_description: "—" + kind_duplicate_description: "Duplicate meta description" + sum_duplicate_description: "Same as :count other pages" + kind_rule_overrides_record: "URL rule overrides the record" + sum_rule_overrides_record: ":fields" + kind_redirect_chain: "Redirect chain" + sum_redirect_chain: ":count hops to :final" + kind_noindex_blocked: "noindex page blocked in robots.txt" + sum_noindex_blocked: "Line :line" + kind_h1_missing: "No H1 heading" + sum_h1_missing: "—" + kind_h1_multiple: "More than one H1" + sum_h1_multiple: ":count H1 headings" + kind_heading_skipped: "Skipped heading level" + sum_heading_skipped: "H:from → H:to" + kind_heading_empty: "Empty heading" + sum_heading_empty: "H:level" + kind_h1_title_mismatch: "H1 does not match the title" + sum_h1_title_mismatch: "“:h1”" diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 3d57af2..8858e2f 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -539,3 +539,46 @@ 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. +issues: + kind_page_status: "Strona zwraca błąd" + sum_page_status: "Kod :status" + kind_sitemap_error: "Mapa witryny zawiera stronę bez kodu 200" + sum_sitemap_error: "Kod :status w :file" + kind_sitemap_noindex: "Mapa witryny zawiera stronę z noindex" + sum_sitemap_noindex: "W :file" + kind_sitemap_blocked: "Mapa witryny zawiera adres zablokowany w robots.txt" + sum_sitemap_blocked: "Blokowany przez wiersz :line" + kind_sitemap_missing: "Indeksowalna strona poza mapami witryny" + sum_sitemap_missing: "Znaleziona przez linki: :count" + kind_canonical_missing: "Brak linku kanonicznego" + sum_canonical_missing: "—" + kind_canonical_error: "Adres kanoniczny wskazuje stronę z błędem" + sum_canonical_error: ":url odpowiada kodem :status" + kind_canonical_noindex: "Adres kanoniczny wskazuje stronę z noindex" + sum_canonical_noindex: ":url" + kind_canonical_redirected: "Adres kanoniczny wskazuje przekierowanie" + sum_canonical_redirected: ":url → :target" + kind_missing_title: "Brak tytułu" + sum_missing_title: "—" + kind_duplicate_title: "Powielony tytuł" + sum_duplicate_title: "Taki sam jak na innych stronach: :count" + kind_missing_description: "Brak opisu meta" + sum_missing_description: "—" + kind_duplicate_description: "Powielony opis meta" + sum_duplicate_description: "Taki sam jak na innych stronach: :count" + kind_rule_overrides_record: "Reguła adresu nadpisuje rekord" + sum_rule_overrides_record: ":fields" + kind_redirect_chain: "Łańcuch przekierowań" + sum_redirect_chain: "Liczba przekierowań: :count, do :final" + kind_noindex_blocked: "Strona z noindex zablokowana w robots.txt" + sum_noindex_blocked: "Wiersz :line" + kind_h1_missing: "Brak nagłówka H1" + sum_h1_missing: "—" + kind_h1_multiple: "Więcej niż jeden nagłówek H1" + sum_h1_multiple: "Nagłówki H1: :count" + kind_heading_skipped: "Pominięty poziom nagłówka" + sum_heading_skipped: "H:from → H:to" + kind_heading_empty: "Pusty nagłówek" + sum_heading_empty: "H:level" + kind_h1_title_mismatch: "H1 nie pasuje do tytułu" + sum_h1_title_mismatch: "„:h1”"