Files
sm-seo-plugin/commands.go
Jakub Zych fc53d93b9d feat: site audit settings and the nightly crawl
- Site audit tab (UI-SPEC C20): crawl_schedule, crawl_max_pages
  (10-50 000), crawl_delay (100-5000 ms), crawl_keep (1-50) and
  generic_anchors seeded with the en and pl lists; ranges refuse with
  lagoon::validation.between on the screen and through the API
- the crawler reads its limits and generic anchors from the settings;
  crawls beyond crawl_keep are deleted with their pages and issues
- seo:crawl [--scheduled] and the nightly schedule entry at 02:30
  (P33-40): off does nothing, weekly runs on Mondays in app.timezone
- TestCrawlSchedule
2026-11-04 15:41:54 +01:00

180 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package seo
import (
"context"
"errors"
"fmt"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/classes/crawl"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// Commands registers seo:generate-entries and seo:crawl.
func (p *Plugin) Commands() []bonfire.Command {
return []bonfire.Command{p.crawlCommand(), {
Name: "seo:generate-entries",
Description: "Queue the job that creates missing SEO entries",
Long: `Queues the entry generation job on the background queue. The job walks the
published records of every content type registered in signpost (or of the
one named by --type), creates the missing SEO entry of each locale with its
current address and refreshes the address and the effective title and
description of existing entries. Values set in the SEO tab are never
changed. A worker (summer serve or queue:work) runs the job.`,
Flags: []bonfire.Flag{
{Name: "type", Description: "Content type key (signpost menu item type) to limit the run to"},
},
Examples: []bonfire.Example{
{Command: "seo:generate-entries", Description: "Queue generation for every content type"},
{Command: "seo:generate-entries --type journal-post", Description: "Queue generation for one content type"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
typeKey, _ := in.Flag("type")
typeKey = strings.TrimSpace(typeKey)
svc := p.Service()
if svc == nil {
return classes.ErrNotBooted
}
if typeKey != "" {
if reg := svc.Registry(); reg != nil {
if _, ok := reg.ContentType(typeKey); !ok {
return fmt.Errorf("Content type %q is not registered", typeKey)
}
}
}
db, done, err := p.commandDB(ctx)
if err != nil {
return err
}
defer done()
id, err := svc.EnqueueGenerate(ctx, db, typeKey)
if err != nil {
return err
}
out.Success(fmt.Sprintf("Queued SEO entry generation (job #%d).", id))
return nil
},
}}
}
// crawlCommand is seo:crawl [--scheduled]: it queues a crawl of the site
// (D-14) for the worker; --scheduled is the nightly entry (P33-40), which
// queues one only when the Site audit setting crawl_schedule is daily, or
// weekly on a Monday in app.timezone, and skips while a crawl runs.
func (p *Plugin) crawlCommand() bonfire.Command {
return bonfire.Command{
Name: "seo:crawl",
Description: "Queue a crawl of the site for the site audit",
Long: `Queues a crawl job on the background queue. The crawler fetches the site's own
pages over HTTP like a search engine (from the home page, the sitemap index
and its files, and same-host links; two requests at a time, the Site audit
settings' pause and page limit) and stores what it finds in SEO › Site audit.
One crawl runs at a time. With --scheduled, as the nightly schedule runs it,
the command queues a crawl only when the Site audit setting "Automatic crawl"
is daily, or weekly and it is Monday in app.timezone, and does nothing while
a crawl is queued or running. A worker (summer serve or queue:work) runs the
job.`,
Flags: []bonfire.Flag{
{Name: "scheduled", Description: "Run as the nightly schedule: follow the Automatic crawl setting", Bare: true},
},
Examples: []bonfire.Example{
{Command: "seo:crawl", Description: "Queue a crawl now"},
{Command: "seo:crawl --scheduled", Description: "What the nightly schedule runs"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
svc := p.Service()
if svc == nil {
return classes.ErrNotBooted
}
db, done, err := p.commandDB(ctx)
if err != nil {
return err
}
defer done()
scheduled := bonfire.FlagBool(in, "scheduled")
trigger, name := models.TriggerAdmin, "console"
if scheduled {
st, err := classes.LoadSettings(db)
if err != nil {
return err
}
if !crawl.ScheduledDue(st.CrawlSchedule, time.Now().In(appLocation(p.app))) {
out.Info("No crawl is due (Automatic crawl: " + st.CrawlSchedule + ").")
return nil
}
trigger, name = models.TriggerSchedule, ""
}
var row models.Crawl
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
var err error
row, err = crawl.Start(ctx, svc, tx, trigger, name)
return err
})
var running *crawl.AlreadyRunningError
switch {
case errors.As(err, &running) && scheduled:
out.Info(fmt.Sprintf("Crawl #%d is still running; no new crawl was queued.", running.Crawl.ID))
return nil
case errors.As(err, &running):
return fmt.Errorf("Crawl #%d is already running. Wait for it, or cancel it in SEO › Crawls.", running.Crawl.ID)
case errors.Is(err, crawl.ErrNoSiteURL):
return fmt.Errorf("The site address for crawls is not configured (golem15.seo.site_url).")
case err != nil:
return err
}
job := uint(0)
if row.JobID != nil {
job = *row.JobID
}
out.Success(fmt.Sprintf("Queued crawl #%d of %s (job #%d).", row.ID, row.Site, job))
return nil
},
}
}
// appLocation is the app.timezone location, UTC when unset or unknown.
func appLocation(app *backpack.App) *time.Location {
if app == nil || app.Config == nil {
return time.UTC
}
if name := strings.TrimSpace(app.Config.String("app.timezone")); name != "" {
if loc, err := time.LoadLocation(name); err == nil {
return loc
}
}
return time.UTC
}
// commandDB returns the app database, opening database.dsn when the binary
// published none for console commands. done closes a pool it opened.
func (p *Plugin) commandDB(ctx context.Context) (*gorm.DB, func(), error) {
app := p.app
if app == nil {
return nil, nil, classes.ErrNotBooted
}
if db, ok := app.Lookup[*gorm.DB](); ok && db != nil {
return db, func() {}, nil
}
if lagoon.DSN(app.Config) == "" {
return nil, nil, fmt.Errorf("golem15.seo: database is not configured (set SUMMER_DATABASE__DSN)")
}
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, app)
if err != nil {
return nil, nil, err
}
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
_ = sqlDB.Close()
return nil, nil, err
}
return gdb, func() { _ = sqlDB.Close() }, nil
}
var _ pact.HasCommands = (*Plugin)(nil)