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 } // RecoverStale ends the running crawls whose job stopped writing its // heartbeat (P33-60): a running row whose COALESCE(heartbeat_at, // started_at, created_at) is older than StaleAfter before now becomes // cancelled when its cancel was requested, else failed with the error // "the crawl job stopped without finishing (no heartbeat since