88197060de
Re-checked every item of the audit's own outcome section against the tree.
Most held, but five items marked fixed had residual gaps — one of them a
live IDOR — and the deferred list was untouched.
Security:
- Analytics IDOR: /api/analytics/{ws}/overview and /projection took
child_id as an unchecked query param, and get_overview called
get_days_left_at_age_4 without workspace_id, so the ownership guard
never ran. Any member could read another workspace's child projection.
Fixed at both layers.
- Scraper: session ids no longer logged at INFO; a persist-callback
failure's raw exception text and the FK maintenance banner's scraped
text no longer reach the poll client.
- Email is now case-normalised at the API boundary, with migration 009
lowercasing existing rows (aborts naming colliding rows rather than
failing opaquely on the UNIQUE constraint).
Frontend correctness:
- usePlanProposal/usePlanSync gained the stale-response guard the other
hooks already had.
- PlanSyncProvider no longer fires POST /plan/sync before auth resolves
off a blindly-read localStorage workspaceId.
- Busy flags are cleared unconditionally across all 16 useLatestRequest
consumers. setBusy(true) was unconditional while the clear was guarded
by isCurrent, so a superseded call stranded its flag forever — under
StrictMode this hung every in-app navigation behind the PageSkeleton.
isCurrent decides which response's data wins, never whether a call
releases the flag it raised.
Performance:
- Calendar no longer blanks on a mutation, and a multi-day save issues N
writes plus one background refetch instead of 2N requests.
- take_pl_on_red_days moved onto GET /auth/context, dropping a redundant
GET /workspace/:id from every cold load.
CI and repo hygiene: least-privilege permissions block, actions pinned to
commit SHAs, npm ci, uv sync --locked, failure-only artifact upload,
dependabot; dead frontend/out.js removed; CSP added to the nginx template
(repeated into the location blocks that set their own add_header, since
nginx does not merge them across levels).
Left deferred by decision: refresh-token revocation, the phase-2 contract
change, M4, M5, L2, and H12's cross-route cache. Details and the smaller
follow-ups browser verification turned up are in the audit report.
Backend 901 passed, coverage 92%. Frontend 1322 tests, coverage
98.17/94.79/92.14/98.17.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PngVF38MLpdkgXcByKfdW6
1081 lines
46 KiB
Python
1081 lines
46 KiB
Python
"""Försäkringskassan headless-browser scraper — Playwright implementation.
|
|
|
|
Legal/ToS review completed (2026-06). Legal has cleared automated read-only
|
|
access for users who have given explicit per-session consent. Operational
|
|
guardrails must be maintained (see §2.3):
|
|
- Sandboxed browser context, torn down after each session.
|
|
- No cookies, session tokens, or QR images ever persisted to disk or DB by
|
|
default. Debug artifacts (HTML + screenshot of the logged-in page) are
|
|
OFF unless both scraper_debug_enabled and scraper_debug_dir are set —
|
|
they contain personal data and are for local debugging only.
|
|
- No personal data (names, personnummer, day counts, raw page text) in
|
|
logs above DEBUG; DEBUG lines log shapes/counts, never scraped values.
|
|
- Per-session user consent required before starting a session.
|
|
- Only the extracted day-count numbers are persisted (see persist_callback).
|
|
- QR frames and results are retained in RAM only for
|
|
scraper_result_retention_seconds after a session ends, then purged; at
|
|
most one live session per user.
|
|
|
|
Enable via APP_SCRAPER_ENABLED=true in the environment or backend/.env.
|
|
|
|
Architecture:
|
|
- All FK-specific selectors live in _SELECTORS below. One-place edit if FK changes their DOM.
|
|
- One in-memory session store per process (sessions are ephemeral by design).
|
|
- Browser context is torn down on completion, error, or cancel — never persisted.
|
|
- Scraped numbers are written to the DB via the persist_callback injected at session start.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import logging
|
|
import re
|
|
import uuid
|
|
from collections.abc import Awaitable, Callable
|
|
from dataclasses import asdict, dataclass, field, replace
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
from uuid import UUID
|
|
|
|
from app.config import settings
|
|
from app.entities.parental_leave import ParentalLeaveFetch
|
|
from app.services.fetch_data_service import FetchDataService
|
|
from app.services.leave_snapshot import build_snapshot
|
|
from app.utils.errors import ValidationError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Selectors — all FK DOM knowledge lives here
|
|
# ---------------------------------------------------------------------------
|
|
|
|
LOGIN_URL = (
|
|
"https://www.forsakringskassan.se/privatperson/logga-in"
|
|
"#/?goto=%2Fprivatperson%2Fmina-sidor%2Falla-e-tjanster"
|
|
"%2Fansok-och-planera-foraldrapenning"
|
|
)
|
|
|
|
# The data-page landing URL after successful BankID auth
|
|
POST_LOGIN_URL_FRAGMENT = "ansok-och-planera-foraldrapenning#/"
|
|
# The data page we navigate to after clicking Ansök
|
|
DATA_PAGE_URL_FRAGMENT = "ansok-och-planera-foraldrapenning#/valjdagar"
|
|
|
|
# The "Min föräldrapenning" e-service page. Reports the transferable-days
|
|
# split ("Dagar ni kan föra över") directly, per-tier, for both parents —
|
|
# figures that cannot be reconstructed from #/valjdagar alone (see
|
|
# `_extract_transferable_days`'s docstring).
|
|
MIN_FP_URL = (
|
|
"https://www.forsakringskassan.se/privatperson/mina-sidor/alla-e-tjanster/min-foraldrapenning"
|
|
)
|
|
|
|
|
|
class _SEL:
|
|
# Login page
|
|
COOKIE_ACCEPT = "button:has-text('Ja, jag godkänner alla kakor')"
|
|
BANKID_QR_LINK = "[data-test='login-link'] a:has-text('BankID med QR-kod')"
|
|
# Maintenance alert banner on login page
|
|
MAINTENANCE_TITLE = "h2.rubrik:has-text('Tekniskt underhål')"
|
|
MAINTENANCE_MESSAGE = ".sv-text-portlet-content"
|
|
|
|
# QR page (idpx.forsakringskassan.se)
|
|
QR_SVG = "[data-testid='mobile-auth-qr-code']"
|
|
QR_BUTTON = "[data-testid='mobile-auth-qr-code-button']"
|
|
|
|
# Post-login landing (forsakringskassan.se/...#/)
|
|
ANSOK_BUTTON = "button.button--primary:has-text('Ansök för')"
|
|
|
|
# Data page (#/valjdagar) — "Återstående dagar" section
|
|
STEP_DAGAR_KVAR = "[data-test='stepDagarKvar']"
|
|
FOR_DIG = "[data-test='forDig']"
|
|
FOR_DIG_DAGAR_KVAR = "[data-test='forDig'] .visa-dagar__dagar-kvar"
|
|
FOR_DIG_DISCRETE = "[data-test='forDig'] .visa-dagar__discrete"
|
|
DUBBELDAG_KVAR = "[data-test='dubbeldag'] .visa-dagar__dagar-kvar"
|
|
# niva-statistik items inside forDig (desktop version, first = sjukpenning, second = lägstanivå)
|
|
NIVA_STATISTIK_DESKTOP = "[data-test='forDig'] .visa-dagar__desktop .niva-statistik__dagar span"
|
|
|
|
# "För dig och annan förälder" family panel — shows the CHILD's combined
|
|
# total ("Totalt för <barn>"), from which the other parent's remaining
|
|
# days can be deduced (family - mine). The exact DOM here is ASSUMED to
|
|
# mirror forDig's structure (same visa-dagar/niva-statistik classes)
|
|
# pending a real capture — see `fk_overview_synthetic.html`'s header
|
|
# comment. Parsing against these selectors must stay fully defensive
|
|
# (see `_extract_family_summary`): if FK's real markup differs, the
|
|
# feature degrades to "no partner deduction" rather than breaking the
|
|
# caller's own extraction.
|
|
FAMILY = "[data-test='forDigOchAnnanForalder']"
|
|
FAMILY_DAGAR_KVAR = FAMILY + " .visa-dagar__dagar-kvar"
|
|
FAMILY_DISCRETE = FAMILY + " .visa-dagar__discrete"
|
|
FAMILY_NIVA_STATISTIK = FAMILY + " .visa-dagar__desktop .niva-statistik__dagar span"
|
|
|
|
# Min föräldrapenning page — "Dagar ni kan föra över" panel (per child).
|
|
# See `MIN_FP_URL`'s comment and `_extract_transferable_days`.
|
|
MIN_FP_BARN_SEKTION = ".min-fp .barn-sektion"
|
|
BARN_RUBRIK = ".barn-rubrik strong"
|
|
PANEL_FORA_OVER_DAGAR = ".panel-fora-over-dagar"
|
|
FORA_OVER_GROUP = ".fora-over-group"
|
|
MINFP_LABEL = "h4.minfp-label"
|
|
EXPANDABLE_BUTTON = ".expandable-paragraph__button"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Family-panel summary and partner deduction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FamilySummary:
|
|
"""Parsed "Totalt för <barn>" family-panel figures (the CHILD's combined pool)."""
|
|
|
|
total_days: int
|
|
days_left: int
|
|
sjukpenning_left: int | None
|
|
lagsta_niva_left: int | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PartnerDeduction:
|
|
"""The other parent's balance, deduced as family totals minus mine."""
|
|
|
|
total_days: int
|
|
days_left: int
|
|
sjukpenning_left: int
|
|
lagsta_niva_left: int
|
|
dubbeldagar_left: int
|
|
transferable_days_left: int | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TransferableDays:
|
|
"""FK's own reported "Dagar ni kan föra över" split, per tier and parent.
|
|
|
|
Read from the `min-foraldrapenning` e-service page (see `MIN_FP_URL`),
|
|
not derived — this is the source of truth `_apply_transferable` feeds
|
|
back into `build_snapshot` as `transferable_days_left`. Partner fields
|
|
are `None` for a solo parent (no "Annan förälder" group on the page).
|
|
"""
|
|
|
|
mine_sjukpenning: int
|
|
mine_lagsta_niva: int
|
|
partner_sjukpenning: int | None = None
|
|
partner_lagsta_niva: int | None = None
|
|
|
|
@property
|
|
def mine_total(self) -> int:
|
|
return self.mine_sjukpenning + self.mine_lagsta_niva
|
|
|
|
@property
|
|
def partner_total(self) -> int | None:
|
|
if self.partner_sjukpenning is None or self.partner_lagsta_niva is None:
|
|
return None
|
|
return self.partner_sjukpenning + self.partner_lagsta_niva
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session state
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SessionStatus = Literal[
|
|
"launching", "awaiting_scan", "authenticating", "fetching", "done", "error", "cancelled"
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class _SessionState:
|
|
session_id: str
|
|
workspace_id: UUID
|
|
personal_data_uuid: UUID
|
|
child_id: UUID
|
|
# The User.id that started this session. `poll_session`/`cancel_session`
|
|
# treat a mismatched owner exactly like an unknown session_id — never a
|
|
# 403, so a foreign session_id can't be distinguished from a made-up one.
|
|
owner_user_id: UUID
|
|
# The child's display name, when the caller had it loaded already (see
|
|
# `start_session`). Used only to pick the right `.barn-sektion` on the
|
|
# min-foraldrapenning page when a workspace has more than one child —
|
|
# see `_extract_transferable_days`. `None` disables that feature for
|
|
# multi-child workspaces (never guesses a section) but never affects
|
|
# the caller's own #/valjdagar extraction.
|
|
child_name: str | None = None
|
|
status: SessionStatus = "launching"
|
|
qr_data: bytes | None = None # raw PNG bytes; never persisted to disk/DB
|
|
result: ParentalLeaveFetch | None = None
|
|
message: str | None = None
|
|
created_at: datetime = field(default_factory=datetime.now)
|
|
# Family-panel summary and the deduced partner balance, when the family
|
|
# panel was present and its numbers were internally consistent with
|
|
# `result` — see `_extract_family_summary`/`_deduce_partner`. Both stay
|
|
# None whenever the family panel is absent, unparsable, or inconsistent;
|
|
# that never affects `result` (the caller's own extraction).
|
|
family: FamilySummary | None = None
|
|
partner_deduction: PartnerDeduction | None = None
|
|
# Called after a successful extraction to persist the extracted numbers.
|
|
# The callback receives the validated ParentalLeaveFetch (mine) and the
|
|
# deduced partner balance (or None). Only the extracted numbers are
|
|
# persisted — never cookies, QR, or session state.
|
|
persist_callback: (
|
|
Callable[[ParentalLeaveFetch, PartnerDeduction | None], Awaitable[None]] | None
|
|
) = field(default=None, repr=False, compare=False)
|
|
# asyncio task running the scrape; kept so we can cancel it
|
|
_task: asyncio.Task | None = field(default=None, repr=False, compare=False)
|
|
|
|
|
|
# In-memory store — ephemeral by design; never written to DB.
|
|
_sessions: dict[str, _SessionState] = {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# FKScraperService
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class FKScraperService(FetchDataService):
|
|
"""Playwright-backed session service for scraping FK föräldrapenning data.
|
|
|
|
The service lives outside the normal FetchDataService "get/list" contract
|
|
because it's session-oriented (async, long-lived, polled). The get/list
|
|
methods remain unimplemented — the planner reads data via ManualFetchService
|
|
after a successful scrape saves the snapshot.
|
|
"""
|
|
|
|
async def get_latest(
|
|
self, personal_data_uuid: UUID, child_id: UUID
|
|
) -> ParentalLeaveFetch | None:
|
|
raise NotImplementedError("Use ManualFetchService for reads; FK scraper is write-only.")
|
|
|
|
async def list_history(
|
|
self, personal_data_uuid: UUID, child_id: UUID
|
|
) -> list[ParentalLeaveFetch]:
|
|
raise NotImplementedError("Use ManualFetchService for reads; FK scraper is write-only.")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public session API
|
|
# ------------------------------------------------------------------
|
|
|
|
async def start_session(
|
|
self,
|
|
workspace_id: UUID,
|
|
personal_data_uuid: UUID,
|
|
child_id: UUID,
|
|
owner_user_id: UUID,
|
|
persist_callback: (
|
|
Callable[[ParentalLeaveFetch, PartnerDeduction | None], Awaitable[None]] | None
|
|
) = None,
|
|
child_name: str | None = None,
|
|
) -> str:
|
|
"""Launch a new headless browser session. Returns the session_id.
|
|
|
|
persist_callback, if provided, is called with the validated ParentalLeaveFetch
|
|
once extraction succeeds. Failure in the callback sets the session to 'error'.
|
|
`owner_user_id` binds the session to the caller that started it — see
|
|
`_SessionState.owner_user_id`.
|
|
|
|
`child_name`, if the caller already has the `Child` loaded, is used
|
|
only to pick the right section on the min-foraldrapenning page when
|
|
a workspace has more than one child — see `_extract_transferable_days`.
|
|
The scraper never looks it up itself (no repository access from here).
|
|
|
|
At most one live (non-terminal) session per `owner_user_id` — starting
|
|
a new one cancels any session already in flight for the same caller
|
|
(see F5: unbounded growth of `_sessions` per owner).
|
|
"""
|
|
for existing_id, existing_state in list(_sessions.items()):
|
|
if existing_state.owner_user_id == owner_user_id and existing_state.status not in (
|
|
"done",
|
|
"error",
|
|
"cancelled",
|
|
):
|
|
await self.cancel_session(existing_id, owner_user_id)
|
|
|
|
session_id = str(uuid.uuid4())
|
|
state = _SessionState(
|
|
session_id=session_id,
|
|
workspace_id=workspace_id,
|
|
personal_data_uuid=personal_data_uuid,
|
|
child_id=child_id,
|
|
owner_user_id=owner_user_id,
|
|
child_name=child_name,
|
|
status="launching",
|
|
persist_callback=persist_callback,
|
|
)
|
|
_sessions[session_id] = state
|
|
|
|
task = asyncio.create_task(_run_session(state))
|
|
state._task = task
|
|
# Schedule TTL watchdog
|
|
asyncio.create_task(_ttl_watchdog(session_id))
|
|
|
|
logger.debug("scraper session started [id=%s]", session_id)
|
|
return session_id
|
|
|
|
async def poll_session(self, session_id: str, owner_user_id: UUID) -> dict:
|
|
"""Return current status, QR bytes (base64), or result.
|
|
|
|
A `session_id` owned by a different caller is treated exactly like
|
|
an unknown one — never a 403, so a foreign session_id can't be
|
|
distinguished from a made-up one.
|
|
"""
|
|
state = _sessions.get(session_id)
|
|
if state is None or state.owner_user_id != owner_user_id:
|
|
return {"status": "error", "message": "Unknown session_id"}
|
|
|
|
response: dict = {"status": state.status}
|
|
if state.status == "awaiting_scan" and state.qr_data:
|
|
response["qr_data"] = base64.b64encode(state.qr_data).decode()
|
|
if state.status == "done" and state.result:
|
|
response["result"] = state.result
|
|
response["partner_deduction"] = (
|
|
asdict(state.partner_deduction) if state.partner_deduction else None
|
|
)
|
|
if state.message:
|
|
response["message"] = state.message
|
|
return response
|
|
|
|
async def cancel_session(self, session_id: str, owner_user_id: UUID) -> None:
|
|
"""Cancel a running session and tear down the browser.
|
|
|
|
A `session_id` owned by a different caller is a no-op, exactly like
|
|
an unknown one — never a 403 (see `poll_session`).
|
|
"""
|
|
state = _sessions.get(session_id)
|
|
if state is None or state.owner_user_id != owner_user_id:
|
|
return
|
|
_set_status(state, "cancelled")
|
|
state.qr_data = None
|
|
if state._task and not state._task.done():
|
|
state._task.cancel()
|
|
logger.debug("scraper session cancelled [id=%s]", session_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _set_status(state: _SessionState, status: SessionStatus, message: str | None = None) -> None:
|
|
state.status = status
|
|
if message:
|
|
state.message = message
|
|
|
|
|
|
def _generic_error_message(exc: Exception) -> str:
|
|
"""Map an internal exception to a fixed, generic message safe to return
|
|
to the poll client (see F4). Never embeds `str(exc)` — a `ValueError`
|
|
raised while parsing the FK page may otherwise carry raw page text.
|
|
Logs carry the exception type name only; full detail is never emitted
|
|
above DEBUG and is not persisted unless debug artifacts are explicitly
|
|
enabled (`scraper_debug_enabled`).
|
|
"""
|
|
if isinstance(exc, ValueError):
|
|
return "Scraper error: could not read data from the FK page. Please try again."
|
|
if type(exc).__name__ == "TimeoutError":
|
|
return "Scraper error: timed out waiting for the FK page. Please try again."
|
|
return "Scraper error: an unexpected error occurred. Please try again."
|
|
|
|
|
|
async def _ttl_watchdog(session_id: str) -> None:
|
|
"""Auto-cancel sessions that sit in awaiting_scan/authenticating too long.
|
|
|
|
Also acts as a secondary safety net (F5): if the session has already
|
|
reached a terminal state by the time this fires, purge it immediately
|
|
rather than rely solely on `_purge_later`.
|
|
"""
|
|
await asyncio.sleep(settings.scraper_session_ttl_seconds)
|
|
state = _sessions.get(session_id)
|
|
if state is None:
|
|
return
|
|
if state.status in ("awaiting_scan", "authenticating", "launching"):
|
|
_set_status(state, "error", "Session timed out waiting for BankID scan")
|
|
state.qr_data = None
|
|
if state._task and not state._task.done():
|
|
state._task.cancel()
|
|
logger.debug("scraper session timed out [id=%s]", session_id)
|
|
elif state.status in ("done", "error", "cancelled"):
|
|
_sessions.pop(session_id, None)
|
|
|
|
|
|
async def _purge_later(session_id: str) -> None:
|
|
"""Remove a finished session from `_sessions` after the retention window.
|
|
|
|
Bounds how long a scraped result or a stale (already-cleared) QR frame
|
|
slot can be read back via `/poll` after the session ended (see F5).
|
|
"""
|
|
await asyncio.sleep(settings.scraper_result_retention_seconds)
|
|
_sessions.pop(session_id, None)
|
|
|
|
|
|
async def _check_maintenance(page) -> str | None:
|
|
"""Check for FK maintenance message. Returns the maintenance message if found, else None."""
|
|
try:
|
|
maintenance_title = page.locator(_SEL.MAINTENANCE_TITLE)
|
|
if await maintenance_title.count() == 0:
|
|
return None
|
|
|
|
# Found maintenance notice; extract the message
|
|
# The message is in a nearby .sv-text-portlet-content div
|
|
message_el = page.locator(
|
|
"h2.rubrik:has-text('Tekniskt underhål') ~ div .sv-text-portlet-content"
|
|
)
|
|
if await message_el.count() > 0:
|
|
message_text = await message_el.first.inner_text(timeout=3_000)
|
|
return message_text.strip()
|
|
|
|
# Fallback: just return a generic message
|
|
return "FK is under maintenance. Please try again later."
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
async def _save_debug_artifacts(page, session_id: str, console_messages: list[str]) -> None:
|
|
"""Save HTML, screenshot, and console log to scraper_debug_dir on error.
|
|
|
|
OFF by default and requires both `scraper_debug_enabled` and
|
|
`scraper_debug_dir` to be set — these artifacts contain personal data
|
|
(name, balances, payout amounts) from the logged-in FK page.
|
|
"""
|
|
debug_dir = settings.scraper_debug_dir
|
|
if not settings.scraper_debug_enabled or not debug_dir:
|
|
return
|
|
try:
|
|
out = Path(debug_dir)
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
prefix = out / session_id
|
|
|
|
console_path = prefix.with_suffix(".console.txt")
|
|
console_path.write_text("\n".join(console_messages), encoding="utf-8")
|
|
|
|
html_path = prefix.with_suffix(".html")
|
|
html_path.write_text(await page.content(), encoding="utf-8")
|
|
|
|
screenshot_path = prefix.with_suffix(".png")
|
|
await page.screenshot(path=str(screenshot_path), full_page=True)
|
|
|
|
logger.debug(
|
|
"scraper debug artifacts saved [id=%s] dir=%s",
|
|
session_id,
|
|
out.resolve(),
|
|
)
|
|
except Exception as artifact_exc:
|
|
logger.warning("failed to save debug artifacts: %s", artifact_exc)
|
|
|
|
|
|
async def _run_session(state: _SessionState) -> None:
|
|
"""Main browser coroutine. Drives FK login → QR relay → data extraction."""
|
|
from playwright.async_api import async_playwright # lazy import — optional dep
|
|
|
|
console_messages: list[str] = []
|
|
|
|
async with async_playwright() as pw:
|
|
browser = await pw.chromium.launch(headless=settings.scraper_headless)
|
|
context = await browser.new_context(locale="sv-SE")
|
|
page = await context.new_page()
|
|
page.on("console", lambda msg: console_messages.append(f"[{msg.type}] {msg.text}"))
|
|
try:
|
|
await _do_login(page, state)
|
|
if state.status in ("error", "cancelled"):
|
|
return
|
|
await _do_extract(page, state)
|
|
if state.status == "done" and settings.scraper_debug_dump_on_success:
|
|
await _save_debug_artifacts(page, state.session_id, console_messages)
|
|
except asyncio.CancelledError:
|
|
_set_status(state, "cancelled")
|
|
except Exception as exc:
|
|
_set_status(state, "error", _generic_error_message(exc))
|
|
logger.error(
|
|
"scraper error [id=%s]: %s", state.session_id, type(exc).__name__, exc_info=False
|
|
)
|
|
await _save_debug_artifacts(page, state.session_id, console_messages)
|
|
finally:
|
|
try:
|
|
await context.close()
|
|
await browser.close()
|
|
except Exception:
|
|
pass # already torn down
|
|
|
|
# Session has reached a terminal state (done/error/cancelled) — schedule
|
|
# its removal from `_sessions` after the retention window (see F5).
|
|
asyncio.create_task(_purge_later(state.session_id))
|
|
|
|
|
|
async def _do_login(page, state: _SessionState) -> None:
|
|
"""Navigate to FK login, handle cookie consent, click BankID QR, relay QR."""
|
|
_set_status(state, "launching")
|
|
logger.debug("[%s] navigating to FK login URL", state.session_id)
|
|
|
|
await page.goto(LOGIN_URL, wait_until="networkidle", timeout=30_000)
|
|
logger.debug("[%s] page loaded: %s", state.session_id, page.url)
|
|
|
|
# Check for maintenance message before proceeding
|
|
maintenance_msg = await _check_maintenance(page)
|
|
if maintenance_msg:
|
|
_set_status(state, "error", "FK is currently under maintenance. Please try again later.")
|
|
logger.debug("[%s] FK maintenance banner text: %s", state.session_id, maintenance_msg)
|
|
logger.info("FK maintenance detected; aborting session")
|
|
return
|
|
|
|
# Accept cookie consent banner if present (only appears once per browser profile)
|
|
try:
|
|
cookie_btn = page.locator(_SEL.COOKIE_ACCEPT)
|
|
if await cookie_btn.count() > 0:
|
|
logger.debug("[%s] accepting cookie consent", state.session_id)
|
|
await cookie_btn.first.click(timeout=5_000)
|
|
else:
|
|
logger.debug("[%s] no cookie consent banner", state.session_id)
|
|
except Exception:
|
|
pass # not present; fine
|
|
|
|
# Check if BankID button is disabled (another maintenance indicator)
|
|
try:
|
|
bankid_btn = page.locator(_SEL.BANKID_QR_LINK)
|
|
if await bankid_btn.count() > 0:
|
|
is_disabled = await bankid_btn.first.is_disabled(timeout=3_000)
|
|
if is_disabled:
|
|
error_msg = "FK login temporarily unavailable (BankID button disabled). Please try again later."
|
|
_set_status(state, "error", error_msg)
|
|
logger.info("BankID button is disabled; aborting session")
|
|
return
|
|
except Exception:
|
|
pass # proceed anyway; may not be disabled
|
|
|
|
# Click "BankID med QR-kod"
|
|
logger.debug("[%s] clicking BankID QR link", state.session_id)
|
|
await page.locator(_SEL.BANKID_QR_LINK).first.click(timeout=30_000)
|
|
|
|
# Wait for the QR SVG to appear (we're on the idpx auth page now)
|
|
logger.debug("[%s] waiting for QR code to appear (url=%s)", state.session_id, page.url)
|
|
await page.wait_for_selector(_SEL.QR_SVG, timeout=15_000)
|
|
logger.debug("[%s] QR code visible; relaying to client", state.session_id)
|
|
_set_status(state, "awaiting_scan")
|
|
|
|
# Relay the QR until FK navigates away (user scanned) or session cancelled/timed out
|
|
relay_count = 0
|
|
while state.status == "awaiting_scan":
|
|
try:
|
|
qr_el = page.locator(_SEL.QR_BUTTON)
|
|
state.qr_data = await qr_el.screenshot(timeout=3_000)
|
|
relay_count += 1
|
|
if relay_count % 5 == 1:
|
|
logger.debug("[%s] QR screenshot relayed (count=%d)", state.session_id, relay_count)
|
|
except Exception:
|
|
pass # transient; keep trying
|
|
await asyncio.sleep(1)
|
|
|
|
# Detect navigation to post-login FK page
|
|
if POST_LOGIN_URL_FRAGMENT in page.url:
|
|
logger.debug("[%s] detected post-login URL; scan complete", state.session_id)
|
|
break
|
|
# If QR element disappears without the post-login URL, we're in "authenticating"
|
|
if await page.locator(_SEL.QR_SVG).count() == 0:
|
|
logger.debug("[%s] QR element gone; BankID app likely confirming", state.session_id)
|
|
break
|
|
|
|
# Leaving awaiting_scan (scanned, cancelled, or timed out) — the last QR
|
|
# frame is no longer needed and must not linger in RAM (see F5).
|
|
state.qr_data = None
|
|
|
|
if state.status == "cancelled":
|
|
return
|
|
|
|
_set_status(state, "authenticating")
|
|
logger.debug(
|
|
"[%s] authenticating — waiting for Ansök button (url=%s)", state.session_id, page.url
|
|
)
|
|
|
|
# Wait for post-login landing (the Ansök button appears after JS hydrates the page)
|
|
await page.wait_for_selector(_SEL.ANSOK_BUTTON, timeout=30_000)
|
|
logger.debug("[%s] Ansök button visible; login complete", state.session_id)
|
|
|
|
|
|
async def _do_extract(page, state: _SessionState) -> None:
|
|
"""Click 'Ansök för …', wait for #/valjdagar, extract parental-leave figures."""
|
|
_set_status(state, "fetching")
|
|
logger.debug("[%s] clicking Ansök button", state.session_id)
|
|
|
|
# Click the child's "Ansök för …" button
|
|
await page.locator(_SEL.ANSOK_BUTTON).first.click(timeout=10_000)
|
|
|
|
# Wait for the valjdagar data page to load
|
|
logger.debug("[%s] waiting for valjdagar page (url=%s)", state.session_id, page.url)
|
|
await page.wait_for_selector(_SEL.STEP_DAGAR_KVAR, timeout=30_000)
|
|
logger.debug("[%s] valjdagar page loaded (url=%s)", state.session_id, page.url)
|
|
|
|
# The panel mounts with placeholder zeros, then hydrates from an async fetch.
|
|
# Wait until the "av N dagar" total is positive — total is always 240/480, never 0,
|
|
# so this is a reliable "real data has arrived" signal (days_left can legitimately be 0).
|
|
logger.debug("[%s] waiting for valjdagar data to hydrate", state.session_id)
|
|
await page.wait_for_function(
|
|
"""(sel) => {
|
|
const el = document.querySelector(sel);
|
|
if (!el) return false;
|
|
const m = el.textContent.match(/av\\s+(\\d+)\\s+dagar/);
|
|
return m && parseInt(m[1], 10) > 0;
|
|
}""",
|
|
arg=_SEL.FOR_DIG_DISCRETE,
|
|
timeout=30_000,
|
|
)
|
|
logger.debug("[%s] valjdagar data hydrated (url=%s)", state.session_id, page.url)
|
|
|
|
data = await _extract_parental_leave(page, state)
|
|
|
|
# Family panel ("för dig och annan förälder") -> deduce the partner's
|
|
# balance. Best-effort only: any failure here must never affect `data`,
|
|
# which is already a fully validated, ready-to-persist result.
|
|
try:
|
|
family = await _extract_family_summary(page, state)
|
|
state.family = family
|
|
if family is not None:
|
|
state.partner_deduction = _deduce_partner(data, family)
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"[%s] family-panel deduction skipped: %s", state.session_id, type(exc).__name__
|
|
)
|
|
state.family = None
|
|
state.partner_deduction = None
|
|
|
|
# "Dagar ni kan föra över" panel on the min-foraldrapenning page -> FK's
|
|
# own reported transferable-days split, replacing the transferable-first
|
|
# assumption in `data`/`state.partner_deduction`. Best-effort only, and
|
|
# must run after the family-panel block above: the family panel only
|
|
# exists on #/valjdagar, and this navigates away from it.
|
|
try:
|
|
transferable = await _extract_transferable_days(page, state, state.child_name)
|
|
if transferable is not None:
|
|
data = _apply_transferable(data, transferable)
|
|
if state.partner_deduction is not None and transferable.partner_total is not None:
|
|
state.partner_deduction = replace(
|
|
state.partner_deduction, transferable_days_left=transferable.partner_total
|
|
)
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"[%s] transferable-days parse skipped: %s", state.session_id, type(exc).__name__
|
|
)
|
|
|
|
if state.persist_callback is not None:
|
|
try:
|
|
await state.persist_callback(data, state.partner_deduction)
|
|
except Exception as exc:
|
|
_set_status(
|
|
state,
|
|
"error",
|
|
"Scrape succeeded but failed to persist the data. Please try again.",
|
|
)
|
|
logger.error(
|
|
"persist error [id=%s]: %s: %s",
|
|
state.session_id,
|
|
type(exc).__name__,
|
|
exc,
|
|
exc_info=False,
|
|
)
|
|
return
|
|
|
|
_set_status(state, "done")
|
|
state.result = data
|
|
logger.info("scraper session done")
|
|
|
|
|
|
async def _extract_parental_leave(page, state: _SessionState) -> ParentalLeaveFetch:
|
|
"""Read day figures from the #/valjdagar page and return a validated entity."""
|
|
|
|
# -- days left for this parent ("För dig" section) --
|
|
logger.debug("[%s] reading days_left (FOR_DIG_DAGAR_KVAR)", state.session_id)
|
|
days_left_text = await page.locator(_SEL.FOR_DIG_DAGAR_KVAR).first.inner_text(timeout=5_000)
|
|
days_left = int(days_left_text.strip())
|
|
logger.debug("[%s] days_left parsed", state.session_id)
|
|
|
|
# -- total days from "av 240 dagar kvar efter ansökan" --
|
|
logger.debug("[%s] reading total days (FOR_DIG_DISCRETE)", state.session_id)
|
|
discrete_text = await page.locator(_SEL.FOR_DIG_DISCRETE).first.inner_text(timeout=5_000)
|
|
total_match = re.search(r"av\s+(\d+)\s+dagar", discrete_text)
|
|
if not total_match:
|
|
raise ValueError("Could not parse total days from FK page text")
|
|
total_days = int(total_match.group(1))
|
|
if total_days == 0:
|
|
raise ValueError(
|
|
"FK panel returned total_days=0 — data had not hydrated when read "
|
|
"(placeholder state). The hydration wait in _do_extract should prevent this."
|
|
)
|
|
logger.debug("[%s] total days parsed", state.session_id)
|
|
|
|
# -- sjukpenningnivå and lägstanivå remaining (desktop niva-statistik) --
|
|
# all_inner_texts() does not accept a timeout argument — wait for the first element first.
|
|
logger.debug("[%s] reading tier breakdown (NIVA_STATISTIK_DESKTOP)", state.session_id)
|
|
await page.locator(_SEL.NIVA_STATISTIK_DESKTOP).first.wait_for(timeout=5_000)
|
|
niva_texts = await page.locator(_SEL.NIVA_STATISTIK_DESKTOP).all_inner_texts()
|
|
logger.debug("[%s] niva items=%d", state.session_id, len(niva_texts))
|
|
# Each item is like "(119 dagar)" — filter to desktop section (first two)
|
|
niva_numbers = []
|
|
for t in niva_texts:
|
|
m = re.search(r"\((\d+)\s+dagar\)", t)
|
|
if m:
|
|
niva_numbers.append(int(m.group(1)))
|
|
if len(niva_numbers) == 2:
|
|
break
|
|
|
|
if len(niva_numbers) < 2:
|
|
raise ValueError(f"Expected 2 tier values, got {len(niva_numbers)}")
|
|
|
|
sjukpenning_left, lagsta_niva_left = niva_numbers[0], niva_numbers[1]
|
|
logger.debug("[%s] tier breakdown parsed", state.session_id)
|
|
|
|
# Cross-check: sjukpenning_left + lagsta_niva_left should equal days_left
|
|
if sjukpenning_left + lagsta_niva_left != days_left:
|
|
logger.warning("Tier sum mismatch [id=%s]", state.session_id)
|
|
|
|
# -- dubbeldagar remaining --
|
|
logger.debug("[%s] reading dubbeldagar (DUBBELDAG_KVAR)", state.session_id)
|
|
dubbeldag_text = await page.locator(_SEL.DUBBELDAG_KVAR).first.inner_text(timeout=5_000)
|
|
dubbeldagar_left = int(dubbeldag_text.strip())
|
|
logger.debug("[%s] dubbeldagar parsed", state.session_id)
|
|
|
|
# -- Derive tier totals, reserved/transferable split, and validate --
|
|
# The page doesn't expose tier totals or the reserved/transferable split
|
|
# directly, only totals-per-tier-left and the grand total; everything
|
|
# else is derived by the single shared function also used by manual
|
|
# entry, so the two paths can never disagree (see
|
|
# `app.services.leave_snapshot.build_snapshot`'s docstring). This
|
|
# raises `ValidationError` (not `ValueError`) on bad data — see
|
|
# `tests/unit/test_fk_scraper.py::TestValidation` for the exact
|
|
# messages a caller may match on.
|
|
return build_snapshot(
|
|
personal_data_uuid=state.personal_data_uuid,
|
|
workspace_id=state.workspace_id,
|
|
child_id=state.child_id,
|
|
source="scraped",
|
|
total_days=total_days,
|
|
sjukpenning_left=sjukpenning_left,
|
|
lagsta_niva_left=lagsta_niva_left,
|
|
dubbeldagar_left=dubbeldagar_left,
|
|
)
|
|
|
|
|
|
async def _extract_family_summary(page, state: _SessionState) -> FamilySummary | None:
|
|
"""Read the "för dig och annan förälder" family panel, if present and parsable.
|
|
|
|
Defensive by design: the real FK DOM for this panel is unconfirmed (see
|
|
`_SEL.FAMILY*`'s comment), so any missing element or unexpected text is
|
|
treated as "no family data available" — logged at DEBUG and returned as
|
|
None — never raised. The caller's own `data` (this parent's snapshot)
|
|
must succeed regardless of what happens here.
|
|
"""
|
|
try:
|
|
family_panel = page.locator(_SEL.FAMILY)
|
|
if await family_panel.count() == 0:
|
|
logger.debug("[%s] family panel not present; skipping deduction", state.session_id)
|
|
return None
|
|
|
|
dagar_kvar_loc = page.locator(_SEL.FAMILY_DAGAR_KVAR)
|
|
if await dagar_kvar_loc.count() == 0:
|
|
logger.debug(
|
|
"[%s] family panel dagar-kvar element not found; skipping deduction",
|
|
state.session_id,
|
|
)
|
|
return None
|
|
dagar_kvar_text = await dagar_kvar_loc.first.inner_text(timeout=2_000)
|
|
days_left = int(dagar_kvar_text.strip())
|
|
|
|
discrete_loc = page.locator(_SEL.FAMILY_DISCRETE)
|
|
if await discrete_loc.count() == 0:
|
|
logger.debug(
|
|
"[%s] family panel discrete element not found; skipping deduction",
|
|
state.session_id,
|
|
)
|
|
return None
|
|
discrete_text = await discrete_loc.first.inner_text(timeout=2_000)
|
|
total_match = re.search(r"av\s+(\d+)\s+dagar", discrete_text)
|
|
if not total_match:
|
|
logger.debug(
|
|
"[%s] could not parse 'av N dagar' from family panel text; skipping deduction",
|
|
state.session_id,
|
|
)
|
|
return None
|
|
total_days = int(total_match.group(1))
|
|
|
|
# Tier spans are optional — best-effort, absence doesn't invalidate the summary.
|
|
sjukpenning_left: int | None = None
|
|
lagsta_niva_left: int | None = None
|
|
niva_loc = page.locator(_SEL.FAMILY_NIVA_STATISTIK)
|
|
if await niva_loc.count() > 0:
|
|
niva_texts = await niva_loc.all_inner_texts()
|
|
niva_numbers = []
|
|
for t in niva_texts:
|
|
m = re.search(r"\((\d+)\s+dagar\)", t)
|
|
if m:
|
|
niva_numbers.append(int(m.group(1)))
|
|
if len(niva_numbers) == 2:
|
|
break
|
|
if len(niva_numbers) == 2:
|
|
sjukpenning_left, lagsta_niva_left = niva_numbers[0], niva_numbers[1]
|
|
|
|
return FamilySummary(
|
|
total_days=total_days,
|
|
days_left=days_left,
|
|
sjukpenning_left=sjukpenning_left,
|
|
lagsta_niva_left=lagsta_niva_left,
|
|
)
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"[%s] family panel parsing failed (%s); skipping deduction",
|
|
state.session_id,
|
|
type(exc).__name__,
|
|
)
|
|
return None
|
|
|
|
|
|
def _deduce_partner(mine: ParentalLeaveFetch, family: FamilySummary) -> PartnerDeduction | None:
|
|
"""Deduce the other parent's balance as family totals minus mine.
|
|
|
|
Returns None (logging a warning) whenever the numbers are internally
|
|
inconsistent — e.g. the family panel reports fewer days than this parent
|
|
alone already accounts for, which can only mean a stale read or a DOM
|
|
assumption that doesn't match FK's real markup.
|
|
"""
|
|
total = family.total_days - mine.total_days
|
|
left = family.days_left - mine.days_left
|
|
|
|
if total <= 0 or left < 0 or left > total:
|
|
logger.warning(
|
|
"family/partner deduction inconsistent: family_total=%d family_left=%d "
|
|
"mine_total=%d mine_left=%d -> deduced_total=%d deduced_left=%d",
|
|
family.total_days,
|
|
family.days_left,
|
|
mine.total_days,
|
|
mine.days_left,
|
|
total,
|
|
left,
|
|
)
|
|
return None
|
|
|
|
rules = settings.rules
|
|
|
|
if family.sjukpenning_left is not None and family.lagsta_niva_left is not None:
|
|
sjuk_left = family.sjukpenning_left - mine.tier_data.sjukpenning_left
|
|
lagsta_left = family.lagsta_niva_left - mine.tier_data.lagsta_niva_left
|
|
if sjuk_left < 0 or lagsta_left < 0:
|
|
logger.warning(
|
|
"family/partner tier deduction inconsistent (sjuk=%d, lagsta=%d); "
|
|
"falling back to proportional split",
|
|
sjuk_left,
|
|
lagsta_left,
|
|
)
|
|
lagsta_share = (
|
|
rules.lagsta_niva_days * total // (rules.sjukpenning_days + rules.lagsta_niva_days)
|
|
)
|
|
lagsta_left = min(left, lagsta_share)
|
|
sjuk_left = left - lagsta_left
|
|
else:
|
|
# No family-level tier breakdown available — assume lägstanivå is
|
|
# untouched first (mirrors the same "transferable spent first"
|
|
# assumption `build_snapshot` makes for the reserved/transferable split).
|
|
lagsta_share = (
|
|
rules.lagsta_niva_days * total // (rules.sjukpenning_days + rules.lagsta_niva_days)
|
|
)
|
|
lagsta_left = min(left, lagsta_share)
|
|
sjuk_left = left - lagsta_left
|
|
|
|
return PartnerDeduction(
|
|
total_days=total,
|
|
days_left=left,
|
|
sjukpenning_left=sjuk_left,
|
|
lagsta_niva_left=lagsta_left,
|
|
dubbeldagar_left=mine.dubbeldagar_left,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Transferable days ("Dagar ni kan föra över") — FK's own reported split
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TRANSFERABLE_RE = re.compile(r"(\d+)\s+dagar?\s+på\s+(sjukpenningnivå|lägstanivå)")
|
|
|
|
|
|
async def _extract_transferable_days(
|
|
page, state: _SessionState, child_name: str | None
|
|
) -> TransferableDays | None:
|
|
"""Read the "Dagar ni kan föra över" panel on the min-foraldrapenning
|
|
e-service page and return FK's own reported transferable-days split.
|
|
|
|
This figure cannot be reconstructed from #/valjdagar (see
|
|
`app.services.leave_snapshot.build_snapshot`'s module docstring) — FK's
|
|
own accounting there is not reproducible by any plausible model, so it
|
|
must be read off this second page directly.
|
|
|
|
Fully defensive by design, mirroring `_extract_family_summary`: any
|
|
missing element, unmatched child, or unexpected text is "no transferable
|
|
data available" — logged at DEBUG and returned as None — never raised.
|
|
The caller's own `data` (already validated) must be unaffected either way.
|
|
"""
|
|
try:
|
|
logger.debug("[%s] navigating to min-foraldrapenning page", state.session_id)
|
|
await page.goto(MIN_FP_URL, wait_until="domcontentloaded")
|
|
|
|
logger.debug("[%s] waiting for transfer panel", state.session_id)
|
|
await page.wait_for_selector(_SEL.PANEL_FORA_OVER_DAGAR, timeout=30_000)
|
|
|
|
# The panel mounts before the async fetch lands — wait for real
|
|
# per-tier figures rather than trusting first paint (mirrors the
|
|
# #/valjdagar hydration guard in `_do_extract`).
|
|
logger.debug("[%s] waiting for transfer panel data to hydrate", state.session_id)
|
|
await page.wait_for_function(
|
|
"""(sel) => {
|
|
const els = document.querySelectorAll(sel);
|
|
for (const el of els) {
|
|
if (/\\d+\\s+dagar?\\s+på/.test(el.textContent)) return true;
|
|
}
|
|
return false;
|
|
}""",
|
|
arg=_SEL.FORA_OVER_GROUP + " p",
|
|
timeout=30_000,
|
|
)
|
|
logger.debug("[%s] transfer panel data hydrated", state.session_id)
|
|
|
|
sections = page.locator(_SEL.MIN_FP_BARN_SEKTION)
|
|
section_count = await sections.count()
|
|
if section_count == 0:
|
|
logger.debug(
|
|
"[%s] no barn-sektion found on min-foraldrapenning page; skipping "
|
|
"transferable-days parse",
|
|
state.session_id,
|
|
)
|
|
return None
|
|
|
|
if section_count == 1:
|
|
chosen = sections.first
|
|
else:
|
|
if not child_name or not child_name.strip():
|
|
logger.debug(
|
|
"[%s] multiple children on min-foraldrapenning page and no "
|
|
"child_name to match; skipping transferable-days parse",
|
|
state.session_id,
|
|
)
|
|
return None
|
|
target = child_name.strip().split()[0].casefold()
|
|
chosen = None
|
|
for i in range(section_count):
|
|
section = sections.nth(i)
|
|
rubrik_loc = section.locator(_SEL.BARN_RUBRIK)
|
|
if await rubrik_loc.count() == 0:
|
|
continue
|
|
rubrik_text = (await rubrik_loc.first.inner_text(timeout=2_000)).strip().casefold()
|
|
if rubrik_text == target:
|
|
chosen = section
|
|
break
|
|
if chosen is None:
|
|
logger.debug(
|
|
"[%s] no barn-sektion matched child_name; skipping transferable-days parse",
|
|
state.session_id,
|
|
)
|
|
return None
|
|
|
|
panel = chosen.locator(_SEL.PANEL_FORA_OVER_DAGAR)
|
|
if await panel.count() == 0:
|
|
logger.debug(
|
|
"[%s] chosen barn-sektion has no transfer panel; skipping transferable-days parse",
|
|
state.session_id,
|
|
)
|
|
return None
|
|
|
|
# Content is present in the DOM even when the accordion is collapsed,
|
|
# so text reads work as-is. Still click through as insurance against
|
|
# FK switching to lazy rendering — a same-page toggle, never a dialog.
|
|
button = panel.locator(_SEL.EXPANDABLE_BUTTON)
|
|
if await button.count() > 0:
|
|
aria_expanded = await button.first.get_attribute("aria-expanded")
|
|
if aria_expanded == "false":
|
|
logger.debug("[%s] expanding collapsed transfer panel", state.session_id)
|
|
await button.first.click(timeout=5_000)
|
|
|
|
groups = panel.locator(_SEL.FORA_OVER_GROUP)
|
|
group_count = await groups.count()
|
|
|
|
mine_sjuk: int | None = None
|
|
mine_lagsta: int | None = None
|
|
partner_sjuk: int | None = None
|
|
partner_lagsta: int | None = None
|
|
|
|
for i in range(group_count):
|
|
group = groups.nth(i)
|
|
label_loc = group.locator(_SEL.MINFP_LABEL)
|
|
if await label_loc.count() == 0:
|
|
continue
|
|
label_text = (await label_loc.first.inner_text(timeout=2_000)).strip()
|
|
|
|
# Direct children only — the tooltip inside "Du kan föra över"
|
|
# holds decoy "45 dagar"/"90 dagar" text that a descendant
|
|
# selector would also pick up.
|
|
p_texts = await group.locator(":scope > p").all_inner_texts()
|
|
sjuk: int | None = None
|
|
lagsta: int | None = None
|
|
for t in p_texts:
|
|
m = _TRANSFERABLE_RE.search(t)
|
|
if not m:
|
|
continue
|
|
value = int(m.group(1))
|
|
if m.group(2) == "sjukpenningnivå":
|
|
sjuk = value
|
|
else:
|
|
lagsta = value
|
|
|
|
if label_text.startswith("Du kan"):
|
|
mine_sjuk, mine_lagsta = sjuk, lagsta
|
|
elif label_text.startswith("Annan förälder"):
|
|
partner_sjuk, partner_lagsta = sjuk, lagsta
|
|
|
|
if mine_sjuk is None or mine_lagsta is None:
|
|
logger.debug(
|
|
"[%s] 'Du kan föra över' group missing or incomplete; skipping "
|
|
"transferable-days parse",
|
|
state.session_id,
|
|
)
|
|
return None
|
|
|
|
return TransferableDays(
|
|
mine_sjukpenning=mine_sjuk,
|
|
mine_lagsta_niva=mine_lagsta,
|
|
partner_sjukpenning=partner_sjuk,
|
|
partner_lagsta_niva=partner_lagsta,
|
|
)
|
|
except Exception as exc:
|
|
logger.debug(
|
|
"[%s] transferable-days panel parsing failed (%s); skipping",
|
|
state.session_id,
|
|
type(exc).__name__,
|
|
)
|
|
return None
|
|
|
|
|
|
def _apply_transferable(
|
|
data: ParentalLeaveFetch, transferable: TransferableDays
|
|
) -> ParentalLeaveFetch:
|
|
"""Re-run `build_snapshot` with FK's own reported transferable-days
|
|
figure in place of the transferable-first assumption used when `data`
|
|
was first built.
|
|
|
|
An FK-reported figure that fails `build_snapshot`'s validation (should
|
|
never happen, but FK's markup is not a contract) degrades to the
|
|
original `data` unchanged rather than failing the whole fetch.
|
|
"""
|
|
try:
|
|
return build_snapshot(
|
|
personal_data_uuid=data.personal_data_uuid,
|
|
workspace_id=data.workspace_id,
|
|
child_id=data.child_id,
|
|
source=data.source,
|
|
total_days=data.total_days,
|
|
sjukpenning_left=data.tier_data.sjukpenning_left,
|
|
lagsta_niva_left=data.tier_data.lagsta_niva_left,
|
|
dubbeldagar_left=data.dubbeldagar_left,
|
|
transferable_days_left=transferable.mine_total,
|
|
fetched_at=data.fetched_at,
|
|
)
|
|
except ValidationError as exc:
|
|
logger.warning(
|
|
"FK-reported transferable_days_left=%d implausible against derived "
|
|
"days_left=%d (child_id=%s); keeping derived reserved/transferable split: %s",
|
|
transferable.mine_total,
|
|
data.days_left,
|
|
data.child_id,
|
|
exc,
|
|
)
|
|
return data
|