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. ๐
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 = 3That 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
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
breakFirecrawl 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.
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 action | Municipalities |
|---|---|
| 2,004 | |
| Phone | 74 |
| Official contact form | 12 |
| Manual research | 20 |
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. ๐