feat: crawler finds a page with two H1 headings and stores the issue (tracer)

- golem15_seo_crawls, crawl pages, links, issues and ignores (P33-37)
- classes/crawl: fetchguard client on the site host and its www twin,
  sitemap index and robots.txt seeds, two workers with a delay, limits
- h1_missing and h1_multiple with the P33-38 fix route
- POST /_seo/api/v1/crawls queues the golem15.seo.crawl job; GET /issues
  answers the stored issues with id, severity, source, fix, found_at,
  ignored and crawl_id
This commit is contained in:
Jakub Zych
2026-11-04 15:05:45 +01:00
parent d38049a956
commit c2b61bf8e9
23 changed files with 2967 additions and 13 deletions

View File

@@ -3,31 +3,96 @@ package api
import (
"context"
"net/http"
"strconv"
"strings"
"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"
"gorm.io/gorm"
)
// ListIssuesInput is the query of GET /_seo/api/v1/issues.
type ListIssuesInput struct {
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only: missing_title, missing_description, duplicate_title, duplicate_description, rule_overrides_record, redirect_chain, canonical_noindex, canonical_redirected, noindex_blocked or sitemap_blocked"`
Kind string `json:"kind,omitempty" query:"kind" jsonschema:"one kind only, e.g. h1_multiple, broken_link, missing_title, redirect_chain or sitemap_blocked"`
}
// ListIssues serves GET /_seo/api/v1/issues (D-13, D-14 CMS part): the
// CMS-data issue report, each issue with its URL, kind, details and the
// tool that fixes it, and a count per kind.
// IssueView is a stored issue in an answer: the Phase 32 keys (kind, url,
// locale, details, tool) and the audit's id, severity, source, fix,
// found_at, ignored and crawl_id. tool is the fixing MCP tool when fix is
// tool; for fix code, details.what names the template element.
type IssueView struct {
ID uint `json:"id"`
Kind string `json:"kind"`
URL string `json:"url"`
Locale string `json:"locale,omitempty"`
Details map[string]any `json:"details,omitempty"`
Tool string `json:"tool"`
Severity string `json:"severity"`
Source string `json:"source"`
Fix string `json:"fix"`
FoundAt *time.Time `json:"found_at"`
Ignored bool `json:"ignored"`
CrawlID *uint `json:"crawl_id"`
}
// issueViews turns stored issues into their answer form.
func issueViews(db *gorm.DB, rows []models.Issue) ([]IssueView, error) {
fps := make([]string, 0, len(rows))
for _, r := range rows {
fps = append(fps, r.Fingerprint)
}
ignored, err := crawl.IgnoredSet(db, fps)
if err != nil {
return nil, err
}
out := make([]IssueView, 0, len(rows))
for _, r := range rows {
out = append(out, IssueView{
ID: r.ID, Kind: r.Kind, URL: r.URL, Locale: r.Locale, Details: r.DetailsMap(), Tool: r.Tool,
Severity: r.Severity, Source: r.Source, Fix: r.Fix, FoundAt: r.FoundAt,
Ignored: ignored[r.Fingerprint], CrawlID: r.CrawlID,
})
}
return out, nil
}
// 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.
func ListIssues(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
issues, err := svc.Issues(ctx)
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
kind := r.URL.Query().Get("kind")
latest, err := crawl.Current(ctx, svc, db)
if err != nil {
return 0, nil, err
}
q := r.URL.Query()
base := crawl.CurrentScope(db.Model(&models.Issue{}), latest)
if id, err := strconv.ParseUint(strings.TrimSpace(q.Get("crawl")), 10, 64); err == nil && id > 0 {
base = db.Model(&models.Issue{}).Where("golem15_seo_issues.crawl_id = ?", id)
}
var all []models.Issue
if err := base.Order("rank ASC, kind ASC, url ASC, id ASC").Find(&all).Error; err != nil {
return 0, nil, err
}
views, err := issueViews(db, all)
if err != nil {
return 0, nil, err
}
kind := strings.TrimSpace(q.Get("kind"))
counts := map[string]int{}
out := make([]classes.Issue, 0, len(issues))
for _, issue := range issues {
counts[issue.Kind]++
if kind == "" || issue.Kind == kind {
out = append(out, issue)
out := make([]IssueView, 0, len(views))
for _, v := range views {
counts[v.Kind]++
if kind == "" || v.Kind == kind {
out = append(out, v)
}
}
return http.StatusOK, map[string]any{"data": out, "counts": counts, "total": len(out)}, nil