---
name: local-business-prospecting
description: Crawl Google Maps for local-business prospect lists and deep per-business intel (reviews, owner names, owner-response voice, ownership badges) without an API key, then turn the intel into tailored per-prospect collateral. Trigger when Rob asks to "pull a list of businesses from Google Maps", build a prospect dossier, generate per-prospect flyers/one-pagers, or verticalize an offer for a local-business niche (bail bonds, HVAC, roofers, etc.). ALSO trigger when building a "free site" tripwire funnel, prospecting for new businesses without websites, or designing a deploy-fee + hosting-retainer revenue ladder for local service leads. Covers the Apify scrape viability check, spam filtering, Source B discovery paths, the build-time generator pattern, and the GHL wiring + pricing model that turned the Gannon build into a repeatable system.
---

# Local Business Prospecting — Maps Crawl + Web-Search Cross-Reference + Tailored Collateral

How to build a named-owner prospect dossier for any local-business niche — using either Google Maps extraction, web-search cross-referencing across multiple directories, or both. Then generate per-prospect collateral from it. Proven live 2026-08-07 on the [ADDRESS] CO bail-bonds market (7 shops, full dossier, 6 tailored flyer PDFs) and 2026-08-09 on [ADDRESS] CO water-mitigation market (17 businesses, full table, TAM analysis), and 2026-08-13 on the Denver garage-door and roofing markets (Apify viability check, dead-domain signal, Source B prospecting).

## Motion canvases (added 2026-08-15)

When a product/motion stalls, create a canvas at `vault/Projects/Active/motion-canvases/[motion-name].md` that answers: (1) what we sell vs what we deliver, (2) target vertical, (3) ICP, (4) list compilation method, (5) the hook, (6) outreach sequence, (7) conversion mechanism, (8) fulfillment, (9) kill criteria, (10) current status, (11) next steps. This exposes where a motion stalled and what to build next. See the four canvases created 2026-08-15: `premium-line-bail-bonds.md`, `free-website-local-services.md`, `performance-lead-gen-offer.md`, `website-audit-track.md`.

**Key finding across all four canvases:** every motion stalled at the same point — outreach. The products were built, the delivery was clear, the GHL funnels existed — but no repeatable "find prospects → send first touch → follow up → close" engine was built. The canvases make this visible so it doesn't happen again.

## Outreach channel hierarchy (corrected 2026-08-15)

Walk-in is **not the primary outreach method** for local service businesses. The Premium Line motion canvas documented that walk-in stalled because bondsmen don't trust strangers at their door. The same principle applies to HVAC, plumbing, garage doors, etc. — owners are busy, skeptical, and don't have time for unannounced visits.

**Universal channel priority for local service outreach:**
1. **Cold email** — professional, low friction, owner checks it. Lead with specific evidence ("I took a quick look at your site and noticed..."). 
2. **Cold call** — voicemail only. Don't expect to get through during business hours. Short, specific, no pitch.
3. **FB Messenger DM** — if they have an active business page. Reference a specific post or review to show you actually looked.
4. **SMS** — post-opt-in only (after they reply to email or call a number). Pre-opt-in cold SMS is TCPA-exposed regardless of channel.
5. **Walk-in** — last resort, only for warm leads who've already engaged. NOT a first touch.

**What to NOT do:** cold SMS to strangers, cold walk-in as first touch, FB ad spend that the agency funds (client funds ads directly).

## The pivot: from "website" to "automation stack applied to real pain"

The Gannon build taught a hard lesson: **what you think they need and what they'll actually pay for are different things.** Bail bonds missed-call capture didn't convert. "Free website" is a foot in the door, not an irresistible offer. A plumber who says "I've got plenty of clients without a website" is not wrong — he's telling you the truth about his current model.

**New rule: do not build anything until you know the pain.** Before building a site, chatbot, or workflow, have five discovery conversations with five prospects in the target trade. Don't pitch. Don't demo. Ask: *"What's the part of this business you hate doing the most?"* and listen.

If three out of five say the same thing, THAT'S your offer. The website is the proof, not the product.

