feat: Site audit and Crawls screens with their MCP tools

- golem15.seo.issues (Site audit): current issues errors first, labels,
  summary and fix columns, filters, P7 issue_details, Check this address
  again, Ignore, Stop ignoring, bulk Ignore, ignored rows badged
- golem15.seo.crawls (Crawls): newest first, P6 crawl_summary with one
  status callout, staging and rendered-comparison lines, stats strip and
  per-kind table; Cancel; a running crawl refuses delete
- Start crawl toolbar action on both, refusing already_running and
  no_site_url; navigation Site audit (gauge) and Crawls (globe)
- routes and MCP tools start/cancel/list/get/delete crawls, list_issues
  with filters (open by default), recheck_url, ignore and unignore
- AdminOperations maps both controllers; toolbar actions get their own
  operation kind
- TestCrawlAdmin, TestCrawlTools, TestIssueKindLabels
This commit is contained in:
Jakub Zych
2026-11-04 15:34:22 +01:00
parent edc03cae4b
commit c4f0b4f94c
32 changed files with 2778 additions and 20 deletions

View File

@@ -45,6 +45,20 @@ func (*Plugin) Navigation() []pact.NavigationItem {
Permissions: []string{controllers.PermissionManage},
Controller: controllers.PagesControllerID,
},
{
Code: "audit",
Label: "golem15.seo::lang.nav.audit",
Icon: "gauge",
Permissions: []string{controllers.PermissionManage},
Controller: controllers.IssuesControllerID,
},
{
Code: "crawls",
Label: "golem15.seo::lang.nav.crawls",
Icon: "globe",
Permissions: []string{controllers.PermissionManage},
Controller: controllers.CrawlsControllerID,
},
{
Code: "robots",
Label: "golem15.seo::lang.nav.robots",

304
classes/crawl/actions.go Normal file
View File

@@ -0,0 +1,304 @@
package crawl
import (
"context"
"errors"
"net/url"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ErrDeleteRunning refuses deleting a queued or running crawl
// (crawls.delete_running).
var ErrDeleteRunning = errors.New("golem15.seo: a running crawl cannot be deleted")
// LoadIssue reads one stored issue.
func LoadIssue(db *gorm.DB, id uint) (models.Issue, error) {
var rows []models.Issue
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
return models.Issue{}, err
}
if len(rows) == 0 {
return models.Issue{}, gorm.ErrRecordNotFound
}
return rows[0], nil
}
// IsIgnored reports whether an issue's fingerprint is ignored.
func IsIgnored(db *gorm.DB, issue models.Issue) (bool, error) {
set, err := IgnoredSet(db, []string{issue.Fingerprint})
return set[issue.Fingerprint], err
}
// Ignore hides issues in this and later crawls (UI-SPEC C23): their
// fingerprints are stored with the administrator and the time. It returns
// the number of fingerprints newly ignored.
func Ignore(tx *gorm.DB, issues []models.Issue, adminID *uint) (int, error) {
n := 0
now := time.Now().UTC()
seen := map[string]bool{}
for _, i := range issues {
if seen[i.Fingerprint] {
continue
}
seen[i.Fingerprint] = true
row := models.IssueIgnore{Fingerprint: i.Fingerprint, AdminID: adminID, IgnoredAt: &now}
res := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "fingerprint"}}, DoNothing: true}).Create(&row)
if res.Error != nil {
return n, res.Error
}
n += int(res.RowsAffected)
}
return n, nil
}
// Unignore shows issues in the audit again; it returns the number of
// fingerprints no longer ignored.
func Unignore(tx *gorm.DB, issues []models.Issue) (int, error) {
var fps []string
for _, i := range issues {
fps = append(fps, i.Fingerprint)
}
if len(fps) == 0 {
return 0, nil
}
res := tx.Where("fingerprint IN ?", fps).Delete(&models.IssueIgnore{})
return int(res.RowsAffected), res.Error
}
// DeleteCrawls deletes crawls with their pages, links and issues; a
// queued or running one refuses the whole call with ErrDeleteRunning.
func DeleteCrawls(tx *gorm.DB, ids []uint) (int, error) {
if len(ids) == 0 {
return 0, nil
}
var active int64
if err := tx.Model(&models.Crawl{}).Where("id IN ? AND status IN ?", ids, []string{models.CrawlQueued, models.CrawlRunning}).Count(&active).Error; err != nil {
return 0, err
}
if active > 0 {
return 0, ErrDeleteRunning
}
res := tx.Where("id IN ?", ids).Delete(&models.Crawl{})
return int(res.RowsAffected), res.Error
}
// Prune deletes the finished, failed and cancelled crawls beyond the
// newest keep, with their pages, links and issues.
func Prune(db *gorm.DB, keep int) error {
if keep < 1 {
keep = 1
}
return db.Exec(`DELETE FROM golem15_seo_crawls WHERE status NOT IN ('queued', 'running') AND id NOT IN (
SELECT id FROM golem15_seo_crawls WHERE status NOT IN ('queued', 'running') ORDER BY id DESC LIMIT ?)`, keep).Error
}
// crossPageKinds are the crawl kinds that depend on other pages of the
// crawl; a recheck of one address keeps them.
var crossPageKinds = map[string]bool{
KindDuplicateTitle: true, KindDuplicateDesc: true, KindSitemapMissing: true,
KindOrphanPage: true, KindBrokenLink: true, KindRedirectedLink: true, KindSitemapBlocked: true,
}
// RecheckResult is what a recheck left for the address.
type RecheckResult struct {
URL string
Remaining int
}
// Recheck fetches one issue's address again through the crawler client
// (with its canonical and hreflang targets) and replaces the address's
// issues in the issue's crawl (UI-SPEC C23): the per-page crawl findings
// are checked again, broken and redirected link findings are dropped once
// the address answers 200, and the CMS-data issues of the address are
// checked again. An issue stored before any crawl refreshes the CMS-data
// issues. Remaining counts the address's issues afterwards.
func Recheck(ctx context.Context, svc *classes.Service, tx *gorm.DB, issue models.Issue) (RecheckResult, error) {
out := RecheckResult{URL: issue.URL}
ctx = classes.WithTx(ctx, tx)
cms, err := svc.Issues(ctx)
if err != nil {
return out, err
}
var mine []classes.Issue
for _, c := range cms {
if c.URL == issue.URL {
mine = append(mine, c)
}
}
if issue.CrawlID == nil {
found := map[string]bool{}
for _, c := range mine {
found[Fingerprint(c.Kind, c.URL, c.Locale, "")] = true
}
var existing []models.Issue
if err := tx.Where("crawl_id IS NULL AND url = ?", issue.URL).Find(&existing).Error; err != nil {
return out, err
}
have := map[string]bool{}
for _, e := range existing {
if found[e.Fingerprint] {
have[e.Fingerprint] = true
} else if err := tx.Delete(&models.Issue{}, e.ID).Error; err != nil {
return out, err
}
}
var fresh []classes.Issue
for _, c := range mine {
if !have[Fingerprint(c.Kind, c.URL, c.Locale, "")] {
fresh = append(fresh, c)
}
}
if err := SaveIssues(tx, nil, nil, fresh); err != nil {
return out, err
}
return out, countRemaining(tx, nil, &out)
}
row, err := LoadCrawl(tx, *issue.CrawlID)
if err != nil {
return out, err
}
f, err := NewFetcher(row.Site, svc.Config())
if err != nil {
return out, err
}
u, err := url.Parse(f.Absolute(issue.URL))
if err != nil || f.Key(u) == "" {
return out, classes.ErrBadURL
}
res := &Result{Pages: map[string]*Page{}, Listed: map[string]string{}, Render: RenderState{Status: renderStatusSkipped}, fetcher: f}
fetch := func(key string) *Page {
p := &Page{Key: key, Resp: f.Get(ctx, key), FetchedAt: time.Now().UTC()}
if p.Resp.Err == "" && p.Resp.Status == 200 && p.Resp.HTML() {
base, _ := url.Parse(f.Absolute(key))
p.Facts = Parse(base, p.Resp.Body)
}
p.Resp.Body = nil
res.Pages[key] = p
return p
}
var stored []models.CrawlPage
if err := tx.Where("crawl_id = ? AND url = ?", row.ID, issue.URL).Limit(1).Find(&stored).Error; err != nil {
return out, err
}
main := fetch(issue.URL)
res.Order = []string{issue.URL}
if len(stored) > 0 && stored[0].InSitemap {
main.InSitemap, main.SitemapFile = true, stored[0].SitemapFile
res.Listed[issue.URL] = stored[0].SitemapFile
}
if main.Facts != nil {
var extra []string
if c, err := url.Parse(main.Facts.Canonical); err == nil && main.Facts.Canonical != "" {
extra = append(extra, f.Key(c))
}
for _, a := range main.Facts.Alternates {
extra = append(extra, f.Key(a.URL))
}
for _, key := range extra {
if key != "" && res.Pages[key] == nil {
fetch(key)
}
}
}
limits, err := LoadLimits(tx)
if err != nil {
return out, err
}
env := &Env{Lookup: Lookup(ctx, svc, tx), Staging: svc.Staging(), GenericAnchors: limits.GenericAnchors}
rec := env.record(issue.URL)
var findings []Finding
findings = append(findings, checkStatus(res, main, rec)...)
if main.OK() {
findings = append(findings, checkHead(res, main, rec)...)
findings = append(findings, checkHeadings(main, rec)...)
findings = append(findings, checkJSONLD(main, rec)...)
findings = append(findings, checkHreflang(res, main, rec)...)
findings = append(findings, checkGenericAnchors(res, main, rec, env.GenericAnchors)...)
findings = append(findings, checkImages(main, rec)...)
findings = append(findings, checkContentCMS(res, main, rec)...)
}
for _, sf := range checkSitemaps(res, env) {
if sf.URL == issue.URL && sf.Kind != KindSitemapMissing && sf.Kind != KindSitemapBlocked {
findings = append(findings, sf)
}
}
var keep []string
for kind := range crossPageKinds {
if main.Resp.Status == 200 && (kind == KindBrokenLink || kind == KindRedirectedLink) {
continue
}
keep = append(keep, kind)
}
// The address's issues found again keep their row (and id); the
// others are deleted, the new ones stored. Cross-page findings stay,
// and so does the rendered comparison, which a recheck does not repeat.
found := map[string]bool{}
for _, f := range findings {
found[Fingerprint(f.Kind, f.URL, f.Locale, f.Check)] = true
}
for _, c := range mine {
found[Fingerprint(c.Kind, c.URL, c.Locale, "")] = true
}
id := row.ID
var existing []models.Issue
if err := tx.Where("crawl_id = ? AND url = ?", id, issue.URL).Find(&existing).Error; err != nil {
return out, err
}
keepKind := map[string]bool{}
for _, k := range keep {
keepKind[k] = true
}
have := map[string]bool{}
var gone []uint
for _, e := range existing {
stays := found[e.Fingerprint] ||
(e.Source == models.SourceCrawl && keepKind[e.Kind]) ||
(e.Source == models.SourceCrawl && e.Kind == KindContentJS && e.DetailsMap()["check"] == checkRendered)
if stays {
have[e.Fingerprint] = true
} else {
gone = append(gone, e.ID)
}
}
if len(gone) > 0 {
if err := tx.Where("id IN ?", gone).Delete(&models.Issue{}).Error; err != nil {
return out, err
}
}
var fresh []Finding
for _, f := range findings {
if !have[Fingerprint(f.Kind, f.URL, f.Locale, f.Check)] {
fresh = append(fresh, f)
}
}
var freshCMS []classes.Issue
for _, c := range mine {
if !have[Fingerprint(c.Kind, c.URL, c.Locale, "")] {
freshCMS = append(freshCMS, c)
}
}
if err := SaveIssues(tx, &id, fresh, freshCMS); err != nil {
return out, err
}
return out, countRemaining(tx, &id, &out)
}
func countRemaining(tx *gorm.DB, crawlID *uint, out *RecheckResult) error {
q := tx.Model(&models.Issue{}).Where("url = ?", out.URL)
if crawlID == nil {
q = q.Where("crawl_id IS NULL")
} else {
q = q.Where("crawl_id = ?", *crawlID)
}
var n int64
if err := q.Count(&n).Error; err != nil {
return err
}
out.Remaining = int(n)
return nil
}

View File

