orchard
mirrorEvery 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
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}