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.
585 lines
19 KiB
Go
585 lines
19 KiB
Go
package crawl
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes"
|
|
"git.golem15.com/golem15/sm-seo-plugin/classes/schema"
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
|
|
"git.golem15.com/golem15/summercms/modules/conga"
|
|
"git.golem15.com/golem15/summercms/modules/diary"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SitemapTool is ToolSitemapSettings while the application publishes the
|
|
// sitemap plugin's contract.Listing (the sitemap plugin is mounted), else
|
|
// "".
|
|
func SitemapTool(svc *classes.Service) string {
|
|
app := svc.App()
|
|
if app == nil {
|
|
return ""
|
|
}
|
|
if _, ok := app.Lookup[contract.Listing](); ok {
|
|
return ToolSitemapSettings
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// CrawlKind is the River kind of the crawl job.
|
|
const CrawlKind = "golem15.seo.crawl"
|
|
|
|
// CrawlArgs are the arguments of the crawl job: the crawl row it runs.
|
|
type CrawlArgs struct {
|
|
CrawlID uint `json:"crawl_id"`
|
|
}
|
|
|
|
// Kind is CrawlKind.
|
|
func (CrawlArgs) Kind() string { return CrawlKind }
|
|
|
|
// The crawl job's time bounds (P33-60). JobTimeout is the job's deadline;
|
|
// FinishTimeout bounds storing what a crawl found, on a context the job's
|
|
// cancellation cannot reach; the job writes heartbeat_at every
|
|
// HeartbeatEvery, and a running crawl without a heartbeat for StaleAfter
|
|
// is ended by the next Start or delete (RecoverStale).
|
|
const (
|
|
JobTimeout = 6 * time.Hour
|
|
FinishTimeout = 10 * time.Minute
|
|
HeartbeatEvery = time.Minute
|
|
StaleAfter = 10 * time.Minute
|
|
)
|
|
|
|
// heartbeatWrite bounds one heartbeat update.
|
|
const heartbeatWrite = 10 * time.Second
|
|
|
|
// The crawl limits used until the Site audit settings say otherwise.
|
|
const (
|
|
DefaultMaxPages = 5000
|
|
DefaultDelay = 250 * time.Millisecond
|
|
)
|
|
|
|
// AlreadyRunningError refuses a start while a crawl is queued or running
|
|
// (P33-39): one crawl runs at a time.
|
|
type AlreadyRunningError struct {
|
|
Crawl models.Crawl
|
|
}
|
|
|
|
func (e *AlreadyRunningError) Error() string { return "golem15.seo: a crawl is already running" }
|
|
|
|
// Since is the time the running crawl started, else its creation time.
|
|
func (e *AlreadyRunningError) Since() time.Time {
|
|
if e.Crawl.StartedAt != nil {
|
|
return *e.Crawl.StartedAt
|
|
}
|
|
if e.Crawl.CreatedAt != nil {
|
|
return *e.Crawl.CreatedAt
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// Start queues a crawl (D-14, P33-39) inside tx: the settings row is
|
|
// locked so two starts cannot both pass the check that no crawl is queued
|
|
// or running, a running crawl whose job stopped writing its heartbeat is
|
|
// ended (RecoverStale), then the crawl row is written and the conga job
|
|
// dispatched in the same transaction. A missing site address refuses with
|
|
// ErrNoSiteURL, a queued or running crawl (also one whose cancel is
|
|
// requested but whose job has not stopped) with *AlreadyRunningError.
|
|
func Start(ctx context.Context, svc *classes.Service, tx *gorm.DB, trigger, name string) (models.Crawl, error) {
|
|
site := svc.SiteURL()
|
|
if site == "" {
|
|
return models.Crawl{}, ErrNoSiteURL
|
|
}
|
|
if _, err := classes.SettingsRow(tx); err != nil {
|
|
return models.Crawl{}, err
|
|
}
|
|
if err := tx.Exec(`SELECT id FROM golem15_seo_settings WHERE id = 1 FOR UPDATE`).Error; err != nil {
|
|
return models.Crawl{}, err
|
|
}
|
|
stale, err := RecoverStale(tx, time.Now().UTC())
|
|
if err != nil {
|
|
return models.Crawl{}, err
|
|
}
|
|
endStaleJobs(ctx, svc, tx, stale)
|
|
active, err := ActiveCrawl(tx)
|
|
if err != nil {
|
|
return models.Crawl{}, err
|
|
}
|
|
if active != nil {
|
|
return models.Crawl{}, &AlreadyRunningError{Crawl: *active}
|
|
}
|
|
limits, err := LoadLimits(tx)
|
|
if err != nil {
|
|
return models.Crawl{}, err
|
|
}
|
|
now := time.Now().UTC()
|
|
row := models.Crawl{
|
|
Status: models.CrawlQueued, Trigger: trigger, TriggerName: name, Site: site,
|
|
MaxPages: limits.MaxPages, RenderStatus: models.RenderSkipped, CreatedAt: &now,
|
|
}
|
|
if err := tx.Create(&row).Error; err != nil {
|
|
return models.Crawl{}, err
|
|
}
|
|
m, err := conga.From(svc.App())
|
|
if err != nil {
|
|
return models.Crawl{}, err
|
|
}
|
|
id, err := m.Dispatch(ctx, tx, CrawlArgs{CrawlID: row.ID}, conga.DispatchOpts{Label: svc.Phrase("golem15.seo::lang.jobs.crawl", nil)})
|
|
if err != nil {
|
|
return models.Crawl{}, err
|
|
}
|
|
row.JobID = &id
|
|
if err := tx.Model(&models.Crawl{}).Where("id = ?", row.ID).Update("job_id", id).Error; err != nil {
|
|
return models.Crawl{}, err
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// Limits are a crawl's politeness limits and the Site audit settings it
|
|
// reads: pages per crawl, the pause between requests, the crawls kept and
|
|
// the generic link texts.
|
|
type Limits struct {
|
|
MaxPages int
|
|
Delay time.Duration
|
|
Keep int
|
|
GenericAnchors []string
|
|
}
|
|
|
|
// DefaultKeep is the number of crawls kept.
|
|
const DefaultKeep = 10
|
|
|
|
// LoadLimits reads the crawl limits from the Site audit settings; a value
|
|
// outside its range (written around the settings screen) falls back to the
|
|
// default.
|
|
func LoadLimits(db *gorm.DB) (Limits, error) {
|
|
st, err := classes.LoadSettings(db)
|
|
if err != nil {
|
|
return Limits{}, err
|
|
}
|
|
out := Limits{MaxPages: DefaultMaxPages, Delay: DefaultDelay, Keep: DefaultKeep, GenericAnchors: ParseAnchors(st.GenericAnchors)}
|
|
if st.CrawlMaxPages >= models.MinCrawlMaxPages && st.CrawlMaxPages <= models.MaxCrawlMaxPages {
|
|
out.MaxPages = st.CrawlMaxPages
|
|
}
|
|
if st.CrawlDelay >= models.MinCrawlDelay && st.CrawlDelay <= models.MaxCrawlDelay {
|
|
out.Delay = time.Duration(st.CrawlDelay) * time.Millisecond
|
|
}
|
|
if st.CrawlKeep >= models.MinCrawlKeep && st.CrawlKeep <= models.MaxCrawlKeep {
|
|
out.Keep = st.CrawlKeep
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LoadCrawl reads one crawl.
|
|
func LoadCrawl(db *gorm.DB, id uint) (models.Crawl, error) {
|
|
var rows []models.Crawl
|
|
if err := db.Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
|
return models.Crawl{}, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return models.Crawl{}, gorm.ErrRecordNotFound
|
|
}
|
|
return rows[0], nil
|
|
}
|
|
|
|
// Run runs one queued crawl: it fetches the site, checks every page,
|
|
// stores the pages, links and findings with the CMS-data issues, and marks
|
|
// the crawl finished, cancelled (pages and issues fetched so far are kept)
|
|
// or failed (with the error; what was fetched before the failure is kept).
|
|
// A crawl that is no longer queued is left alone.
|
|
//
|
|
// The job's context ends on a cancel (River cancels a running job's
|
|
// context) or at the job timeout; what was fetched is then stored on a
|
|
// context detached from it with its own FinishTimeout bound, so a
|
|
// cancelled or timed-out crawl keeps its results and never stays running.
|
|
// While it runs, the job writes heartbeat_at every HeartbeatEvery.
|
|
func Run(ctx context.Context, svc *classes.Service, id uint) error {
|
|
gdb, err := svc.GormDB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
db := gdb.WithContext(ctx)
|
|
row, err := LoadCrawl(db, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if row.Status != models.CrawlQueued {
|
|
return nil
|
|
}
|
|
now := time.Now().UTC()
|
|
moved := db.Model(&models.Crawl{}).Where("id = ? AND status = ?", id, models.CrawlQueued).
|
|
Updates(map[string]any{"status": models.CrawlRunning, "started_at": now, "heartbeat_at": now})
|
|
if moved.Error != nil {
|
|
return moved.Error
|
|
}
|
|
if moved.RowsAffected == 0 {
|
|
return nil
|
|
}
|
|
stop := heartbeat(ctx, gdb, id)
|
|
defer stop()
|
|
res, runErr := run(ctx, svc, db, row)
|
|
fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), FinishTimeout)
|
|
defer cancel()
|
|
return finish(fctx, svc, gdb.WithContext(fctx), row, res, runErr, ctx.Err())
|
|
}
|
|
|
|
// heartbeat writes the crawl's heartbeat_at every HeartbeatEvery until the
|
|
// returned stop is called; each write runs on a context detached from the
|
|
// job's, bounded by heartbeatWrite.
|
|
func heartbeat(ctx context.Context, gdb *gorm.DB, id uint) (stop func()) {
|
|
done := make(chan struct{})
|
|
finished := make(chan struct{})
|
|
go func() {
|
|
defer close(finished)
|
|
t := time.NewTicker(HeartbeatEvery)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-t.C:
|
|
hctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), heartbeatWrite)
|
|
_ = gdb.WithContext(hctx).Model(&models.Crawl{}).Where("id = ? AND status = ?", id, models.CrawlRunning).
|
|
Update("heartbeat_at", time.Now().UTC()).Error
|
|
cancel()
|
|
}
|
|
}
|
|
}()
|
|
return func() {
|
|
close(done)
|
|
<-finished
|
|
}
|
|
}
|
|
|
|
// run fetches and checks the site of one crawl.
|
|
func run(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.Crawl) (*Result, error) {
|
|
f, err := NewFetcher(row.Site, svc.Config())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limits, err := LoadLimits(db)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if row.MaxPages > 0 {
|
|
limits.MaxPages = row.MaxPages
|
|
}
|
|
var m *conga.Manager
|
|
if app := svc.App(); app != nil {
|
|
m, _ = conga.From(app)
|
|
}
|
|
// The rendered comparison: the test seam's renderer, else Chrome from
|
|
// golem15.seo.crawl_chrome_path (none: skipped). Chrome is closed when
|
|
// the crawl finishes, fails or is cancelled.
|
|
renderer, renderErr := rendererFrom(ctx), error(nil)
|
|
if renderer == nil {
|
|
renderer, renderErr = NewRenderer(ctx, svc.Config(), f.Site(), f.IsTrusted(), f.UA())
|
|
if renderer != nil {
|
|
defer func() { _ = renderer.Close() }()
|
|
}
|
|
}
|
|
opt := Options{
|
|
MaxPages: limits.MaxPages,
|
|
Delay: limits.Delay,
|
|
Renderer: renderer,
|
|
RenderErr: renderErr,
|
|
RenderMax: RenderMax(svc.Config()),
|
|
Canceled: func(ctx context.Context) bool {
|
|
if cancelRequested(db, row.ID) {
|
|
return true
|
|
}
|
|
if m != nil && row.JobID != nil {
|
|
if c, err := m.CheckIfCanceled(ctx, *row.JobID); err == nil && c {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
Progress: func(ctx context.Context, n int) {
|
|
_ = db.Model(&models.Crawl{}).Where("id = ?", row.ID).Update("pages", n).Error
|
|
if m != nil && row.JobID != nil {
|
|
_ = m.UpdateJobState(ctx, *row.JobID, n, nil)
|
|
}
|
|
},
|
|
}
|
|
if m != nil && row.JobID != nil {
|
|
_ = m.StartJob(ctx, *row.JobID, limits.MaxPages)
|
|
}
|
|
return Crawl(ctx, f, opt)
|
|
}
|
|
|
|
// cancelRequested reports whether a cancel of the crawl was requested: its
|
|
// cancel_requested_at is set, or its status is cancelled.
|
|
func cancelRequested(db *gorm.DB, id uint) bool {
|
|
var rows []struct {
|
|
Status string
|
|
Requested bool
|
|
}
|
|
if err := db.Raw(`SELECT status, cancel_requested_at IS NOT NULL AS requested FROM golem15_seo_crawls WHERE id = ?`, id).Scan(&rows).Error; err != nil || len(rows) == 0 {
|
|
return false
|
|
}
|
|
return rows[0].Requested || rows[0].Status == models.CrawlCancelled
|
|
}
|
|
|
|
// timeLimitError is the error of a crawl that reached JobTimeout.
|
|
func timeLimitError() string {
|
|
limit := JobTimeout.String()
|
|
if JobTimeout%time.Hour == 0 {
|
|
limit = strconv.Itoa(int(JobTimeout/time.Hour)) + "h"
|
|
}
|
|
return "the crawl reached its time limit of " + limit
|
|
}
|
|
|
|
// finalStatus decides a crawl's final status and error: a requested
|
|
// cancel, or a cancelled job context, ends cancelled; the job's deadline
|
|
// fails it with the time-limit error; another run error fails it with that
|
|
// error; else it finished (cancelled when the crawl stopped on a cancel).
|
|
func finalStatus(requested bool, res *Result, runErr, ctxErr error) (string, string) {
|
|
switch {
|
|
case requested:
|
|
return models.CrawlCancelled, ""
|
|
case errors.Is(ctxErr, context.DeadlineExceeded) || errors.Is(runErr, context.DeadlineExceeded):
|
|
return models.CrawlFailed, timeLimitError()
|
|
case errors.Is(ctxErr, context.Canceled) || errors.Is(runErr, context.Canceled):
|
|
return models.CrawlCancelled, ""
|
|
case runErr != nil:
|
|
return models.CrawlFailed, runErr.Error()
|
|
case res != nil && res.Canceled:
|
|
return models.CrawlCancelled, ""
|
|
}
|
|
return models.CrawlFinished, ""
|
|
}
|
|
|
|
// finish stores what a crawl found and writes its final state. ctx is the
|
|
// detached, bounded context of Run, ctxErr the job context's error (nil
|
|
// while it was alive). A LoadLimits failure falls back to the default
|
|
// limits.
|
|
func finish(ctx context.Context, svc *classes.Service, db *gorm.DB, row models.Crawl, res *Result, runErr, ctxErr error) error {
|
|
status, errText := finalStatus(cancelRequested(db, row.ID), res, runErr, ctxErr)
|
|
limits, err := LoadLimits(db)
|
|
if err != nil {
|
|
pluginLog.Warn("golem15.seo: crawl limits, using the defaults", "crawl", row.ID, "error", err)
|
|
limits = Limits{MaxPages: DefaultMaxPages, Delay: DefaultDelay, Keep: DefaultKeep}
|
|
}
|
|
store := func(ctx context.Context, tx *gorm.DB) error {
|
|
if res == nil {
|
|
return nil
|
|
}
|
|
if err := SavePages(tx, row.ID, res); err != nil {
|
|
return err
|
|
}
|
|
keyphrases, err := Keyphrases(tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
findings := Check(res, &Env{Lookup: Lookup(ctx, svc, tx), Staging: svc.Staging(), GenericAnchors: limits.GenericAnchors, Keyphrases: keyphrases, SitemapTool: SitemapTool(svc)})
|
|
cms, err := svc.Issues(classes.WithTx(ctx, tx))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := row.ID
|
|
return SaveIssues(tx, &id, findings, cms)
|
|
}
|
|
if err := lagoon.Transaction(ctx, db, store); err != nil {
|
|
status, errText = models.CrawlFailed, err.Error()
|
|
}
|
|
updates := map[string]any{"status": status, "error": errText, "finished_at": time.Now().UTC()}
|
|
if res != nil {
|
|
updates["pages"] = len(res.Order)
|
|
updates["limit_hit"] = res.LimitHit
|
|
updates["render_status"] = res.Render.Status
|
|
updates["render_pages"] = res.Render.Pages
|
|
updates["render_note"] = res.Render.Note
|
|
}
|
|
if err := db.Model(&models.Crawl{}).Where("id = ?", row.ID).Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
if m, err := conga.From(svc.App()); err == nil && row.JobID != nil {
|
|
meta := map[string]any{"crawl_id": row.ID, "status": status}
|
|
switch status {
|
|
case models.CrawlFailed:
|
|
_ = m.FailJob(ctx, *row.JobID, meta)
|
|
case models.CrawlCancelled:
|
|
_ = m.StopJob(ctx, *row.JobID, meta)
|
|
default:
|
|
_ = m.CompleteJob(ctx, *row.JobID, meta)
|
|
}
|
|
}
|
|
if status == models.CrawlFailed {
|
|
pluginLog.Warn("golem15.seo: crawl failed", "crawl", row.ID, "error", errText)
|
|
}
|
|
if err := Prune(db, limits.Keep); err != nil {
|
|
pluginLog.Warn("golem15.seo: prune crawls", "error", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// endStaleJobs fails or stops the conga jobs of crawls RecoverStale ended,
|
|
// once tx commits; best effort.
|
|
func endStaleJobs(ctx context.Context, svc *classes.Service, tx *gorm.DB, rows []models.Crawl) {
|
|
if len(rows) == 0 {
|
|
return
|
|
}
|
|
lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) {
|
|
m, err := conga.From(svc.App())
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, r := range rows {
|
|
if r.JobID == nil {
|
|
continue
|
|
}
|
|
meta := map[string]any{"crawl_id": r.ID, "status": r.Status}
|
|
if r.Status == models.CrawlCancelled {
|
|
_ = m.StopJob(ctx, *r.JobID, meta)
|
|
} else {
|
|
_ = m.FailJob(ctx, *r.JobID, meta)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Lookup returns the CMS record behind a crawled address (cached per
|
|
// address): the content type whose Match answers the path exactly, its
|
|
// descriptor facts in the matched locale, its primary parent's address and
|
|
// whether its SEO tab stores custom JSON-LD.
|
|
func Lookup(ctx context.Context, svc *classes.Service, db *gorm.DB) func(key string) *Record {
|
|
cache := map[string]*Record{}
|
|
reg := svc.Registry()
|
|
unprefixed := svc.UnprefixedLocale()
|
|
return func(key string) *Record {
|
|
if reg == nil {
|
|
return nil
|
|
}
|
|
path, _, _ := strings.Cut(key, "?")
|
|
if r, ok := cache[path]; ok {
|
|
return r
|
|
}
|
|
cache[path] = nil
|
|
typeKey, match, err := reg.MatchPath(ctx, path, unprefixed)
|
|
if err != nil || match == nil || !match.Exact {
|
|
return nil
|
|
}
|
|
typ, ok := reg.ContentType(typeKey)
|
|
if !ok || typ.Content == nil {
|
|
return nil
|
|
}
|
|
rec, err := reg.Describe(ctx, typeKey, match.ID, unprefixed)
|
|
if err != nil || rec == nil {
|
|
return nil
|
|
}
|
|
out := &Record{Type: typeKey, ID: match.ID, Locale: match.Locale, TopHeading: rec.TopHeading, Tools: typ.Content.Tools}
|
|
var loc *pact.ContentLocale
|
|
for i := range rec.Locales {
|
|
if rec.Locales[i].Locale == match.Locale {
|
|
loc = &rec.Locales[i]
|
|
break
|
|
}
|
|
}
|
|
if loc == nil && len(rec.Locales) > 0 {
|
|
loc = &rec.Locales[0]
|
|
}
|
|
if loc != nil {
|
|
out.Locale = loc.Locale
|
|
out.Title, out.Words, out.Headings, out.Images = loc.Title, loc.Words, loc.Headings, loc.Images
|
|
}
|
|
if len(rec.Parents) > 0 {
|
|
if parent, err := reg.Describe(ctx, rec.Parents[0].Type, rec.Parents[0].ID, unprefixed); err == nil && parent != nil {
|
|
for _, pl := range parent.Locales {
|
|
if pl.Locale == out.Locale && pl.URL != "" {
|
|
out.Links = append(out.Links, pl.URL)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if entry, found, err := classes.LoadEntry(db, typ.Content.Model, match.ID, out.Locale); err == nil && found && entry.Data != nil {
|
|
if raw, _ := entry.Data["schema"].(string); raw != "" {
|
|
if v, err := schema.ParseValue(raw); err == nil && strings.TrimSpace(v.Custom) != "" {
|
|
out.CustomJSONLD = true
|
|
}
|
|
}
|
|
}
|
|
cache[path] = out
|
|
return out
|
|
}
|
|
}
|
|
|
|
// Keyphrases maps each address with an SEO entry to its focus keyphrase.
|
|
func Keyphrases(db *gorm.DB) (map[string]string, error) {
|
|
var rows []struct {
|
|
URL string
|
|
Keyphrase string
|
|
}
|
|
if err := db.Raw(`SELECT url, keyphrase FROM golem15_seo_entries WHERE url <> '' AND keyphrase <> ''`).Scan(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]string, len(rows))
|
|
for _, r := range rows {
|
|
out[r.URL] = r.Keyphrase
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// errCancelledCrawl refuses cancelling a crawl that is not queued or
|
|
// running.
|
|
var errCancelledCrawl = errors.New("golem15.seo: the crawl is not running")
|
|
|
|
// Cancel stops a queued or running crawl inside tx. A queued crawl ends
|
|
// cancelled at once (by name, finished_at set). A running crawl gets
|
|
// cancel_requested_at and cancelled_by and stays running: its job stops
|
|
// between requests and writes cancelled with the pages and issues fetched
|
|
// so far, and until then no other crawl starts. The conga job is cancelled
|
|
// in both cases.
|
|
func Cancel(ctx context.Context, svc *classes.Service, tx *gorm.DB, row models.Crawl, name string) error {
|
|
if !row.Active() {
|
|
return errCancelledCrawl
|
|
}
|
|
now := time.Now().UTC()
|
|
queued := tx.Model(&models.Crawl{}).Where("id = ? AND status = ?", row.ID, models.CrawlQueued).
|
|
Updates(map[string]any{"status": models.CrawlCancelled, "cancelled_by": name, "finished_at": now})
|
|
if queued.Error != nil {
|
|
return queued.Error
|
|
}
|
|
if queued.RowsAffected == 0 {
|
|
running := tx.Model(&models.Crawl{}).Where("id = ? AND status = ?", row.ID, models.CrawlRunning).
|
|
Updates(map[string]any{"cancel_requested_at": now, "cancelled_by": name})
|
|
if running.Error != nil {
|
|
return running.Error
|
|
}
|
|
if running.RowsAffected == 0 {
|
|
return errCancelledCrawl
|
|
}
|
|
}
|
|
if row.JobID != nil {
|
|
if m, err := conga.From(svc.App()); err == nil {
|
|
if err := m.CancelJob(ctx, *row.JobID); err != nil {
|
|
pluginLog.Warn("golem15.seo: cancel crawl job", "crawl", row.ID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ErrNotRunning is a cancel of a crawl that is not queued or running.
|
|
var ErrNotRunning = errCancelledCrawl
|
|
|
|
// pluginLog is the crawler's logger (component seo).
|
|
var pluginLog = diary.For("seo")
|
|
|
|
// ScheduledDue reports whether the nightly entry starts a crawl on day
|
|
// (in the app's location) for the crawl_schedule setting: daily every
|
|
// night, weekly on Mondays, never when off (P33-40).
|
|
func ScheduledDue(schedule string, day time.Time) bool {
|
|
switch schedule {
|
|
case models.CrawlDaily:
|
|
return true
|
|
case models.CrawlWeekly:
|
|
return day.Weekday() == time.Monday
|
|
}
|
|
return false
|
|
}
|