@@ -102,15 +102,22 @@ func Start(ctx context.Context, svc *classes.Service, tx *gorm.DB, trigger, name
return row, nil
}
// Limits are a crawl's politeness limits.
// Limits are a crawl's politeness limits and the Site audit settings it
// reads: pages per crawl, the pause between requests, the crawls kept and
// the generic link texts.
type Limits struct {
MaxPages int
Delay time.Duration
MaxPages int
Delay time.Duration
Keep int
GenericAnchors []string
}
// DefaultKeep is the number of crawls kept.
const DefaultKeep = 10
// LoadLimits reads the crawl limits.
func LoadLimits(db *gorm.DB) (Limits, error) {
return Limits{MaxPages: DefaultMaxPages, Delay: DefaultDelay}, nil
return Limits{MaxPages: DefaultMaxPages, Delay: DefaultDelay, Keep: DefaultKeep, GenericAnchors: DefaultGenericAnchors}, nil
}
// LoadCrawl reads one crawl.
@@ -214,6 +221,10 @@ func finish(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.C
if current == models.CrawlCancelled || (res != nil && res.Canceled) {
status = models.CrawlCancelled
}
limits, err := LoadLimits(db)
if err != nil {
return err
}
store := func(ctx context.Context, tx *gorm.DB) error {
if res == nil {
return nil
@@ -225,7 +236,7 @@ func finish(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.C
if err != nil {
return err
}
findings := Check(res, &Env{Lookup: Lookup(ctx, svc, tx), Staging: svc.Staging(), GenericAnchors: DefaultGenericAnchors, Keyphrases: keyphrases})
findings := Check(res, &Env{Lookup: Lookup(ctx, svc, tx), Staging: svc.Staging(), GenericAnchors: limits.GenericAnchors, Keyphrases: keyphrases})
cms, err := svc.Issues(classes.WithTx(ctx, tx))
if err != nil {
return err
@@ -261,6 +272,9 @@ func finish(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.C
if status == models.CrawlFailed {
pluginLog.Warn("golem15.seo: crawl failed", "crawl", row.ID, "error", errText)
}
if err := Prune(db, limits.Keep); err != nil {
pluginLog.Warn("golem15.seo: prune crawls", "error", err)
}
return nil
}

View File

@@ -232,17 +232,51 @@ func Current(ctx context.Context, svc *classes.Service, db *gorm.DB) (*models.Cr
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.
// RefreshCMS brings the stored CMS-data issues of a site not crawled yet
// up to date with the report, in one transaction: an issue found again
// keeps its row (and id), the others are deleted and new ones stored.
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 {
found := map[string]classes.Issue{}
for _, c := range cms {
found[Fingerprint(c.Kind, c.URL, c.Locale, "")] = c
}
var existing []models.Issue
if err := tx.Where("crawl_id IS NULL").Find(&existing).Error; err != nil {
return err
}
return SaveIssues(tx, nil, nil, cms)
have := map[string]bool{}
var gone []uint
now := time.Now().UTC()
for _, e := range existing {
c, ok := found[e.Fingerprint]
if !ok || have[e.Fingerprint] {
gone = append(gone, e.ID)
continue
}
have[e.Fingerprint] = true
row := issueRow(nil, models.SourceCMS, cmsFinding(c), now)
if err := tx.Model(&models.Issue{}).Where("id = ?", e.ID).Updates(map[string]any{
"details": row.Details, "tool": row.Tool, "fix": row.Fix, "severity": row.Severity, "rank": row.Rank,
}).Error; err != nil {
return err
}
}
if len(gone) > 0 {
if err := tx.Where("id IN ?", gone).Delete(&models.Issue{}).Error; err != nil {
return err
}
}
var fresh []classes.Issue
for _, c := range cms {
if !have[Fingerprint(c.Kind, c.URL, c.Locale, "")] {
fresh = append(fresh, c)
}
}
return SaveIssues(tx, nil, nil, fresh)
})
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/url"
"reflect"
"strconv"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
@@ -427,3 +428,110 @@ func (s *Service) primaryGoneCallout(ctx context.Context, db *gorm.DB, key, id s
Title: s.PhraseCtx(ctx, "golem15.seo::lang.status.primary_gone", map[string]string{"name": name}),
}, true
}
// IssueParams turns an issue's details into the :name parameters of its
// issues.sum_<kind> phrase: scalars as text, lists by their length or
// joined (fields, also), with the aliases the Phase 32 kinds need (count
// from also or hops, url from canonical).
func IssueParams(details map[string]any) map[string]string {
out := map[string]string{}
for k, v := range details {
switch x := v.(type) {
case string:
out[k] = x
case float64:
out[k] = strconv.FormatFloat(x, 'f', -1, 64)
case int:
out[k] = strconv.Itoa(x)
case bool:
out[k] = strconv.FormatBool(x)
case []any:
parts := make([]string, 0, len(x))
for _, e := range x {
if s, ok := e.(string); ok {
parts = append(parts, s)
}
}
out[k] = strings.Join(parts, ", ")
case []string:
out[k] = strings.Join(x, ", ")
}
}
if _, ok := out["count"]; !ok {
if also, ok := details["also"].([]any); ok {
out["count"] = strconv.Itoa(len(also))
} else if hops, ok := out["hops"]; ok {
out["count"] = hops
}
}
if _, ok := out["url"]; !ok {
if c, ok := out["canonical"]; ok {
out["url"] = c
}
}
return out
}
// IssueSummary is the one-line Details cell of an issue (issues.sum_<kind>).
func (s *Service) IssueSummary(ctx context.Context, kind string, details map[string]any) string {
return s.PhraseCtx(ctx, "golem15.seo::lang.issues.sum_"+kind, IssueParams(details))
}
// IssueRow is one row of P7's "What was found" table.
type IssueRow struct {
Label string
Value string
Mono bool
}
// IssueLink is a page named in P7 (a linking page or a suggestion): its
// address and, when the page has an admin form, the admin path.
type IssueLink struct {
URL string
Admin string
Phrase string
}
// IssueDetailsView is the P7 issue_details view model (UI-SPEC C23).
type IssueDetailsView struct {
Rows []IssueRow
LinkedFrom []IssueLink
More string
Orphan bool
Suggestions []IssueLink
FixText string
PageURL string
RichResults bool
RichURL string
IgnoredNotes string
}
// CrawlStat is one item of P6's stats strip.
type CrawlStat struct {
Label string
Value string
}
// CrawlKindRow is one row of P6's per-kind table.
type CrawlKindRow struct {
Kind string
Label string
Severity string
Count int
Link string
}
// CrawlSummaryView is the P6 crawl_summary view model (UI-SPEC C24): one
// status callout (Tone "", warning or danger), the staging and rendered
// comparison lines, the stats strip and the per-kind table.
type CrawlSummaryView struct {
Tone string
Title string
Text string
Staging string
Render string
Stats []CrawlStat
Kinds []CrawlKindRow
NoIssues bool
HasCallout bool
}

View File

@@ -33,6 +33,8 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController {
notFoundAdminController{app: app},
rulesAdminController{app: app},
pagesAdminController{app: app},
issuesAdminController{app: app},
crawlsAdminController{app: app},
robotsAdminController{app: app},
historyAdminController{app: app},
transferAdminController{app: app},

View File

@@ -4,6 +4,8 @@ import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
@@ -61,7 +63,7 @@ func StartCrawl(service func() *classes.Service) http.HandlerFunc {
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
return http.StatusAccepted, map[string]any{"data": crawlMap(ctx, svc, row), "message": svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.started", nil)}, nil
})
}
@@ -88,3 +90,222 @@ func StartRefusal(ctx context.Context, svc *classes.Service, err error) error {
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")})
}
// RenderView is a crawl's rendered comparison in an answer (P33-55):
// skipped (no golem15.seo.crawl_chrome_path), done with the pages
// compared, or failed with the note; message says it in the request
// locale.
type RenderView struct {
Status string `json:"status"`
Pages int `json:"pages"`
Note string `json:"note,omitempty"`
Message string `json:"message"`
}
// renderView is the answer form of a crawl's comparison.
func renderView(ctx context.Context, svc *classes.Service, c models.Crawl) RenderView {
out := RenderView{Status: c.RenderStatus, Pages: c.RenderPages, Note: c.RenderNote}
switch c.RenderStatus {
case models.RenderDone:
out.Message = svc.ChoiceCtx(ctx, "golem15.seo::lang.crawls.render_done", c.RenderPages, nil)
case models.RenderFailed:
out.Message = svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.render_failed", map[string]string{"error": c.RenderNote})
default:
out.Status = models.RenderSkipped
out.Message = svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.render_skipped", nil)
}
return out
}
// ListCrawlsInput is the query of GET /_seo/api/v1/crawls.
type ListCrawlsInput struct {
Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"`
PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"`
Status string `json:"status,omitempty" query:"status" jsonschema:"queued, running, finished, failed or cancelled"`
}
// CrawlIDInput names one crawl.
type CrawlIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the crawl id"`
}
// DeleteCrawlsInput is the query of DELETE /_seo/api/v1/crawls.
type DeleteCrawlsInput struct {
IDs string `json:"ids" query:"ids" jsonschema:"the crawl ids to delete, comma-separated, e.g. 3,4; a queued or running crawl refuses the whole call"`
}
// ListCrawls serves GET /_seo/api/v1/crawls: the crawls, newest first,
// with their issue counts and rendered comparison.
func ListCrawls(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
in := listParams(r)
q := db.Model(&models.Crawl{}).Order("id DESC")
if s := strings.TrimSpace(r.URL.Query().Get("status")); s != "" {
q = q.Where("status = ?", s)
}
var rows []models.Crawl
total, err := paginate(q, in, &rows)
if err != nil {
return 0, nil, err
}
ids := make([]uint, 0, len(rows))
for _, c := range rows {
ids = append(ids, c.ID)
}
counts := map[uint]int{}
if len(ids) > 0 {
var cs []struct {
CrawlID uint
N int
}
if err := db.Raw(`SELECT crawl_id, COUNT(*) AS n FROM golem15_seo_issues WHERE crawl_id IN ? GROUP BY crawl_id`, ids).Scan(&cs).Error; err != nil {
return 0, nil, err
}
for _, c := range cs {
counts[c.CrawlID] = c.N
}
}
out := make([]map[string]any, 0, len(rows))
for _, c := range rows {
v := crawlMap(ctx, svc, c)
v["issues"] = counts[c.ID]
out = append(out, v)
}
return http.StatusOK, listBody(out, in, total), nil
})
}
// crawlMap is a crawl with its rendered comparison.
func crawlMap(ctx context.Context, svc *classes.Service, c models.Crawl) map[string]any {
v := crawlView(c)
return map[string]any{
"id": v.ID, "status": v.Status, "trigger": v.Trigger, "trigger_name": v.TriggerName, "site": v.Site,
"max_pages": v.MaxPages, "pages": v.Pages, "limit_hit": v.LimitHit, "error": v.Error,
"started_at": v.StartedAt, "finished_at": v.FinishedAt, "created_at": v.CreatedAt,
"cancelled_by": c.CancelledBy, "render": renderView(ctx, svc, c),
}
}
// ShowCrawl serves GET /_seo/api/v1/crawls/{id}: one crawl with its
// rendered comparison, whether the staging lock's noindex was left out,
// and its issues per kind and severity.
func ShowCrawl(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
c, err := crawl.LoadCrawl(db, id)
if err != nil {
return 0, nil, err
}
var kinds []struct {
Kind string `json:"kind"`
Severity string `json:"severity"`
Count int `json:"count"`
}
if err := db.Raw(`SELECT kind, severity, COUNT(*) AS count FROM golem15_seo_issues WHERE crawl_id = ? GROUP BY kind, severity ORDER BY MIN(rank), COUNT(*) DESC, kind`, id).Scan(&kinds).Error; err != nil {
return 0, nil, err
}
out := crawlMap(ctx, svc, c)
out["staging"] = svc.Staging()
bySeverity := map[string]int{}
total := 0
for _, k := range kinds {
bySeverity[k.Severity] += k.Count
total += k.Count
}
out["issues"] = total
out["severities"] = bySeverity
out["kinds"] = kinds
return http.StatusOK, map[string]any{"data": out}, nil
})
}
// CancelCrawl serves POST /_seo/api/v1/crawls/{id}/cancel: a queued or
// running crawl stops and keeps what it fetched; another answers 409
// crawls.not_running.
func CancelCrawl(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
var row models.Crawl
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
c, err := crawl.LoadCrawl(tx, id)
if err != nil {
return err
}
_, name, _ := svc.Actor(ctx, tx)
if err := crawl.Cancel(ctx, svc, tx, c, name); err != nil {
return err
}
row, err = crawl.LoadCrawl(tx, id)
return err
})
if errors.Is(err, crawl.ErrNotRunning) {
return 0, nil, Fail(http.StatusConflict, "crawls.not_running")
}
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"data": crawlMap(ctx, svc, row), "message": svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.cancelled", nil)}, nil
})
}
// DeleteCrawls serves DELETE /_seo/api/v1/crawls?ids=…: the crawls go with
// their pages and issues; a queued or running one refuses the whole call
// with 403 crawls.delete_running.
func DeleteCrawls(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
ids, err := idList(r.URL.Query().Get("ids"))
if err != nil {
return 0, nil, err
}
n := 0
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var err error
n, err = crawl.DeleteCrawls(tx, ids)
return err
})
if errors.Is(err, crawl.ErrDeleteRunning) {
return 0, nil, &apiError{Status: http.StatusForbidden, Body: map[string]any{
"error": "crawls.delete_running",
"message": svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.delete_running", nil),
}}
}
if err != nil {
return 0, nil, err
}
return http.StatusOK, map[string]any{"deleted": n}, nil
})
}
// idList reads a comma-separated id list of at most 500 ids.
func idList(raw string) ([]uint, error) {
var out []uint
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.ParseUint(part, 10, 64)
if err != nil || n == 0 {
return nil, Fail(http.StatusUnprocessableEntity, "ids_invalid")
}
out = append(out, uint(n))
}
if len(out) == 0 || len(out) > 500 {
return nil, Fail(http.StatusUnprocessableEntity, "ids_invalid")
}
return out, nil
}

View File

