Open the website of a random dental clinic, furniture shop or wedding organizer in your city on your phone, on mobile data. A lot of them take five, eight, sometimes fifteen seconds to show anything. A 4 MB hero photo straight from a camera, twelve plugins loading scripts on every page, a slider nobody looks at, no caching at all.
The owner usually doesn’t know. They open the site on office Wi-Fi, from cache, and it looks fine. Their customers bounce and message a competitor instead.
That gap is a service you can sell this week: find slow sites in bulk with Google’s free PageSpeed Insights API, send the owner a short report with their own numbers, then fix the obvious stuff (images, caching, unused scripts) for a fixed fee. Most of the work on a typical WordPress site is boring and repeatable, which is exactly what you want in a side hustle.
In this guide:
- what Google actually measures (Core Web Vitals) and the real thresholds
- a Python script that scans a list of 200 sites for $0 and ranks them by “how badly they need you”
- a Gemini step that turns raw Lighthouse data into a plain-language pitch, without inventing numbers
- the fix playbook for WordPress, pricing, costs, realistic earnings
- the risks: breaking a client site, cold outreach rules, and the “fake 100 score” trick you should never sell
What Google measures, exactly
Google’s Core Web Vitals are three field metrics, measured from real Chrome users (the Chrome UX Report, “CrUX”). Thresholds from web.dev, judged at the 75th percentile of page loads:
| Metric | What it means | Good | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | When the main content shows up | ≤ 2.5 s | > 4.0 s |
| INP (Interaction to Next Paint) | How fast the page reacts to a tap | ≤ 200 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | How much the layout jumps around | ≤ 0.1 | > 0.25 |
INP replaced FID as a Core Web Vital in March 2024. Small local sites often have no field data (not enough Chrome traffic), so PageSpeed Insights falls back to a lab test (Lighthouse, run on an emulated mid-range phone over throttled 4G). The lab test produces the famous 0–100 Performance score.
Two honest points you’ll repeat to clients:
- Speed is a small ranking factor, not a magic one. Google says its core ranking systems reward good page experience, but relevance and content matter far more. Never promise “page 1 on Google.”
- The money argument is conversions. Deloitte’s 2020 study for Google, Milliseconds Make Millions, found a 0.1 s improvement in mobile site speed raised retail conversions by 8.4% and travel conversions by 10.1%. Be honest about the context: it measured 37 big European and US brands, not a local clinic, so use it as a direction (“faster sites convert better”), not a promise of +8%.
What people pay
Search Fiverr for “wordpress speed optimization” and you’ll see hundreds of gigs. At the time of writing, sellers with lots of reviews mostly list basic packages around $20–$100 per site and premium tiers of $150–$300, many of them promising a “90+ score.” Check the current listings yourself before setting your price; this market moves.
For local Indonesian clients I’d price by outcome, not by score:
| Package | What’s included | Suggested price |
|---|---|---|
| Audit only | PDF/WhatsApp report: mobile score, LCP, top 5 problems, fix plan | Free (it’s your pitch) |
| Quick fix | Image compression + WebP, caching plugin, Cloudflare free, remove 2–3 heavy plugins | Rp 750k–1.5 juta |
| Full fix | Above + theme/script cleanup, font loading, lazy-load, before/after report | Rp 2–4 juta |
| Monthly care | Updates, backups, monthly speed scan, fix regressions | Rp 250–500k/month |
That’s my pricing logic for small businesses, not a market survey. The monthly care plan is where it becomes semi-passive income: a cron job does the scanning, you only act when a number drops.
What you need
| Item | Cost | Notes |
|---|---|---|
| Google Cloud project + API key with PageSpeed Insights API enabled | $0 | Default quota is 25,000 queries/day; check APIs & Services → Quotas in your project |
Python 3.10+, requests, google-genai | $0 | Any laptop |
| Gemini API key | $0 to start | aistudio.google.com, for the pitch step |
| A list of local business websites | $0 | Google Maps by hand, directories, or the Places API (see this guide) |
| Cloudflare account | $0 | Free plan includes CDN and caching |
| WordPress plugins for delivery | $0–$59/yr | LiteSpeed Cache and Autoptimize are free; WP Rocket starts at $59/year for one site (check wp-rocket.me) |
| UpdraftPlus (backups) | $0 | Free version is enough for a pre-fix backup |
Gemini pricing (check ai.google.dev/pricing before relying on it): Gemini 3.5 Flash-Lite paid tier is $0.30 per 1M input tokens and $2.50 per 1M output tokens. A pitch is about 2,000 tokens in and 300 out, so roughly $0.0014 per prospect. 200 pitches cost under $0.30.
Step 1: Get a PageSpeed Insights API key
- Go to console.cloud.google.com, create a project (e.g.
speed-prospecting). - APIs & Services → Library, search PageSpeed Insights API, click Enable.
- APIs & Services → Credentials → Create credentials → API key. Restrict the key to the PageSpeed Insights API.
The API is free; you don’t need billing enabled for it. You can call it without a key for a few tests, but you’ll hit rate limits fast.
mkdir speedgig && cd speedgig
python3 -m venv .venv && source .venv/bin/activate
pip install requests google-genai
export PSI_KEY="your-pagespeed-api-key"
export GEMINI_API_KEY="your-gemini-key"
# sanity check: should print a number between 0 and 1
curl -s "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://example.com&strategy=mobile&key=$PSI_KEY" \
| python3 -c "import sys,json; print(json.load(sys.stdin)[\"lighthouseResult\"][\"categories\"][\"performance\"][\"score\"])"
Step 2: Build the prospect list
Put one URL per line in urls.txt. Pick one niche in one city so your pitch and portfolio stack up: “klinik gigi Bandung”, “furniture Jepara”, “wedding organizer Surabaya”. Businesses that already paid for a website (and maybe ads) are better prospects than ones with no site, because they already believe a website matters.
https://www.example-klinik.co.id
https://example-furniture.com
https://example-wo.id
Skip marketplace pages, Linktree pages and Instagram links. You can only fix a site the owner controls.
Step 3: Batch-scan with Python
Save as speedscan.py:
import csv, os, sys, time
from concurrent.futures import ThreadPoolExecutor
import requests
API = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"
KEY = os.environ["PSI_KEY"]
def scan(url):
params = {"url": url, "strategy": "mobile", "category": "performance", "key": KEY}
for attempt in range(3):
try:
r = requests.get(API, params=params, timeout=180)
except requests.RequestException as e:
err = str(e)
time.sleep(10)
continue
if r.status_code == 200:
return r.json()
err = f"HTTP {r.status_code}"
if r.status_code in (429, 500, 503):
time.sleep(15 * (attempt + 1))
continue
break
return {"error": err}
def summarize(url, data):
if "error" in data:
return {"url": url, "error": data["error"]}
lh = data["lighthouseResult"]
score = lh["categories"]["performance"]["score"]
if score is None: # Lighthouse could not load the page
return {"url": url, "error": lh.get("runtimeError", {}).get("message", "no score")}
audits = lh["audits"]
field = data.get("loadingExperience", {}).get("metrics", {})
problems = []
for a in audits.values():
saving = (a.get("metricSavings") or {}).get("LCP") or \
(a.get("details") or {}).get("overallSavingsMs") or 0
if a.get("score") is not None and a["score"] < 0.9 and saving > 0:
problems.append((saving, a["title"], a.get("displayValue", "")))
problems.sort(reverse=True)
return {
"url": url,
"score": round(score * 100),
"lab_lcp_s": round(audits["largest-contentful-paint"]["numericValue"] / 1000, 1),
"lab_cls": round(audits["cumulative-layout-shift"]["numericValue"], 3),
"page_mb": round(audits["total-byte-weight"]["numericValue"] / 1_000_000, 1),
"field_lcp_ms": (field.get("LARGEST_CONTENTFUL_PAINT_MS") or {}).get("percentile"),
"field_inp_ms": (field.get("INTERACTION_TO_NEXT_PAINT") or {}).get("percentile"),
"top_problems": " | ".join(f"{t} ({d})" if d else t for _, t, d in problems[:5]),
}
def main(src, out):
urls = [u.strip() for u in open(src) if u.strip().startswith("http")]
with ThreadPoolExecutor(max_workers=4) as pool:
rows = list(pool.map(lambda u: summarize(u, scan(u)), urls))
rows.sort(key=lambda r: r.get("score", 999)) # worst sites first, errors last
fields = ["url", "score", "lab_lcp_s", "lab_cls", "page_mb",
"field_lcp_ms", "field_inp_ms", "top_problems", "error"]
with open(out, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(rows)
print(f"{len(rows)} sites -> {out}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
Run it:
python speedscan.py urls.txt scan.csv
Each Lighthouse run takes roughly 10–30 seconds on Google’s side, so with 4 workers expect 200 sites in 15–30 minutes. That’s well inside the default daily quota; 4 parallel workers also stay under the per-minute limit (check the exact numbers on your project’s Quotas page).
Open scan.csv in a spreadsheet. The top rows are your prospects. A typical pattern on small business WordPress sites: score in the 20s–40s, lab LCP of 8–20 seconds, page weight of 5–15 MB, with “Improve image delivery” at the top of the problem list (since Lighthouse 13, the old image audits like “Properly size images” are merged into that one insight). Image problems are the best kind: easy to fix, big visible gains.
Note that field_lcp_ms and field_inp_ms will be empty for most small sites. That’s normal; it means Chrome doesn’t have enough real-user data. Don’t claim field numbers you don’t have.
Step 4: Turn the numbers into a pitch with Gemini
Raw Lighthouse output means nothing to a clinic owner. Gemini rewrites it into a short WhatsApp/email message in Bahasa Indonesia. The script checks every number in the output against the scan data and flags anything Gemini made up.
Save as pitch.py:
import csv, os, re, sys
from google import genai
MODEL = os.environ.get("PITCH_MODEL", "gemini-3.5-flash-lite")
PRICE_IN, PRICE_OUT = 0.30, 2.50 # USD per 1M tokens, check ai.google.dev/pricing
client = genai.Client() # reads GEMINI_API_KEY
PROMPT = """Tulis pesan WhatsApp singkat (maks 110 kata, Bahasa Indonesia, sopan, tidak lebay)
untuk pemilik website {url}. Tujuannya menawarkan audit kecepatan gratis.
Gunakan HANYA angka dari data ini, jangan menambah angka atau persentase lain:
- Skor performa mobile Google PageSpeed: {score}/100
- Waktu konten utama muncul di HP (lab test): {lab_lcp_s} detik (standar Google: 2.5 detik)
- Ukuran halaman: {page_mb} MB
- Masalah terbesar (bahasa teknis, jelaskan dengan bahasa awam): {top_problems}
Jangan janjikan ranking Google atau kenaikan penjualan. Akhiri dengan satu pertanyaan:
apakah boleh saya kirim laporan lengkapnya. Tanpa emoji berlebihan, tanpa hashtag."""
def numbers(text):
return {n.replace(",", ".") for n in re.findall(r"\d+(?:[.,]\d+)?", text)}
def main(src, out, max_score=50):
rows = [r for r in csv.DictReader(open(src)) if r["score"] and int(r["score"]) <= max_score]
cost = 0.0
with open(out, "w") as f:
for r in rows:
prompt = PROMPT.format(**r)
resp = client.models.generate_content(model=MODEL, contents=prompt)
msg = resp.text.strip()
u = resp.usage_metadata
cost += (u.prompt_token_count * PRICE_IN + u.candidates_token_count * PRICE_OUT) / 1e6
invented = numbers(msg) - numbers(prompt)
flag = f"CHECK NUMBERS: {sorted(invented)}" if invented else "ok"
url = r["url"]
f.write(f"### {url} [{flag}]\n{msg}\n\n")
print(f"{len(rows)} pitches -> {out}, est. cost ${cost:.4f}")
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
python pitch.py scan.csv pitches.md
Read every message before sending. Anything marked CHECK NUMBERS gets fixed by hand. The model is a writing assistant here, not the source of facts.
Step 5: Send the pitch (carefully)
Use the contact the business publishes for enquiries: the WhatsApp button on their site, their Google Business Profile, or the email on their contact page. Send 10–20 a day by hand, not 200 through a bulk tool. Attach a screenshot of their PageSpeed Insights result (pagespeed.web.dev) so they can verify it themselves in 30 seconds. That screenshot is what makes the message credible instead of spammy.
When someone replies, send the full report: score, LCP, page weight, the top problems in plain language, and what you’d do. Quote the fixed price in the same message.
Step 6: Deliver the fix (WordPress playbook)
Most local business sites are WordPress on shared hosting. This order handles the majority of cases:
- Back up first. Install UpdraftPlus, run a full backup, download it. Or use the host’s backup tool. No backup, no work.
- Ask for your own admin user, never the owner’s password. Delete it when the job ends.
- Images. This is usually the biggest win. Resize anything wider than ~1920 px and convert to WebP. For a bulk local pass:
# requires: sudo apt install imagemagick (work on a COPY of wp-content/uploads)
# '1920>' only shrinks images wider than 1920 px, it never upscales small ones
find uploads/ \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) | while read -r img; do
convert "$img" -resize '1920>' -quality 80 "${img%.*}.webp"
done
This only creates .webp files; WordPress won’t serve them until the media library and pages point to them. On a live site it’s simpler to use a plugin that converts and serves WebP automatically (LiteSpeed Cache’s image optimization runs through QUIC.cloud and has a free quota; otherwise a free tier of an image plugin such as ShortPixel or Imagify works for small sites).
4. Caching. If the host runs LiteSpeed (many Indonesian shared hosts do; check the server header in DevTools), use LiteSpeed Cache; its page cache only works on a LiteSpeed server. Otherwise use WP Rocket or a free cache plugin plus Autoptimize. Turn on page cache, lazy-load images, and defer non-critical JavaScript.
5. Plugins and scripts. Sliders, page-builder add-ons, chat widgets, three analytics tags. Remove what isn’t used. Don’t lazy-load the hero image; it is the LCP element.
6. Cloudflare free plan in front of the site for CDN and static caching.
7. Re-scan, take the after screenshot, and send the before/after table. Test the contact form, WhatsApp button and checkout by hand after every change.
A quick-fix job like this takes 2–4 hours once you’ve done a few. Your first one will take a full day.
Step 7: The monthly care plan (the semi-passive part)
Keep paying clients in clients.txt, run mkdir scans once, and scan them every month with the same script:
# crontab -e : 1st of every month, 03:00
0 3 1 * * cd /home/you/speedgig && .venv/bin/python speedscan.py clients.txt "scans/$(date +\%Y-\%m).csv"
Compare this month’s CSV to last month’s. A drop of 10+ points usually means a new plugin, a giant uploaded image, or a theme update. That’s a 20-minute fix and the reason the client keeps paying. Ten clients at Rp 300k is Rp 3 juta a month for a few hours of work.
Honest cost breakdown
| Item | Monthly cost |
|---|---|
| PageSpeed Insights API | $0 |
| Gemini pitches (200/month) | ~$0.30 |
| Cloudflare, LiteSpeed Cache, Autoptimize, UpdraftPlus | $0 |
| WP Rocket (optional, bill it to the client or buy a multi-site plan later) | $0–5 |
| VPS for cron (optional, your laptop works) | $0–6 |
| Total | under $10/month |
The real cost is your time: building the list, reading and sending pitches, and replying.
Realistic earnings
These are planning assumptions, not guarantees. Track your own numbers from the first week.
| Funnel step | Per month |
|---|---|
| Sites scanned | 400 |
| Score ≤ 50, worth pitching | ~150–250 |
| Pitches sent by hand (15/day, weekdays) | ~300 max, realistically 150–200 |
| Replies (cold outreach often lands at 2–5%) | 4–10 |
| Paid jobs | 2–4 |
At Rp 1–2 juta per job, that’s roughly Rp 2–8 juta a month once the funnel works; your first month may well be zero while you build a portfolio, plus any care-plan subscriptions that start compounding from month two. Selling on Fiverr to foreign clients is an alternative: no cold outreach, but more competition and 20% goes to Fiverr.
Time: about 1 hour a day for outreach and 2–4 hours per job.
Risks (read before your first job)
Breaking a client’s site. This is the real risk. Aggressive JavaScript deferral or CSS minification can break menus, sliders, forms and checkout. Always back up first, change one setting at a time, and test the money pages (contact form, WhatsApp button, cart) after each change. Put a sentence in your quote: “Backup dibuat sebelum pekerjaan; jika ada masalah, situs dikembalikan ke kondisi awal.”
Cold outreach rules. Messaging the published business contact of a company to offer a relevant service is common B2B practice, but you’re still processing contact data. Indonesia’s Personal Data Protection Law (UU PDP No. 27/2022) is fully in force, and if you email EU or US businesses, GDPR and CAN-SPAM apply. Keep it to business contacts, low volume, one follow-up maximum, and stop immediately when someone says no. Mass WhatsApp blasting through unofficial tools also gets your number banned by WhatsApp; WhatsApp bans numbers that many recipients block or report.
Don’t scrape what you shouldn’t. Building the URL list by hand or with the official Places API is fine. Scraping Google Maps directly breaks Google’s Terms of Service and can get your IP or account blocked.
The “fake 100 score” trap. Some sellers detect Lighthouse’s user agent and serve it a stripped-down page so the score jumps to 95+, while real visitors still get the slow site. That’s cloaking. It’s deceptive to the client, does nothing for real users or real CrUX data, and is exactly the kind of thing that ends in a Fiverr dispute, a refund and a bad review. Don’t sell it, and don’t sell “guaranteed 90+” either: a heavy WooCommerce theme on cheap shared hosting may never get there.
Access and liability. Use your own admin account, never ask for hosting or domain passwords unless you truly need them, and remove your access when done. For larger jobs, write a one-page agreement listing scope, price, and that you’re not liable for plugins the client adds later.
Platform risk. Google changes Lighthouse scoring and audits between versions (Lighthouse 13, released October 2025, replaced many old audits with consolidated “insights”, so audit names in your CSV can change). A site’s score can move a few points with no change on your side. Lab scores vary run to run, so run PageSpeed Insights two or three times, quote the median, and report LCP and page weight alongside the score.
Reality check
The score is not the product. Owners don’t care about 47 vs 92. They care that the site opens fast on a customer’s phone. Lead with seconds and megabytes, show the before/after side by side.
Hosting can be the ceiling. If the server takes 2+ seconds to respond (check the “Document request latency” insight, which replaced “Reduce initial server response time” in Lighthouse 13, or the TTFB in the report), no plugin fixes that. Say so, and recommend a hosting upgrade instead of taking money for a job you can’t finish.
Page builders fight back. Elementor or Divi sites with dozens of widgets can be improved but rarely reach green. Price those higher or skip them.
Model names change. If gemini-3.5-flash-lite is retired, set PITCH_MODEL to the current Flash-Lite model and update PRICE_IN/PRICE_OUT.
Final checklist
- PageSpeed Insights API enabled, key restricted
- One niche, one city, 200+ URLs in
urls.txt -
scan.csvgenerated; worst sites at the top - Pitches reviewed by hand, no
CHECK NUMBERSleft unfixed - 10–20 personal messages a day, with a PageSpeed screenshot
- Backup before every job, own admin user, test forms/checkout after
- Sell seconds and megabytes, never “guaranteed 90+” or ranking promises
- Care-plan clients in
clients.txt, monthly cron scan
Start today: build a list of 50 sites in one niche, run speedscan.py, and fix your own site or a friend’s first so you have a real before/after to show in every pitch.