I needed 2,110 municipal contacts. So I built a crawler that refuses to guess.

A public contact list sounds easy... until 2,110 websites, four languages, changing staff, and source evidence get involved. ๐Ÿ˜…

Share
I needed 2,110 municipal contacts. So I built a crawler that refuses to guess.

TL/DR: I turned 2,110 Swiss municipalities into an outreach queue with 2,090 usable public contact channels. The remaining 20 stay deliberately unresolved.


An NPO programme I work with receives a large part of its funding from Swiss municipalities. A programme change is coming up, which means we need to contact them. We obviously have the contacts we previously talked to in our CRM, but there is the usual old-contact-list problem: people change jobs, municipalities merge, generic inboxes go nowhere, and an email addressed to the wrong person wastes time on both sides. Multiplied by up to 2,110 ๐Ÿ™„

The relevant municipal contact is usually public. Finding it manually 2,110 times sounded like a bad use of a human life, though.

The address book was a data problem

I started with current municipality records from the Swiss Federal Statistical Office (BFS). The crawler reads the municipalities from my โ„๏ธ Snowflake copy of that data:

select
    municipality.bfscode,
    municipality.name,
    canton.shortname as canton
from raw.bfs.gemeinde municipality
    join raw.bfs.gemeinde district
        on district.historicalcode = municipality.parent
        and district.level = 2
    join raw.bfs.gemeinde canton
        on canton.historicalcode = district.parent
        and canton.level = 1
where municipality.level = 3

That gives a current scope, BFS identifiers, municipality names, and cantons. The identifier matters: Municipality names are less unique than they first appear, especially once spelling variants, brackets, accents, and language borders enter chat.

From there, I needed two facts for every BFS code:

  • the official municipal domain
  • a published contact channel for a clerk-equivalent municipal administration role
The target was never โ€œcollect every email address and phone numberโ€. I needed a defensible route into the municipal administration, with a source URL and enough surrounding evidence to explain why that route was selected.

A domain is not proof

A web search for a municipality name returns a colourful mix of tourist sites, Facebook pages, directory listings, canton pages, property listings, and sometimes a municipality with the same name in another country. Fun ๐Ÿฅณ

I therefore search for candidates, then scrape and validate their root pages. The validator looks for municipal wording, municipality identity, a matching hostname or a strong title match, and rejects known directory and social hosts.

if verify_official_domain(municipality, candidate, content):
    selection = candidate
    root_content = content
    break

Firecrawl does the primary search, scrape, and site map work. Perplexity Search and Perplexity Agent enter as fallbacks. Even then, Firecrawl retrieves the suggested page before I store anything... An LLM may suggest a useful lead. It does not get to become the source of record.

The result: 1,733 current domains came from Firecrawl directly. Search or agent fallbacks found most of the awkward remainder. A few were manually verified. This was worth the extra effort because every later contact decision depends on the domain being right.

A name alone doesn't make a contact

Swiss municipalities use several titles for the person who runs, or is the entry point to, the administration (federalism, yay!). In German that may be Gemeindeschreiber, Stadtschreiber, Gemeindeverwalter, or Geschรคftsleiter. French, Italian, and Romansh municipalities bring their own variants.

The crawler searches for those roles on the selected domain, maps relevant pages, and looks at only a small capped set of candidates. It also uses canton directories where available (though only very few cantons even provide such a list).

Then comes the part I cared about most: evidence binding.

status = "accepted" if (
    first_name
    and last_name
    and has_evidence
    and has_target_role
) else "manual_review"

An accepted personal contact needs a published name, qualifying role, personal email address, municipal binding, source URL, and evidence tying those values together. The crawler accepts a short 200-character span, or one bounded contact card with a single email address. It rejects deputies, former employees, interim roles, inferred addresses, and person-role-email combinations assembled from unrelated parts of a page.

For example, anna.mueller@example.ch is not enough because an extraction model thinks it looks plausible. The literal address needs to appear in the public source beside enough context to show that Anna Mueller is the relevant municipal contact.

Generic municipal inboxes remain useful. The crawler stores them as generic channels and never pretends that they are personal addresses.

Public data still has gaps

The current crawl history contains 97,726 contact observations. Most are rejected or retained for manual review. That number sounds mildly ridiculous until looking at municipal websites: repeated staff pages, stale PDFs, department pages, navigation fragments, search results, and partial contact cards all create observations.

The final selection keeps one ranked route per current municipality. It prefers a source-validated personal email/phone, then a named contact with a personal email/phone needing normal review, then a named contact with an official inbox/phone, then other usable official channels.

Current output looks like this:

Next actionMunicipalities
Email2,004
Phone74
Official contact form12
Manual research20

That is 2,090 municipalities with a usable public channel. The email group includes personal addresses and official administration inboxes. I found 1,051 personal emails and 1,594 generic inboxes across the selected records. Some municipalities have both.

I also ran a separate recovery process for the difficult cases. It produced 331 independently reviewed channels: 217 official inboxes, 73 phone-only channels, 28 personal emails, and 12 contact forms.

Twenty empty rows are a feature

The remaining 20 municipalities go into a small manual-research queue. Some have a named person but no safe current channel. Others have stale material, or evidence tied to a regional body rather than the municipality itself.

I could have guessed an email format. Many municipal addresses are predictable. That would have made the spreadsheet look more complete while quietly creating a worse outreach list.

So the export treats missing evidence as a result. Every unresolved row stays marked needs_verification. No inferred email address enters the contact workflow.

if row["outreach_action"] == "research":
    row["contact_channel"] = ""
    row["decision_status"] = "needs_verification"
    row["decision_reason"] = "no_usable_contact_channel"

I also generate local-language salutations only when the role grammar or a clearly classified first name makes that safe. Otherwise the output falls back to Guten Tag, Bonjour, or Buongiorno. Better a neutral greeting than a confident mistake.

The work does not end at export

The final CSV is an outreach queue, not a claim that every contact will remain current forever. Municipal staff change. Websites change. BFS records change. A later run should refresh the scope and revisit the public sources.

I also keep the DuckDB file in access-controlled local storage. The information is public professional data, yet the database contains source snippets, addresses, phones, and personal email addresses. Public does not mean careless.

And that is really it: 2,090 contactable municipalities, 20 honest manual tasks, and far fewer emails sent and phone numbers called into the void. ๐Ÿ˜Ž