@@ -2,6 +2,7 @@ package api
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
@@ -15,7 +16,23 @@ import (
// ListIssuesInput is the query of GET /_seo/api/v1/issues.
type ListIssuesInput struct {
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only, e.g. h1_multiple, broken_link, missing_title, redirect_chain or sitemap_blocked"`
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only, e.g. h1_multiple, broken_link, missing_title, content_js, orphan_page, redirect_chain or sitemap_blocked"`
Severity string `json:"severity,omitempty" query:"severity" jsonschema:"error, warning or notice"`
Source string `json:"source,omitempty" query:"source" jsonschema:"crawl (found by the crawler) or cms (CMS data checks)"`
Fix string `json:"fix,omitempty" query:"fix" jsonschema:"tool (fixable through the MCP tool the issue names) or code (needs a change in the site's templates)"`
Status string `json:"status,omitempty" query:"status" jsonschema:"open (the default), ignored or all"`
URL string `json:"url,omitempty" query:"url" jsonschema:"only issues of this address (a path such as /blog/post)"`
Crawl uint `json:"crawl,omitempty" query:"crawl" jsonschema:"the issues of this crawl id instead of the current ones"`
}
// IssueIDInput names one issue.
type IssueIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the issue id"`
}
// IssueIDsInput is the body of the ignore and unignore calls.
type IssueIDsInput struct {
IDs []uint `json:"ids" jsonschema:"the issue ids"`
}
// IssueView is a stored issue in an answer: the Phase 32 keys (kind, url,
@@ -61,8 +78,11 @@ func issueViews(db *gorm.DB, rows []models.Issue) ([]IssueView, error) {
// 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.
// as they are now (crawl selects another crawl's). Each issue names the
// tool that fixes it, or fix code. The filters are severity, kind,
// source, fix, url and status (open by default: ignored issues are left
// out; ignored or all). counts holds the number per kind before the kind
// filter.
func ListIssues(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
@@ -86,15 +106,127 @@ func ListIssues(service func() *classes.Service) http.HandlerFunc {
if err != nil {
return 0, nil, err
}
kind := strings.TrimSpace(q.Get("kind"))
status := strings.TrimSpace(q.Get("status"))
if status == "" {
status = models.IssueOpen
}
match := map[string]string{
"kind": strings.TrimSpace(q.Get("kind")), "severity": strings.TrimSpace(q.Get("severity")),
"source": strings.TrimSpace(q.Get("source")), "fix": strings.TrimSpace(q.Get("fix")),
"url": strings.TrimSpace(q.Get("url")),
}
counts := map[string]int{}
out := make([]IssueView, 0, len(views))
for _, v := range views {
if status == models.IssueOpen && v.Ignored || status == models.IssueIgnored && !v.Ignored {
continue
}
if !matches(match["severity"], v.Severity) || !matches(match["source"], v.Source) || !matches(match["fix"], v.Fix) || !matches(match["url"], v.URL) {
continue
}
counts[v.Kind]++
if kind == "" || v.Kind == kind {
if matches(match["kind"], v.Kind) {
out = append(out, v)
}
}
return http.StatusOK, map[string]any{"data": out, "counts": counts, "total": len(out)}, nil
})
}
// matches reports whether a filter value (empty: any) matches.
func matches(filter, value string) bool { return filter == "" || filter == value }
// RecheckIssue serves POST /_seo/api/v1/issues/{id}/recheck: the issue's
// address is fetched again through the crawler client and its issues are
// replaced; the answer has the address, the issues left and the message.
func RecheckIssue(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
id, err := pathID(r, "id")
if err != nil {
return 0, nil, err
}
var res crawl.RecheckResult
err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
issue, err := crawl.LoadIssue(tx, id)
if err != nil {
return err
}
res, err = crawl.Recheck(ctx, svc, tx, issue)
return err
})
if errors.Is(err, crawl.ErrNoSiteURL) {
return 0, nil, StartRefusal(ctx, svc, err)
}
if err != nil {
return 0, nil, err
}
msg := svc.PhraseCtx(ctx, "golem15.seo::lang.issues.recheck_fixed", nil)
if res.Remaining > 0 {
msg = svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.rechecked", res.Remaining, nil)
}
return http.StatusOK, map[string]any{"url": res.URL, "remaining": res.Remaining, "message": msg}, nil
})
}
// IgnoreIssues serves POST /_seo/api/v1/issues/ignore: the issues are
// hidden in this and later crawls until they are no longer ignored.
func IgnoreIssues(service func() *classes.Service) http.HandlerFunc {
return ignoreHandler(service, true)
}
// UnignoreIssues serves POST /_seo/api/v1/issues/unignore: the issues are
// shown in the audit again.
func UnignoreIssues(service func() *classes.Service) http.HandlerFunc {
return ignoreHandler(service, false)
}
func ignoreHandler(service func() *classes.Service, ignore bool) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
var in IssueIDsInput
if err := decode(r, maxBody, &in); err != nil {
return 0, nil, err
}
if len(in.IDs) == 0 || len(in.IDs) > 1000 {
return 0, nil, Fail(http.StatusUnprocessableEntity, "ids_invalid")
}
n := 0
err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error {
var rows []models.Issue
if err := tx.Where("id IN ?", in.IDs).Find(&rows).Error; err != nil {
return err
}
if len(rows) != len(uniqueIDs(in.IDs)) {
return gorm.ErrRecordNotFound
}
var err error
if ignore {
adminID, _, _ := svc.Actor(ctx, tx)
n, err = crawl.Ignore(tx, rows, adminID)
} else {
n, err = crawl.Unignore(tx, rows)
}
return err
})
if err != nil {
return 0, nil, err
}
key, field := "golem15.seo::lang.issues.unignored", "unignored"
if ignore {
key, field = "golem15.seo::lang.issues.ignored", "ignored"
}
return http.StatusOK, map[string]any{field: n, "message": svc.ChoiceCtx(ctx, key, n, nil)}, nil
})
}
// uniqueIDs drops repeated ids.
func uniqueIDs(ids []uint) []uint {
seen := map[uint]bool{}
var out []uint
for _, id := range ids {
if !seen[id] {
seen[id] = true
out = append(out, id)
}
}
return out
}

350
controllers/crawls.go Normal file
View File

@@ -0,0 +1,350 @@
package controllers
import (
"context"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
"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"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// CrawlsControllerID is the Crawls screen (UI-SPEC C24).
const CrawlsControllerID = "golem15.seo.crawls"
// CrawlActionCancel is the preview's Cancel crawl.
const CrawlActionCancel = "cancel"
// errCrawlReadOnly refuses creating or editing crawls.
var errCrawlReadOnly = errors.New("golem15.seo: crawls are read-only")
// crawlsAdminController lists the crawls, newest first, and previews one
// with P6 and Cancel while it is queued or running; the toolbar starts a
// crawl and deletes the selected ones (a running crawl refuses).
type crawlsAdminController struct {
app func() *backpack.App
}
func (crawlsAdminController) ID() string { return CrawlsControllerID }
func (crawlsAdminController) ModelName() string { return `Golem15\SEO\Models\Crawl` }
func (crawlsAdminController) ConfigDir() string { return "controllers/crawls" }
func (crawlsAdminController) NewRecord() any { return &models.Crawl{} }
func (crawlsAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// FormBeforeCreate refuses: crawls are started with Start crawl.
func (crawlsAdminController) FormBeforeCreate(context.Context, any) error { return errCrawlReadOnly }
// FormBeforeUpdate refuses: crawls are never edited.
func (crawlsAdminController) FormBeforeUpdate(context.Context, any) error { return errCrawlReadOnly }
// FormBeforeDelete refuses a queued or running crawl with
// crawls.delete_running (403).
func (c crawlsAdminController) FormBeforeDelete(ctx context.Context, model any) error {
row, ok := model.(*models.Crawl)
if ok && row != nil && row.Active() {
return &cabana.ForbiddenError{Message: Phrase(ctx, translator(c.app), "golem15.seo::lang.crawls.delete_running", nil)}
}
return nil
}
func (c crawlsAdminController) service() *classes.Service {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil
}
return svc
}
// AdminActions is the toolbar's Start crawl.
func (c crawlsAdminController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{startCrawl(c.app)}
}
// AdminRecordActions is Cancel crawl while queued or running; the pages
// and issues fetched so far are kept.
func (c crawlsAdminController) AdminRecordActions() []pact.AdminRecordAction {
return []pact.AdminRecordAction{{
Name: CrawlActionCancel,
Label: "golem15.seo::lang.crawls.cancel",
Confirm: "golem15.seo::lang.crawls.cancel_confirm",
Permissions: []string{PermissionManage},
Applies: func(_ context.Context, record any) (bool, error) {
row, ok := record.(*models.Crawl)
return ok && row != nil && row.Active(), nil
},
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
row, ok := in.Record.(*models.Crawl)
svc := c.service()
if !ok || row == nil || svc == nil {
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
_, name, _ := svc.Actor(ctx, db)
if err := crawl.Cancel(ctx, svc, db, *row, name); err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: "golem15.seo::lang.crawls.cancelled"}, nil
},
}}
}
// statusKeys are the phrase keys of the crawl states.
var statusKeys = map[string]string{
models.CrawlQueued: "golem15.seo::lang.crawls.st_queued",
models.CrawlRunning: "golem15.seo::lang.crawls.st_running",
models.CrawlFinished: "golem15.seo::lang.crawls.st_finished",
models.CrawlFailed: "golem15.seo::lang.crawls.st_failed",
models.CrawlCancelled: "golem15.seo::lang.crawls.st_cancelled",
}
// TriggerLabel is the Started by cell: Manual: name, Schedule or MCP:
// client.
func TriggerLabel(ctx context.Context, svc *classes.Service, row models.Crawl) string {
switch row.Trigger {
case models.TriggerSchedule:
return svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.by_schedule", nil)
case models.TriggerMCP:
return svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.by_mcp", map[string]string{"client": row.TriggerName})
}
return svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.by_admin", map[string]string{"name": row.TriggerName})
}
// Duration is a crawl's duration, e.g. "4 min 12 s"; "—" before it ends.
func Duration(row models.Crawl) string {
if row.StartedAt == nil {
return "—"
}
end := time.Now().UTC()
if row.FinishedAt != nil {
end = *row.FinishedAt
}
d := end.Sub(*row.StartedAt).Round(time.Second)
if d < 0 {
d = 0
}
h, m, s := int(d.Hours()), int(d.Minutes())%60, int(d.Seconds())%60
switch {
case h > 0:
return fmt.Sprintf("%d h %d min", h, m)
case m > 0:
return fmt.Sprintf("%d min %d s", m, s)
}
return fmt.Sprintf("%d s", s)
}
// issueCounts counts the issues of crawls.
func issueCounts(db *gorm.DB, ids []uint) (map[uint]int, error) {
out := map[uint]int{}
if len(ids) == 0 {
return out, nil
}
var rows []struct {
CrawlID uint
N int
}
if err := db.Raw(`SELECT crawl_id, COUNT(*) AS n FROM golem15_seo_issues WHERE crawl_id IN ? GROUP BY crawl_id`, ids).Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
out[r.CrawlID] = r.N
}
return out, nil
}
// ListExtendRecords shows the state, pages (with the limit note), issue
// count, trigger and duration of each crawl in the request locale.
func (c crawlsAdminController) ListExtendRecords(ctx context.Context, db *gorm.DB, records []any) error {
svc := c.service()
if svc == nil {
return nil
}
var ids []uint
for _, r := range records {
if row, ok := r.(*models.Crawl); ok && row != nil {
ids = append(ids, row.ID)
}
}
counts, err := issueCounts(db.WithContext(ctx), ids)
if err != nil {
return err
}
for _, r := range records {
row, ok := r.(*models.Crawl)
if !ok || row == nil {
continue
}
row.PagesCell = strconv.Itoa(row.Pages)
if row.LimitHit {
row.PagesCell = svc.PhraseCtx(ctx, "golem15.seo::lang.crawls.pages_limit", map[string]string{"count": strconv.Itoa(row.Pages)})
}
row.IssuesCell = strconv.Itoa(counts[row.ID])
row.DurationCell = Duration(*row)
row.TriggerCell = TriggerLabel(ctx, svc, *row)
if key, ok := statusKeys[row.Status]; ok {
row.Status = svc.PhraseCtx(ctx, key, nil)
}
}
return nil
}
// DropdownOptions labels the preview's read-only status and trigger.
func (crawlsAdminController) DropdownOptions(field string) []pact.Option {
switch field {
case "status":
out := make([]pact.Option, 0, len(models.CrawlStatuses))
for _, s := range models.CrawlStatuses {
out = append(out, pact.Option{Value: s, Label: statusKeys[s]})
}
return out
case "trigger":
return []pact.Option{
{Value: models.TriggerAdmin, Label: "golem15.seo::lang.crawls.trigger_admin"},
{Value: models.TriggerSchedule, Label: "golem15.seo::lang.crawls.trigger_schedule"},
{Value: models.TriggerMCP, Label: "golem15.seo::lang.crawls.trigger_mcp"},
}
}
return nil
}
// FilterOptions serves the status filter.
func (c crawlsAdminController) FilterOptions(scope string) []pact.Option {
if scope != models.FilterCrawlStatus {
return nil
}
return c.DropdownOptions("status")
}
// PartialData serves P6 crawl_summary (UI-SPEC C24).
func (c crawlsAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
row, ok := record.(*models.Crawl)
svc := c.service()
if name != "crawl_summary" || !ok || row == nil || svc == nil {
return classes.CrawlSummaryView{}, nil
}
db, err := database(ctx, c.app)
if err != nil {
return nil, err
}
return CrawlSummary(ctx, svc, db, c.app, *row)
}
// CrawlSummary builds P6: one status callout, the staging and rendered
// comparison lines, the stats strip and the per-kind table whose counts
// link to the Site audit list filtered by the kind.
func CrawlSummary(ctx context.Context, svc *classes.Service, db *gorm.DB, app func() *backpack.App, row models.Crawl) (classes.CrawlSummaryView, error) {
view := classes.CrawlSummaryView{}
p := func(key string, params map[string]string) string {
return svc.PhraseCtx(ctx, "golem15.seo::lang.crawls."+key, params)
}
stamp := func(t *time.Time) string {
if t == nil {
return "—"
}
return t.UTC().Format("2006-01-02 15:04 UTC")
}
switch row.Status {
case models.CrawlRunning:
view.Title = p("running_title", nil)
view.Text = p("running_text", map[string]string{"done": strconv.Itoa(row.Pages), "limit": strconv.Itoa(row.MaxPages)})
case models.CrawlQueued:
view.Text = p("queued_text", nil)
case models.CrawlFailed:
view.Tone = "danger"
view.Title = p("failed_title", nil)
view.Text = p("failed_text", map[string]string{"error": row.Error})
case models.CrawlCancelled:
view.Text = p("cancelled_text", map[string]string{"name": row.CancelledBy, "time": stamp(row.FinishedAt)})
case models.CrawlFinished:
if row.LimitHit {
view.Tone = "warning"
view.Text = p("limit_text", map[string]string{"limit": strconv.Itoa(row.MaxPages)})
}
}
view.HasCallout = view.Text != ""
if svc.Staging() {
view.Staging = p("staging_text", nil)
}
switch row.RenderStatus {
case models.RenderDone:
view.Render = svc.ChoiceCtx(ctx, "golem15.seo::lang.crawls.render_done", row.RenderPages, nil)
case models.RenderFailed:
view.Render = p("render_failed", map[string]string{"error": row.RenderNote})
default:
view.Render = p("render_skipped", nil)
}
var rows []struct {
Kind string
Severity string
N int
}
if err := db.Raw(`SELECT kind, severity, COUNT(*) AS n FROM golem15_seo_issues WHERE crawl_id = ? GROUP BY kind, severity`, row.ID).Scan(&rows).Error; err != nil {
return view, err
}
sevCount := map[string]int{}
prefix, err := cabana.AdminPrefix(appOf(app))
if err != nil {
prefix = cabana.DefaultAdminPrefix
}
for _, r := range rows {
sevCount[r.Severity] += r.N
view.Kinds = append(view.Kinds, classes.CrawlKindRow{
Kind: r.Kind,
Label: svc.PhraseCtx(ctx, "golem15.seo::lang.issues.kind_"+r.Kind, nil),
Severity: svc.PhraseCtx(ctx, labelKeys[r.Severity], nil),
Count: r.N,
Link: prefix + "/golem15/seo/issues?" + url.Values{"filter[kind]": {r.Kind}}.Encode(),
})
}
rank := map[string]int{}
for _, r := range rows {
rank[r.Kind] = models.SeverityRank(r.Severity)
}
sort.SliceStable(view.Kinds, func(i, j int) bool {
a, b := view.Kinds[i], view.Kinds[j]
if rank[a.Kind] != rank[b.Kind] {
return rank[a.Kind] < rank[b.Kind]
}
if a.Count != b.Count {
return a.Count > b.Count
}
return a.Kind < b.Kind
})
view.NoIssues = len(view.Kinds) == 0 && !row.Active()
view.Stats = []classes.CrawlStat{
{Label: p("stat_pages", nil), Value: strconv.Itoa(row.Pages)},
{Label: p("stat_errors", nil), Value: strconv.Itoa(sevCount[models.SeverityError])},
{Label: p("stat_warnings", nil), Value: strconv.Itoa(sevCount[models.SeverityWarning])},
{Label: p("stat_notices", nil), Value: strconv.Itoa(sevCount[models.SeverityNotice])},
{Label: p("stat_duration", nil), Value: Duration(row)},
}
return view, nil
}
var (
_ pact.AdminController = crawlsAdminController{}
_ pact.AdminPermissioned = crawlsAdminController{}
_ pact.AdminRecordSource = crawlsAdminController{}
_ pact.FormBeforeCreate = crawlsAdminController{}
_ pact.FormBeforeUpdate = crawlsAdminController{}
_ pact.FormBeforeDelete = crawlsAdminController{}
_ pact.HasAdminActions = crawlsAdminController{}
_ pact.HasAdminRecordActions = crawlsAdminController{}
_ pact.ListExtendRecords = crawlsAdminController{}
_ pact.DropdownOptionsProvider = crawlsAdminController{}
_ pact.FilterOptions = crawlsAdminController{}
_ pact.AdminPartialData = crawlsAdminController{}
)

