Files
sm-seo-plugin/classes/crawl/actions.go
Jakub Zych cd7182395f fix: a crawl cancel waits for its job, and a stale crawl is ended
Cancelling a running crawl records cancel_requested_at and leaves the
row running until its job stops and writes cancelled, so no second
crawl starts and the row cannot be deleted or pruned meanwhile. The job
writes heartbeat_at every minute; a running crawl without a heartbeat
for 10 minutes is ended by the next start or delete.
2026-11-04 22:51:34 +01:00

310 lines
9.7 KiB
Go

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 (also one whose cancel is requested) refuses the
// whole call with ErrDeleteRunning. A running crawl without a heartbeat is
// ended first (RecoverStale), so it can be deleted.
func DeleteCrawls(tx *gorm.DB, ids []uint) (int, error) {
if len(ids) == 0 {
return 0, nil
}
if _, err := RecoverStale(tx, time.Now()); err != nil {
return 0, err
}
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
}