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

20.2 KB · 813 lines · Go Raw History
  1package main
  2
  3import (
  4	"fmt"
  5	"sort"
  6	"strings"
  7)
  8
  9const (
 10	typeSEO     = "seo"
 11	typeLinks   = "links"
 12	typeA11y    = "accessibility"
 13	typeContent = "content"
 14	typePerf    = "performance"
 15	typeSec     = "security"
 16
 17	sevError = "error"
 18	sevWarn  = "warning"
 19	sevInfo  = "info"
 20)
 21
 22// Insight is one finding. The JSON names are stored in
 23// properties.crawler_insights, so renaming one strands the history.
 24type Insight struct {
 25	URL      string `json:"url"`
 26	Issue    string `json:"issue"`
 27	Item     string `json:"item"`
 28	Type     string `json:"type"`
 29	Severity string `json:"severity"`
 30}
 31
 32func finding(url, issue, kind, severity, item string) Insight {
 33	return Insight{URL: url, Issue: issue, Item: item, Type: kind, Severity: severity}
 34}
 35
 36type checkCtx struct {
 37	startURL    string
 38	host        string
 39	pages       []*Page
 40	htmlPages   []*Page
 41	statusByURL map[string]int
 42	externalRaw map[string]int
 43	sitemapURLs []string
 44	robots      RobotsCtx
 45	compression string
 46}
 47
 48func isRedirectStatus(code int) bool {
 49	switch code {
 50	case 301, 302, 303, 307, 308:
 51		return true
 52	}
 53	return false
 54}
 55
 56// normalizeText is the key duplicate detection groups on, so "Home  Page" and
 57// "home page" count as one title.
 58func normalizeText(s string) string {
 59	return strings.ToLower(collapse(s))
 60}
 61
 62// runChecks runs every check and returns one flat list. The order matters,
 63// since the dashboard preserves it inside a group, and a check may dereference
 64// HTML on anything in ctx.htmlPages, which is filtered rather than asserted.
 65func runChecks(result *CrawlResult) []Insight {
 66	var htmlPages []*Page
 67	statusByURL := make(map[string]int, len(result.Pages))
 68	for _, p := range result.Pages {
 69		statusByURL[p.URL] = p.Status
 70		if p.IsHTML && p.HTML != nil {
 71			htmlPages = append(htmlPages, p)
 72		}
 73	}
 74
 75	ctx := &checkCtx{
 76		startURL:    result.StartURL,
 77		host:        result.Host,
 78		pages:       result.Pages,
 79		htmlPages:   htmlPages,
 80		statusByURL: statusByURL,
 81		externalRaw: result.ExternalLinkStatus,
 82		sitemapURLs: result.SitemapURLs,
 83		robots:      result.Robots,
 84		compression: result.Compression,
 85	}
 86
 87	checks := []func(*checkCtx) []Insight{
 88		checkTitleMissing,
 89		checkTitleLength,
 90		checkDuplicateTitles,
 91		checkDescriptionMissing,
 92		checkDescriptionLength,
 93		checkDuplicateDescriptions,
 94		checkH1Missing,
 95		checkH1Multiple,
 96		checkH1Length,
 97		checkDuplicateH1s,
 98		checkHeadingHierarchy,
 99		checkCanonicalMissing,
100		checkCanonicalOffDomain,
101		checkCanonicalBroken,
102		checkRobotsMetaNoindex,
103		checkLangMissing,
104		checkViewportMissing,
105		checkOGIncomplete,
106		checkTwitterCard,
107		checkFavicon,
108		checkJSONLDParseError,
109		checkBrokenInternalLinks,
110		checkBrokenExternalLinks,
111		checkRedirectChains,
112		checkNofollowInternalLinks,
113		checkRobotsMissing,
114		checkSitemapMissing,
115		checkSitemapNotInRobots,
116		checkSitemapBrokenURLs,
117		checkPagesMissingFromSitemap,
118		checkImagesMissingAlt,
119		checkEmptyAnchorText,
120		checkFormInputsUnlabeled,
121		checkThinContent,
122		checkDuplicateContent,
123		checkSlowPages,
124		checkMissingCompression,
125		checkOversizedPages,
126		checkMixedContent,
127	}
128
129	out := []Insight{}
130	for _, check := range checks {
131		out = append(out, check(ctx)...)
132	}
133	return out
134}
135
136// groupPages buckets HTML pages by a normalised field, skipping the empty
137// ones. The keys come back sorted, or the findings reshuffle between crawls.
138func groupPages(pages []*Page, field func(*Page) string) []([]*Page) {
139	buckets := map[string][]*Page{}
140	for _, p := range pages {
141		v := field(p)
142		if v == "" {
143			continue
144		}
145		key := normalizeText(v)
146		buckets[key] = append(buckets[key], p)
147	}
148
149	keys := make([]string, 0, len(buckets))
150	for k := range buckets {
151		keys = append(keys, k)
152	}
153	sort.Strings(keys)
154
155	out := make([][]*Page, 0, len(keys))
156	for _, k := range keys {
157		out = append(out, buckets[k])
158	}
159	return out
160}
161
162func h1sOf(p *Page) []string { return p.HTML.Headings["h1"] }
163
164func checkTitleMissing(ctx *checkCtx) []Insight {
165	var out []Insight
166	for _, p := range ctx.htmlPages {
167		if p.HTML.Title == "" {
168			out = append(out, finding(p.URL, "Page has no title", typeSEO, sevError, ""))
169		}
170	}
171	return out
172}
173
174// checkTitleLength flags titles outside the 30-60 characters Google renders
175// before truncating, counted in runes and not bytes.
176func checkTitleLength(ctx *checkCtx) []Insight {
177	var out []Insight
178	for _, p := range ctx.htmlPages {
179		t := p.HTML.Title
180		n := len([]rune(t))
181		if t != "" && (n < 30 || n > 60) {
182			out = append(out, finding(p.URL,
183				fmt.Sprintf("Title length is %d chars (recommended 30-60)", n),
184				typeSEO, sevWarn, t))
185		}
186	}
187	return out
188}
189
190func checkDuplicateTitles(ctx *checkCtx) []Insight {
191	var out []Insight
192	for _, group := range groupPages(ctx.htmlPages, func(p *Page) string { return p.HTML.Title }) {
193		if len(group) < 2 {
194			continue
195		}
196		for _, p := range group {
197			out = append(out, finding(p.URL, "Duplicate title", typeSEO, sevWarn, p.HTML.Title))
198		}
199	}
200	return out
201}
202
203func checkDescriptionMissing(ctx *checkCtx) []Insight {
204	var out []Insight
205	for _, p := range ctx.htmlPages {
206		if p.HTML.Description == "" {
207			out = append(out, finding(p.URL, "Page has no meta description", typeSEO, sevError, ""))
208		}
209	}
210	return out
211}
212
213func checkDescriptionLength(ctx *checkCtx) []Insight {
214	var out []Insight
215	for _, p := range ctx.htmlPages {
216		d := p.HTML.Description
217		n := len([]rune(d))
218		if d != "" && (n < 70 || n > 160) {
219			out = append(out, finding(p.URL,
220				fmt.Sprintf("Description length is %d chars (recommended 70-160)", n),
221				typeSEO, sevWarn, d))
222		}
223	}
224	return out
225}
226
227func checkDuplicateDescriptions(ctx *checkCtx) []Insight {
228	var out []Insight
229	for _, group := range groupPages(ctx.htmlPages, func(p *Page) string { return p.HTML.Description }) {
230		if len(group) < 2 {
231			continue
232		}
233		for _, p := range group {
234			out = append(out, finding(p.URL, "Duplicate meta description", typeSEO, sevWarn, p.HTML.Description))
235		}
236	}
237	return out
238}
239
240func checkH1Missing(ctx *checkCtx) []Insight {
241	var out []Insight
242	for _, p := range ctx.htmlPages {
243		if len(h1sOf(p)) == 0 {
244			out = append(out, finding(p.URL, "Page has no h1", typeSEO, sevError, ""))
245		}
246	}
247	return out
248}
249
250func checkH1Multiple(ctx *checkCtx) []Insight {
251	var out []Insight
252	for _, p := range ctx.htmlPages {
253		h1s := h1sOf(p)
254		if len(h1s) > 1 {
255			sample := h1s
256			if len(sample) > 3 {
257				sample = sample[:3]
258			}
259			out = append(out, finding(p.URL,
260				fmt.Sprintf("Page has %d h1 tags (expected 1)", len(h1s)),
261				typeSEO, sevWarn, strings.Join(sample, " | ")))
262		}
263	}
264	return out
265}
266
267func checkH1Length(ctx *checkCtx) []Insight {
268	var out []Insight
269	for _, p := range ctx.htmlPages {
270		h1s := h1sOf(p)
271		if len(h1s) == 0 {
272			continue
273		}
274		n := len([]rune(h1s[0]))
275		if n < 20 || n > 70 {
276			out = append(out, finding(p.URL,
277				fmt.Sprintf("H1 length is %d chars (recommended 20-70)", n),
278				typeSEO, sevWarn, h1s[0]))
279		}
280	}
281	return out
282}
283
284func checkDuplicateH1s(ctx *checkCtx) []Insight {
285	var withH1 []*Page
286	for _, p := range ctx.htmlPages {
287		if len(h1sOf(p)) > 0 {
288			withH1 = append(withH1, p)
289		}
290	}
291	var out []Insight
292	for _, group := range groupPages(withH1, func(p *Page) string { return h1sOf(p)[0] }) {
293		if len(group) < 2 {
294			continue
295		}
296		for _, p := range group {
297			out = append(out, finding(p.URL, "Duplicate h1", typeSEO, sevWarn, h1sOf(p)[0]))
298		}
299	}
300	return out
301}
302
303// checkHeadingHierarchy stops at the first skipped heading level. It compares
304// which levels are present and not the order they appear in.
305func checkHeadingHierarchy(ctx *checkCtx) []Insight {
306	var out []Insight
307	for _, p := range ctx.htmlPages {
308		var levels []int
309		for level := 1; level <= 6; level++ {
310			if len(p.HTML.Headings[fmt.Sprintf("h%d", level)]) > 0 {
311				levels = append(levels, level)
312			}
313		}
314		for i := 1; i < len(levels); i++ {
315			if levels[i]-levels[i-1] > 1 {
316				out = append(out, finding(p.URL,
317					fmt.Sprintf("Heading hierarchy skips from h%d to h%d", levels[i-1], levels[i]),
318					typeSEO, sevInfo, ""))
319				break
320			}
321		}
322	}
323	return out
324}
325
326func checkCanonicalMissing(ctx *checkCtx) []Insight {
327	var out []Insight
328	for _, p := range ctx.htmlPages {
329		if p.HTML.Canonical == "" {
330			out = append(out, finding(p.URL, "Page has no canonical URL", typeSEO, sevWarn, ""))
331		}
332	}
333	return out
334}
335
336func checkCanonicalOffDomain(ctx *checkCtx) []Insight {
337	var out []Insight
338	for _, p := range ctx.htmlPages {
339		c := p.HTML.Canonical
340		if c != "" && !sameSite(c, ctx.host) {
341			out = append(out, finding(p.URL, "Canonical URL points off-domain", typeSEO, sevWarn, c))
342		}
343	}
344	return out
345}
346
347// checkCanonicalBroken only fires for a canonical the crawl visited, since an
348// unvisited one is unknown rather than broken.
349func checkCanonicalBroken(ctx *checkCtx) []Insight {
350	var out []Insight
351	for _, p := range ctx.htmlPages {
352		c := p.HTML.Canonical
353		if c == "" {
354			continue
355		}
356		if status, ok := ctx.statusByURL[c]; ok && status != 200 {
357			out = append(out, finding(p.URL,
358				fmt.Sprintf("Canonical URL returns %d", status), typeSEO, sevError, c))
359		}
360	}
361	return out
362}
363
364func checkRobotsMetaNoindex(ctx *checkCtx) []Insight {
365	var out []Insight
366	for _, p := range ctx.htmlPages {
367		rm := p.HTML.RobotsMeta
368		if strings.Contains(strings.ToLower(rm), "noindex") {
369			out = append(out, finding(p.URL, "Page has noindex in meta robots tag", typeSEO, sevWarn, rm))
370		}
371	}
372	return out
373}
374
375func checkLangMissing(ctx *checkCtx) []Insight {
376	var out []Insight
377	for _, p := range ctx.htmlPages {
378		if p.HTML.Lang == "" {
379			out = append(out, finding(p.URL, "HTML lang attribute missing", typeSEO, sevWarn, ""))
380		}
381	}
382	return out
383}
384
385func checkViewportMissing(ctx *checkCtx) []Insight {
386	var out []Insight
387	for _, p := range ctx.htmlPages {
388		if p.HTML.Viewport == "" {
389			out = append(out, finding(p.URL, "Viewport meta tag missing (mobile)", typeSEO, sevWarn, ""))
390		}
391	}
392	return out
393}
394
395func checkOGIncomplete(ctx *checkCtx) []Insight {
396	var out []Insight
397	for _, p := range ctx.htmlPages {
398		og := p.HTML.OG
399		var missing []string
400		if og.Title == "" {
401			missing = append(missing, "og:title")
402		}
403		if og.Description == "" {
404			missing = append(missing, "og:description")
405		}
406		if og.Image == "" {
407			missing = append(missing, "og:image")
408		}
409		if og.URL == "" {
410			missing = append(missing, "og:url")
411		}
412		if len(missing) > 0 {
413			out = append(out, finding(p.URL,
414				"Open Graph tags missing: "+strings.Join(missing, ", "),
415				typeSEO, sevInfo, ""))
416		}
417	}
418	return out
419}
420
421func checkTwitterCard(ctx *checkCtx) []Insight {
422	var out []Insight
423	for _, p := range ctx.htmlPages {
424		if p.HTML.Twitter.Card == "" {
425			out = append(out, finding(p.URL, "Twitter card meta tag missing", typeSEO, sevInfo, ""))
426		}
427	}
428	return out
429}
430
431func checkFavicon(ctx *checkCtx) []Insight {
432	var out []Insight
433	for _, p := range ctx.htmlPages {
434		if p.HTML.Favicon == "" {
435			out = append(out, finding(p.URL, "Favicon link missing", typeSEO, sevInfo, ""))
436		}
437	}
438	return out
439}
440
441func checkJSONLDParseError(ctx *checkCtx) []Insight {
442	var out []Insight
443	for _, p := range ctx.htmlPages {
444		if p.HTML.JSONLDBad > 0 {
445			out = append(out, finding(p.URL,
446				"JSON-LD structured data failed to parse", typeSEO, sevWarn, ""))
447		}
448	}
449	return out
450}
451
452// linkPair keys the per-page dedupe, so the same link twice in one nav is one
453// finding but the same broken link on twenty pages is twenty.
454type linkPair struct{ from, to string }
455
456func checkBrokenInternalLinks(ctx *checkCtx) []Insight {
457	var out []Insight
458	reported := map[linkPair]bool{}
459	for _, p := range ctx.htmlPages {
460		for _, link := range p.HTML.Links {
461			if !sameSite(link.URL, ctx.host) {
462				continue
463			}
464			status, ok := ctx.statusByURL[link.URL]
465			if !ok || status == 200 || isRedirectStatus(status) {
466				continue
467			}
468			key := linkPair{p.URL, link.URL}
469			if reported[key] {
470				continue
471			}
472			reported[key] = true
473			out = append(out, finding(p.URL,
474				"Broken internal link ("+statusLabel(status)+")", typeLinks, sevError, link.URL))
475		}
476	}
477	return out
478}
479
480func checkBrokenExternalLinks(ctx *checkCtx) []Insight {
481	var out []Insight
482	reported := map[linkPair]bool{}
483	for _, p := range ctx.htmlPages {
484		for _, link := range p.HTML.Links {
485			if sameSite(link.URL, ctx.host) {
486				continue
487			}
488			status, ok := ctx.externalRaw[link.URL]
489			if !ok || (status != 0 && status < 400) {
490				continue
491			}
492			key := linkPair{p.URL, link.URL}
493			if reported[key] {
494				continue
495			}
496			reported[key] = true
497			// A warning and not an error, since the fix is on somebody else's
498			// server and today's 404 may be a site that is down this minute.
499			out = append(out, finding(p.URL,
500				"Broken external link ("+statusLabel(status)+")", typeLinks, sevWarn, link.URL))
501		}
502	}
503	return out
504}
505
506func statusLabel(status int) string {
507	if status == 0 {
508		return "unreachable"
509	}
510	return fmt.Sprintf("status %d", status)
511}
512
513// checkRedirectChains flags more than one hop, since a single redirect is
514// normal (http to https, apex to www) and two is a chain worth collapsing.
515func checkRedirectChains(ctx *checkCtx) []Insight {
516	var out []Insight
517	for _, p := range ctx.pages {
518		if p.RedirectHops > 1 {
519			out = append(out, finding(p.URL,
520				fmt.Sprintf("Redirect chain has %d hops", p.RedirectHops),
521				typeLinks, sevInfo, p.RequestedURL))
522		}
523	}
524	return out
525}
526
527func checkNofollowInternalLinks(ctx *checkCtx) []Insight {
528	var out []Insight
529	reported := map[linkPair]bool{}
530	for _, p := range ctx.htmlPages {
531		for _, link := range p.HTML.Links {
532			if !sameSite(link.URL, ctx.host) {
533				continue
534			}
535			nofollow := false
536			for _, rel := range link.Rel {
537				if rel == "nofollow" {
538					nofollow = true
539					break
540				}
541			}
542			if !nofollow {
543				continue
544			}
545			key := linkPair{p.URL, link.URL}
546			if reported[key] {
547				continue
548			}
549			reported[key] = true
550			out = append(out, finding(p.URL,
551				"Internal link has rel=nofollow", typeLinks, sevInfo, link.URL))
552		}
553	}
554	return out
555}
556
557func checkRobotsMissing(ctx *checkCtx) []Insight {
558	if ctx.robots.Exists {
559		return nil
560	}
561	return []Insight{finding(ctx.startURL, "robots.txt missing", typeSEO, sevWarn, ctx.robots.URL)}
562}
563
564func checkSitemapMissing(ctx *checkCtx) []Insight {
565	if len(ctx.sitemapURLs) > 0 {
566		return nil
567	}
568	return []Insight{finding(ctx.startURL, "sitemap.xml missing or empty", typeSEO, sevWarn, "")}
569}
570
571func checkSitemapNotInRobots(ctx *checkCtx) []Insight {
572	if !ctx.robots.Exists || len(ctx.sitemapURLs) == 0 || ctx.robots.ReferencesSitemap {
573		return nil
574	}
575	return []Insight{finding(ctx.startURL,
576		"robots.txt does not reference a sitemap", typeSEO, sevInfo, "")}
577}
578
579func checkSitemapBrokenURLs(ctx *checkCtx) []Insight {
580	var out []Insight
581	for _, u := range ctx.sitemapURLs {
582		status, ok := ctx.statusByURL[u]
583		if !ok || status == 200 || isRedirectStatus(status) {
584			continue
585		}
586		out = append(out, finding(u,
587			fmt.Sprintf("URL listed in sitemap returns %d", status), typeSEO, sevError, ""))
588	}
589	return out
590}
591
592// checkPagesMissingFromSitemap reports crawled pages the sitemap does not list,
593// skipping anything marked noindex, which is correctly absent.
594func checkPagesMissingFromSitemap(ctx *checkCtx) []Insight {
595	if len(ctx.sitemapURLs) == 0 {
596		return nil
597	}
598	listed := make(map[string]bool, len(ctx.sitemapURLs))
599	for _, u := range ctx.sitemapURLs {
600		listed[u] = true
601	}
602
603	var out []Insight
604	for _, p := range ctx.htmlPages {
605		if listed[p.URL] {
606			continue
607		}
608		if strings.Contains(strings.ToLower(p.HTML.RobotsMeta), "noindex") {
609			continue
610		}
611		out = append(out, finding(p.URL, "Page not listed in sitemap", typeSEO, sevInfo, ""))
612	}
613	return out
614}
615
616// checkImagesMissingAlt counts images with no alt attribute at all. alt="" is
617// the right markup for a decorative image and is not a finding.
618func checkImagesMissingAlt(ctx *checkCtx) []Insight {
619	var out []Insight
620	for _, p := range ctx.htmlPages {
621		var missing []Image
622		for _, img := range p.HTML.Images {
623			if img.Alt == nil {
624				missing = append(missing, img)
625			}
626		}
627		if len(missing) == 0 {
628			continue
629		}
630		out = append(out, finding(p.URL,
631			fmt.Sprintf("%d image(s) missing alt attribute", len(missing)),
632			typeA11y, sevWarn, truncate(missing[0].Src, 160)))
633	}
634	return out
635}
636
637func checkEmptyAnchorText(ctx *checkCtx) []Insight {
638	var out []Insight
639	for _, p := range ctx.htmlPages {
640		var empty []Link
641		for _, link := range p.HTML.Links {
642			if link.Text == "" {
643				empty = append(empty, link)
644			}
645		}
646		if len(empty) == 0 {
647			continue
648		}
649		out = append(out, finding(p.URL,
650			fmt.Sprintf("%d link(s) have no visible text", len(empty)),
651			typeA11y, sevInfo, truncate(empty[0].URL, 160)))
652	}
653	return out
654}
655
656// checkFormInputsUnlabeled reports the first form on a page with unlabeled
657// inputs, since one template usually generates all of them.
658func checkFormInputsUnlabeled(ctx *checkCtx) []Insight {
659	// These carry no user-entered value, or their own text is the label.
660	ignore := map[string]bool{
661		"hidden": true, "submit": true, "button": true, "reset": true, "image": true,
662	}
663
664	var out []Insight
665	for _, p := range ctx.htmlPages {
666		for _, form := range p.HTML.Forms {
667			labeled := make(map[string]bool, len(form.LabelFors))
668			for _, id := range form.LabelFors {
669				labeled[id] = true
670			}
671
672			unlabeled := 0
673			for _, input := range form.Inputs {
674				if ignore[input.Type] {
675					continue
676				}
677				if input.AriaLabel != nil {
678					continue
679				}
680				if input.ID != nil && labeled[*input.ID] {
681					continue
682				}
683				unlabeled++
684			}
685
686			if unlabeled > 0 {
687				out = append(out, finding(p.URL,
688					fmt.Sprintf("%d form input(s) without associated label", unlabeled),
689					typeA11y, sevWarn, form.Action))
690				break
691			}
692		}
693	}
694	return out
695}
696
697func truncate(s string, n int) string {
698	runes := []rune(s)
699	if len(runes) <= n {
700		return s
701	}
702	return string(runes[:n])
703}
704
705func checkThinContent(ctx *checkCtx) []Insight {
706	var out []Insight
707	for _, p := range ctx.htmlPages {
708		if wc := p.HTML.WordCount; wc < 300 {
709			out = append(out, finding(p.URL,
710				fmt.Sprintf("Thin content (%d words)", wc), typeContent, sevWarn, ""))
711		}
712	}
713	return out
714}
715
716// checkDuplicateContent groups pages by the hash of their visible text, in
717// sorted hash order so the findings do not reshuffle between crawls.
718func checkDuplicateContent(ctx *checkCtx) []Insight {
719	buckets := map[string][]string{}
720	for _, p := range ctx.htmlPages {
721		if h := p.HTML.TextHash; h != "" {
722			buckets[h] = append(buckets[h], p.URL)
723		}
724	}
725
726	hashes := make([]string, 0, len(buckets))
727	for h := range buckets {
728		hashes = append(hashes, h)
729	}
730	sort.Strings(hashes)
731
732	var out []Insight
733	for _, h := range hashes {
734		urls := buckets[h]
735		if len(urls) < 2 {
736			continue
737		}
738		for _, u := range urls {
739			// Name one of the others, so the finding says what the page is a
740			// duplicate of and not only that it is one.
741			other := urls[0]
742			if other == u {
743				other = urls[1]
744			}
745			out = append(out, finding(u,
746				"Page has duplicate visible content with another page",
747				typeContent, sevWarn, other))
748		}
749	}
750	return out
751}
752
753func checkSlowPages(ctx *checkCtx) []Insight {
754	var out []Insight
755	for _, p := range ctx.pages {
756		if !p.IsHTML {
757			continue
758		}
759		if p.ElapsedMS > 1000 {
760			out = append(out, finding(p.URL,
761				fmt.Sprintf("Slow response (%d ms)", p.ElapsedMS), typePerf, sevWarn, ""))
762		}
763	}
764	return out
765}
766
767// checkMissingCompression probes the start URL only, since compression is a
768// server-wide setting and one probe answers for the whole site.
769func checkMissingCompression(ctx *checkCtx) []Insight {
770	if ctx.compression != "" {
771		return nil
772	}
773	return []Insight{finding(ctx.startURL,
774		"Response not compressed (no Content-Encoding header)", typePerf, sevInfo, "")}
775}
776
777// checkOversizedPages measures the HTML document alone and not the page weight
778// a browser reports, since images and scripts are Lighthouse's job.
779func checkOversizedPages(ctx *checkCtx) []Insight {
780	var out []Insight
781	for _, p := range ctx.pages {
782		if p.Bytes > 500_000 {
783			out = append(out, finding(p.URL,
784				fmt.Sprintf("Oversized page (%d KB)", p.Bytes/1024), typePerf, sevWarn, ""))
785		}
786	}
787	return out
788}
789
790// checkMixedContent finds http:// subresources on an https:// page, which
791// browsers block outright for scripts and stylesheets.
792func checkMixedContent(ctx *checkCtx) []Insight {
793	var out []Insight
794	for _, p := range ctx.htmlPages {
795		if !strings.HasPrefix(p.URL, "https://") {
796			continue
797		}
798		var insecure []string
799		for _, r := range p.HTML.Resources {
800			if strings.HasPrefix(r, "http://") {
801				insecure = append(insecure, r)
802			}
803		}
804		if len(insecure) == 0 {
805			continue
806		}
807		out = append(out, finding(p.URL,
808			fmt.Sprintf("Mixed content: %d http:// resource(s) on https:// page", len(insecure)),
809			typeSec, sevWarn, insecure[0]))
810	}
811	return out
812}