Examples of real pains that convert:
- "I hate calling outstanding invoices" → GHL/n8n payment-reminder workflow + voice-agent calls in owner's voice
- "I'm under a door and miss calls anyway" → missed-call SMS + chatbot intake
- "I spend half my day quoting jobs that don't turn into jobs" → automated quoting bot with posted pricing
- "I have no idea where my leads are coming from" → CRM + source-tracking + lead notifications
- "Keeping up with reviews is a nightmare" → reputation automation + review-request workflows

**The website becomes the proof.** You show up, you listen, you say "I built this for a guy who hated X," and you hand them the same automation stack applied to their specific pain. The site earns trust; the automation delivers the result.

## The core discovery: Maps renders server-side if you use the right URL shapes

Google Maps data comes through plain `web_extract` — no API key, no Places API billing — IF you use these URL shapes:

| URL shape | Renders? | What you get |
|---|---|---|
| `google.com/maps/search/<niche>+near+<City,+State>` | ✅ | Full results list: name, rating, review count, address, phone, hours, website, service flags |
| `google.com/maps/place/<Name>/data=!4m7...` (the share link from the list) | ❌ | Empty shell — detail data is JS-loaded |
| `google.com/maps/search/<Business Name>+<City>+ST` | ✅ | **Full detail page**: reviews with text + reviewer name + relative date, OWNER RESPONSES, ownership badges, services, full hours |

The third row is the trick: to get a business's detail page, don't follow its place link — **search its name + city**. The search URL spotlights the business and renders the complete detail panel server-side.

web_extract truncates at the char budget and saves full text to `/root/.hermes/cache/web/*.md` — read the saved file for the review sections, which live past the map-tile noise. Strip `![]()` image lines and `maps/vt` tile URLs when parsing.

## Apify scrape viability check (2026-08-13)

Before investing in Apify Google Maps scrapes, test the trade with a small batch:

1. Run 300 listings via Apify for the trade × metro.
2. **Spam-check the no-website results** — look for invented village names, grammatical errors, null-review clusters, and the possessive-apostrophe pattern ("Garage Door's" vs "Garage Doors"). National lead-gen brokers fill Maps with fake listings; the no-website filter selects FOR this spam.
3. **Yield is trade-dependent.** Garage doors: 300 listings → 1 genuine no-website prospect, ~37 null-website but 95% spam. Roofing: 300 listings → 7 clean prospects, zero spam.
4. **Conclusion:** Source A (Maps, filtered to no-website) works for some trades, not others. Always hand-verify before building. See `references/apify-denver-garage-doors-2026-08-13.md` and `references/apify-roofing-vs-garage-doors-2026-08-13.md`.

## Scoring filter (apply after scrape or manual discovery)

Keep:
- Review count **5–60** — enough traction to be real, small enough that no agency has them
- Rating **4.2+** — you want them to succeed, and the reviews become site content
- Possessive name (`Gannon's`, `Mike's`, `Dave's`) — strong owner-operator signal
- Mobile number rather than a landline/toll-free
- Category in the urgent-trade list below

Drop:
- **150+ reviews** — almost certainly has an agency or an in-house person
- Franchise names — no local decision authority, corporate handles web
- Zero reviews — may not be actively operating
- Toll-free or call-center numbers — you won't reach the owner

## The trades that fit best

Gannon converted on a specific dynamic: **urgent, high-ticket, mobile-searched, and price-opaque**. Posted pricing only lands as a differentiator in industries where everyone else hides it.

Strong fit:
- Garage doors
- Locksmiths
- Water heater / emergency plumbing
- HVAC
- Roofing (especially post-hail — Denver metro is ideal for this)
- Appliance repair
- Septic
- Tree removal
- Water damage / restoration
- Towing

Weaker fit: landscaping, house cleaning, painting. Lower urgency, more comparison shopping, lower ticket, and pricing transparency isn't a shock to anyone.

## Source B: the no-GBP segment (where Gannon actually lives)

Worth stating plainly: **Gannon has no Google Business Profile.** Source A would never have surfaced him. If the archetype for this offer is invisible to your primary scrape, the scrape isn't the whole method.

This segment is smaller-volume and more manual, but it converts better for two reasons. They need you more — no website AND no Maps presence means they're running entirely on word of mouth. And you have a free opener that costs you twenty minutes: setting up their Google Business Profile. That's a real gift, it's genuinely worth more to them than the website in the short run, and it starts the relationship with you giving.

