git:20260528.a073e6c to git:20260528.9ddb460

28 added, 21 removed. Audit A to A.

---
name: update-municipal-contacts
- description: Refresh the per-município mayor email contacts (data/officials/municipal_contacts/index.json) by scraping the iisda.government.bg "Кметове на общини" registry. Use when the daily watch report flags `iisda_mayors` as changed, when the user asks to refresh mayor contacts / municipal contacts / kmet emails, after a local-election or chmi cycle when new mayors take office, or after a fresh git clone if data/officials/municipal_contacts/index.json carries an empty `contactsByObshtina`.
+ description: Refresh the per-município mayor + deputy-mayor email contacts (data/officials/municipal_contacts/index.json) by scraping the iisda.government.bg "Кметове на общини" registry. Use when the daily watch report flags `iisda_mayors` as changed, when the user asks to refresh mayor contacts / municipal contacts / kmet emails, after a local-election or chmi cycle when new mayors take office, or after a fresh git clone if data/officials/municipal_contacts/index.json carries an empty `contactsByObshtina`.
allowed-tools:
- Read
- Bash
- Edit
- Write
---
# Update municipal contacts skill
- Refreshes `data/officials/municipal_contacts/index.json` — the per-municipality mayor email contact list that powers the My-Area dashboard's "Контакти на общината" tile and the "Свържете се" button on each mayor card. Covers 260 of Bulgaria's 265 municipalities; Sofia city contributes once under the synthetic `SOF00` code that fans out to the 24 районы via the hook fallback.
+ Refreshes `data/officials/municipal_contacts/index.json` — the per-municipality mayor + deputy-mayor email contacts that power the inline `mailto:` icons next to each official's name on the My-Area dashboard's `MyAreaGovernmentCard`. Covers 258 of Bulgaria's 265 municipalities with at least one official, and ~600 deputy-mayor emails on top of the 260 município mayors. Sofia city contributes once under the synthetic `SOF00` code (with the 12 city-wide deputies) and fans out to the 24 районы via the hook fallback.
Source contract:
| Field | Source | Granularity | Cadence |
|---|---|---|---|
- | `email`, `mayor`, `iisda_id` | [iisda.government.bg `governing_body/<id>` detail pages](https://iisda.government.bg/ras/governing_bodies/gb_municipality_administrations) — IDs in the contiguous block 4400..4950 | per municipality (obshtina code) | shifts on regular & partial local-election cycles |
+ | `email`, `mayor`, `iisda_id`, `officials[]` (role + name + email per person) | [iisda.government.bg `governing_body/<id>` detail pages](https://iisda.government.bg/ras/governing_bodies/gb_municipality_administrations) — IDs in the contiguous block 4400..4950 | per municipality (obshtina code) | shifts on regular & partial local-election cycles, and on individual zам.-кмет appointments/dismissals |
- `scripts/officials/municipal_contacts/scrape_iisda.ts` walks the ID range serially with a 200 ms polite delay, caches each detail page to `raw_data/officials/iisda_mayors/<id>.html`, filters for pages whose body contains `Кмет на община` AND `Общинска администрация`, extracts the first email address + município name, and name-matches against `data/municipalities.json` with two manual aliases (`Добричка` → `DOB15`, `Столична община` → `SOF00`).
+ `scripts/officials/municipal_contacts/scrape_iisda.ts` walks the ID range serially with a 200 ms polite delay, caches each detail page to `raw_data/officials/iisda_mayors/<id>.html`, filters for pages whose body contains `Кмет на община` AND `Общинска администрация`, then walks every `<li class="level-1">` block on the page to pull (role label, full name, `mailto:` email) — one row for the mayor plus one per deputy mayor in the "Заместници" section. Each block must carry a `<span class="li-sub-text-name">` and a `mailto:` href to be emitted; iisda's "Степен на разпоредител с бюджет" block doesn't and is skipped. Município name is matched against `data/municipalities.json` with two manual aliases (`Добричка` → `DOB15`, `Столична община` → `SOF00`).
## When to run
| Trigger | Action |
|---|---|
| Daily watcher reports `iisda.government.bg — Кметове на общини` as changed | Step 1 |
| User asks to "refresh mayor contacts" / "update municipal contacts" / "rescrape iisda" | Step 1 |
| New regular-local cycle (mi*) or chmi cycle has finalized — new mayors took office | Step 1 with `--force` to refetch HTML |
| Fresh clone with empty `contactsByObshtina` | Step 1 |
The iisda registry is dormant between election cycles; routine fingerprint flips are usually individual chmi (частични избори) replacements, not bulk changes.
## Step 1 — Scrape
```bash
npx tsx scripts/officials/municipal_contacts/scrape_iisda.ts # uses cached HTML when present
```
Force a full re-fetch (after a regular-local cycle):
```bash
rm -rf raw_data/officials/iisda_mayors
npx tsx scripts/officials/municipal_contacts/scrape_iisda.ts # ~2 min cold
```
Expected output on a routine run:
```
scanned 100/551 · mayors=50 · matched=50
scanned 200/551 · mayors=100 · matched=99
scanned 300/551 · mayors=150 · matched=149
...
scanned 550/551 · mayors=260 · matched=260
Done. Wrote .../data/officials/municipal_contacts/index.json — 260/260 mayors matched, 0 unmatched.
```
## Step 2 — Spot-check
- Cross-check the new entries against the email domains — they should align with the município's name conventions (`kmet@varna.bg`, `obshtina@burgas.bg`, etc.):
+ Cross-check the new entries against the email domains — they should align with the município's name conventions (`kmet@varna.bg`, `obshtina@burgas.bg`, etc.) — and confirm deputy-mayor coverage:
```bash
python3 -c "
import json
- c = json.load(open('data/officials/municipal_contacts/index.json'))
- samples = list(c['contactsByObshtina'].items())[:5]
+ c = json.load(open('data/officials/municipal_contacts/index.json'))['contactsByObshtina']
+ samples = list(c.items())[:5]
for code, x in samples:
- print(f' {code}: {x.get(\"email\")} (iisda_id={x.get(\"iisda_id\")})')
- print(f'total: {len(c[\"contactsByObshtina\"])} municipalities')
+ deputies = len([o for o in x.get('officials', []) if o['role'] == 'deputy_mayor'])
+ print(f' {code}: {x.get(\"email\")} (iisda_id={x.get(\"iisda_id\")}, deputies={deputies})')
+ deputy_total = sum(len([o for o in v.get('officials', []) if o['role']=='deputy_mayor']) for v in c.values())
+ with_officials = sum(1 for v in c.values() if v.get('officials'))
+ print(f'total: {len(c)} municipalities · {with_officials} with officials[] · {deputy_total} deputy-mayor emails')
"
```
Sanity checks:
- - Total ≈ 260 (5 short of 265: Sofia city is one record under SOF00; 4 other gaps mean the iisda ID range needs widening — see "Known limitations").
- - BGS04 → `@burgas.bg`, RSE27 → `@ruse-bg.eu`, SOF00 → `@sofia.bg`.
+ - Total municipalities ≈ 260 (5 short of 265: Sofia city is one record under SOF00; 4 other gaps mean the iisda ID range needs widening — see "Known limitations").
+ - Deputy-mayor emails ≈ 600 (cycle average; varies with chmi appointments).
+ - BGS04 → `@burgas.bg`, RSE27 → `@ruse-bg.eu`, SOF00 → `@sofia.bg` with 12 deputies.
- If the unmatched-mayors line shows > 5, the name → obshtina-code join broke. Look at the printed "Unmatched" sample and either widen `MANUAL_ALIASES` in the script or extend the matching heuristics.
+ If the unmatched-mayors line shows > 5, the name → obshtina-code join broke. Look at the printed "Unmatched" sample and either widen `MANUAL_ALIASES` in the script or extend the matching heuristics. If the deputy-mayor count drops sharply (< 400), the `extractPeople()` per-block parse likely broke — re-check the iisda HTML for layout changes in the `<li class="level-1">` blocks.
## Step 3 — Commit + bucket sync
```bash
git add data/officials/municipal_contacts/index.json
git commit -m "officials: refresh municipal mayor contacts from iisda"
npm run bucket:sync:dry
npm run bucket:sync
```
## Step 4 — Stamp success
```bash
npx tsx scripts/stamp-ingest.ts update-municipal-contacts \
- --summary "260/260 mayors matched · 0 unmatched"
+ --summary "260/260 mayors matched · 0 unmatched · ~600 deputy-mayor emails"
```
Only stamp on the public data-changes log when emails actually changed:
```bash
if [ -n "$(git diff --stat data/officials/municipal_contacts/)" ]; then
npx tsx scripts/append-data-change.ts update-municipal-contacts \
- --summary "260/260 mayors matched · 0 unmatched" \
+ --summary "260/260 mayors matched · 0 unmatched · ~600 deputy-mayor emails" \
--source "iisda.government.bg — Кметове на общини"
fi
```
## Known limitations
- - **iisda only exposes email** — phone, website, postal address, working hours are not on the detail pages (only declaratively in the page text). The tile renders email + tel: link if a phone shows up under a future scrape extension.
- - **Sofia city contributes one record** under SOF00; the 24 районы see it via `useMunicipalContacts`' SOF00 fallback.
+ - **iisda only exposes email** — phone, website, postal address, working hours are not on the detail pages (only declaratively in the page text). Email is the only channel we surface today.
+ - **Mayor + deputy mayors only.** Council chairs, council members, chief architects, and кмет на кметство (village mayors) are NOT in iisda's executive registry — they live in CACBG declarations (the `update-officials` municipal slice) and in CIK local-elections HTML. The hook returns `undefined` for those names; the icon silently omits.
+ - **Sofia city contributes one record** under SOF00 with the city-wide mayor + 12 deputies; the 24 районы see it via `useMunicipalContacts`' SOF00 fallback. Each Sofia район has its own iisda detail page in the "Кметове на райони" section (different ID block) which is NOT scraped today — extending to that block would surface per-район mayor + deputy emails.
+ - **Name match is tolerant but not perfect.** The hook tries an exact normalized full-name match, then falls back to a first-three-tokens hyphen-stripped stem (so iisda's "Дончева-Терзийска" matches the CACBG roster's "Дончева"). Other transliteration mismatches still drop through silently.
- The **ID range 4400..4950 is hand-chosen** based on the observed mayor-ID distribution. If iisda re-allocates IDs after a major government re-organisation, widen the range (`ID_RANGE_START` / `ID_RANGE_END` in the script) and re-run with `--force`.
## What this skill does NOT do
- - **Does not refresh кметство (village-mayor) contacts.** Sub-municipal village mayors are scraped from CIK local-elections HTML by `update-local-elections` and lifted into `data/local_mayors/`; this skill is only the município-level mayor.
+ - **Does not refresh кметство (village-mayor) contacts.** Sub-municipal village mayors are scraped from CIK local-elections HTML by `update-local-elections` and lifted into `data/local_mayors/`; this skill is only the município-level mayor + deputies.
- **Does not touch the кметство → EKATTE lookup.** That's a `update-local-elections` derived step (`scripts/parsers_local/backfill_kmetstvo_ekatte.ts`).
+ - **Does not scrape "Кметове на райони".** Sofia/Varna/Plovdiv районы have their own iisda detail-page block (not in the 4400..4950 mayor range); folding it in would extend `MunicipalityInfo` join logic + add a second ID-range scan.
- **Does not stamp the public data-changes log** when only the `scrapedAt` timestamp moved.
## File map
| Path | Purpose |
|---|---|
- | `scripts/officials/municipal_contacts/scrape_iisda.ts` | CLI entry — walk iisda IDs, extract emails |
+ | `scripts/officials/municipal_contacts/scrape_iisda.ts` | CLI entry — walk iisda IDs, extract mayor + deputy-mayor emails via per-`<li class="level-1">` block parser |
| `raw_data/officials/iisda_mayors/<id>.html` | cached detail-page HTML (skip-on-exist) |
- | `data/officials/municipal_contacts/index.json` | output — `contactsByObshtina` map |
+ | `data/officials/municipal_contacts/index.json` | output — `contactsByObshtina` map with `{email, mayor, iisda_id, officials[]}` per município |
| `scripts/watch/sources/iisda_mayors.ts` | watcher — fingerprints pagination count + first-page IDs |
- | `src/data/officials/useMunicipalContacts.tsx` | React Query hook with Sofia район → SOF00 fallback |
- | `src/screens/myarea/MyAreaContactsTile.tsx` | "Контакти на общината" tile |
+ | `src/data/officials/useMunicipalContacts.tsx` | React Query hook with Sofia район → SOF00 fallback + `emailForName()` lookup (exact + hyphen-stem fallback) |
+ | `src/screens/myarea/MyAreaGovernmentCard.tsx` | renders inline `Mail` icon (`mailto:` link) next to each official's name — mayor, council chair (if matched), deputy mayors, chief architect |