repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

6.7 KB · 270 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log/slog"
  7	"net/http"
  8	"net/url"
  9	"sort"
 10	"strings"
 11	"sync"
 12	"time"
 13)
 14
 15// Page is one crawled URL plus, when it was HTML, everything parsed out of it.
 16type Page struct {
 17	URL          string
 18	RequestedURL string
 19	Status       int
 20	ContentType  string
 21	ElapsedMS    int64
 22	Bytes        int
 23	Headers      map[string]string
 24	RedirectHops int
 25	Err          string
 26	IsHTML       bool
 27	HTML         *ParsedHTML
 28}
 29
 30// CrawlResult is everything one crawl learned, before any of it is judged.
 31type CrawlResult struct {
 32	StartURL           string
 33	Host               string
 34	Pages              []*Page
 35	ExternalLinkStatus map[string]int
 36	SitemapURLs        []string
 37	Robots             RobotsCtx
 38	// Compression is the start URL's Content-Encoding, or "" if it answered
 39	// uncompressed.
 40	Compression string
 41}
 42
 43// normalizeURL is the key pages are deduplicated by. Dropping the trailing
 44// slash can merge two paths a server really does serve differently, but
 45// keeping it crawls most sites twice.
 46func normalizeURL(raw string) string {
 47	u, err := url.Parse(raw)
 48	if err != nil {
 49		return raw
 50	}
 51	u.Fragment = ""
 52	s := u.String()
 53	if trimmed := strings.TrimSuffix(s, "/"); trimmed != "" {
 54		return trimmed
 55	}
 56	return s
 57}
 58
 59// RunSEOSpider crawls a site and returns the insights. progress, if non-nil,
 60// is called with the running page count after each batch.
 61func RunSEOSpider(ctx context.Context, startURL string, progress func(pages int)) ([]Insight, error) {
 62	started := time.Now()
 63	slog.Info(fmt.Sprintf("starting %s", startURL), slog.String("component", "crawler"))
 64
 65	result, err := crawl(ctx, startURL, progress)
 66	if err != nil {
 67		return nil, err
 68	}
 69	insights := runChecks(result)
 70
 71	slog.Info(fmt.Sprintf("done %s: %d pages, %d insights, %.1fs",
 72		startURL, len(result.Pages), len(insights), time.Since(started).Seconds()), slog.String("component", "crawler"))
 73	return insights, nil
 74}
 75
 76func crawl(ctx context.Context, startURL string, progress func(pages int)) (*CrawlResult, error) {
 77	start, err := parseHTTPURL(startURL)
 78	if err != nil {
 79		return nil, err
 80	}
 81	host := start.Hostname()
 82	origin := start.Scheme + "://" + start.Host
 83
 84	ctx, cancel := context.WithTimeout(ctx, CrawlDeadline)
 85	defer cancel()
 86
 87	client := newCrawlClient()
 88
 89	compression := probeCompression(ctx, client, startURL)
 90	robots, robotsCtx := loadRobots(ctx, client, origin)
 91	sitemapURLs := loadSitemap(ctx, client, origin, robotsCtx.Raw)
 92
 93	var (
 94		seen    = map[string]bool{}
 95		fetched = map[string]bool{}
 96		queue   []string
 97		pages   []*Page
 98	)
 99
100	enqueue := func(raw string) {
101		key := normalizeURL(raw)
102		if seen[key] {
103			return
104		}
105		seen[key] = true
106		queue = append(queue, raw)
107	}
108
109	enqueue(startURL)
110	// The sitemap is a second entry point and not just a checklist, since it
111	// surfaces pages nothing links to.
112	for i, u := range sitemapURLs {
113		if i >= PageCap {
114			break
115		}
116		if sameSite(u, host) {
117			enqueue(u)
118		}
119	}
120
121	hitDeadline := false
122	for len(queue) > 0 && len(pages) < PageCap {
123		if ctx.Err() != nil {
124			hitDeadline = true
125			break
126		}
127
128		var batch []string
129		for len(queue) > 0 && len(batch) < Concurrency && len(pages)+len(batch) < PageCap {
130			next := queue[0]
131			queue = queue[1:]
132			if !robots.Allowed(next) {
133				continue
134			}
135			batch = append(batch, next)
136		}
137		if len(batch) == 0 {
138			break
139		}
140
141		results := make([]FetchResult, len(batch))
142		var wg sync.WaitGroup
143		for i, u := range batch {
144			wg.Add(1)
145			go func(i int, u string) {
146				defer wg.Done()
147				results[i] = fetchPage(ctx, client, u)
148			}(i, u)
149		}
150		wg.Wait()
151
152		for _, r := range results {
153			// A redirect can land two queued URLs on the same page, so dedupe
154			// on the final URL, which the queue cannot know yet.
155			finalKey := normalizeURL(r.URL)
156			if fetched[finalKey] {
157				seen[finalKey] = true
158				continue
159			}
160			fetched[finalKey] = true
161			seen[finalKey] = true
162
163			page := &Page{
164				URL:          r.URL,
165				RequestedURL: r.RequestedURL,
166				Status:       r.Status,
167				ContentType:  r.ContentType,
168				ElapsedMS:    r.ElapsedMS,
169				Bytes:        len(r.Body),
170				Headers:      r.Headers,
171				RedirectHops: r.RedirectHops,
172				Err:          r.Err,
173				IsHTML:       r.Status == 200 && strings.Contains(r.ContentType, "text/html"),
174			}
175
176			if page.IsHTML {
177				parsed, err := parseHTML(r.Body, r.URL)
178				if err != nil {
179					slog.Error(fmt.Sprintf("parse failed for %s: %v", r.URL, err), slog.String("component", "crawler"))
180					page.IsHTML = false
181				} else {
182					page.HTML = parsed
183					for _, link := range parsed.Links {
184						if sameSite(link.URL, host) {
185							enqueue(link.URL)
186						}
187					}
188				}
189			}
190
191			pages = append(pages, page)
192		}
193		if progress != nil {
194			progress(len(pages))
195		}
196	}
197
198	if hitDeadline || ctx.Err() != nil {
199		slog.Info(fmt.Sprintf("hit deadline for %s after %d pages", startURL, len(pages)), slog.String("component", "crawler"))
200	}
201
202	return &CrawlResult{
203		StartURL:           startURL,
204		Host:               host,
205		Pages:              pages,
206		ExternalLinkStatus: probeExternalLinks(ctx, client, pages, host, startURL),
207		SitemapURLs:        sitemapURLs,
208		Robots:             robotsCtx,
209		Compression:        compression,
210	}, nil
211}
212
213// probeExternalLinks HEADs every distinct off-site link. Sorting before the
214// cap keeps the probed subset the same from one run to the next.
215func probeExternalLinks(ctx context.Context, client *http.Client, pages []*Page, host, startURL string) map[string]int {
216	external := map[string]bool{}
217	for _, p := range pages {
218		if !p.IsHTML || p.HTML == nil {
219			continue
220		}
221		for _, link := range p.HTML.Links {
222			if sameSite(link.URL, host) || isCrawlerHostile(link.URL) {
223				continue
224			}
225			external[link.URL] = true
226		}
227	}
228
229	status := map[string]int{}
230	if len(external) == 0 || ctx.Err() != nil {
231		return status
232	}
233
234	urls := make([]string, 0, len(external))
235	for u := range external {
236		urls = append(urls, u)
237	}
238	sort.Strings(urls)
239	if len(urls) > ExternalLinkCap {
240		slog.Info(fmt.Sprintf("capping external link probes at %d of %d for %s",
241			ExternalLinkCap, len(urls), startURL), slog.String("component", "crawler"))
242		urls = urls[:ExternalLinkCap]
243	}
244
245	var mu sync.Mutex
246	for i := 0; i < len(urls); i += Concurrency {
247		// Without this a slow tail of probes runs past the crawl budget and the
248		// watchdog records a finished crawl as an interruption.
249		if ctx.Err() != nil {
250			slog.Info(fmt.Sprintf("hit deadline during external link probes for %s", startURL), slog.String("component", "crawler"))
251			break
252		}
253
254		end := min(i+Concurrency, len(urls))
255		var wg sync.WaitGroup
256		for _, u := range urls[i:end] {
257			wg.Add(1)
258			go func(u string) {
259				defer wg.Done()
260				code := headStatus(ctx, client, u)
261				mu.Lock()
262				status[u] = code
263				mu.Unlock()
264			}(u)
265		}
266		wg.Wait()
267	}
268	return status
269}