**1. Facebook local buy/sell/trade and neighborhood groups.** This is literally where you found Gannon — he posted his own ad in a Brighton group. Trades advertise in these constantly, and the ones posting are self-selecting as marketing-motivated. Search group posts for "garage door," "plumber," "handyman" plus your metro. Low volume, very high quality.

**2. Colorado Secretary of State new business filings.** Public record, searchable, and a brand-new trade LLC is by definition website-less. Filter new filings for trade keywords and you get prospects at the exact moment they're setting things up. This one is underused because it takes a little work — which is why it's still open.

**3. Nextdoor recommendations.** Homeowners recommend trades by name constantly. A guy getting recommended repeatedly with no website is a perfect target — he already has word-of-mouth demand and no way to capture the searches it generates.

**4. Yelp with no website field.** Same logic as Maps, different index. Gannon was on Yelp (1 review) with no site. Some businesses appear on one platform and not the other, so it's worth running both.

**Bonus signal — the dead domain.** Gannon owns `gannongaragedoors.com` and it serves nothing. That's the single highest-intent prospect there is: he already decided he wanted a website and got stuck. Take any prospect list, check whether a domain matching the business name is registered, then check whether it resolves. A registered-but-dead domain moves someone to the top of the queue and gives you an opening line: *"Noticed you grabbed the domain but nothing's up on it yet."*

## Pipeline to build (you have every piece connected)

```
Apify Google Maps scraper
   ↓  category × ZIP, scheduled weekly
n8n workflow
   ↓  filter website == null
   ↓  apply scoring rules
   ↓  DNS check: does a matching domain exist / resolve?
   ↓  dedupe against existing records
Airtable "Prospects" base
   ↓  status: New → Qualified → Site Built → Contacted → Closed
Build queue
   ↓  filled intake prompt per client
   ↓  4-page site generated
Outreach
   ↓  text first, email backup
```

Airtable fields worth having: business name, trade, phone, city, review count, rating, GBP URL, domain registered (y/n), domain resolves (y/n), FB page URL, logo available (y/n), pricing published (y/n), status, site URL, contacted date.

That last group — logo available, pricing published — is your build-priority score. A prospect with an existing logo and posted pricing is a one-session build like Gannon was. One with neither takes longer and converts worse.

## Sequencing suggestion

Don't scrape 500 and build 500. Run it in batches:

1. Scrape one trade across the metro (say, garage doors — you now have a template and proven copy for it)
2. Qualify down to 15–20
3. Build 5, contact 5, measure
4. Same trade again before switching, because your second garage door site takes a fraction of the time and the copy angles carry over

Trade-by-trade beats scattershot. The industry knowledge compounds, the copy compounds, and the `industry-download` skill you have installed is built for exactly this warm-up.

## Vertical kit architecture (Gannon-tested, repeatable per trade)

The Gannon build produced a repeatable system. Key rules:

1. **One client per trade per metro — REMOVED 2026-08-14.** The Apify scrape data does not support the exclusivity premise: garage-door Maps results are 95% spam, and the highest-intent prospects (like Gannon) have no GBP and would never appear in Source A. Selling "one per town" requires a pipeline that doesn't exist at volume. See the pivot note above. If prospect density improves in a future trade, this can be revisited per-vertical.

2. **Four-layer build model.** ~75% is identical across every client in a trade (chassis: page structure, nav, sticky mobile bar, form markup, schema, chatbot slot, accessibility, responsive CSS). ~25% must vary (swap: NAP/logo/colors/hours; trade content: symptom cards/service copy; signature: the one memorable hero angle from the intake).

3. **Signature layer comes from the intake.** The differentiator question in the client prompt isn't nice-to-have — it's the design brief. Flat-rate pricing produced the stamped-steel rate plate. 24/7 emergency produces a live-responding device. Family-owned produces a timeline. You aren't inventing variation; it falls out of doing the intake properly.

4. **Spec-build the home page only** if building before anyone has said yes. One page sells; it's a fraction of the work, and you finish the other three after they're paying.

## Site build: intake → output pattern

### Client intake — required before starting a build

Do not start generating pages until these are answered (Rob will supply via the per-client prompt):

