# Data Refresh Plan — Försäkringskassan BankID Scraper + `login` Test Harness
**Owner:** Senior developer
**Status:** Ready to implement
**Last updated:** 2026-06-12
This plan covers the **data-acquisition (scraping) part only**: a headless browser that logs into Försäkringskassan via BankID (QR code), shows the QR to the user so they can scan it, then navigates and pulls the parental-leave figures. It also covers a local **`login` test harness** (a dev web page with a "Refresh" button → QR → fetched data) so we can iterate without ceremony.
It deliberately slots into the existing architecture rather than inventing new contracts. The interfaces, entities, and endpoint stubs already exist — this plan fills in the real implementation behind them.
---
## 0. Decisions locked for this work
These were confirmed with the product owner; do not re-litigate without checking back.
1. **Scraper runtime = Playwright for Python, inside the existing backend.** The implementation lives in `backend/app/scrapers/fk_scraper.py` behind the existing `FetchDataService` / `FKScraperService` contract. We do **not** spin up a separate Node scraper service. `npm run login` is a thin dev convenience wrapper, not a second implementation.
2. **QR handling = relay Försäkringskassan's own rendered QR.** FK controls the BankID session; its login page renders the animated QR itself. We screenshot / extract that QR element and stream it to our page. We do **not** integrate with the BankID API directly, start our own BankID order, or handle order references / certificates. This keeps us out of the BankID protocol entirely.
3. **Page captures from product owner = HTML + PNG per page**, plus a one-line note of which element to click and the URL/hash at that step. See §6 for the exact capture protocol.
4. **Legal gate still applies.** Scraping ships disabled (`settings.scraper_enabled = False`) until the legal/ToS + BankID-automation review in ARCHITECTURE §2.3 clears. This plan builds the capability; it does not flip the switch for public launch. Local/dev testing runs with the flag on in a dev profile only.
---
## 1. Where this fits in the existing codebase
The scaffolding is already in place — read these before writing code:
| File | What's there now | What this plan adds |
|---|---|---|
| `backend/app/scrapers/fk_scraper.py` | `FKScraperService` stub: `start_bankid_session`, `poll_session`, `cancel_session`, `get_latest`, `list_history` all raise `NotImplementedError` behind a legal-gate message. | Real Playwright implementation of the session lifecycle + extraction. |
| `backend/app/services/fetch_data_service.py` | `FetchDataService` ABC + `ManualFetchService` (the always-works fallback). | No change to the contract. `FKScraperService` must satisfy the same ABC so the rest of the app stays decoupled from FK's DOM. |
| `backend/app/routers/fetch_data.py` | `/api/fetch-data/start|poll|cancel` return **501** when `scraper_enabled` is false. Manual endpoints are live. | Wire these three to the real `FKScraperService`; keep the 501 guard for when the flag is off. |
| `backend/app/entities/parental_leave.py` | `ParentalLeaveFetch` + `TierData` — the **stable contract** the scraper must produce. | Scraper outputs this exact shape with `source='scraped'`. |
| `backend/app/config.py` | `scraper_enabled: bool = False`. | Add the scraper config block in §7 (timeouts, headless flag, dev profile). |
| `backend/app/models/parental_leave.py` | Pydantic request/response schemas incl. field bounds (`total_days` 0–480, `dubbeldagar_left` 0–60, etc.). | Reuse for validating scraped output before persisting. |
**Golden rule (from ARCHITECTURE §2.3):** every FK-specific selector and flow detail lives *only* inside `fk_scraper.py`. The rest of the app depends on `ParentalLeaveFetch`, never on FK's markup. If FK changes their site, exactly one file should need editing.
---
## 2. The data we must extract (target contract)
The scraper's job is to populate a `ParentalLeaveFetch` (see `entities/parental_leave.py`). These are the fields and where they come from on FK's "Ansök och planera föräldrapenning" pages. Use the captures (§6) to confirm exact Swedish labels and the page each lives on.
| Field | Meaning | Notes |
|---|---|---|
| `total_days` | Total föräldrapenning days for the child | Per parent; whole days only (no fractional). |
| `days_used` | Days already taken | |
| `days_left` | Derived = `total_days - days_used` | Don't trust a displayed "left" blindly — recompute and cross-check; flag mismatch. |
| `reserved_days_total` | Reserved (non-transferable) days, default 90 | |
| `reserved_days_left` | Reserved days remaining | Must be ≤ `reserved_days_total`. |
| `transferable_days_left` | Transferable days remaining | |
| `dubbeldagar_left` | Double-days remaining, max 60 (first 15 months only) | |
| `tier_data.sjukpenning_total/used/left` | Sjukpenningnivå tier (the ~390 income-based days) | `left = total - used`. |
| `tier_data.lagsta_niva_total/used/left` | Lägstanivå tier (the 90 flat-rate days) | `left = total - used`. |
| `source` | `'scraped'` | Set by scraper. Manual entry uses `'manual'`. |
| `fetched_at` | Timestamp of this fetch | |
**Validation before persisting:** reuse the bounds in `ManualFetchRequest` and the invariants `ManualFetchService.save_manual_entry` already enforces (`days_left >= 0`, `reserved_days_left <= reserved_days_total`). If FK's numbers violate an invariant, do **not** silently persist — return an `error` status with a clear message so we can fix the parser. Scraping a wrong number into the planner is worse than failing loudly.
---
## 3. Session lifecycle / state machine
Because FK owns the BankID session, our flow is a long-lived, polled session that mirrors what FK shows. One headless browser context per session.
```
start ──▶ launching ──▶ awaiting_scan ──▶ authenticating ──▶ fetching ──▶ done
│ │ │
└──────────────────┴────────────────┴────▶ error / cancelled / timeout
```
**Statuses returned to the client (`poll`):**
- `launching` — browser starting, navigating to the login URL.
- `awaiting_scan` — QR is being shown. `poll` returns the **current** QR image (base64 PNG). The QR animates (BankID refreshes it roughly once per second), so each `poll` re-captures it.
- `authenticating` — user scanned; BankID/FK is completing auth.
- `fetching` — authenticated; scraper is navigating FK pages and reading values.
- `done` — `result` contains the `ParentalLeaveFetchResponse`. Browser context is torn down and cookies/session discarded.
- `error` — `message` explains what broke (selector miss, timeout, validation failure). Browser torn down.
- `cancelled` — user/cancel endpoint stopped it. Browser torn down.
**Endpoint contracts** (already stubbed in `routers/fetch_data.py`):
```
POST /api/fetch-data/start
body: { workspace_id, personal_data_uuid, child_id }
→ 200 { session_id, status: "launching" }
→ 501 if scraper_enabled is false
GET /api/fetch-data/poll?session_id=...
→ 200 { status, qr_data?: base64png, result?: ParentalLeaveFetchResponse, message?: string }
POST /api/fetch-data/cancel
body: { session_id }
→ 200 { status: "cancelled" }
```
**Session store:** in-memory dict `session_id -> SessionState` (Playwright context handle + status + latest QR bytes + result + created_at). This is fine because the scraper runs in a single sandboxed worker and sessions are ephemeral. **Never persist** the context, cookies, or QR to disk or DB.
**Timeouts:** auto-cancel any session that sits in `awaiting_scan`/`authenticating` longer than a configurable TTL (default 180s) and tear down the browser. Belt-and-suspenders against orphaned headless Chromiums.
**Concurrency for v1:** one active scrape session at a time is acceptable for the test harness. Note it as a known limitation; a session pool/queue is a later concern.
---
## 4. Playwright login + navigation flow
Implement in `fk_scraper.py`. Pseudocode of the happy path — exact selectors come from the §6 captures.
```python
# 1. Launch
browser = await playwright.chromium.launch(headless=settings.scraper_headless)
context = await browser.new_context(locale="sv-SE") # Swedish UI for stable labels
page = await context.new_page()
# 2. Go to login, choose BankID med QR-kod
await page.goto(LOGIN_URL, wait_until="networkidle") # the goto= deep link from §5
await page.get_by_role("button", name="BankID med QR-kod").click() # confirm via capture
# 3. Relay the QR (loop while status == awaiting_scan)
qr = page.locator(QR_SELECTOR) # the /