View File

@@ -0,0 +1,31 @@
{{- with .Data -}}
{{- if .HasCallout -}}
<div class="summer-callout{{ if .Tone }} summer-callout--{{ .Tone }}{{ end }}" role="status">
{{- if .Title -}}<p class="summer-callout__title">{{ .Title }}</p>{{- end -}}
<p class="summer-callout__text">{{ .Text }}</p>
</div>
{{- end -}}
{{- if .Staging -}}<p>{{ .Staging }}</p>{{- end -}}
{{- if .Render -}}<p>{{ .Render }}</p>{{- end -}}
{{- if .Stats -}}
<dl class="summer-stats">
{{- range .Stats -}}
<div class="summer-stat"><dt class="summer-stat__label">{{ .Label }}</dt><dd class="summer-stat__value">{{ .Value }}</dd></div>
{{- end -}}
</dl>
{{- end -}}
{{- if .Kinds -}}
<div class="summer-table-wrap">
<table class="summer-table">
<thead><tr><th>{{ trans "golem15.seo::lang.crawls.kinds_problem" }}</th><th>{{ trans "golem15.seo::lang.crawls.kinds_severity" }}</th><th class="summer-table__num">{{ trans "golem15.seo::lang.crawls.kinds_count" }}</th></tr></thead>
<tbody>
{{- range .Kinds -}}
<tr><td>{{ .Label }}</td><td>{{ .Severity }}</td><td class="summer-table__num"><a href="{{ .Link }}">{{ .Count }}</a></td></tr>
{{- end -}}
</tbody>
</table>
</div>
{{- else if .NoIssues -}}
<p>{{ trans "golem15.seo::lang.crawls.no_issues" }}</p>
{{- end -}}
{{- end -}}

View File

@@ -0,0 +1,11 @@
# Crawls filters (UI-SPEC C24): status and start date.
scopes:
status:
label: golem15.seo::lang.crawls.status
modelClass: Golem15\SEO\Models\Crawl
nameFrom: status
scope: FilterCrawlStatus
started_at:
label: golem15.seo::lang.crawls.started_at
type: daterange
column: started_at

View File

@@ -0,0 +1,14 @@
# Crawl preview (UI-SPEC C24): read-only, P6 above the fields and Cancel
# while the crawl is queued or running.
name: golem15.seo::lang.crawls.name
form: ~/plugins/golem15/seo/models/crawl/fields.yaml
modelClass: Golem15\SEO\Models\Crawl
defaultRedirect: golem15/seo/crawls
preview:
headerPartial: crawl_summary
recordActions: [cancel]
messages:
preview: golem15.seo::lang.crawls.preview_title

View File

@@ -0,0 +1,22 @@
# Crawls (UI-SPEC C24): newest first; the toolbar starts a crawl and
# deletes the selected ones (a running crawl refuses).
list: ~/plugins/golem15/seo/models/crawl/columns.yaml
modelClass: Golem15\SEO\Models\Crawl
title: golem15.seo::lang.crawls.list_title
recordUrl: golem15/seo/crawls/preview/:id
noRecordsMessage: golem15.seo::lang.crawls.list_empty
recordsPerPage: 20
showCheckboxes: true
showSetup: true
filter: config_filter.yaml
defaultSort:
column: started_at
direction: desc
toolbar:
buttons: [start_crawl, delete]
messages:
empty: golem15.seo::lang.crawls.list_empty
deleteConfirm: golem15.seo::lang.crawls.delete_selected_confirm

505
controllers/issues.go Normal file
View File