1. Business name, trade/industry, and one-line description of what makes them different
2. Phone number (for tel: links) and service area (cities/county)
3. List of services offered (and pricing/packages if they want them shown)
4. Logo and brand colors, or "none — propose one"
5. Testimonials/reviews to feature (with names/initials), or "none yet — use a placeholder"
6. Credentials: licensed, insured, years in business, certifications, guarantees
7. Emergency/same-day service: yes/no
8. Any special offer or lead magnet to feature (e.g., "$50 off first service," free inspection)

### Build output

Four-page, mobile-first lead-gen funnel front-end built to run on GHL (or static hosting / handoff). Each page is a single clean HTML file referencing shared CSS/JS, plus a `single-file-for-ghl/` copy with everything inlined for pasting into a GHL custom code element. See `references/gannon-site-build-summary-2026-08-13.md` for the Gannon build.

## GHL wiring (form + chatbot)

### Contact form — implemented pattern

Keep the custom form to preserve design. POST to a GHL inbound webhook.

Front-end rules from the Gannon build:
- Constant `GHL_WEBHOOK_URL` at the top of the submit handler
- Phone normalized to E.164 (`+1XXXXXXXXXX`) before sending — GHL matches contacts on phone number, inconsistent formatting creates duplicates
- Payload shape: `name`, `phone`, `service`, `message`, `source: 'website-contact-form'`, `page: window.location.pathname`
- Button disabled during send, restored on error
- Failure path must give the customer the phone number — a form that silently fails loses the lead permanently

Workflow behind the webhook:
1. Create/update contact from the payload
2. Tag by service type
3. SMS the owner immediately — he's under a door, not at a desk
4. Auto-reply text to the customer: "Got your request, [Owner] will call shortly"
5. If no contact attempt logged in 30 min, re-notify

### Copy perspective rule

The Claude vault build uses third-person copy by design — standard for public business sites. Do not rewrite to first person on a demo unless Rob explicitly asks. If he asks for first-person copy, that is a content rewrite, not a find-and-replace; queue it as a separate build step only after the prospect is interested.

### Chatbot embed slot — VERSION 2 RULE (2026-08-14)

Every page MUST keep the `<!-- GHL CHATBOT EMBED -->` comment placeholder before `</body>`, but **do not populate the widget on the initial free-site demo.** Leave the markup slot empty until the prospect buys hosting or explicitly asks for the chatbot upsell. Reason: Rob decided the demo chat is clutter — the upsell gets clearer once they've seen the site and want more.

When the time comes, paste the widget script into the slot, set bottom offset to clear the sticky mobile CTA bar (~80px on mobile), and seed it with posted pricing.

### Deploy source: use the Claude vault build, not the work stub

When Rob needs a link fast, use the Claude-produced build from the vault, not the earlier stub in `work/`. The canonical Claude build lives at:
- Source: `/root/.hermes/vault/Free Website Local Services - Lead Magnet/gannons-garage-doors/` (multi-file)
- GHL-ready single-file copies: `single-file-for-ghl/` subfolder
- These have real brand assets, flat-rate pricing, LocalBusiness schema, and the chatbot placeholder already in place.

Common mistake: copying `/root/.hermes/work/gannons-garage-doors/index.html` instead. That is the earlier single-page stub, not the 4-page Claude build.

### Visit tracking for temp demo pages

These demo pages ship without a tracking pixel by default. Before inserting GTM, use nginx access logs to confirm visits:

```bash
sudo tail -n 20 /var/log/nginx/access.log
```

Look for hits on `/tmp-demo-<name>/` paths. That is enough to verify a prospect actually opened the link.

### GTM injection for static demo pages (verified 2026-08-14)

When the prospect needs visit tracking on a temp VPS-hosted demo, inject GTM directly into the HTML files. Pattern:

