- 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
283 lines
8.6 KiB
Go
283 lines
8.6 KiB
Go
package crawl
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// insertBatch is the rows written per INSERT.
|
|
const insertBatch = 200
|
|
|
|
// SavePages writes the pages and links one crawl fetched. The crawler
|
|
// writes only its own tables (D-14).
|
|
func SavePages(tx *gorm.DB, crawlID uint, res *Result) error {
|
|
if res == nil {
|
|
return nil
|
|
}
|
|
in := map[string]int{}
|
|
var links []models.CrawlLink
|
|
for _, key := range res.Order {
|
|
p := res.Pages[key]
|
|
if p.Facts == nil {
|
|
continue
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, l := range p.Facts.Links {
|
|
to := ""
|
|
internal := false
|
|
if res.fetcher != nil {
|
|
to = res.fetcher.Key(l.URL)
|
|
internal = to != ""
|
|
}
|
|
if !internal {
|
|
to = l.URL.String()
|
|
}
|
|
links = append(links, models.CrawlLink{CrawlID: crawlID, FromURL: key, ToURL: to, Text: truncateUTF8(l.Text, 500), Rel: l.Rel, Internal: internal})
|
|
if internal && to != key && !seen[to] {
|
|
seen[to] = true
|
|
in[to]++
|
|
}
|
|
}
|
|
}
|
|
pages := make([]models.CrawlPage, 0, len(res.Order))
|
|
for _, key := range res.Order {
|
|
p := res.Pages[key]
|
|
row := models.CrawlPage{CrawlID: crawlID, URL: key, Depth: p.Depth, InSitemap: p.InSitemap, SitemapFile: p.SitemapFile, LinksIn: in[key]}
|
|
if !p.FetchedAt.IsZero() {
|
|
t := p.FetchedAt
|
|
row.FetchedAt = &t
|
|
}
|
|
if r := p.Resp; r != nil {
|
|
row.Status, row.Location, row.ContentType, row.XRobots = r.Status, r.Location, truncateUTF8(r.ContentType, 200), truncateUTF8(r.XRobots, 500)
|
|
}
|
|
if f := p.Facts; f != nil {
|
|
row.Title, row.Description, row.Canonical, row.Robots = truncateUTF8(f.Title, 1000), truncateUTF8(f.Description, 2000), truncateUTF8(f.Canonical, 2048), truncateUTF8(f.Robots, 500)
|
|
row.H1 = lagoon.Jsonable[[]string]{Data: f.H1(), Valid: true}
|
|
hs := make([]models.CrawlHeading, 0, len(f.Headings))
|
|
for _, h := range f.Headings {
|
|
hs = append(hs, models.CrawlHeading{Level: h.Level, Text: truncateUTF8(h.Text, 500)})
|
|
}
|
|
row.Headings = lagoon.Jsonable[[]models.CrawlHeading]{Data: hs, Valid: true}
|
|
row.Words, row.Text = f.Words, truncateUTF8(f.Text, models.MaxPageText)
|
|
row.LinksOut = len(f.Links)
|
|
}
|
|
pages = append(pages, row)
|
|
}
|
|
if len(pages) > 0 {
|
|
if err := tx.CreateInBatches(pages, insertBatch).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(links) > 0 {
|
|
if err := tx.CreateInBatches(links, insertBatch).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// issueRow turns a finding into its stored row.
|
|
func issueRow(crawlID *uint, source string, f Finding, now time.Time) models.Issue {
|
|
fix, tool, what := Route(f)
|
|
details := map[string]any{}
|
|
for k, v := range f.Details {
|
|
details[k] = v
|
|
}
|
|
if what != "" {
|
|
details["what"] = what
|
|
}
|
|
if f.Check != "" {
|
|
details["check"] = f.Check
|
|
}
|
|
sev := Severity(f.Kind)
|
|
t := now
|
|
return models.Issue{
|
|
CrawlID: crawlID,
|
|
Source: source,
|
|
Kind: f.Kind,
|
|
Severity: sev,
|
|
Rank: models.SeverityRank(sev),
|
|
URL: truncateUTF8(f.URL, 2048),
|
|
Locale: f.Locale,
|
|
Details: lagoon.Jsonable[map[string]any]{Data: details, Valid: true},
|
|
Tool: tool,
|
|
Fix: fix,
|
|
Fingerprint: Fingerprint(f.Kind, f.URL, f.Locale, f.Check),
|
|
FoundAt: &t,
|
|
}
|
|
}
|
|
|
|
// cmsFinding turns a CMS-data issue of the classes report into a finding:
|
|
// its tool is the one the report names; a CMS issue without one is fixed
|
|
// in the record's admin form.
|
|
func cmsFinding(issue classes.Issue) Finding {
|
|
f := Finding{Kind: issue.Kind, URL: issue.URL, Locale: issue.Locale, Details: issue.Details, Tool: issue.Tool}
|
|
if f.Tool == "" {
|
|
f.What = "the record in its admin form"
|
|
}
|
|
return f
|
|
}
|
|
|
|
// SaveIssues stores a crawl's findings and the CMS-data issues with it
|
|
// (crawlID nil: the CMS issues of a site not crawled yet). A CMS issue
|
|
// with the fingerprint of a crawl finding is the same problem and is not
|
|
// stored twice.
|
|
func SaveIssues(tx *gorm.DB, crawlID *uint, findings []Finding, cms []classes.Issue) error {
|
|
now := time.Now().UTC()
|
|
seen := map[string]bool{}
|
|
var rows []models.Issue
|
|
for _, f := range findings {
|
|
row := issueRow(crawlID, models.SourceCrawl, f, now)
|
|
if seen[row.Fingerprint] {
|
|
continue
|
|
}
|
|
seen[row.Fingerprint] = true
|
|
rows = append(rows, row)
|
|
}
|
|
for _, issue := range cms {
|
|
row := issueRow(crawlID, models.SourceCMS, cmsFinding(issue), now)
|
|
if seen[row.Fingerprint] {
|
|
continue
|
|
}
|
|
seen[row.Fingerprint] = true
|
|
rows = append(rows, row)
|
|
}
|
|
sort.SliceStable(rows, func(i, j int) bool {
|
|
if rows[i].Rank != rows[j].Rank {
|
|
return rows[i].Rank < rows[j].Rank
|
|
}
|
|
if rows[i].Kind != rows[j].Kind {
|
|
return rows[i].Kind < rows[j].Kind
|
|
}
|
|
return rows[i].URL < rows[j].URL
|
|
})
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
return tx.CreateInBatches(rows, insertBatch).Error
|
|
}
|
|
|
|
// LatestCrawl returns the newest crawl that is no longer queued or
|
|
// running, nil when there is none: its issues are the audit's current
|
|
// ones.
|
|
func LatestCrawl(db *gorm.DB) (*models.Crawl, error) {
|
|
var rows []models.Crawl
|
|
if err := db.Where("status NOT IN ?", []string{models.CrawlQueued, models.CrawlRunning}).Order("id DESC").Limit(1).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
// ActiveCrawl returns the queued or running crawl, nil when none.
|
|
func ActiveCrawl(db *gorm.DB) (*models.Crawl, error) {
|
|
var rows []models.Crawl
|
|
if err := db.Where("status IN ?", []string{models.CrawlQueued, models.CrawlRunning}).Order("id DESC").Limit(1).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
// CurrentScope narrows an issues query to the audit's current issues: the
|
|
// newest finished, failed or cancelled crawl's, or before any crawl the
|
|
// stored CMS-data issues.
|
|
func CurrentScope(db *gorm.DB, latest *models.Crawl) *gorm.DB {
|
|
if latest == nil {
|
|
return db.Where("golem15_seo_issues.crawl_id IS NULL")
|
|
}
|
|
return db.Where("golem15_seo_issues.crawl_id = ?", latest.ID)
|
|
}
|
|
|
|
// IgnoredSet returns which of the fingerprints are ignored.
|
|
func IgnoredSet(db *gorm.DB, fingerprints []string) (map[string]bool, error) {
|
|
out := map[string]bool{}
|
|
if len(fingerprints) == 0 {
|
|
return out, nil
|
|
}
|
|
var rows []models.IssueIgnore
|
|
for start := 0; start < len(fingerprints); start += 1000 {
|
|
end := min(start+1000, len(fingerprints))
|
|
var part []models.IssueIgnore
|
|
if err := db.Where("fingerprint IN ?", fingerprints[start:end]).Find(&part).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
rows = append(rows, part...)
|
|
}
|
|
for _, r := range rows {
|
|
out[r.Fingerprint] = true
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Current returns the crawl whose issues are the audit's current ones (nil
|
|
// before any crawl). Before any crawl, the CMS-data checks run now and
|
|
// their issues replace the stored ones, so the list shows the CMS data as
|
|
// it is (UI-SPEC C23: when the list is opened with no crawl yet).
|
|
func Current(ctx context.Context, svc *classes.Service, db *gorm.DB) (*models.Crawl, error) {
|
|
latest, err := LatestCrawl(db)
|
|
if err != nil || latest != nil {
|
|
return latest, err
|
|
}
|
|
return nil, RefreshCMS(ctx, svc, db)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
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)
|
|
})
|
|
}
|