@@ -0,0 +1,505 @@
package controllers
import (
"context"
"errors"
"fmt"
"strings"
"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"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// IssuesControllerID is the Site audit (UI-SPEC C23).
const IssuesControllerID = "golem15.seo.issues"
// The Site audit's actions: the toolbar's start, the record actions and
// the bulk ignore.
const (
ActionStartCrawl = "start_crawl"
IssueActionCheck = "recheck"
IssueActionIgnore = "ignore"
IssueActionShow = "unignore"
)
// errIssueReadOnly refuses creating or editing issues: the audit writes
// them.
var errIssueReadOnly = errors.New("golem15.seo: issues are read-only")
// issuesAdminController lists the audit's current issues, errors first,
// and previews one with P7 and its record actions (check again, ignore,
// stop ignoring); the toolbar starts a crawl and the bulk action ignores.
type issuesAdminController struct {
app func() *backpack.App
}
func (issuesAdminController) ID() string { return IssuesControllerID }
func (issuesAdminController) ModelName() string { return `Golem15\SEO\Models\Issue` }
func (issuesAdminController) ConfigDir() string { return "controllers/issues" }
func (issuesAdminController) NewRecord() any { return &models.Issue{} }
func (issuesAdminController) RequiredPermissions() []string {
return []string{PermissionManage}
}
// FormBeforeCreate refuses: the audit writes issues.
func (issuesAdminController) FormBeforeCreate(context.Context, any) error { return errIssueReadOnly }
// FormBeforeUpdate refuses: issues are never edited.
func (issuesAdminController) FormBeforeUpdate(context.Context, any) error { return errIssueReadOnly }
func (c issuesAdminController) service() *classes.Service {
svc, err := classes.NewService(appOf(c.app))
if err != nil {
return nil
}
return svc
}
// ListExtendQuery narrows the list to the current issues (the newest
// crawl's; before any crawl the CMS-data issues, checked now) and puts
// errors first, then warnings, then notices.
func (c issuesAdminController) ListExtendQuery(ctx context.Context, q *gorm.DB) *gorm.DB {
svc := c.service()
db, err := database(ctx, c.app)
if svc == nil || err != nil {
return q.Where("1 = 0")
}
latest, err := crawl.Current(ctx, svc, db)
if err != nil {
log.Warn("golem15.seo: site audit list", "error", err)
return q.Where("1 = 0")
}
return crawl.CurrentScope(q, latest).Order("golem15_seo_issues.rank ASC")
}
// startCrawl is the toolbar's Start crawl (also on Crawls): it refuses with
// crawls.already_running or crawls.no_site_url as an action error and
// toasts crawls.started.
func startCrawl(app func() *backpack.App) pact.AdminAction {
return pact.AdminAction{
Name: ActionStartCrawl,
Label: "golem15.seo::lang.crawls.start",
Permissions: []string{PermissionManage},
Run: func(ctx context.Context, _ pact.AdminActionInput) (pact.AdminActionResult, error) {
svc, err := classes.NewService(appOf(app))
if err != nil {
return pact.AdminActionResult{}, err
}
db, err := database(ctx, app)
if err != nil {
return pact.AdminActionResult{}, err
}
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
_, name, _ := svc.Actor(ctx, tx)
_, err := crawl.Start(ctx, svc, tx, models.TriggerAdmin, name)
return err
})
if err != nil {
return pact.AdminActionResult{}, startRefusal(ctx, app, err)
}
return pact.AdminActionResult{Message: "golem15.seo::lang.crawls.started"}, nil
},
}
}
// startRefusal turns a refused start into the admin's action error.
func startRefusal(ctx context.Context, app func() *backpack.App, err error) error {
tr := translator(app)
var running *crawl.AlreadyRunningError
switch {
case errors.As(err, &running):
return &cabana.ForbiddenError{Message: Phrase(ctx, tr, "golem15.seo::lang.crawls.already_running", map[string]string{"time": running.Since().UTC().Format("2006-01-02 15:04 UTC")})}
case errors.Is(err, crawl.ErrNoSiteURL):
return &cabana.ForbiddenError{Message: Phrase(ctx, tr, "golem15.seo::lang.crawls.no_site_url", nil)}
}
return err
}
// AdminActions is the toolbar's Start crawl.
func (c issuesAdminController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{startCrawl(c.app)}
}
// ignored reports whether a record is an ignored issue.
func (c issuesAdminController) ignored(ctx context.Context, record any) (bool, error) {
issue, ok := record.(*models.Issue)
if !ok || issue == nil {
return false, nil
}
db, err := database(ctx, c.app)
if err != nil {
return false, err
}
return crawl.IsIgnored(db, *issue)
}
// AdminRecordActions are the preview's Check this address again and Ignore
// (while open) and Stop ignoring (while ignored).
func (c issuesAdminController) AdminRecordActions() []pact.AdminRecordAction {
open := func(ctx context.Context, record any) (bool, error) {
ig, err := c.ignored(ctx, record)
return !ig, err
}
return []pact.AdminRecordAction{
{
Name: IssueActionCheck,
Label: "golem15.seo::lang.issues.recheck",
Confirm: "golem15.seo::lang.issues.recheck_confirm",
Permissions: []string{PermissionManage},
Applies: open,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
issue, ok := in.Record.(*models.Issue)
svc := c.service()
if !ok || issue == nil || svc == nil {
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
res, err := crawl.Recheck(ctx, svc, db, *issue)
if err != nil {
if errors.Is(err, crawl.ErrNoSiteURL) {
return pact.AdminRecordActionResult{}, &cabana.ForbiddenError{Message: Phrase(ctx, translator(c.app), "golem15.seo::lang.crawls.no_site_url", nil)}
}
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: RecheckMessage(ctx, svc, res)}, nil
},
},
{
Name: IssueActionIgnore,
Label: "golem15.seo::lang.issues.ignore",
Confirm: "golem15.seo::lang.issues.ignore_confirm",
Permissions: []string{PermissionManage},
Applies: open,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return c.ignore(ctx, []any{in.Record})
},
},
{
Name: IssueActionShow,
Label: "golem15.seo::lang.issues.unignore",
Confirm: "golem15.seo::lang.issues.unignore_confirm",
Permissions: []string{PermissionManage},
Applies: c.ignored,
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
issue, ok := in.Record.(*models.Issue)
if !ok || issue == nil {
return pact.AdminRecordActionResult{}, classes.ErrNotBooted
}
db, err := database(ctx, c.app)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
n, err := crawl.Unignore(db, []models.Issue{*issue})
if err != nil {
return pact.AdminRecordActionResult{}, err
}
svc := c.service()
return pact.AdminRecordActionResult{Message: svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.unignored", n, nil)}, nil
},
},
}
}
// RecheckMessage is the toast of a recheck: issues.rechecked with the
// remaining count, or issues.recheck_fixed.
func RecheckMessage(ctx context.Context, svc *classes.Service, res crawl.RecheckResult) string {
if res.Remaining == 0 {
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.recheck_fixed", nil)
}
return svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.rechecked", res.Remaining, nil)
}
// ignore stores the fingerprints of records as ignored.
func (c issuesAdminController) ignore(ctx context.Context, records []any) (pact.AdminRecordActionResult, error) {
svc := c.service()
db, err := database(ctx, c.app)
if err != nil || svc == nil {
return pact.AdminRecordActionResult{}, errors.Join(err, classes.ErrNotBooted)
}
var list []models.Issue
for _, r := range records {
if issue, ok := r.(*models.Issue); ok && issue != nil {
list = append(list, *issue)
}
}
adminID, _, _ := svc.Actor(ctx, db)
n, err := crawl.Ignore(db, list, adminID)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
return pact.AdminRecordActionResult{Message: svc.ChoiceCtx(ctx, "golem15.seo::lang.issues.ignored", n, nil)}, nil
}
// AdminBulkActions is the list's bulk Ignore issues.
func (c issuesAdminController) AdminBulkActions() []pact.AdminBulkAction {
return []pact.AdminBulkAction{{
Name: IssueActionIgnore,
Label: "golem15.seo::lang.issues.ignore_selected",
Confirm: "golem15.seo::lang.issues.ignore_selected_confirm",
Permissions: []string{PermissionManage},
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
res, err := c.ignore(ctx, in.Records)
if err != nil {
return pact.AdminBulkActionResult{}, err
}
return pact.AdminBulkActionResult{Message: res.Message, Affected: len(in.Records)}, nil
},
}}
}
// ListRowStates marks ignored issues disabled (issues.state_ignored).
func (issuesAdminController) ListRowStates(ctx context.Context, db *gorm.DB, records []any) ([][]pact.RowState, error) {
var fps []string
for _, r := range records {
if issue, ok := r.(*models.Issue); ok && issue != nil {
fps = append(fps, issue.Fingerprint)
}
}
set, err := crawl.IgnoredSet(db.WithContext(ctx), fps)
if err != nil {
return nil, err
}
out := make([][]pact.RowState, len(records))
for i, r := range records {
if issue, ok := r.(*models.Issue); ok && issue != nil && set[issue.Fingerprint] {
out[i] = []pact.RowState{pact.RowStateDisabled}
}
}
return out, nil
}
// labelKeys are the phrase keys of the stored severity and source values.
var labelKeys = map[string]string{
models.SeverityError: "golem15.seo::lang.issues.sev_error",
models.SeverityWarning: "golem15.seo::lang.issues.sev_warning",
models.SeverityNotice: "golem15.seo::lang.issues.sev_notice",
models.SourceCrawl: "golem15.seo::lang.issues.source_crawl",
models.SourceCMS: "golem15.seo::lang.issues.source_cms",
}
// FixCell is the Fix column: issues.fix_tool with the tool, or
// issues.fix_code.
func FixCell(ctx context.Context, svc *classes.Service, issue models.Issue) string {
if issue.Fix == models.FixTool {
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_tool", map[string]string{"tool": issue.Tool})
}
return svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_code", nil)
}
// ListExtendRecords shows the severity, kind and source labels, the
// one-line summary and the fix route in the request locale.
func (c issuesAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error {
svc := c.service()
if svc == nil {
return nil
}
for _, r := range records {
issue, ok := r.(*models.Issue)
if !ok || issue == nil {
continue
}
issue.Summary = svc.IssueSummary(ctx, issue.Kind, issue.DetailsMap())
issue.FixCell = FixCell(ctx, svc, *issue)
issue.KindCell = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.kind_"+issue.Kind, nil)
if key, ok := labelKeys[issue.Severity]; ok {
issue.Severity = svc.PhraseCtx(ctx, key, nil)
}
if key, ok := labelKeys[issue.Source]; ok {
issue.Source = svc.PhraseCtx(ctx, key, nil)
}
}
return nil
}
// DropdownOptions labels the preview's read-only kind, severity and
// source fields.
func (issuesAdminController) DropdownOptions(field string) []pact.Option {
switch field {
case "kind":
out := make([]pact.Option, 0, len(crawl.Kinds()))
for _, k := range crawl.Kinds() {
out = append(out, pact.Option{Value: k, Label: "golem15.seo::lang.issues.kind_" + k})
}
return out
case "severity":
return []pact.Option{
{Value: models.SeverityError, Label: labelKeys[models.SeverityError]},
{Value: models.SeverityWarning, Label: labelKeys[models.SeverityWarning]},
{Value: models.SeverityNotice, Label: labelKeys[models.SeverityNotice]},
}
case "source":
return []pact.Option{
{Value: models.SourceCrawl, Label: labelKeys[models.SourceCrawl]},
{Value: models.SourceCMS, Label: labelKeys[models.SourceCMS]},
}
}
return nil
}
// FilterOptions serves the severity, kind, source, fix and status filters.
func (c issuesAdminController) FilterOptions(scope string) []pact.Option {
switch scope {
case models.FilterIssueSeverity:
return c.DropdownOptions("severity")
case models.FilterIssueKind:
return c.DropdownOptions("kind")
case models.FilterIssueSource:
return c.DropdownOptions("source")
case models.FilterIssueFix:
return []pact.Option{
{Value: models.FixTool, Label: "golem15.seo::lang.issues.filter_fix_tool"},
{Value: models.FixCode, Label: "golem15.seo::lang.issues.filter_fix_code"},
}
case models.FilterIssueStatus:
return []pact.Option{
{Value: models.IssueOpen, Label: "golem15.seo::lang.issues.status_open"},
{Value: models.IssueIgnored, Label: "golem15.seo::lang.issues.status_ignored"},
}
}
return nil
}
// maxLinkedShown caps P7's linking pages before issues.and_more.
const maxLinkedShown = 10
// PartialData serves P7 issue_details (UI-SPEC C23).
func (c issuesAdminController) PartialData(ctx context.Context, name string, record any) (any, error) {
issue, ok := record.(*models.Issue)
svc := c.service()
if name != "issue_details" || !ok || issue == nil || svc == nil {
return classes.IssueDetailsView{}, nil
}
return IssueDetails(ctx, svc, c.app, *issue), nil
}
// IssueDetails builds P7: the kind's found rows, the linking pages, the
// orphan suggestions, how to fix it and the page address.
func IssueDetails(ctx context.Context, svc *classes.Service, app func() *backpack.App, issue models.Issue) classes.IssueDetailsView {
d := issue.DetailsMap()
view := classes.IssueDetailsView{}
label := func(key string) string { return svc.PhraseCtx(ctx, "golem15.seo::lang.issues."+key, nil) }
view.Rows = append(view.Rows, classes.IssueRow{Label: label("summary"), Value: svc.IssueSummary(ctx, issue.Kind, d)})
add := func(labelKey string, value any, mono bool) {
if value == nil {
return
}
text := fmt.Sprint(value)
if list, ok := value.([]any); ok {
parts := make([]string, 0, len(list))
for _, e := range list {
switch x := e.(type) {
case map[string]any:
var kv []string
for _, k := range []string{"type", "property", "text", "target", "locale", "url", "status", "from", "to"} {
if v, ok := x[k]; ok {
kv = append(kv, fmt.Sprint(v))
}
}
parts = append(parts, strings.Join(kv, " · "))
default:
parts = append(parts, fmt.Sprint(x))
}
}
text = strings.Join(parts, "; ")
}
if strings.TrimSpace(text) == "" {
return
}
view.Rows = append(view.Rows, classes.IssueRow{Label: label(labelKey), Value: text, Mono: mono})
}
add("detail_h1", d["h1"], false)
add("detail_skips", d["skips"], false)
add("detail_missing", d["missing"], false)
add("detail_error", d["error"], true)
add("detail_anchors", d["anchors"], false)
add("detail_alternates", d["alternates"], true)
add("detail_images", d["images"], true)
add("detail_target", d["target"], true)
add("detail_what", d["what"], false)
if from, ok := d["from"].([]any); ok && issue.Kind != classes.IssueImageAlt {
if _, isLevel := d["to"]; !isLevel {
for i, f := range from {
if i >= maxLinkedShown {
break
}
view.LinkedFrom = append(view.LinkedFrom, classes.IssueLink{URL: fmt.Sprint(f)})
}
total := len(from)
if n, ok := d["count"].(float64); ok && int(n) > total {
total = int(n)
}
if total > len(view.LinkedFrom) {
view.More = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.and_more", map[string]string{"count": fmt.Sprint(total - len(view.LinkedFrom))})
}
}
}
if issue.Kind == crawl.KindOrphanPage {
view.Orphan = true
if list, ok := d["suggestions"].([]any); ok {
for _, e := range list {
s, _ := e.(map[string]any)
u := fmt.Sprint(s["url"])
view.Suggestions = append(view.Suggestions, classes.IssueLink{URL: u, Phrase: fmt.Sprint(s["phrase"])})
}
}
}
if issue.Fix == models.FixTool {
view.FixText = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_tool_text", map[string]string{"tool": issue.Tool, "where": fixWhere(ctx, svc, issue)})
} else {
what, _ := d["what"].(string)
view.FixText = svc.PhraseCtx(ctx, "golem15.seo::lang.issues.fix_code_text", map[string]string{"what": what})
}
site := svc.SiteURL()
if strings.HasPrefix(issue.URL, "/") && site != "" {
view.PageURL = site + issue.URL
}
if strings.HasPrefix(issue.Kind, "jsonld_") && view.PageURL != "" {
view.RichResults = true
view.RichURL = "https://search.google.com/test/rich-results?url=" + view.PageURL
}
return view
}
// fixWhere names the admin screen where a CMS tool's value is changed.
func fixWhere(ctx context.Context, svc *classes.Service, issue models.Issue) string {
key := "golem15.seo::lang.issues.where_record"
switch issue.Tool {
case crawl.ToolUpdateRobots:
key = "golem15.seo::lang.issues.where_robots"
case crawl.ToolUpdateSettings:
key = "golem15.seo::lang.issues.where_settings"
case crawl.ToolCreateRedirect, "seo_update_redirect":
key = "golem15.seo::lang.issues.where_redirects"
case crawl.ToolCreatePage, crawl.ToolUpdatePage:
key = "golem15.seo::lang.issues.where_pages"
case "seo_update_rule":
key = "golem15.seo::lang.issues.where_rules"
}
return svc.PhraseCtx(ctx, key, nil)
}
var (
_ pact.AdminController = issuesAdminController{}
_ pact.AdminPermissioned = issuesAdminController{}
_ pact.AdminRecordSource = issuesAdminController{}
_ pact.FormBeforeCreate = issuesAdminController{}
_ pact.FormBeforeUpdate = issuesAdminController{}
_ pact.ListExtendQuery = issuesAdminController{}
_ pact.HasAdminActions = issuesAdminController{}
_ pact.HasAdminRecordActions = issuesAdminController{}
_ pact.HasAdminBulkActions = issuesAdminController{}
_ pact.ListRowStates = issuesAdminController{}
_ pact.ListExtendRecords = issuesAdminController{}
_ pact.DropdownOptionsProvider = issuesAdminController{}
_ pact.FilterOptions = issuesAdminController{}
_ pact.AdminPartialData = issuesAdminController{}
)

View File

@@ -0,0 +1,48 @@
{{- with .Data -}}
{{- if .Rows -}}
<p><strong>{{ trans "golem15.seo::lang.issues.found_heading" }}</strong></p>
<div class="summer-table-wrap">
<table class="summer-table">
<tbody>
{{- range .Rows -}}
<tr><th scope="row">{{ .Label }}</th><td>{{ if .Mono }}<code>{{ .Value }}</code>{{ else }}{{ .Value }}{{ end }}</td></tr>
{{- end -}}
</tbody>
</table>
</div>
{{- end -}}
{{- if .LinkedFrom -}}
<p>{{ trans "golem15.seo::lang.issues.linked_from" }}</p>
<ul>
{{- range .LinkedFrom -}}<li><code>{{ .URL }}</code></li>{{- end -}}
</ul>
{{- if .More -}}<p class="summer-table__sub">{{ .More }}</p>{{- end -}}
{{- end -}}
{{- if .Orphan -}}
<p>{{ trans "golem15.seo::lang.issues.orphan_suggestions" }}</p>
{{- if .Suggestions -}}
<div class="summer-table-wrap">
<table class="summer-table">
<thead><tr><th>{{ trans "golem15.seo::lang.issues.orphan_suggestions_page" }}</th><th>{{ trans "golem15.seo::lang.issues.orphan_suggestions_phrase" }}</th></tr></thead>
<tbody>
{{- range .Suggestions -}}
<tr><td><code>{{ .URL }}</code></td><td>{{ .Phrase }}</td></tr>
{{- end -}}
</tbody>
</table>
</div>
{{- else -}}
<p class="summer-table__sub">{{ trans "golem15.seo::lang.issues.orphan_none" }}</p>
{{- end -}}
{{- end -}}
<div class="summer-callout" role="status">
<p class="summer-callout__title">{{ trans "golem15.seo::lang.issues.fix_heading" }}</p>
<p class="summer-callout__text">{{ .FixText }}</p>
</div>
{{- if .PageURL -}}
<p>{{ trans "golem15.seo::lang.issues.open_page" }}: <code>{{ .PageURL }}</code></p>
{{- end -}}
{{- if .RichResults -}}
<p>{{ trans "golem15.seo::lang.issues.rich_results" }}: <code>{{ .RichURL }}</code></p>
{{- end -}}
{{- end -}}

View File

@@ -0,0 +1,28 @@
# Site audit filters (UI-SPEC C23): severity, kind, source, fix and status
# (Open or Ignored).
scopes:
severity:
label: golem15.seo::lang.issues.filter_severity
modelClass: Golem15\SEO\Models\Issue
nameFrom: severity
scope: FilterIssueSeverity
kind:
label: golem15.seo::lang.issues.filter_kind
modelClass: Golem15\SEO\Models\Issue
nameFrom: kind
scope: FilterIssueKind
source:
label: golem15.seo::lang.issues.filter_source
modelClass: Golem15\SEO\Models\Issue
nameFrom: source
scope: FilterIssueSource
fix:
label: golem15.seo::lang.issues.filter_fix
modelClass: Golem15\SEO\Models\Issue
nameFrom: fix
scope: FilterIssueFix
status:
label: golem15.seo::lang.issues.filter_status
modelClass: Golem15\SEO\Models\Issue
nameFrom: url
scope: FilterIssueStatus

View File

@@ -0,0 +1,14 @@
# Issue preview (UI-SPEC C23): read-only, P7 above the fields and the
# record actions Check this address again, Ignore and Stop ignoring.
name: golem15.seo::lang.issues.name
form: ~/plugins/golem15/seo/models/issue/fields.yaml
modelClass: Golem15\SEO\Models\Issue
defaultRedirect: golem15/seo/issues
preview:
headerPartial: issue_details
recordActions: [recheck, ignore, unignore]
messages:
preview: golem15.seo::lang.issues.preview

View File

@@ -0,0 +1,25 @@
# Site audit (UI-SPEC C23): the current issues, errors first (the
# controller's ListExtendQuery), then by kind; ignored issues badged.
list: ~/plugins/golem15/seo/models/issue/columns.yaml
modelClass: Golem15\SEO\Models\Issue
title: golem15.seo::lang.issues.list_title
recordUrl: golem15/seo/issues/preview/:id
noRecordsMessage: golem15.seo::lang.issues.list_empty
recordsPerPage: 50
showCheckboxes: true
showSetup: true
filter: config_filter.yaml
bulkActions: [ignore]
defaultSort:
column: kind
direction: asc
toolbar:
buttons: [start_crawl]
search:
prompt: backend::lang.list.search_prompt
messages:
empty: golem15.seo::lang.issues.list_empty
rowStateDisabled: golem15.seo::lang.issues.state_ignored

325
crawl_admin_test.go Normal file
View File

@@ -0,0 +1,325 @@
package seo_test
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"strings"
"testing"
seo "git.golem15.com/golem15/sm-seo-plugin"
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
"github.com/goccy/go-yaml"
)
var (
issuesAPI = adminAPI("/golem15/seo/issues")
crawlsAPI = adminAPI("/golem15/seo/crawls")
)
// adminList is a list answer of the admin.
type adminList struct {
Data []map[string]any `json:"data"`
Meta struct {
Total int `json:"total"`
RowStates map[string][]string `json:"row_states"`
} `json:"meta"`
}
func (e *testEnv) adminList(t *testing.T, path, token string) adminList {
t.Helper()
var out adminList
if err := json.Unmarshal(e.expect(t, http.StatusOK, http.MethodGet, path, token, nil).Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
// TestCrawlAdmin walks Site audit and Crawls (UI-SPEC C23, C24): the
// toolbar start and its refusals, the issue list (errors first, labels,
// fix column, ignored rows badged, the status filter), P7, check again,
// ignore and stop ignoring, the crawl list and P6 with its status callout
// and the rendered comparison line, the crawl API's render answer, cancel
// and the refused delete of a running crawl.
func TestCrawlAdmin(t *testing.T) {
env := bootCrawlEnv(t)
token := env.admin(t, true)
site := newFakeSite()
site.sitemap("/", "/p/two-h1", "/p/no-desc", "/p/lonely")
site.plant(
doc{path: "/", title: "Home", links: []string{"/p/two-h1", "/p/no-desc", "/p/gone"}},
doc{path: "/p/two-h1", title: "Two", h1: []string{"Two", "Again"}},
doc{path: "/p/no-desc", title: "No description", noDesc: true},
doc{path: "/p/lonely", title: "Lonely"},
)
site.set("/p/gone", 404, "<html><body>gone</body></html>")
ctx := siteCtx(t, site)
// Start crawl from the toolbar, then a second start refuses.
rec := env.expect(t, http.StatusOK, http.MethodPost, issuesAPI+"/toolbar/start_crawl", token, map[string]any{})
if !strings.Contains(rec.Body.String(), "Crawl started") {
t.Fatalf("start = %s", rec.Body.String())
}
rec = env.call(t, http.MethodPost, crawlsAPI+"/toolbar/start_crawl", token, map[string]any{})
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "A crawl is already running") {
t.Fatalf("second start = %d %s", rec.Code, rec.Body.String())
}
if rec := env.call(t, http.MethodPost, "/_seo/api/v1/crawls", token, map[string]any{}); rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "crawls.already_running") {
t.Fatalf("API second start = %d %s", rec.Code, rec.Body.String())
}
// The queued crawl: P6 queued, render skipped line; deleting it refuses.
crawls := env.adminList(t, crawlsAPI, token)
if len(crawls.Data) != 1 {
t.Fatalf("crawls = %v", crawls.Data)
}
queuedID := fmt.Sprint(crawls.Data[0]["id"])
p6 := env.expect(t, http.StatusOK, http.MethodGet, crawlsAPI+"/partials/crawl_summary?id="+queuedID, token, nil).Body.String()
if !strings.Contains(p6, "Waiting for a free worker") {
t.Fatalf("queued P6 = %s", p6)
}
if rec := env.call(t, http.MethodDelete, crawlsAPI+"/"+queuedID, token, nil); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "A running crawl cannot be deleted") {
t.Fatalf("delete queued = %d %s", rec.Code, rec.Body.String())
}
if rec := env.call(t, http.MethodDelete, "/_seo/api/v1/crawls?ids="+queuedID, token, nil); rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "crawls.delete_running") {
t.Fatalf("API delete queued = %d %s", rec.Code, rec.Body.String())
}
var id uint
fmt.Sscan(queuedID, &id)
env.runCrawl(t, ctx, id)
// Site audit: errors first, labels, fix column.
issues := env.adminList(t, issuesAPI, token)
if len(issues.Data) == 0 {
t.Fatal("no issues in the Site audit list")
}
if first := issues.Data[0]; first["severity"] != "Error" {
t.Fatalf("first row = %v, want an error", first)
}
var lastRank int
for _, row := range issues.Data {
rank := map[string]int{"Error": 1, "Warning": 2, "Notice": 3}[fmt.Sprint(row["severity"])]
if rank < lastRank {
t.Fatalf("rows not errors first: %v", issues.Data)
}
lastRank = rank
}
var h1ID, descID string
for _, row := range issues.Data {
switch {
case row["kind_cell"] == "More than one H1":
h1ID = fmt.Sprint(row["id"])
if row["fix_cell"] != "Site templates (code)" || row["summary"] != "2 H1 headings" || row["source"] != "Crawl" {
t.Fatalf("h1 row = %v", row)
}
case row["kind_cell"] == "Missing meta description":
descID = fmt.Sprint(row["id"])
if row["fix_cell"] != "MCP: seo_set_url_seo" {
t.Fatalf("description row = %v", row)
}
}
}
if h1ID == "" || descID == "" {
t.Fatalf("rows = %v", issues.Data)
}
// P7 of the broken link and of the orphan page.
var goneID, orphanID string
for _, row := range issues.Data {
if row["url"] == "/p/gone" {
goneID = fmt.Sprint(row["id"])
}
if row["url"] == "/p/lonely" && row["kind_cell"] == "Orphan page (no internal links)" {
orphanID = fmt.Sprint(row["id"])
}
}
if orphanID == "" {
t.Fatalf("no orphan row: %v", issues.Data)
}
p7 := env.expect(t, http.StatusOK, http.MethodGet, issuesAPI+"/partials/issue_details?id="+goneID, token, nil).Body.String()
for _, want := range []string{"What was found", "Linked from", "How to fix", "seo_create_redirect", "Open the page", "summer-callout"} {
if !strings.Contains(p7, want) {
t.Fatalf("P7 broken link lacks %q: %s", want, p7)
}
}
if p7 := env.expect(t, http.StatusOK, http.MethodGet, issuesAPI+"/partials/issue_details?id="+orphanID, token, nil).Body.String(); !strings.Contains(p7, "No suggestions") {
t.Fatalf("P7 orphan = %s", p7)
}
// Ignore, row state, status filter, stop ignoring.
env.expect(t, http.StatusOK, http.MethodPost, issuesAPI+"/"+h1ID+"/actions/ignore", token, map[string]any{})
issues = env.adminList(t, issuesAPI, token)
if st := issues.Meta.RowStates[h1ID]; len(st) != 1 || st[0] != "disabled" {
t.Fatalf("ignored row states = %v", issues.Meta.RowStates)
}
open := env.adminList(t, issuesAPI+"?filter[status]=open", token)
for _, row := range open.Data {
if fmt.Sprint(row["id"]) == h1ID {
t.Fatal("an ignored issue in the open filter")
}
}
if rec := env.call(t, http.MethodPost, issuesAPI+"/"+h1ID+"/actions/ignore", token, map[string]any{}); rec.Code != http.StatusConflict {
t.Fatalf("ignore twice = %d", rec.Code)
}
env.expect(t, http.StatusOK, http.MethodPost, issuesAPI+"/"+h1ID+"/actions/unignore", token, map[string]any{})
rec = env.expect(t, http.StatusOK, http.MethodPost, issuesAPI+"/bulk/ignore", token, map[string]any{"ids": []string{h1ID, descID}})
if !strings.Contains(rec.Body.String(), `"affected":2`) {
t.Fatalf("bulk ignore = %s", rec.Body.String())
}
env.expect(t, http.StatusOK, http.MethodPost, issuesAPI+"/"+descID+"/actions/unignore", token, map[string]any{})
// Check again: the description is fixed on the site.
site.plant(doc{path: "/p/no-desc", title: "No description"})
rec = env.callCtx(t, ctx, http.MethodPost, issuesAPI+"/"+descID+"/actions/recheck", token, map[string]any{})
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "no issues remain") {
t.Fatalf("recheck = %d %s", rec.Code, rec.Body.String())
}
rec = env.callCtx(t, ctx, http.MethodPost, "/_seo/api/v1/issues/"+goneID+"/recheck", token, map[string]any{})
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"remaining":1`) || !strings.Contains(rec.Body.String(), "1 issue remains") {
t.Fatalf("API recheck of a still broken link = %d %s", rec.Code, rec.Body.String())
}
// Crawls: the finished crawl's P6 and the API's render answer.
crawls = env.adminList(t, crawlsAPI, token)
row := crawls.Data[0]
if row["status"] != "Finished" || !strings.HasPrefix(fmt.Sprint(row["trigger_cell"]), "Manual: ") || row["pages_cell"] == nil {
t.Fatalf("crawl row = %v", row)
}
p6 = env.expect(t, http.StatusOK, http.MethodGet, crawlsAPI+"/partials/crawl_summary?id="+queuedID, token, nil).Body.String()
for _, want := range []string{"summer-stats", "Pages fetched", "More than one H1", "filter%5Bkind%5D=h1_multiple", "comparison with the JavaScript-rendered pages was skipped", "golem15.seo.crawl_chrome_path"} {
if !strings.Contains(p6, want) {
t.Fatalf("P6 lacks %q: %s", want, p6)
}
}
got := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/crawls/"+queuedID, token, nil).Body.String()
if !jsonContains(got, `"render":{"status":"skipped","pages":0,"message":"The comparison with the JavaScript-rendered pages was skipped`) || !jsonContains(got, `"kinds":[`) {
t.Fatalf("GET crawl = %s", got)
}
// Cancel a queued crawl: it keeps its row, P6 says who cancelled it.
env.expect(t, http.StatusOK, http.MethodPost, issuesAPI+"/toolbar/start_crawl", token, map[string]any{})
crawls = env.adminList(t, crawlsAPI, token)
nextID := fmt.Sprint(crawls.Data[0]["id"])
if nextID == queuedID {
t.Fatalf("crawls not newest first: %v", crawls.Data)
}
env.expect(t, http.StatusOK, http.MethodPost, crawlsAPI+"/"+nextID+"/actions/cancel", token, map[string]any{})
p6 = env.expect(t, http.StatusOK, http.MethodGet, crawlsAPI+"/partials/crawl_summary?id="+nextID, token, nil).Body.String()
if !strings.Contains(p6, "Cancelled by") {
t.Fatalf("cancelled P6 = %s", p6)
}
var next uint
fmt.Sscan(nextID, &next)
env.runCrawl(t, ctx, next)
if c := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/crawls/"+nextID, token, nil).Body.String(); !jsonContains(c, `"status":"cancelled"`) {
t.Fatalf("cancelled crawl after its job = %s", c)
}
// The cancelled crawl is the newest finished one; the audit shows its
// (empty) result, and the earlier crawl can be deleted.
env.expect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/crawls?ids="+queuedID, token, nil)
if list := env.adminList(t, crawlsAPI, token); len(list.Data) != 1 {
t.Fatalf("crawls after delete = %v", list.Data)
}
}
// TestCrawlTools checks the MCP parity of the audit (D-10): the nine
// tools exist, bind to their routes and methods, the reads are GET, the
// delete is destructive, and MCP can start, follow and read a crawl and
// ignore an issue.
func TestCrawlTools(t *testing.T) {
p := &seo.Plugin{}
want := map[string]string{
"start_crawl": "POST /_seo/api/v1/crawls",
"cancel_crawl": "POST /_seo/api/v1/crawls/{id}/cancel",
"list_crawls": "GET /_seo/api/v1/crawls",
"get_crawl": "GET /_seo/api/v1/crawls/{id}",
"delete_crawls": "DELETE /_seo/api/v1/crawls",
"list_issues": "GET /_seo/api/v1/issues",
"recheck_url": "POST /_seo/api/v1/issues/{id}/recheck",
"ignore_issues": "POST /_seo/api/v1/issues/ignore",
"unignore_issues": "POST /_seo/api/v1/issues/unignore",
}
found := 0
for _, tool := range p.MCP().Tools {
route, ok := want[tool.Name]
if !ok {
continue
}
found++
if got := tool.Method + " " + tool.Path; got != route {
t.Errorf("tool %s binds %s, want %s", tool.Name, got, route)
}
if tool.Name == "delete_crawls" && (tool.Destructive == nil || !*tool.Destructive) {
t.Error("delete_crawls is not destructive")
}
}
if found != len(want) {
t.Fatalf("found %d of the %d audit tools", found, len(want))
}
if testing.Short() {
return
}
env := bootCrawlEnv(t)
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
site := newFakeSite()
site.sitemap("/")
site.plant(doc{path: "/", title: "Home", h1: []string{"Home", "Twice"}})
started := env.mcpExpect(t, http.StatusAccepted, http.MethodPost, "/_seo/api/v1/crawls", agent, map[string]any{})
data, _ := started["data"].(map[string]any)
if data["trigger"] != "mcp" {
t.Fatalf("MCP start = %v", started)
}
var id uint
fmt.Sscan(fmt.Sprint(data["id"]), &id)
env.runCrawl(t, siteCtx(t, site), id)
list := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/crawls", agent, nil)
if rows, _ := list["data"].([]any); len(rows) != 1 {
t.Fatalf("list_crawls = %v", list)
}
issues := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/issues?kind=h1_multiple", agent, nil)
rows, _ := issues["data"].([]any)
if len(rows) != 1 {
t.Fatalf("list_issues = %v", issues)
}
issueID := rows[0].(map[string]any)["id"]
env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/issues/ignore", agent, map[string]any{"ids": []any{issueID}})
if open := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/issues?kind=h1_multiple", agent, nil); len(open["data"].([]any)) != 0 {
t.Fatalf("an ignored issue is listed as open: %v", open)
}
if ignored := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/issues?kind=h1_multiple&status=ignored", agent, nil); len(ignored["data"].([]any)) != 1 {
t.Fatalf("status=ignored = %v", ignored)
}
env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/issues/unignore", agent, map[string]any{"ids": []any{issueID}})
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/crawls/"+fmt.Sprint(id)+"/cancel", agent, map[string]any{}); rec.Code != http.StatusConflict {
t.Fatalf("cancel a finished crawl = %d", rec.Code)
}
}
// TestIssueKindLabels checks that every kind the audit stores has its
// issues.kind_<kind> label and issues.sum_<kind> Details cell in both
// phrasebooks (UI-SPEC C23).
func TestIssueKindLabels(t *testing.T) {
for _, locale := range []string{"en", "pl"} {
raw, err := fs.ReadFile(os.DirFS("lang/"+locale), "lang.yaml")
if err != nil {
t.Fatal(err)
}
var book struct {
Issues map[string]any `yaml:"issues"`
}
if err := yaml.Unmarshal(raw, &book); err != nil {
t.Fatal(err)
}
for _, kind := range crawl.Kinds() {
for _, key := range []string{"kind_" + kind, "sum_" + kind} {
if v, ok := book.Issues[key].(string); !ok || v == "" {
t.Errorf("lang/%s: issues.%s is missing", locale, key)
}
}
}
}
}