```python
from pathlib import Path

gtm_id = "GTM-XXXXXXX"
dir_path = Path("/var/www/tmp-demo-<name>")

head_snippet = f"""<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){{w[l]=w[l]||[];w[l].push({{'gtm.start':
new Date().getTime(),event:'gtm.js'}});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
}})(window,document,'script','dataLayer','{gtm_id}');</script>
<!-- End Google Tag Manager -->"""

body_snippet = f"""<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id={gtm_id}"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->"""

for html_file in sorted(dir_path.glob("*.html")):
    content = html_file.read_text()
    if head_snippet not in content:
        content = content.replace("<head>", f"<head>\n{head_snippet}\n")
    if body_snippet not in content:
        content = content.replace("<body>", f"<body>\n{body_snippet}\n")
    html_file.write_text(content)
    print(f"Updated: {html_file.name}")
```

Run once, then verify with:
```bash
grep -c "GTM-XXXXXXX" /var/www/tmp-demo-<name>/*.html
```

Each file should show `2` (head script + noscript iframe).

### GTM via AI Studio on GHL sites (verified 2026-08-14)

For sites built in GHL AI Studio, GTM can be injected sitewide by having the AI Studio bot add the container snippet. Verified pattern:
- Provide the GTM container ID to the AI Studio chat agent
- It injects the snippet into the global template or across all pages
- Result: GTM loads on home, subpages, AND blog posts without manual per-page edits
- Same container ID can be shared between the GHL site and temp demo pages

### Domain + GTM watchdog cron (verified 2026-08-14)

For ongoing monitoring of live domains, use the script at `scripts/domain-gtm-watchdog.sh`. It checks:
1. HTTP 200 on sample paths for each domain
2. Presence of the GTM container ID in the response body

Schedule: twice daily (03:00 + 15:00 UTC) is sufficient for static sites. The script exits non-zero on any failure, so the cron job stays silent when healthy and only alerts on issues.

### GTM install blocker

Adding RRR GTM requires the container ID. If it is not already stored in vault/work/skills, ask Rob for the `GTM-XXXXXXX` ID before editing the pages.

### Temp live URL recipe (VPS nginx)

When GHL can't publish the page (funnels API is read-only) but Rob needs a link to text a prospect RIGHT NOW, drop the HTML on the VPS instead of waiting for GHL. Pattern:

```bash
sudo mkdir -p /var/www/tmp-demo-<name>
cp /path/to/site/index.html /var/www/tmp-demo-<name>/
sudo chown -R www-data:www-data /var/www/tmp-demo-<name>
sudo chmod -R 755 /var/www/tmp-demo-<name>
```

Then add to the existing `robblake.cloud` 443 server block in `/etc/nginx/sites-enabled/robblake.cloud`:

```nginx
location /tmp-demo-<name>/ {
    alias /var/www/tmp-demo-<name>/;
    index index.html;
    try_files $uri $uri/ =404;
}
```

`sudo nginx -t && sudo systemctl reload nginx`, then verify with `curl -I https://robblake.cloud/tmp-demo-<name>/`. Expected: `200 OK`, `Content-Type: text/html`.

Cleanup when the real GHL funnel goes live: remove the `location` block, `sudo rm -rf /var/www/tmp-demo-<name>`, reload nginx.

### Domain + GTM watchdog cron (verified 2026-08-14)

For ongoing monitoring of live domains, use the script at `scripts/domain-gtm-watchdog.sh`. It checks:
1. HTTP 200 on sample paths for each domain
2. Presence of the GTM container ID in the response body

Schedule: twice daily (03:00 + 15:00 UTC) is sufficient for static sites. The script exits non-zero on any failure, so the cron job stays silent when healthy and only alerts on issues.

**Text first** — he answers his own cell. Email goes to a Gmail he might check twice a week.

> Hey Gannon, built a quick demo site for your garage door business. Not final — just wanted to show you what a one-page site could look like. Take a look and let me know what you think: https://robblake.cloud/tmp-demo-gannon/

**Deploy before sending.** A live URL converts; an attachment reads like malware. If GHL can't host the page (funnels API is read-only), use the temp VPS nginx recipe below instead of waiting — a live link proves the hosting story in the same breath.

**Which build to deploy:** When a prospect needs a link fast, use the Claude-produced build from the vault, not the earlier stub in `work/`. The canonical Claude build lives at:
- Source: `/root/.hermes/vault/Free Website Local Services - Lead Magnet/gannons-garage-doors/` (multi-file)
- GHL-ready single-file copies: `single-file-for-ghl/` subfolder
- These have real brand assets, flat-rate pricing, LocalBusiness schema, and the chatbot placeholder already in place.

