- 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
233 lines
8.3 KiB
Go
233 lines
8.3 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"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, 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,
|
|
// 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 (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)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
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
|
|
}
|
|
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 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
|
|
}
|