View File

@@ -1,11 +1,14 @@
package seo_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"sort"
@@ -581,3 +584,28 @@ func auditRenderer(site *fakeSite) *fakeRenderer {
"/p/render-h1": doc{path: "/p/render-h1"}.render(),
}}
}
// callCtx runs one request with ctx (a crawler transport for a recheck).
func (e *testEnv) callCtx(t *testing.T, ctx context.Context, method, path, token string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader).WithContext(ctx)
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", seq.Add(1)%250+1)
req.Header.Set("Accept-Language", "en")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}

View File

@@ -323,7 +323,7 @@ func TestIssueMerge(t *testing.T) {
}
site.plant(doc{path: "/p/fixme", title: "Fix me"})
second := env.crawlOnce(t, token, ctx)
list = env.issues(t, token, "")
list = env.issues(t, token, "status=all")
again := findIssue(t, list, "heading_skipped", "/cat/news")
if !again.Ignored || again.CrawlID == nil || *again.CrawlID != second {
t.Fatalf("ignored issue in the next crawl = %+v", again)

View File

@@ -2,6 +2,8 @@ plugin:
name: SEO
description: Search engine metadata for every content type, URL rules and global SEO settings.
nav:
audit: "Site audit"
crawls: "Crawls"
pages: Pages
robots: robots.txt
notfound: 404 log
@@ -522,10 +524,142 @@ schema:
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:
start: "Start crawl"
list_title: "Crawls"
name: "Crawl"
started_at: "Started"
finished_at: "Finished"
status: "Status"
pages: "Pages"
issues: "Issues"
trigger: "Started by"
duration: "Duration"
site: "Site address"
st_queued: "Queued"
st_running: "Running"
st_finished: "Finished"
st_failed: "Failed"
st_cancelled: "Cancelled"
pages_limit: ":count (limit reached)"
by_admin: "Manual: :name"
by_schedule: "Schedule"
by_mcp: "MCP: :client"
trigger_admin: "Manual"
trigger_schedule: "Schedule"
trigger_mcp: "MCP"
preview_title: "Crawl"
preview: "Crawl of :url"
running_title: "In progress"
running_text: ":done pages fetched so far, up to :limit. Reload the page to see the progress."
queued_text: "Waiting for a free worker. It starts within a minute."
failed_title: "The crawl failed"
failed_text: ":error. Pages fetched before the failure and their issues are kept."
cancelled_text: "Cancelled by :name at :time."
limit_text: "The crawl stopped at the limit of :limit pages. Raise the limit in SEO settings › Site audit to crawl the whole site."
staging_text: "On this environment every page carries noindex from the non-production lock; the crawl does not report it."
render_skipped: "The comparison with the JavaScript-rendered pages was skipped: no Chrome path is configured (golem15.seo.crawl_chrome_path). The raw-HTML checks ran."
render_done:
one: ":count page was compared with its JavaScript-rendered version."
other: ":count pages were compared with their JavaScript-rendered versions."
render_failed: "The comparison with the JavaScript-rendered pages stopped: :error. The raw-HTML checks ran."
stat_pages: "Pages fetched"
stat_errors: "Errors"
stat_warnings: "Warnings"
stat_notices: "Notices"
stat_duration: "Duration"
kinds_problem: "Problem"
kinds_severity: "Severity"
kinds_count: "Addresses"
no_issues: "This crawl found no issues."
cancel: "Cancel crawl"
cancel_confirm: "Cancel this crawl? Pages fetched so far and their issues are kept."
cancelled: "Crawl cancelled."
delete_selected_confirm:
one: "Delete :count crawl? Its pages and issues are deleted with it."
other: "Delete :count crawls? Their pages and issues are deleted with them."
delete_running: "A running crawl cannot be deleted. Cancel it first."
list_empty: "No crawls yet. A crawl fetches the site like a search engine and lists what it finds in SEO › Site audit."
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:
list_title: "Site audit"
name: "Issue"
severity: "Severity"
kind: "Problem"
kind_code: "Problem code"
url: "Address"
summary: "Details"
source: "Found by"
fix: "Fix"
found: "Found"
locale: "Language"
sev_error: "Error"
sev_warning: "Warning"
sev_notice: "Notice"
source_crawl: "Crawl"
source_cms: "CMS data check"
fix_tool: "MCP: :tool"
fix_code: "Site templates (code)"
state_ignored: "Ignored"
filter_severity: "Severity"
filter_kind: "Problem"
filter_source: "Found by"
filter_fix: "Fix"
filter_status: "Status"
filter_fix_tool: "Fixable through MCP"
filter_fix_code: "Needs a code change"
status_open: "Open"
status_ignored: "Ignored"
preview: "Issue details"
found_heading: "What was found"
fix_heading: "How to fix"
fix_tool_text: "Ask your MCP client to fix it with the tool “:tool”, or change it in the admin: :where."
fix_code_text: "This comes from the site's templates, not from CMS data. Ask the site's developer to change :what."
open_page: "Open the page"
rich_results: "Check in Google's Rich Results Test"
linked_from: "Linked from"
orphan_suggestions: "Pages that could link here"
orphan_suggestions_page: "Page"
orphan_suggestions_phrase: "Shared phrase"
orphan_none: "No suggestions: no other page mentions this page's title or focus keyphrase."
and_more: "and :count more"
recheck: "Check this address again"
recheck_confirm: "Fetch “:url” again now and update its issues?"
rechecked:
one: "Checked again: :count issue remains for this address."
other: "Checked again: :count issues remain for this address."
recheck_fixed: "Checked again: no issues remain for this address."
ignore: "Ignore issue"
ignore_confirm: "Ignore this issue? It stays hidden in later crawls until you stop ignoring it."
ignore_selected: "Ignore issues"
ignore_selected_confirm:
one: "Ignore :count issue? It stays hidden in later crawls."
other: "Ignore :count issues? They stay hidden in later crawls."
ignored:
one: ":count issue is ignored."
other: ":count issues are ignored."
unignore: "Stop ignoring"
unignore_confirm: "Show this issue in the audit again?"
unignored:
one: ":count issue is shown again."
other: ":count issues are shown again."
list_empty: "No issues. Problems found by the last crawl and by the CMS data checks appear here. Start a crawl to check the site again."
detail_h1: "H1 headings"
detail_skips: "Skipped levels"
detail_missing: "Missing properties"
detail_error: "Error"
detail_anchors: "Links"
detail_alternates: "Alternates"
detail_images: "Images"
detail_target: "Target"
detail_what: "Missing or to change"
where_record: "the SEO tab of the page"
where_robots: "SEO › robots.txt"
where_settings: "SEO settings"
where_redirects: "SEO › Redirects"
where_pages: "SEO › Pages"
where_rules: "SEO › URL rules"
kind_orphan_page: "Orphan page (no internal links)"
sum_orphan_page: ":count suggestions"
kind_h1_in_content: "H1 in the content"

View File

@@ -2,6 +2,8 @@ plugin:
name: SEO
description: Metadane dla wyszukiwarek we wszystkich typach treści, reguły adresów i globalne ustawienia SEO.
nav:
audit: "Audyt witryny"
crawls: "Skanowania"
pages: Strony
robots: robots.txt
notfound: Dziennik 404
@@ -536,10 +538,154 @@ schema:
currency_invalid: "Użyj trzyliterowego kodu waluty, np. PLN."
merged: "Zapisano. Twój JSON-LD zastępuje :props w wygenerowanym węźle :node."
crawls:
start: "Uruchom skanowanie"
list_title: "Skanowania"
name: "Skanowanie"
started_at: "Rozpoczęto"
finished_at: "Zakończono"
status: "Stan"
pages: "Strony"
issues: "Problemy"
trigger: "Uruchomienie"
duration: "Czas trwania"
site: "Adres witryny"
st_queued: "W kolejce"
st_running: "W toku"
st_finished: "Zakończone"
st_failed: "Nieudane"
st_cancelled: "Anulowane"
pages_limit: ":count (osiągnięto limit)"
by_admin: "Ręcznie: :name"
by_schedule: "Harmonogram"
by_mcp: "MCP: :client"
trigger_admin: "Ręcznie"
trigger_schedule: "Harmonogram"
trigger_mcp: "MCP"
preview_title: "Skanowanie"
preview: "Skanowanie :url"
running_title: "W toku"
running_text: "Dotąd pobrano stron: :done, najwyżej :limit. Odśwież stronę, aby zobaczyć postęp."
queued_text: "Czeka na wolny proces. Rozpocznie się w ciągu minuty."
failed_title: "Skanowanie nie powiodło się"
failed_text: ":error. Strony pobrane przed błędem i ich problemy zostają zachowane."
cancelled_text: "Anulowane przez: :name, :time."
limit_text: "Skanowanie zatrzymało się na limicie :limit stron. Zwiększ limit w Ustawieniach SEO › Audyt witryny, aby przeskanować całą witrynę."
staging_text: "W tym środowisku każda strona ma noindex z blokady nieprodukcyjnej; skanowanie tego nie zgłasza."
render_skipped: "Porównanie ze stronami wyrenderowanymi przez JavaScript pominięto: nie skonfigurowano ścieżki do Chrome (golem15.seo.crawl_chrome_path). Sprawdzenia surowego HTML zostały wykonane."
render_done:
one: "Porównano :count stronę z wersją wyrenderowaną przez JavaScript."
few: "Porównano :count strony z wersją wyrenderowaną przez JavaScript."
many: "Porównano :count stron z wersją wyrenderowaną przez JavaScript."
other: "Porównano :count strony z wersją wyrenderowaną przez JavaScript."
render_failed: "Porównanie ze stronami wyrenderowanymi przez JavaScript zostało przerwane: :error. Sprawdzenia surowego HTML zostały wykonane."
stat_pages: "Pobrane strony"
stat_errors: "Błędy"
stat_warnings: "Ostrzeżenia"
stat_notices: "Uwagi"
stat_duration: "Czas trwania"
kinds_problem: "Problem"
kinds_severity: "Waga"
kinds_count: "Adresy"
no_issues: "To skanowanie nie wykryło problemów."
cancel: "Anuluj skanowanie"
cancel_confirm: "Anulować to skanowanie? Pobrane dotąd strony i ich problemy zostaną zachowane."
cancelled: "Anulowano skanowanie."
delete_selected_confirm:
one: "Usunąć :count skanowanie? Jego strony i problemy zostaną usunięte."
few: "Usunąć :count skanowania? Ich strony i problemy zostaną usunięte."
many: "Usunąć :count skanowań? Ich strony i problemy zostaną usunięte."
other: "Usunąć :count skanowania? Ich strony i problemy zostaną usunięte."
delete_running: "Nie można usunąć trwającego skanowania. Najpierw je anuluj."
list_empty: "Nie było jeszcze skanowań. Skanowanie pobiera witrynę jak wyszukiwarka i wypisuje znalezione problemy w SEO › Audyt witryny."
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:
list_title: "Audyt witryny"
name: "Problem"
severity: "Waga"
kind: "Problem"
kind_code: "Kod problemu"
url: "Adres"
summary: "Szczegóły"
source: "Wykryte przez"
fix: "Naprawa"
found: "Wykryto"
locale: "Język"
sev_error: "Błąd"
sev_warning: "Ostrzeżenie"
sev_notice: "Uwaga"
source_crawl: "Skanowanie"
source_cms: "Kontrola danych CMS"
fix_tool: "MCP: :tool"
fix_code: "Szablony witryny (kod)"
state_ignored: "Zignorowany"
filter_severity: "Waga"
filter_kind: "Problem"
filter_source: "Wykryte przez"
filter_fix: "Naprawa"
filter_status: "Stan"
filter_fix_tool: "Do naprawy przez MCP"
filter_fix_code: "Wymaga zmiany w kodzie"
status_open: "Otwarte"
status_ignored: "Zignorowane"
preview: "Szczegóły problemu"
found_heading: "Co znaleziono"
fix_heading: "Jak naprawić"
fix_tool_text: "Poproś klienta MCP o naprawę narzędziem „:tool” lub zmień to w panelu: :where."
fix_code_text: "To wynika z szablonów witryny, a nie z danych CMS. Poproś dewelopera witryny o zmianę: :what."
open_page: "Otwórz stronę"
rich_results: "Sprawdź w Teście wyników z elementami rozszerzonymi Google"
linked_from: "Linkowane z"
orphan_suggestions: "Strony, które mogą tu linkować"
orphan_suggestions_page: "Strona"
orphan_suggestions_phrase: "Wspólna fraza"
orphan_none: "Brak propozycji: żadna inna strona nie wspomina tytułu ani frazy kluczowej tej strony."
and_more: "i :count więcej"
recheck: "Sprawdź ten adres ponownie"
recheck_confirm: "Pobrać teraz ponownie „:url” i zaktualizować jego problemy?"
rechecked:
one: "Sprawdzono ponownie: dla tego adresu został :count problem."
few: "Sprawdzono ponownie: dla tego adresu zostały :count problemy."
many: "Sprawdzono ponownie: dla tego adresu zostało :count problemów."
other: "Sprawdzono ponownie: dla tego adresu zostało :count problemu."
recheck_fixed: "Sprawdzono ponownie: dla tego adresu nie ma już problemów."
ignore: "Ignoruj problem"
ignore_confirm: "Zignorować ten problem? Pozostanie ukryty w kolejnych skanowaniach, dopóki nie przestaniesz go ignorować."
ignore_selected: "Ignoruj problemy"
ignore_selected_confirm:
one: "Zignorować :count problem? Pozostanie ukryty w kolejnych skanowaniach."
few: "Zignorować :count problemy? Pozostaną ukryte w kolejnych skanowaniach."
many: "Zignorować :count problemów? Pozostaną ukryte w kolejnych skanowaniach."
other: "Zignorować :count problemu? Pozostaną ukryte w kolejnych skanowaniach."
ignored:
one: "Zignorowano :count problem."
few: "Zignorowano :count problemy."
many: "Zignorowano :count problemów."
other: "Zignorowano :count problemu."
unignore: "Przestań ignorować"
unignore_confirm: "Ponownie pokazywać ten problem w audycie?"
unignored:
one: "Ponownie pokazywany jest :count problem."
few: "Ponownie pokazywane są :count problemy."
many: "Ponownie pokazywanych jest :count problemów."
other: "Ponownie pokazywane jest :count problemu."
list_empty: "Brak problemów. Tutaj pojawiają się problemy z ostatniego skanowania i z kontroli danych CMS. Uruchom skanowanie, aby ponownie sprawdzić witrynę."
detail_h1: "Nagłówki H1"
detail_skips: "Pominięte poziomy"
detail_missing: "Brakujące właściwości"
detail_error: "Błąd"
detail_anchors: "Linki"
detail_alternates: "Wersje językowe"
detail_images: "Obrazy"
detail_target: "Cel"
detail_what: "Brakuje lub do zmiany"
where_record: "zakładka SEO strony"
where_robots: "SEO › robots.txt"
where_settings: "Ustawienia SEO"
where_redirects: "SEO › Przekierowania"
where_pages: "SEO › Strony"
where_rules: "SEO › Reguły adresów"
kind_orphan_page: "Strona osierocona (brak linków wewnętrznych)"
sum_orphan_page: "Propozycje: :count"
kind_h1_in_content: "H1 w treści"

85
mcp.go
View File

@@ -382,16 +382,97 @@ func (p *Plugin) MCP() pact.MCP {
Permissions: []string{mcpPermission},
},
// Audit (D-13, D-14 CMS part).
// Site audit and crawls (D-13, D-14, SC-3).
{
Name: "list_issues",
Title: "List SEO issues",
Description: "Lists the CMS-data SEO issues: missing and duplicate titles and descriptions, URL rules overriding a record, redirect chains, canonicals pointing to noindex or redirected addresses, and noindex addresses robots.txt blocks; each names the tool that fixes it.",
Description: "Lists the site audit's current issues, errors first: what the newest crawl found per URL (statuses, sitemaps, canonicals, titles and descriptions, headings, structured data, hreflang, links, images, content that needs JavaScript, orphan pages) and the CMS-data checks (missing and duplicate titles, URL rules over records, redirect chains, canonicals to noindex or redirected addresses, robots.txt blocks, images without alt text, H1 in content). Each issue names the MCP tool that fixes it (fix tool), or fix code with details.what naming the template element the site's developer changes. Ignored issues are left out unless status is ignored or all.",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_seo/api/v1/issues",
Input: apicontrollers.ListIssuesInput{},
Permissions: []string{mcpPermission},
},
{
Name: "recheck_url",
Title: "Check an issue's address again",
Description: "Fetches the address of one issue again through the crawler and replaces that address's issues; answers the issues left for it. Use it after fixing an issue to confirm the fix.",
Guard: mcpGuard,
Method: http.MethodPost,
Path: "/_seo/api/v1/issues/{id}/recheck",
Input: apicontrollers.IssueIDInput{},
Permissions: []string{mcpPermission},
},
{
Name: "ignore_issues",
Title: "Ignore SEO issues",
Description: "Ignores issues by id: they stay hidden in later crawls until seo_unignore_issues shows them again.",
Guard: mcpGuard,
Method: http.MethodPost,
Path: "/_seo/api/v1/issues/ignore",
Input: apicontrollers.IssueIDsInput{},
Permissions: []string{mcpPermission},
},
{
Name: "unignore_issues",
Title: "Stop ignoring SEO issues",
Description: "Shows ignored issues in the audit again, by id.",
Guard: mcpGuard,
Method: http.MethodPost,
Path: "/_seo/api/v1/issues/unignore",
Input: apicontrollers.IssueIDsInput{},
Permissions: []string{mcpPermission},
},
{
Name: "start_crawl",
Title: "Start a site crawl",
Description: "Starts a crawl of the site: the crawler fetches the site's own pages like a search engine (two requests at a time, within the Site audit limits) and stores what it finds as issues. One crawl runs at a time; a second start answers crawls.already_running. Follow it with seo_get_crawl; when it finishes, read the issues with seo_list_issues.",
Guard: mcpGuard,
Method: http.MethodPost,
Path: "/_seo/api/v1/crawls",
Input: apicontrollers.StartCrawlInput{},
Permissions: []string{mcpPermission},
},
{
Name: "cancel_crawl",
Title: "Cancel a crawl",
Description: "Cancels a queued or running crawl by id; the pages fetched so far and their issues are kept.",
Guard: mcpGuard,
Method: http.MethodPost,
Path: "/_seo/api/v1/crawls/{id}/cancel",
Input: apicontrollers.CrawlIDInput{},
Permissions: []string{mcpPermission},
},
{
Name: "list_crawls",
Title: "List crawls",
Description: "Lists the crawls, newest first, with their status, pages, issue count, who started them and the comparison with the JavaScript-rendered pages (render: skipped, done or failed).",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_seo/api/v1/crawls",
Input: apicontrollers.ListCrawlsInput{},
Permissions: []string{mcpPermission},
},
{
Name: "get_crawl",
Title: "Get a crawl",
Description: "Shows one crawl: status, pages fetched and whether the page limit stopped it, the error of a failed crawl, the issues per kind and severity, whether the staging lock's noindex was left out, and render {status, pages, note}: whether the pages were compared with their JavaScript-rendered versions or that comparison was skipped (no Chrome path configured) or failed.",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_seo/api/v1/crawls/{id}",
Input: apicontrollers.CrawlIDInput{},
Permissions: []string{mcpPermission},
},
{
Name: "delete_crawls",
Title: "Delete crawls",
Description: "Deletes crawls by id with their pages and issues; a queued or running crawl refuses the call (crawls.delete_running).",
Guard: mcpGuard,
Method: http.MethodDelete,
Path: "/_seo/api/v1/crawls",
Input: apicontrollers.DeleteCrawlsInput{},
Permissions: []string{mcpPermission},
Destructive: &destructive,
},
}}
}

