- E10 golem15-seo-links in the SEO tab after the graph preview (types with a body): inbound and outbound counts from the last finished crawl, the orphan warning, up to 10 pages to link to (Copy address) and 10 that could link here - classes/linkgraph.go builds the report from the crawl's pages and links; the crawler's orphan suggestions share NormalText - GET /_seo/api/v1/links and seo_get_link_suggestions; widget action seo_links mapped in AdminOperations - element bundle rebuilt
365 lines
12 KiB
Go
365 lines
12 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-seo-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// MaxLinkSuggestions caps each suggestion list of the internal links
|
|
// widget (UI-SPEC C19).
|
|
const MaxLinkSuggestions = 10
|
|
|
|
// minLinkPhrase is the shortest phrase a link suggestion matches, in
|
|
// bytes (the orphan suggestions' limit).
|
|
const minLinkPhrase = 3
|
|
|
|
// LinksActionName is the SEO tab's internal links widget action (E10).
|
|
const LinksActionName = "seo_links"
|
|
|
|
// LinkOut is a page this text could link to: the phrase found in this
|
|
// page's crawled text, the target's title and its public address.
|
|
type LinkOut struct {
|
|
Phrase string `json:"phrase"`
|
|
Title string `json:"title"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// LinkIn is a page that could link here: the phrase of this page found in
|
|
// its crawled text, its title and its admin form ("" when no record of
|
|
// the admin is behind it).
|
|
type LinkIn struct {
|
|
Phrase string `json:"phrase"`
|
|
Title string `json:"title"`
|
|
AdminURL string `json:"adminUrl"`
|
|
}
|
|
|
|
// LinkReport is the internal links of a page from the last finished crawl
|
|
// (D-82, UI-SPEC C19): the time of that crawl, the pages linking here and
|
|
// the pages linked from here (both nil before the first crawl), whether
|
|
// nothing links to the page, and up to 10 pages this text could link to
|
|
// and 10 that could link here.
|
|
type LinkReport struct {
|
|
CrawledAt *time.Time `json:"crawledAt"`
|
|
Inbound *int `json:"inbound"`
|
|
Outbound *int `json:"outbound"`
|
|
Orphan bool `json:"orphan"`
|
|
Out []LinkOut `json:"out"`
|
|
In []LinkIn `json:"in"`
|
|
}
|
|
|
|
// NormalText is text in lower case with its whitespace collapsed to one
|
|
// space: the form the crawler's suggestions compare phrases in.
|
|
func NormalText(s string) string { return strings.ToLower(strings.Join(strings.Fields(s), " ")) }
|
|
|
|
// errEnough stops a batched scan once a list is full.
|
|
var errEnough = errors.New("golem15.seo: enough suggestions")
|
|
|
|
// lastFinishedCrawl is the newest finished crawl, nil before the first.
|
|
func lastFinishedCrawl(db *gorm.DB) (*models.Crawl, error) {
|
|
var rows []models.Crawl
|
|
if err := db.Where("status = ?", models.CrawlFinished).Order("finished_at DESC NULLS LAST, id DESC").Limit(1).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &rows[0], nil
|
|
}
|
|
|
|
// pageHeading is a crawled page's first H1, else its title.
|
|
func pageHeading(p models.CrawlPage) (h1, title string) {
|
|
if list := p.H1.Get(); len(list) > 0 {
|
|
h1 = strings.TrimSpace(list[0])
|
|
}
|
|
title = h1
|
|
if title == "" {
|
|
title = strings.TrimSpace(p.Title)
|
|
}
|
|
return h1, title
|
|
}
|
|
|
|
// entryKeyphrases maps each address with an SEO entry to its focus
|
|
// keyphrase.
|
|
func entryKeyphrases(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
|
|
}
|
|
|
|
// phraseList keeps the phrases of at least 3 bytes, once each (by their
|
|
// normal form), in order.
|
|
func phraseList(phrases ...string) []string {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, p := range phrases {
|
|
p = strings.Join(strings.Fields(p), " ")
|
|
n := NormalText(p)
|
|
if len(n) < minLinkPhrase || seen[n] {
|
|
continue
|
|
}
|
|
seen[n] = true
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mentioned returns the first phrase whose normal form normal (a page's
|
|
// NormalText) contains, "" when none.
|
|
func mentioned(normal string, phrases []string) string {
|
|
for _, p := range phrases {
|
|
if strings.Contains(normal, NormalText(p)) {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// adminURLOf is the admin form of the record behind a site path, "" when
|
|
// no content type with an admin controller owns it.
|
|
func (s *Service) adminURLOf(ctx context.Context, path string) string {
|
|
reg := s.Registry()
|
|
if reg == nil {
|
|
return ""
|
|
}
|
|
p, _, _ := strings.Cut(path, "?")
|
|
key, match, err := reg.MatchPath(ctx, p, s.UnprefixedLocale())
|
|
if err != nil || match == nil || !match.Exact {
|
|
return ""
|
|
}
|
|
typ, ok := reg.ContentType(key)
|
|
if !ok || typ.Content == nil || strings.TrimSpace(typ.Content.Controller) == "" {
|
|
return ""
|
|
}
|
|
return s.adminFormURL(typ.Content.Controller, match.ID)
|
|
}
|
|
|
|
// LinkSuggestions builds the internal links of the page at path from the
|
|
// last finished crawl (D-82, UI-SPEC C19), like the crawl's orphan
|
|
// suggestions: the pages whose crawled text contains one of phrases (the
|
|
// page's title and focus keyphrase; its crawled H1 is added) and do not
|
|
// link here yet, and the pages whose H1 or focus keyphrase this page's
|
|
// crawled text contains and that it does not link to yet. Before the
|
|
// first crawl the counts are nil and the lists empty.
|
|
func (s *Service) LinkSuggestions(ctx context.Context, db *gorm.DB, path string, phrases []string) (LinkReport, error) {
|
|
out := LinkReport{Out: []LinkOut{}, In: []LinkIn{}}
|
|
db = db.WithContext(ctx)
|
|
c, err := lastFinishedCrawl(db)
|
|
if err != nil || c == nil {
|
|
return out, err
|
|
}
|
|
out.CrawledAt = c.FinishedAt
|
|
var links []models.CrawlLink
|
|
if err := db.Select("from_url", "to_url").Where("crawl_id = ? AND internal AND from_url <> to_url AND (from_url = ? OR to_url = ?)", c.ID, path, path).Find(&links).Error; err != nil {
|
|
return out, err
|
|
}
|
|
linkedFrom, linksTo := map[string]bool{}, map[string]bool{}
|
|
for _, l := range links {
|
|
if l.FromURL == path {
|
|
linksTo[l.ToURL] = true
|
|
} else {
|
|
linkedFrom[l.FromURL] = true
|
|
}
|
|
}
|
|
inbound, outbound := len(linkedFrom), len(linksTo)
|
|
out.Inbound, out.Outbound = &inbound, &outbound
|
|
var self []models.CrawlPage
|
|
if err := db.Where("crawl_id = ? AND url = ?", c.ID, path).Limit(1).Find(&self).Error; err != nil {
|
|
return out, err
|
|
}
|
|
keyphrases, err := entryKeyphrases(db)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
site := s.SiteURL()
|
|
if len(self) > 0 {
|
|
out.Orphan = inbound == 0 && path != "/"
|
|
h1, _ := pageHeading(self[0])
|
|
phrases = append(phrases, h1)
|
|
text := NormalText(self[0].Text)
|
|
var others []models.CrawlPage
|
|
if err := db.Select("id", "url", "title", "h1").Where("crawl_id = ? AND url <> ? AND status = 200", c.ID, path).Order("url ASC").Find(&others).Error; err != nil {
|
|
return out, err
|
|
}
|
|
for _, o := range others {
|
|
if len(out.Out) >= MaxLinkSuggestions {
|
|
break
|
|
}
|
|
if linksTo[o.URL] {
|
|
continue
|
|
}
|
|
oh1, title := pageHeading(o)
|
|
if p := mentioned(text, phraseList(oh1, keyphrases[o.URL])); p != "" {
|
|
out.Out = append(out.Out, LinkOut{Phrase: p, Title: title, URL: site + o.URL})
|
|
}
|
|
}
|
|
}
|
|
own := phraseList(append(phrases, keyphrases[path])...)
|
|
if len(own) == 0 {
|
|
return out, nil
|
|
}
|
|
var batch []models.CrawlPage
|
|
err = db.Select("id", "url", "title", "h1", "text").Where("crawl_id = ? AND url <> ? AND status = 200", c.ID, path).
|
|
FindInBatches(&batch, 100, func(*gorm.DB, int) error {
|
|
for _, p := range batch {
|
|
if len(out.In) >= MaxLinkSuggestions {
|
|
return errEnough
|
|
}
|
|
if linkedFrom[p.URL] {
|
|
continue
|
|
}
|
|
if phrase := mentioned(NormalText(p.Text), own); phrase != "" {
|
|
_, title := pageHeading(p)
|
|
out.In = append(out.In, LinkIn{Phrase: phrase, Title: title, AdminURL: s.adminURLOf(ctx, p.URL)})
|
|
}
|
|
}
|
|
return nil
|
|
}).Error
|
|
if err != nil && !errors.Is(err, errEnough) {
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// sitePath is the path of a path or an absolute URL; ErrBadURL otherwise.
|
|
func sitePath(raw string) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if strings.HasPrefix(raw, "/") && !strings.HasPrefix(raw, "//") {
|
|
path, _, _ := strings.Cut(raw, "#")
|
|
return path, nil
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return "", ErrBadURL
|
|
}
|
|
path := u.EscapedPath()
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
if u.RawQuery != "" {
|
|
path += "?" + u.RawQuery
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
// LinksOfURL is the internal links of a page addressed by URL (a path or
|
|
// an absolute URL of the site): the record behind it gives its title in
|
|
// the matched locale.
|
|
func (s *Service) LinksOfURL(ctx context.Context, db *gorm.DB, raw string) (LinkReport, error) {
|
|
path, err := sitePath(raw)
|
|
if err != nil {
|
|
return LinkReport{}, err
|
|
}
|
|
var phrases []string
|
|
if reg := s.Registry(); reg != nil {
|
|
p, _, _ := strings.Cut(path, "?")
|
|
if key, match, err := reg.MatchPath(ctx, p, s.UnprefixedLocale()); err == nil && match != nil && match.Exact {
|
|
if rec, err := reg.Describe(ctx, key, match.ID, s.UnprefixedLocale()); err == nil && rec != nil {
|
|
for _, l := range rec.Locales {
|
|
if l.Locale == match.Locale {
|
|
phrases = append(phrases, l.Title)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return s.LinkSuggestions(ctx, db, path, phrases)
|
|
}
|
|
|
|
// LinksOfRecord is the internal links of record id of content type key in
|
|
// locale: its address and title in that locale and its focus keyphrase. A
|
|
// record without an address in the locale answers the crawl time only.
|
|
func (s *Service) LinksOfRecord(ctx context.Context, db *gorm.DB, key, id, locale string) (LinkReport, error) {
|
|
reg := s.Registry()
|
|
if reg == nil {
|
|
return LinkReport{Out: []LinkOut{}, In: []LinkIn{}}, nil
|
|
}
|
|
rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale())
|
|
if err != nil {
|
|
return LinkReport{}, err
|
|
}
|
|
var loc *pact.ContentLocale
|
|
if rec != nil {
|
|
for i := range rec.Locales {
|
|
if rec.Locales[i].Locale == locale {
|
|
loc = &rec.Locales[i]
|
|
}
|
|
}
|
|
}
|
|
if loc == nil || strings.TrimSpace(loc.URL) == "" {
|
|
out := LinkReport{Out: []LinkOut{}, In: []LinkIn{}}
|
|
c, err := lastFinishedCrawl(db.WithContext(ctx))
|
|
if err == nil && c != nil {
|
|
out.CrawledAt = c.FinishedAt
|
|
}
|
|
return out, err
|
|
}
|
|
phrases := []string{loc.Title}
|
|
if typ, ok := reg.ContentType(key); ok && typ.Content != nil {
|
|
if entry, found, err := LoadEntry(db, typ.Content.Model, id, locale); err == nil && found {
|
|
phrases = append(phrases, entry.Keyphrase)
|
|
}
|
|
}
|
|
return s.LinkSuggestions(ctx, db, loc.URL, phrases)
|
|
}
|
|
|
|
// linksPayload is the seo_links action's payload.
|
|
type linksPayload struct {
|
|
Op string `json:"op"`
|
|
Locale string `json:"locale"`
|
|
}
|
|
|
|
// RecordLinksAction is the SEO tab's internal links widget action (E10,
|
|
// UI-SPEC C19) for content type key: {op: links, locale} answers the
|
|
// record's LinkReport in that locale (the default when empty).
|
|
func RecordLinksAction(svc func() *Service, key, label, permission string) pact.AdminAction {
|
|
return pact.AdminAction{
|
|
Name: LinksActionName,
|
|
Label: label,
|
|
Permissions: []string{permission},
|
|
Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
|
|
s := svc()
|
|
if s == nil {
|
|
return pact.AdminActionResult{}, ErrNotBooted
|
|
}
|
|
var p linksPayload
|
|
if len(in.Payload) > 0 {
|
|
if err := json.Unmarshal(in.Payload, &p); err != nil || (p.Op != "" && p.Op != "links") {
|
|
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.api.value_invalid"}}}
|
|
}
|
|
}
|
|
db, err := s.DB(ctx)
|
|
if err != nil {
|
|
return pact.AdminActionResult{}, err
|
|
}
|
|
if in.RecordID == nil {
|
|
return pact.AdminActionResult{Data: LinkReport{Out: []LinkOut{}, In: []LinkIn{}}}, nil
|
|
}
|
|
locale := p.Locale
|
|
if locale == "" {
|
|
locale = s.DefaultLocale(ctx, db)
|
|
}
|
|
report, err := s.LinksOfRecord(ctx, db, key, strconv.FormatUint(*in.RecordID, 10), locale)
|
|
return pact.AdminActionResult{Data: report}, err
|
|
},
|
|
}
|
|
}
|