- 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
180 lines
6.3 KiB
Go
180 lines
6.3 KiB
Go
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)
|