27
models/crawl/columns.yaml Normal file
View File

@@ -0,0 +1,27 @@
# Crawls columns (UI-SPEC C24). status shows its label; pages_cell,
# issues, trigger_cell and duration are filled by ListExtendRecords.
columns:
started_at:
label: golem15.seo::lang.crawls.started_at
type: datetime
sortable: true
status:
label: golem15.seo::lang.crawls.status
type: text
sortable: true
pages_cell:
label: golem15.seo::lang.crawls.pages
type: text
sortable: false
issues:
label: golem15.seo::lang.crawls.issues
type: text
sortable: false
trigger_cell:
label: golem15.seo::lang.crawls.trigger
type: text
sortable: false
duration:
label: golem15.seo::lang.crawls.duration
type: text
sortable: false

29
models/crawl/fields.yaml Normal file
View File

@@ -0,0 +1,29 @@
# Crawl preview (UI-SPEC C24): every field is read-only.
fields:
started_at:
label: golem15.seo::lang.crawls.started_at
type: datepicker
mode: datetime
span: left
context: preview
finished_at:
label: golem15.seo::lang.crawls.finished_at
type: datepicker
mode: datetime
span: right
context: preview
status:
label: golem15.seo::lang.crawls.status
type: dropdown
span: left
context: preview
trigger:
label: golem15.seo::lang.crawls.trigger
type: dropdown
span: right
context: preview
site:
label: golem15.seo::lang.crawls.site
type: text
span: full
context: preview

