feat: management API and an MCP tool per SEO admin operation
- /_seo/api/v1 routes for the effective SEO of a URL with sources and owner, URL rules, redirects (loops refused with redirect_loop), the 404 log actions, robots.txt and its tester, settings and history - the CMS-data issue report: missing and duplicate titles and descriptions, rules over records, redirect chains, canonicals to noindex or redirected addresses, noindex addresses robots.txt blocks - 24 MCP tools on those routes (backend guard, golem15.seo.manage) - AdminOperations maps every admin operation to its tool; TestEveryAdminOperationHasTool and TestMCPReadToolsAreGET guard drift - robots.txt writes go through classes.SaveRobots from the admin too
This commit is contained in:
259
classes/issues.go
Normal file
259
classes/issues.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
)
|
||||
|
||||
// The kinds of the CMS-data issue report (D-13, D-14 CMS part). The
|
||||
// crawler's checks of the rendered pages are Phase 33.
|
||||
const (
|
||||
IssueMissingTitle = "missing_title"
|
||||
IssueMissingDescription = "missing_description"
|
||||
IssueDuplicateTitle = "duplicate_title"
|
||||
IssueDuplicateDesc = "duplicate_description"
|
||||
IssueRuleOverridesRecord = "rule_overrides_record"
|
||||
IssueRedirectChain = "redirect_chain"
|
||||
IssueCanonicalNoindex = "canonical_noindex"
|
||||
IssueCanonicalRedirected = "canonical_redirected"
|
||||
IssueNoindexBlocked = "noindex_blocked"
|
||||
)
|
||||
|
||||
// Issue is one finding of the issue report: the address, the kind, the
|
||||
// details of the finding and the MCP tool that fixes it.
|
||||
type Issue struct {
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
}
|
||||
|
||||
// The tools the issues point to (pier prefixes them with seo_).
|
||||
const (
|
||||
toolSetURLSEO = "seo_set_url_seo"
|
||||
toolUpdateRule = "seo_update_rule"
|
||||
toolUpdateRedirect = "seo_update_redirect"
|
||||
toolUpdateRobots = "seo_update_robots"
|
||||
)
|
||||
|
||||
// Issues builds the CMS-data issue report (D-13, D-14): missing effective
|
||||
// titles and descriptions per record and locale, effective titles and
|
||||
// descriptions shared by more than one address (records and URL rules),
|
||||
// URL rules overriding a record, redirect chains with their final
|
||||
// targets, canonicals pointing to a noindex or redirected address, and
|
||||
// noindex addresses robots.txt keeps crawlers from. Records are read
|
||||
// through their SEO entries (the generation job creates one per
|
||||
// published record and locale).
|
||||
func (s *Service) Issues(ctx context.Context) ([]Issue, error) {
|
||||
db, err := s.DB(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Issue
|
||||
var entries []models.Entry
|
||||
if err := db.Where("url <> ''").Order("url ASC, locale ASC").Find(&entries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rules []models.Rule
|
||||
if err := db.Order("url ASC").Find(&rules).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Missing and duplicate effective values.
|
||||
type place struct{ url, locale string }
|
||||
titles := map[string][]place{}
|
||||
descs := map[string][]place{}
|
||||
for _, e := range entries {
|
||||
if strings.TrimSpace(e.EffectiveTitle) == "" {
|
||||
out = append(out, Issue{Kind: IssueMissingTitle, URL: e.URL, Locale: e.Locale, Tool: toolSetURLSEO})
|
||||
} else {
|
||||
k := e.Locale + "|" + strings.ToLower(strings.TrimSpace(e.EffectiveTitle))
|
||||
titles[k] = append(titles[k], place{e.URL, e.Locale})
|
||||
}
|
||||
if strings.TrimSpace(e.EffectiveDescription) == "" {
|
||||
out = append(out, Issue{Kind: IssueMissingDescription, URL: e.URL, Locale: e.Locale, Tool: toolSetURLSEO})
|
||||
} else {
|
||||
k := e.Locale + "|" + strings.ToLower(strings.TrimSpace(e.EffectiveDescription))
|
||||
descs[k] = append(descs[k], place{e.URL, e.Locale})
|
||||
}
|
||||
}
|
||||
locale := s.DefaultLocale(ctx, db)
|
||||
for _, r := range rules {
|
||||
if r.Match != models.MatchExact {
|
||||
continue
|
||||
}
|
||||
if t := strings.TrimSpace(r.Title); t != "" {
|
||||
k := locale + "|" + strings.ToLower(t)
|
||||
titles[k] = append(titles[k], place{r.URL, ""})
|
||||
}
|
||||
if d := strings.TrimSpace(r.Description); d != "" {
|
||||
k := locale + "|" + strings.ToLower(d)
|
||||
descs[k] = append(descs[k], place{r.URL, ""})
|
||||
}
|
||||
}
|
||||
duplicates := func(groups map[string][]place, kind string) {
|
||||
for key, list := range groups {
|
||||
if len(list) < 2 {
|
||||
continue
|
||||
}
|
||||
_, text, _ := strings.Cut(key, "|")
|
||||
for i, p := range list {
|
||||
others := make([]string, 0, len(list)-1)
|
||||
for j, o := range list {
|
||||
if j != i && o.url != p.url {
|
||||
others = append(others, o.url)
|
||||
}
|
||||
}
|
||||
if len(others) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, Issue{Kind: kind, URL: p.url, Locale: p.locale, Tool: toolSetURLSEO, Details: map[string]any{"value": text, "also": others}})
|
||||
}
|
||||
}
|
||||
}
|
||||
duplicates(titles, IssueDuplicateTitle)
|
||||
duplicates(descs, IssueDuplicateDesc)
|
||||
|
||||
// URL rules overriding a record.
|
||||
if reg := s.Registry(); reg != nil {
|
||||
for _, r := range rules {
|
||||
if r.Match != models.MatchExact || strings.Contains(r.URL, "?") {
|
||||
continue
|
||||
}
|
||||
var fields []string
|
||||
for _, p := range models.RuleProperties {
|
||||
if strings.TrimSpace(r.Value(p.Column)) != "" {
|
||||
fields = append(fields, p.Column)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
key, match, err := reg.MatchPath(ctx, trimSlash(r.URL), s.UnprefixedLocale())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if match == nil || !match.Exact {
|
||||
continue
|
||||
}
|
||||
out = append(out, Issue{Kind: IssueRuleOverridesRecord, URL: r.URL, Locale: match.Locale, Tool: toolUpdateRule, Details: map[string]any{
|
||||
"rule_id": r.ID, "type": key, "id": match.ID, "fields": fields,
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect chains.
|
||||
redirects, err := LoadRedirects(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := NewRedirectSet(redirects)
|
||||
site := s.SiteURL()
|
||||
for _, r := range redirects {
|
||||
if r.Status == models.Status410 {
|
||||
continue
|
||||
}
|
||||
path, query, ok := redirectProbe(r)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ch, err := set.Follow(site, path, query, MaxRedirectHops)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ch.Loop || len(ch.Hops) < 2 || ch.Hops[0].Rule.ID != r.ID {
|
||||
continue
|
||||
}
|
||||
out = append(out, Issue{Kind: IssueRedirectChain, URL: r.Source, Tool: toolUpdateRedirect, Details: map[string]any{
|
||||
"redirect_id": r.ID, "hops": len(ch.Hops), "final": displayURL(site, ch.Final), "path": displayPath(r, ch),
|
||||
}})
|
||||
}
|
||||
|
||||
// Canonicals pointing to a noindex or redirected address.
|
||||
canonical := func(u, loc, value, tool string, details map[string]any) error {
|
||||
kind, target, err := s.canonicalProblem(ctx, value)
|
||||
if err != nil || kind == "" {
|
||||
return err
|
||||
}
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
details["canonical"] = value
|
||||
if target != "" {
|
||||
details["target"] = target
|
||||
}
|
||||
out = append(out, Issue{Kind: kind, URL: u, Locale: loc, Tool: tool, Details: details})
|
||||
return nil
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := canonical(e.URL, e.Locale, e.Canonical, toolSetURLSEO, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, r := range rules {
|
||||
if err := canonical(r.URL, "", r.Canonical, toolUpdateRule, map[string]any{"rule_id": r.ID}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Noindex addresses robots.txt blocks.
|
||||
robots, err := LoadRobots(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocked, err := s.BlockedNoindex(db, robots.Content, robots.Bots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range blocked {
|
||||
out = append(out, Issue{Kind: IssueNoindexBlocked, URL: u, Tool: toolUpdateRobots})
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Kind != out[j].Kind {
|
||||
return out[i].Kind < out[j].Kind
|
||||
}
|
||||
if out[i].URL != out[j].URL {
|
||||
return out[i].URL < out[j].URL
|
||||
}
|
||||
return out[i].Locale < out[j].Locale
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// canonicalProblem checks a canonical on this site (D-06): a redirect
|
||||
// answers canonical_redirected with the final address, a page served
|
||||
// noindex (in production) canonical_noindex. Other sites and empty values
|
||||
// answer no kind.
|
||||
func (s *Service) canonicalProblem(ctx context.Context, canonical string) (kind, target string, err error) {
|
||||
canonical = strings.TrimSpace(canonical)
|
||||
if canonical == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
ref := canonical
|
||||
if u, err := url.Parse(canonical); err == nil && u.IsAbs() {
|
||||
if !sameSite(s.SiteURL(), u.Host) {
|
||||
return "", "", nil
|
||||
}
|
||||
ref = u.String()
|
||||
}
|
||||
res, err := s.Resolver().Resolve(ctx, ref)
|
||||
if err != nil {
|
||||
if err == ErrBadURL {
|
||||
return "", "", nil
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
if res.Action == ActionRedirect {
|
||||
return IssueCanonicalRedirected, res.Location, nil
|
||||
}
|
||||
if s.Staging() || res.SEO == nil || !strings.Contains(res.SEO.Robots, "noindex") {
|
||||
return "", "", nil
|
||||
}
|
||||
return IssueCanonicalNoindex, "", nil
|
||||
}
|
||||
Reference in New Issue
Block a user