**What to say when he asks "what's the catch":** No catch on the site — it's built and it's yours to look at. If you want me to host it and keep it running, that's $197/mo and includes the AI chat widget that answers questions and captures leads when you're under a door. If you just want the files, I'll send them over. Don't lead with price. Let him see it first.

**Second touch (if he ghosts):** Google Business Profile setup offer. Genuinely valuable, costs 20 minutes, restarts the conversation with you giving rather than asking.

## Field inventory (verified)

- ✅ Name, address, phone, hours, website, rating, review count, service flags ("online appointments")
- ✅ Review text + reviewer name + reviewer review-count (a "Local Guide · 55 reviews" reviewer is credible — quoting them hits harder)
- ✅ **Owner responses** — reveals whether they do reputation management AND their voice (template-corporate vs. personal-with-emoji)
- ✅ **Ownership badges** ("Identifies as women-owned / Latino-owned") — personalize the pitch
- ✅ **Staff/owner first names** — mine review text ("Katie was great!", "Thank you Sherri and Chris")
- ❌ Exact review dates (Google only shows "2 months ago")
- 🟡 Owner's name is inferable, never labeled — cross-check their website if it matters

## Alternative method: Web-search + Yelp + BBB cross-reference (when Maps isn't optimal)

Maps crawling works great for niches where you can geo-target a single city. For service-area businesses that cover a radius, the Maps search for a single city may miss businesses headquartered outside the city that serve it.

### Method — Multi-source cross-reference sweep

1. **Run 3-5 web searches** with different phrasings targeting the niche + city.
2. **Cross-reference against Yelp** — extract the full Yelp search page. Yelp's business listings include ratings, review counts, hours, and service-area tags.
3. **Cross-reference against BBB** — search `BBB [niche] [City] [ST]` to find accredited businesses and verify legitimacy.
4. **Check individual business websites** — for each candidate, visit their site and extract physical address, 24/7 claims, service area pages, phone numbers.
5. **Deduplicate** — merge across all sources.

### Geo-classification: "Serving" vs. "Located in"

- **Serving the area:** The business lists the target city/radius in its service area, regardless of HQ location.
- **Located in the radius:** The business's physical headquarters/office address falls within the defined radius.

## 24/7 claim detection

Businesses that advertise 24/7 emergency service are the highest-priority targets for missed-call-capture products. Check website header/tagline, Yelp hours, hero section, footer for "24/7", "24 hours", "around the clock".

## New Businesses Without Websites

Discovery path: Search local town/neighborhood Facebook groups, Nextdoor, and Google Maps for businesses with no website. Facebook group search is TOS-safe within the app. Do NOT use Apify/scrapers against Facebook — active anti-bot blocking, TOS violation, brittle.

## PDF rendering (markdown → print-ready PDF on the VPS)

`pandoc`/`wkhtmltopdf` are not installed; the working pipeline:

```bash
apt-get install -y -q libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0  # one-time, weasyprint system deps
```

```python
import markdown
from weasyprint import HTML
body = markdown.markdown(open(path).read(), extensions=['tables'])
HTML(string=f"<html><head><style>{CSS}</style></head><body>{body}</body></html>").write_pdf(out)
```

Use DejaVu Sans (installed), Letter size, 2cm margins. Verify output by rendering page 1 to PNG with pymupdf (`pip install pymupdf`, `import fitz` → deprecation warning is harmless) and vision-checking it.

## Delivery

Deliver bundles to Rob via the Drive route from `composio-mcp-ops` (stage on nginx → GOOGLEDRIVE_UPLOAD_FROM_URL → cleanup → verify 404 + byte-count match).

- **chmod 644** after every `cp` into /var/www/html/stage/ — files copied from /root inherit 600 perms → nginx 403 "Permission denied".
- **System-path writes** (`/etc/nginx/...`) must go through `terminal` — the `patch` tool refuses them, and a refused patch followed by a successful terminal edit confuses file-mutation verifiers. Re-verify disk state after any refused write.
- **Zip with Python `zipfile` stdlib** — the `zip` binary isn't installed.
- Include markdown sources alongside PDFs so he can edit without calling you.