43
models/issue/columns.yaml Normal file
View File

@@ -0,0 +1,43 @@
# Site audit columns (UI-SPEC C23). severity, kind_cell, summary, source
# and fix_cell are filled by the controller's ListExtendRecords.
columns:
severity:
label: golem15.seo::lang.issues.severity
type: text
sortable: false
kind_cell:
label: golem15.seo::lang.issues.kind
type: text
sortable: false
kind:
label: golem15.seo::lang.issues.kind_code
type: text
sortable: true
invisible: true
url:
label: golem15.seo::lang.issues.url
type: text
searchable: true
sortable: true
summary:
label: golem15.seo::lang.issues.summary
type: text
sortable: false
source:
label: golem15.seo::lang.issues.source
type: text
sortable: true
fix_cell:
label: golem15.seo::lang.issues.fix
type: text
sortable: false
found_at:
label: golem15.seo::lang.issues.found
type: datetime
sortable: true
invisible: true
locale:
label: golem15.seo::lang.issues.locale
type: text
sortable: true
invisible: true

33
models/issue/fields.yaml Normal file
View File

@@ -0,0 +1,33 @@
# Issue preview (UI-SPEC C23): every field is read-only.
fields:
url:
label: golem15.seo::lang.issues.url
type: text
span: full
context: preview
kind:
label: golem15.seo::lang.issues.kind
type: dropdown
span: left
context: preview
severity:
label: golem15.seo::lang.issues.severity
type: dropdown
span: right
context: preview
source:
label: golem15.seo::lang.issues.source
type: dropdown
span: left
context: preview
found_at:
label: golem15.seo::lang.issues.found
type: datepicker
mode: datetime
span: right
context: preview
locale:
label: golem15.seo::lang.issues.locale
type: text
span: left
context: preview

View File

@@ -26,6 +26,8 @@ const (
OpBulkAction = "bulk_action"
// OpWidgetAction is a widget action ("widget_action:<name>").
OpWidgetAction = "widget_action"
// OpToolbarAction is a list toolbar action ("toolbar_action:<name>").
OpToolbarAction = "toolbar_action"
)
// ContentController stands for the form of every content type the SEO tab
@@ -94,6 +96,23 @@ var AdminOperations = []AdminOperation{
{controllers.PagesControllerID, OpDelete, "delete_page"},
{controllers.PagesControllerID, OpExtensionSave, "update_page"},
// Site audit: the current issues, their record and bulk actions, and
// the toolbar's Start crawl.
{controllers.IssuesControllerID, OpList, "list_issues"},
{controllers.IssuesControllerID, OpShow, "list_issues"},
{controllers.IssuesControllerID, OpToolbarAction + ":" + controllers.ActionStartCrawl, "start_crawl"},
{controllers.IssuesControllerID, OpRecordAction + ":" + controllers.IssueActionCheck, "recheck_url"},
{controllers.IssuesControllerID, OpRecordAction + ":" + controllers.IssueActionIgnore, "ignore_issues"},
{controllers.IssuesControllerID, OpRecordAction + ":" + controllers.IssueActionShow, "unignore_issues"},
{controllers.IssuesControllerID, OpBulkAction + ":" + controllers.IssueActionIgnore, "ignore_issues"},
// Crawls.
{controllers.CrawlsControllerID, OpList, "list_crawls"},
{controllers.CrawlsControllerID, OpShow, "get_crawl"},
{controllers.CrawlsControllerID, OpDelete, "delete_crawls"},
{controllers.CrawlsControllerID, OpToolbarAction + ":" + controllers.ActionStartCrawl, "start_crawl"},
{controllers.CrawlsControllerID, OpRecordAction + ":" + controllers.CrawlActionCancel, "cancel_crawl"},
// robots.txt and AI bots.
{controllers.RobotsControllerID, OpShow, "show_robots"},
{controllers.RobotsControllerID, OpUpdate, "update_robots"},

View File

@@ -128,7 +128,13 @@ func requiredOperations(t *testing.T, p *Plugin) []AdminOperation {
}
if h, ok := c.(pact.HasAdminActions); ok {
for _, a := range h.AdminActions() {
need(id, OpWidgetAction+":"+a.Name)
// An action named in the list toolbar is a toolbar action,
// any other one a form widget's.
if slices.Contains(buttons, a.Name) {
need(id, OpToolbarAction+":"+a.Name)
} else {
need(id, OpWidgetAction+":"+a.Name)
}
}
}
}

View File

@@ -81,7 +81,14 @@ 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("/issues/ignore", apicontrollers.IgnoreIssues(p.Service), api)
g.Post("/issues/unignore", apicontrollers.UnignoreIssues(p.Service), api)
g.Post("/issues/{id}/recheck", apicontrollers.RecheckIssue(p.Service), api)
g.Get("/crawls", apicontrollers.ListCrawls(p.Service), api)
g.Post("/crawls", apicontrollers.StartCrawl(p.Service), api)
g.Delete("/crawls", apicontrollers.DeleteCrawls(p.Service), api)
g.Get("/crawls/{id}", apicontrollers.ShowCrawl(p.Service), api)
g.Post("/crawls/{id}/cancel", apicontrollers.CancelCrawl(p.Service), api)
})
return nil
}

View File

@@ -594,7 +594,10 @@ func TestLangKeysComplete(t *testing.T) {
}
for _, m := range langKeyPattern.FindAllStringSubmatch(string(raw), -1) {
key := m[1]
if strings.HasSuffix(key, "via_") || strings.HasSuffix(key, "source_") {
// Keys built from a value: via_<via>, source_<source>, and the
// issue kinds' kind_<kind> and sum_<kind> (TestIssueKindLabels
// checks every kind).
if strings.HasSuffix(key, "via_") || strings.HasSuffix(key, "source_") || strings.HasSuffix(key, "kind_") || strings.HasSuffix(key, "sum_") {
continue
}
seen[key] = true