DEV Community

Cover image for Turn a list of prospect websites into honest page-speed reports for outreach
Joshua Smith
Joshua Smith

Posted on

Turn a list of prospect websites into honest page-speed reports for outreach

A cold email saying "your website is slow" gets deleted. One saying "your services page takes N seconds to show its main content on a phone, and two fixes would shorten that" gets read, because the owner can check it. Below: pages to pick, four numbers, a ranking script and the etiquette. Google's PageSpeed Insights API does the measuring; PageSpeed Insights Audit is an Apify Actor that runs it over a list of URLs.

Pick the pages that matter

Audit three or four pages per prospect, chosen as a customer would use the site:

  • The homepage, because the owner has seen it.
  • The page that earns money: the main service, product or booking page.
  • One page that attracts visitors, such as a popular blog post.
  • The contact or checkout page.

Use mobile, since Google Search ranks with mobile data. Open each URL in a browser: Google must fetch it publicly, so logins and staging fail.

Four numbers an owner can follow

  1. Performance score (scores.performance), 0 to 100. 90 or more is "good", 50 to 89 "needs improvement".
  2. Time until the main content shows (labMetrics.lcpMs, milliseconds, so divide by 1,000). Describe it as when the biggest thing on screen, usually a hero image or headline, has appeared.
  3. What real visitors get (fieldData.overallCategory: FAST, AVERAGE or SLOW). It is Chrome user data, stronger evidence than the lab score. It is null for low-traffic pages, and originFallback: true means whole-site data. Say which you have.
  4. The biggest fix (the first entry of opportunities, with its title and savingsMs).

If they disagree (a poor score but FAST visitor data), say so or drop the prospect.

Turn opportunities into plain English

Google words findings for developers ("Eliminate render-blocking resources"). Write one sentence per common id, about what a visitor notices:

  • render-blocking-resources: "Visitors wait on a blank screen while files load."
  • unused-javascript: "The page downloads code it never runs."

Present savings as estimates, show at most three, and do not add them up, because they overlap. For an id without a sentence, use Google's title rather than guess.

Run the audits

Input for two prospects. categories is narrowed because a speed report needs nothing else; includeAuditDetails adds opportunities:

{
    "urls": [
        "https://www.prospect-one.example/",
        "https://www.prospect-one.example/services",
        "https://www.prospect-two.example/",
        "https://www.prospect-two.example/book"
    ],
    "strategy": "mobile",
    "categories": ["performance"],
    "includeAuditDetails": true
}
Enter fullscreen mode Exit fullscreen mode

Save it as input.json and make one call:

curl -X POST "https://api.apify.com/v2/acts/josh99smith~pagespeed-insights-audit/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H "Content-Type: application/json" -d @input.json > results.json
Enter fullscreen mode Exit fullscreen mode

The call waits for the run to finish, so keep it to a short list; for a longer one, start the run in the Console and download the Output tab as JSON. Allow about 10 minutes per 100 URLs at the default concurrency.

Rank prospects and write one summary each

Run python rank_prospects.py results.json (Python 3.9+). The script groups pages by hostname, keeps successful mobile records, ranks sites by mean performance score (lowest first) and writes one report-01-<site>.md-style file per site.

import json, sys
from collections import defaultdict
from urllib.parse import urlparse

PLAIN = {
    "render-blocking-resources": "Visitors wait on a blank screen while files load.",
    "unused-javascript": "The page downloads code it never runs.",
}
host = lambda u: urlparse("//" + u.split("//", 1)[-1]).hostname.removeprefix("www.")
score = lambda p: p["scores"]["performance"]

ok, bad = defaultdict(list), defaultdict(list)
for r in json.load(open(sys.argv[1], encoding="utf-8")):
    if r.get("strategy") == "mobile":
        usable = r.get("success") and None not in (score(r), r["labMetrics"]["lcpMs"])
        (ok if usable else bad)[host(r["url"])].append(r)

avg = lambda h: sum(map(score, ok[h])) / len(ok[h])
ranked = sorted((h for h in ok if min(map(score, ok[h])) < 90), key=avg)

for rank, h in enumerate(ranked, 1):
    pages = sorted(ok[h], key=score)
    out = [f"# {h}: where the site loses points", "",
           f"Google Lighthouse, mobile, {pages[0]['fetchedAt'][:10]}. Scores move a few points between runs.", "",
           "| Page | Score | Main content visible | Real visitors |", "|---|---|---|---|"]
    fixes = []
    for p in pages:
        field = p.get("fieldData") or {}
        real = field.get("overallCategory", "NONE").lower().replace("none", "not enough data")
        if field.get("originFallback"):
            real += " (whole site)"
        out.append(f"| {p['url']} | {score(p)} | {p['labMetrics']['lcpMs'] / 1000:.1f} s | {real} |")
        for o in (p.get("opportunities") or [])[:2]:
            ms = o.get("savingsMs") or 0
            note = f" Estimated saving: {ms / 1000:.1f} s." if ms else ""
            fixes.append((ms, f"- {p['url']}: {PLAIN.get(o['id'], o['title'])}{note}"))
    if fixes:
        out += ["", "## Biggest fixes", "", "Estimates overlap; do not add them up.", ""]
        out += [text for _, text in sorted(fixes, reverse=True)[:3]]
    open(f"report-{rank:02d}-{h}.md", "w", encoding="utf-8").write("\n".join(out) + "\n")
    print(rank, h, f"avg {avg(h):.0f}", f"failed {len(bad[h])}")

for h in sorted(set(ok) | set(bad)):
    if h not in ranked:
        print("not reported:", h, "(all 90+)" if h in ok else "(failed)")
Enter fullscreen mode Exit fullscreen mode

Sites where every page scores 90 or more are skipped: there is no honest story. Failed pages are counted, not reported, so an unloadable page never reads as a bad score. Good pages stay in the table. Read each file before sending.

Be accurate, be kind

  • Public pages only. Never audit staging sites or anything behind a login.
  • Be accurate. Give the URL, date, device, and that it is a lab result; re-run borderline pages.
  • Do not exaggerate. Quote what you measured, and never turn seconds into money: you cannot see their analytics.
  • Do not scare. A score of 62 is "needs improvement", not "failing". Say what is fine.
  • Send once and accept a no. Check outreach rules where you and the prospect are based.

Cost and limits

$0.004 per audited page and device strategy. Worked example: 40 prospects with 3 pages each on mobile is 120 audits, or $0.48. Adding desktop makes 240 audits, $0.96. If 10 of the 120 pages cannot be loaded, 110 audits are billed: $0.44. No start-up charge, and a run stops at the maximum cost you set.

  • Variance. Lighthouse scores differ between runs by a few points.
  • Lab versus field. scores.performance is a lab estimate; real-user data is stronger but needs enough traffic.
  • Failed pages. Pages Google cannot load return free success: false records with an errorType such as dns or timeout.
  • API key. By default the Actor uses a built-in key shared by all its users: 25,000 requests a day and a few hundred a minute between them. When that runs out you get free rate-limited records; retry later, or paste your own free Google key into apiKey for a dedicated quota.

If this saves you time, a review on the Store page helps other people find it, and a star on GitHub helps too.

Disclosure: I built this Actor. Source: github.com/josh99smith/pagespeed-insights-audit. PageSpeed Insights and Lighthouse are Google trademarks; the Actor is not affiliated with Google.

Top comments (0)