foxygit / RPA-Remisser Log in
commit 17c96365a5e97d246545f66372fc0409f1e184c8
Author:     Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
AuthorDate: Fri Aug 28 09:23:57 2026 +0200
Commit:     Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
CommitDate: Fri Aug 28 09:23:57 2026 +0200

    Initial commit: Egenremiss RPA - flow-based Playwright automation for personal.1177.se
---
 .gitignore                 |   8 +
 README.md                  | 190 +++++++++++++++
 TODO.md                    |   4 +
 main.py                    |  49 ++++
 requirements.txt           |   4 +
 rpa/__init__.py            |   0
 rpa/auth.py                |  50 ++++
 rpa/browser.py             |  26 ++
 rpa/codegen.py             |  37 +++
 rpa/config.py              |  34 +++
 rpa/discover.py            |  49 ++++
 rpa/flow_engine.py         | 364 ++++++++++++++++++++++++++++
 rpa/notify.py              |  18 ++
 rpa/runs.py                |  90 +++++++
 rpa/scraper.py             |  85 +++++++
 rpa/show_trace.py          |  42 ++++
 rpa/state.py               |  27 +++
 run_webui.py               |   6 +
 webui/__init__.py          |   0
 webui/app.py               | 103 ++++++++
 webui/templates/index.html | 575 +++++++++++++++++++++++++++++++++++++++++++++
 21 files changed, 1761 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9e0a3d3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+.venv/
+.venv-1/
+__pycache__/
+*.pyc
+
+# Innehåller BankID-session, nedladdade dokument, traces, skärmdumpar och
+# exporterad data - kan innehålla personlig hälsoinformation. Se README.
+data/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..eb8b900
--- /dev/null
+++ b/README.md
@@ -0,0 +1,190 @@
+# Egenremiss RPA
+
+Halvautomatisk hämtning av egenremisser från personal.1177.se.
+
+Inloggning via BankID kräver din manuella approval i telefonen - det går
+inte att helautomatisera på ett säkert sätt. Skriptet öppnar en synlig
+webbläsare, väntar på att du loggar in, och tar sedan över: listar
+egenremisser, laddar ner ev. dokument, jämför mot förra körningen och
+notifierar om nytt/ändrat.
+
+## Setup
+
+```powershell
+python -m venv .venv
+.venv\Scripts\Activate.ps1
+pip install -r requirements.txt
+python -m playwright install chromium
+```
+
+## Första körningen: hitta rätt selectors
+
+Jag har inte kunnat inspektera den inloggade sidans faktiska HTML-struktur
+(kräver ju BankID), så `rpa/scraper.py` och `rpa/config.py` bygger på
+rimliga men ovverifierade gissningar om länktexter och sidstruktur.
+
+**Rekommenderat sätt: Playwright Codegen.** Den öppnar en webbläsare +
+ett inspector-fönster; klicka runt manuellt så genereras exakta
+selectors åt dig i realtid – inget gissningsarbete:
+
+```powershell
+python -m rpa.codegen
+```
+
+Den återanvänder samma profil (`data/browser_profile/`) som webui:t/`main.py`,
+så en giltig BankID-session följer med. Stäng ev. körande flöde i webui:t
+först (en Chromium-profilmapp kan bara användas av en process åt gången).
+Använd de genererade text-/CSS-värdena direkt i webui:ts steg (t.ex.
+"Klicka", "Skriv i textfält", "Lista poster") eller i `rpa/config.py`.
+
+Alternativ (mer manuellt): `python -m rpa.discover` dumpar HTML +
+skärmdump av aktuell sida till `data/discover/` så du kan läsa strukturen
+själv:
+
+```powershell
+python -m rpa.discover
+```
+
+1. Godkänn BankID i telefonen när webbläsaren öppnas.
+2. Navigera manuellt till sidan med dina egenremisser.
+3. Tryck Enter i terminalen för att spara en HTML-dump + skärmdump i
+   `data/discover/`.
+4. Öppna dumpen, hitta den exakta länktexten till egenremiss-sidan och
+   listans faktiska HTML-struktur (tabell? ul/li? kort?).
+5. Uppdatera:
+   - `rpa/config.py` → `REMISS_LINK_TEXT_CANDIDATES` med exakt länktext.
+   - `rpa/scraper.py` → `list_egenremisser` selectorn `"main li, main tr, main article"`
+     om listan har en annan struktur.
+
+## Vanlig körning
+
+```powershell
+python main.py
+```
+
+Sessionen sparas i `data/browser_profile/` (persistent Chromium-profil),
+så om den fortfarande är giltig nästa gång slipper du godkänna BankID igen.
+
+Resultat:
+- `data/state.json` - senaste kända lista, används för att upptäcka nytt/ändrat.
+- `data/downloads/` - nedladdade dokument/PDF:er kopplade till egenremisser.
+- Skrivbordsnotis + terminalutskrift vid nya/ändrade poster.
+
+## Webb-UI för att bygga flöden
+
+Istället för att koda steg direkt kan du bygga och köra flöden i en enkel
+lokal webbsida:
+
+```powershell
+python run_webui.py
+```
+
+Öppna sedan http://127.0.0.1:8765 i webbläsaren. Servern lyssnar bara på
+127.0.0.1 (inte nätverket) eftersom den styr en riktig webbläsare mot din
+inloggade 1177-session.
+
+I UI:t bygger du ett flöde som en lista av steg:
+
+- **Gå till URL** – navigerar (t.ex. `https://www.1177.se/`)
+- **Vänta (sekunder)** – fast paus
+- **Vänta på att sidan laddas** – väntar på domcontentloaded
+- **Vänta på inloggning (BankID)** – pausar tills du godkänt BankID i telefonen
+- **Vänta på element** – pausar tills ett element via text/CSS/roll finns/syns
+- **Klicka** – hittar ett element via text/CSS/roll och klickar
+- **Skriv i textfält** – hittar ett fält via text/CSS/roll och skriver in text
+- **Tryck tangent** – trycker en tangent (t.ex. Enter) på ett element eller,
+  om inget element anges, på hela sidan
+- **Välj i dropdown** – väljer ett alternativ (synlig text) i en `<select>`
+- **Kryssa checkbox/radio** – kryssar i eller ur en checkbox/radioknapp
+- **Läs text till variabel** – läser textinnehållet i ett element och sparar
+  det under ett variabelnamn
+- **Sätt variabel** – sätter en variabel till ett fast textvärde
+- **Lägg till i lista** – lägger till ett värde i en listvariabel (skapas
+  automatiskt vid behov); värdet kan antingen läsas från ett element
+  (samma via/text/CSS/roll som "Läs text till variabel") eller vara ett
+  fast värde. Praktiskt inuti en loop för att samla ihop en lista av poster.
+- **Spara variabel (JSON/CSV)** – sparar en variabel till `data/exports/`.
+  Format gissas från filändelsen (.json/.csv) om inget anges explicit.
+  "Läge" = "append" lägger till i en befintlig fil istället för att skriva
+  över den.
+- **Läs in variabel (JSON/CSV)** – läser tillbaka en tidigare sparad fil
+  från `data/exports/` till en variabel, t.ex. för att fortsätta bygga på
+  en lista i en senare körning.
+- **Kör Python-script** – kör ett separat Python-script (sökväg relativt
+  projektroten eller absolut), loggar dess stdout/stderr i körloggen och
+  kan spara dess stdout som en variabel (tolkas som JSON om möjligt,
+  annars som text). Flödet stoppas om scriptet avslutar med felkod.
+- **Ta skärmdump** – sparar en skärmdump i `data/screenshots/` (bra för
+  felsökning av flöden)
+- **Lista poster** – hittar alla element som matchar en CSS-selector och
+  sparar dem under ett variabelnamn (t.ex. `poster`)
+- **Loop (för varje post)** – itererar över en variabel från "Lista poster";
+  steg inuti loopen kan sätta Sökområde = "item" för att bara söka inom
+  aktuell post
+- **Ladda ner** – klickar ett element och sparar filen i `data/downloads/`
+
+Det motsvarar din ursprungliga pseudokod:
+
+```
+Gå till URL → Vänta på att sidan laddas → Klicka → Klicka
+→ Lista poster → Loop { Klicka, Ladda ner, Vänta 8 }
+```
+
+Flöden sparas som JSON i `data/flows/<namn>.json` – redigerbara även för
+hand om du vill. "Kör flöde" sparar automatiskt och kör sedan i en synlig
+webbläsare, med live-logg i sidan.
+
+Bredvid "Kör flöde" väljer du vad som ska hända med webbläsaren efteråt:
+
+- **Stäng webbläsaren** – stängs alltid direkt när flödet är klart (standard).
+- **Håll öppen vid fel** – stängs bara om flödet lyckades; vid fel hålls den
+  öppen så du kan se vad som klickats fel.
+- **Håll alltid öppen** – stängs aldrig automatiskt.
+
+När webbläsaren hålls öppen visas en "Stäng webbläsare"-knapp i sidan –
+klicka på den när du är klar med att inspektera.
+
+### Felsöka en körning (trace)
+
+Varje körning spelar automatiskt in en Playwright-trace – skärmdumpar,
+DOM-snapshots och nätverk för varje steg – och sparar den i
+`data/traces/<run_id>.zip`. När körningen är klar dyker en "Visa trace"-knapp
+upp i sidan; klicka på den för att öppna Playwrights Trace Viewer i ett eget
+fönster och steg-för-steg se exakt vad som klickades och var det gick fel.
+
+Samma sak från terminalen:
+
+```powershell
+python -m rpa.show_trace          # senaste sparade trace
+python -m rpa.show_trace <run_id> # en specifik körning
+```
+
+Precis som med `main.py` vet jag inte i förväg exakt vilken text/selector
+som matchar dina riktiga knappar och listor på 1177 – kör
+`python -m rpa.codegen` (se ovan) för att ta reda på dem, och använd samma
+värden i UI:ts steg.
+
+## Schemaläggning
+
+Kör manuellt vid behov (`python main.py`). Vill du schemalägga via Windows
+Task Scheduler senare: tänk på att BankID-godkännande krävs varje gång
+sessionen i `data/browser_profile/` har gått ut, så helt obevakad körning
+fungerar bara så länge sessionen är giltig.
+
+## Att tänka på
+
+- Respektera 1177:s användarvillkor - detta är byggt för att hämta din
+  egen personliga information, inte för massuttag eller belastning.
+  Skriptet kör med synlig, "vanlig" webbläsare utan bot-detection-kringgång.
+  Lägg gärna in rimliga pauser om du kör ofta.
+- Spara inte `data/browser_profile/`, `data/downloads/`, `data/traces/`,
+  `data/screenshots/` eller `data/exports/` i något delat eller
+  versionshanterat ställe - de kan innehålla inloggningssession och
+  personlig hälsoinformation (traces och skärmdumpar visar sidans faktiska
+  innehåll steg för steg, och exports kan innehålla data du samlat in
+  från sidan via "Lägg till i lista"/"Spara variabel").
+- "Kör Python-script" kör vad som helst du pekar ut, med samma rättigheter
+  som webui-processen - bara för egna, betrodda script, inte för att köra
+  kod du inte litar på.
+- `data/` ligger i `.gitignore` och ska inte versionshanteras alls, av
+  samma anledning som ovan.
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..1a1a8a9
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,4 @@
+Lägga till att kunna köra custom python kod mellan
+ex. lista extraheras
+lista körs genom custom script för att få ut datan
+lägg som vairabel
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..11b1ccd
--- /dev/null
+++ b/main.py
@@ -0,0 +1,49 @@
+import sys
+
+from playwright.sync_api import sync_playwright
+
+from rpa import config, state
+from rpa.auth import ensure_logged_in
+from rpa.browser import get_page, launch_context
+from rpa.notify import notify_new_items
+from rpa.scraper import download_attachments, goto_egenremisser, list_egenremisser
+
+
+def run() -> None:
+    with sync_playwright() as p:
+        context = launch_context(p)
+        page = get_page(context)
+
+        ensure_logged_in(page)
+
+        if not goto_egenremisser(page):
+            print(
+                "Kunde inte hitta en länk till egenremisser automatiskt. "
+                "Navigera dit manuellt i webbläsarfönstret, tryck sedan "
+                "Enter här för att fortsätta."
+            )
+            input()
+
+        items = list_egenremisser(page)
+        print(f"Hittade {len(items)} post(er) på sidan.")
+
+        previous = state.load_previous()
+        new_or_changed = state.diff_new_or_changed(previous, items)
+
+        for item in new_or_changed:
+            saved = download_attachments(page, item, config.DOWNLOAD_DIR)
+            if saved:
+                print(f"  Nedladdat för post {item['id']}: {saved}")
+
+        state.save_current(items)
+        notify_new_items(new_or_changed)
+
+        context.close()
+
+
+if __name__ == "__main__":
+    try:
+        run()
+    except TimeoutError as exc:
+        print(f"Fel: {exc}", file=sys.stderr)
+        sys.exit(1)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..48ba2f8
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+playwright>=1.45
+plyer>=2.1
+fastapi>=0.111
+uvicorn>=0.30
diff --git a/rpa/__init__.py b/rpa/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/rpa/auth.py b/rpa/auth.py
new file mode 100644
index 0000000..e2ddb0a
--- /dev/null
+++ b/rpa/auth.py
@@ -0,0 +1,50 @@
+from typing import Callable
+
+from playwright.sync_api import Page
+
+from . import config
+
+
+def is_logged_in(page: Page) -> bool:
+    for text in config.LOGGED_IN_INDICATORS:
+        if page.get_by_text(text, exact=False).count() > 0:
+            return True
+    return False
+
+
+def wait_for_login(page: Page, timeout_s: int = 180, log: Callable[[str], None] = print) -> None:
+    """Väntar tills sidan visar inloggad status (se config.LOGGED_IN_INDICATORS).
+
+    Om redan inloggad returnerar direkt. Annars pollar tills tidsgränsen
+    slår till, medan du godkänner BankID manuellt i telefonen.
+    """
+    if is_logged_in(page):
+        log("Redan inloggad (återanvänd session).")
+        return
+
+    log(
+        "Ej inloggad. Logga in manuellt i webbläsarfönstret som öppnades "
+        "(välj BankID och godkänn i appen). Väntar automatiskt upp till "
+        f"{timeout_s} sekunder."
+    )
+
+    poll_interval_s = 2
+    waited = 0
+    while waited < timeout_s:
+        if is_logged_in(page):
+            log("Inloggning klar.")
+            return
+        page.wait_for_timeout(poll_interval_s * 1000)
+        waited += poll_interval_s
+
+    raise TimeoutError(
+        "Ingen inloggning upptäcktes inom tidsgränsen. Höj timeout eller "
+        "kontrollera config.LOGGED_IN_INDICATORS mot faktisk sidtext "
+        "(kör python -m rpa.discover)."
+    )
+
+
+def ensure_logged_in(page: Page, bankid_timeout_s: int = 180) -> None:
+    """Bakåtkompatibel wrapper: navigerar till startsidan och loggar in."""
+    page.goto(config.START_URL, wait_until="domcontentloaded")
+    wait_for_login(page, bankid_timeout_s)
diff --git a/rpa/browser.py b/rpa/browser.py
new file mode 100644
index 0000000..830ece2
--- /dev/null
+++ b/rpa/browser.py
@@ -0,0 +1,26 @@
+from playwright.sync_api import sync_playwright, BrowserContext
+
+from . import config
+
+
+def launch_context(playwright) -> BrowserContext:
+    """Startar en synlig, persistent webbläsarprofil.
+
+    Persistent context = cookies/session sparas mellan körningar i
+    config.BROWSER_PROFILE_DIR. Är sessionen fortfarande giltig nästa
+    gång skriptet körs behöver du inte godkänna BankID igen.
+    """
+    context = playwright.chromium.launch_persistent_context(
+        user_data_dir=str(config.BROWSER_PROFILE_DIR),
+        headless=False,
+        accept_downloads=True,
+        viewport={"width": 1280, "height": 900},
+        locale="sv-SE",
+    )
+    return context
+
+
+def get_page(context: BrowserContext):
+    if context.pages:
+        return context.pages[0]
+    return context.new_page()
diff --git a/rpa/codegen.py b/rpa/codegen.py
new file mode 100644
index 0000000..9a6f9a2
--- /dev/null
+++ b/rpa/codegen.py
@@ -0,0 +1,37 @@
+"""Startar Playwright Codegen mot samma inloggade profil som flödena använder.
+
+Öppnar en synlig webbläsare + ett inspector-fönster. Klicka runt manuellt
+(logga in med BankID om sessionen gått ut) så genereras exakt matchande
+selectors/kod i inspector-fönstret - använd samma text/CSS-värden i
+webui:ts "Klicka"/"Skriv i textfält"/"Lista poster"-steg.
+
+Eftersom samma profilmapp (data/browser_profile/) används som av
+main.py/webui återanvänds en giltig BankID-session. Chromium tillåter bara
+en process per profilmapp - stäng ev. körande flöde i webui:t först.
+
+Användning:
+    python -m rpa.codegen                          # startar på config.START_URL
+    python -m rpa.codegen https://www.1177.se/sida  # startar på angiven URL
+"""
+
+import subprocess
+import sys
+
+from . import config
+
+
+def main() -> None:
+    url = sys.argv[1] if len(sys.argv) > 1 else config.START_URL
+    print(f"Startar Playwright Codegen mot {url}")
+    print(f"Återanvänder profil: {config.BROWSER_PROFILE_DIR}")
+    print("Stäng ev. körande flöde i webui:t först (samma profilmapp kan inte användas av två processer samtidigt).")
+    subprocess.run([
+        sys.executable, "-m", "playwright", "codegen",
+        "--user-data-dir", str(config.BROWSER_PROFILE_DIR),
+        "--target", "python",
+        url,
+    ])
+
+
+if __name__ == "__main__":
+    main()
diff --git a/rpa/config.py b/rpa/config.py
new file mode 100644
index 0000000..dad26b7
--- /dev/null
+++ b/rpa/config.py
@@ -0,0 +1,34 @@
+from pathlib import Path
+
+ROOT_DIR = Path(__file__).resolve().parent.parent
+DATA_DIR = ROOT_DIR / "data"
+BROWSER_PROFILE_DIR = DATA_DIR / "browser_profile"
+DOWNLOAD_DIR = DATA_DIR / "downloads"
+DISCOVER_DIR = DATA_DIR / "discover"
+FLOWS_DIR = DATA_DIR / "flows"
+SCREENSHOT_DIR = DATA_DIR / "screenshots"
+TRACE_DIR = DATA_DIR / "traces"
+EXPORT_DIR = DATA_DIR / "exports"
+STATE_FILE = DATA_DIR / "state.json"
+
+START_URL = "https://www.1177.se/"
+
+# Text som skiftlägesokänsligt matchas mot länkar/rubriker för att hitta
+# rätt sida. Fyll på/justera efter vad --discover visar på den riktiga sidan.
+REMISS_LINK_TEXT_CANDIDATES = [
+    "Egenremiss",
+    "Egenremisser",
+    "Mina remisser",
+    "Skickade remisser",
+    "Remisser",
+]
+
+# Text som indikerar att inloggning lyckats (letas efter i sidans innehåll).
+LOGGED_IN_INDICATORS = [
+    "Logga ut",
+    "Mina vårdkontakter",
+    "Min profil",
+]
+
+for d in (DATA_DIR, BROWSER_PROFILE_DIR, DOWNLOAD_DIR, DISCOVER_DIR, FLOWS_DIR, SCREENSHOT_DIR, TRACE_DIR, EXPORT_DIR):
+    d.mkdir(parents=True, exist_ok=True)
diff --git a/rpa/discover.py b/rpa/discover.py
new file mode 100644
index 0000000..6b1de2b
--- /dev/null
+++ b/rpa/discover.py
@@ -0,0 +1,49 @@
+"""Hjälpverktyg för att inspektera den inloggade sidans faktiska struktur.
+
+Körs separat (python -m rpa.discover) eftersom selectors i scraper.py och
+config.py bygger på gissad textstruktur. Detta skript loggar in (manuellt,
+BankID) och låter dig sedan, för varje sida du manuellt navigerar till,
+trycka Enter i terminalen för att spara en HTML-dump + skärmdump av aktuell
+sida i data/discover/. Använd dumparna för att hitta exakt länktext/selectors
+och uppdatera config.REMISS_LINK_TEXT_CANDIDATES samt scraper.list_egenremisser.
+"""
+
+from datetime import datetime
+
+from playwright.sync_api import sync_playwright
+
+from . import config
+from .auth import ensure_logged_in
+from .browser import get_page, launch_context
+
+
+def main() -> None:
+    with sync_playwright() as p:
+        context = launch_context(p)
+        page = get_page(context)
+        ensure_logged_in(page)
+
+        print(
+            "Navigera manuellt i webbläsarfönstret till sidan du vill "
+            "inspektera (t.ex. sidan som listar egenremisser). Tryck sedan "
+            "Enter i den här terminalen för att spara en dump. Skriv 'q' "
+            "för att avsluta."
+        )
+        n = 0
+        while True:
+            cmd = input("[Enter]=spara dump, q=avsluta: ").strip().lower()
+            if cmd == "q":
+                break
+            n += 1
+            stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+            html_path = config.DISCOVER_DIR / f"{stamp}_{n}.html"
+            png_path = config.DISCOVER_DIR / f"{stamp}_{n}.png"
+            html_path.write_text(page.content(), encoding="utf-8")
+            page.screenshot(path=str(png_path), full_page=True)
+            print(f"Sparade: {html_path.name} och {png_path.name} (url: {page.url})")
+
+        context.close()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/rpa/flow_engine.py b/rpa/flow_engine.py
new file mode 100644
index 0000000..3e17dcb
--- /dev/null
+++ b/rpa/flow_engine.py
@@ -0,0 +1,364 @@
+"""Tolkar och kör användardefinierade flöden (lista av steg som JSON).
+
+Stegtyper:
+  goto              {url}
+  wait              {seconds}
+  wait_for_load     {}
+  wait_for_login    {timeout}
+  wait_for_selector {by, value, role?, scope?, nth?, timeout}
+  click             {by, value, role?, scope?, nth?}
+  type              {by, value, role?, scope?, nth?, text}       -- skriver text i fält (fill)
+  press_key         {key, by?, value?, role?, scope?, nth?}      -- utan by/value: tangent mot sidan
+  select_option     {by, value, role?, scope?, nth?, option}     -- väljer alternativ i <select>
+  check             {by, value, role?, scope?, nth?, checked}    -- kryssar i/ur checkbox/radio
+  extract_text      {by, value, role?, scope?, nth?, var}        -- läser textinnehåll till variabel
+  set_var           {var, text}                                 -- sätter variabel till ett literalt värde
+  list_append       {var, by?, value?, role?, scope?, nth?, text?}
+                                                                  -- lägger till i en listvariabel (skapas
+                                                                  -- vid behov); via selector (som extract_text)
+                                                                  -- eller literalt 'text' om ingen selector satt
+  save_var          {var, filename, format?, mode?}              -- sparar variabel till data/exports/
+                                                                  -- (format: "json"|"csv", gissas från filnamn;
+                                                                  -- mode: "overwrite" (default)|"append")
+  load_var          {var, filename}                               -- läser in variabel från data/exports/
+  run_script        {path, args?, timeout?, var?}                 -- kör ett separat Python-script,
+                                                                    -- loggar stdout/stderr, sparar ev.
+                                                                    -- stdout (JSON om möjligt) i var
+  screenshot        {name?}                                     -- sparar skärmdump i data/screenshots
+  list              {value, var}                       -- CSS-selector, sparar antal+selector i var
+  loop              {over, steps: [...]}                -- itererar över "list"-resultat
+  download          {by, value, role?, scope?, nth?}
+
+by: "text" | "css" | "role"
+scope: "page" (default) | "item"  -- "item" begränsar sökningen till aktuell loop-post
+"""
+
+import csv
+import json
+import re
+import shlex
+import subprocess
+import sys
+from pathlib import Path
+from typing import Callable
+
+from playwright.sync_api import Page, sync_playwright
+
+from . import config
+from .auth import wait_for_login
+from .browser import get_page, launch_context
+
+LogFn = Callable[[str], None]
+
+
+class FlowError(Exception):
+    pass
+
+
+def _resolve_locator(page: Page, item_locator, step: dict):
+    scope = step.get("scope", "page")
+    base = item_locator if (scope == "item" and item_locator is not None) else page
+    by = step.get("by", "css")
+    value = step.get("value", "")
+    nth = int(step.get("nth", 0))
+
+    if by == "text":
+        loc = base.get_by_text(re.compile(re.escape(value), re.I))
+    elif by == "role":
+        role = step.get("role", "button")
+        loc = base.get_by_role(role, name=re.compile(re.escape(value), re.I))
+    elif by == "css":
+        loc = base.locator(value)
+    else:
+        raise FlowError(f"Okänt 'by'-värde: {by}")
+
+    return loc.nth(nth)
+
+
+def _selector_or_literal_text(page: Page, item_locator, step: dict) -> str:
+    """Text från ett element (om 'value'/selector är satt) annars step['text']."""
+    if step.get("value"):
+        loc = _resolve_locator(page, item_locator, step)
+        return loc.first.inner_text()
+    return step.get("text", "")
+
+
+def _run_steps(
+    page: Page,
+    steps: list,
+    log: LogFn,
+    vars_: dict,
+    item_locator,
+    download_dir: Path,
+) -> None:
+    for step in steps:
+        t = step.get("type")
+
+        if t == "goto":
+            log(f"Går till {step['url']}")
+            page.goto(step["url"], wait_until="domcontentloaded")
+
+        elif t == "wait":
+            seconds = float(step.get("seconds", 1))
+            log(f"Väntar {seconds}s")
+            page.wait_for_timeout(seconds * 1000)
+
+        elif t == "wait_for_load":
+            log("Väntar på att sidan laddas klart")
+            page.wait_for_load_state("domcontentloaded")
+
+        elif t == "wait_for_login":
+            timeout_s = int(step.get("timeout", 180))
+            wait_for_login(page, timeout_s, log=log)
+
+        elif t == "wait_for_selector":
+            loc = _resolve_locator(page, item_locator, step)
+            timeout_s = float(step.get("timeout", 15))
+            log(f"Väntar på element: {step.get('by')}={step.get('value')!r}")
+            loc.first.wait_for(timeout=timeout_s * 1000)
+
+        elif t == "click":
+            loc = _resolve_locator(page, item_locator, step)
+            log(f"Klickar: {step.get('by')}={step.get('value')!r}")
+            loc.first.click()
+            page.wait_for_load_state("domcontentloaded")
+
+        elif t == "type":
+            loc = _resolve_locator(page, item_locator, step)
+            text = step.get("text", "")
+            log(f"Skriver text i: {step.get('by')}={step.get('value')!r}")
+            loc.first.fill(text)
+
+        elif t == "press_key":
+            key = step.get("key", "Enter")
+            if step.get("value"):
+                loc = _resolve_locator(page, item_locator, step)
+                log(f"Trycker '{key}' på: {step.get('by')}={step.get('value')!r}")
+                loc.first.press(key)
+            else:
+                log(f"Trycker '{key}'")
+                page.keyboard.press(key)
+
+        elif t == "select_option":
+            loc = _resolve_locator(page, item_locator, step)
+            option = step.get("option", "")
+            log(f"Väljer '{option}' i: {step.get('by')}={step.get('value')!r}")
+            loc.first.select_option(label=option)
+
+        elif t == "check":
+            loc = _resolve_locator(page, item_locator, step)
+            checked_raw = step.get("checked", True)
+            checked = checked_raw if isinstance(checked_raw, bool) else str(checked_raw).lower() == "true"
+            log(f"{'Kryssar i' if checked else 'Kryssar ur'}: {step.get('by')}={step.get('value')!r}")
+            if checked:
+                loc.first.check()
+            else:
+                loc.first.uncheck()
+
+        elif t == "extract_text":
+            loc = _resolve_locator(page, item_locator, step)
+            var_name = step["var"]
+            text = loc.first.inner_text()
+            vars_[var_name] = text
+            log(f"Läste text -> variabel '{var_name}': {text[:60]!r}")
+
+        elif t == "set_var":
+            var_name = step["var"]
+            value = step.get("text", "")
+            vars_[var_name] = value
+            log(f"Sätter variabel '{var_name}' = {value[:60]!r}")
+
+        elif t == "list_append":
+            var_name = step["var"]
+            value = _selector_or_literal_text(page, item_locator, step)
+            lst = vars_.setdefault(var_name, [])
+            if not isinstance(lst, list):
+                raise FlowError(f"Variabel '{var_name}' är inte en lista (innehåller {type(lst).__name__}).")
+            lst.append(value)
+            log(f"Lägger till i lista '{var_name}' ({len(lst)} poster): {value[:60]!r}")
+
+        elif t == "save_var":
+            var_name = step["var"]
+            if var_name not in vars_:
+                raise FlowError(f"Okänd variabel '{var_name}' i save_var.")
+            value = vars_[var_name]
+            filename = step.get("filename") or f"{var_name}.json"
+            if Path(filename).name != filename:
+                raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
+            fmt = step.get("format") or ("csv" if filename.lower().endswith(".csv") else "json")
+            append = step.get("mode") == "append"
+            config.EXPORT_DIR.mkdir(parents=True, exist_ok=True)
+            target = config.EXPORT_DIR / filename
+
+            if fmt == "csv":
+                items = value if isinstance(value, list) else [value]
+                rows = [item if isinstance(item, dict) else {"value": item} for item in items]
+                fieldnames = list(rows[0].keys()) if rows else ["value"]
+                write_header = not (append and target.exists())
+                with open(target, "a" if append else "w", newline="", encoding="utf-8") as f:
+                    writer = csv.DictWriter(f, fieldnames=fieldnames)
+                    if write_header:
+                        writer.writeheader()
+                    writer.writerows(rows)
+            else:
+                to_write = value
+                if append and target.exists():
+                    if not isinstance(value, list):
+                        raise FlowError("save_var i append-läge (JSON) kräver att variabeln är en lista.")
+                    try:
+                        existing = json.loads(target.read_text(encoding="utf-8"))
+                    except (json.JSONDecodeError, OSError):
+                        existing = []
+                    to_write = (existing if isinstance(existing, list) else [existing]) + value
+                target.write_text(json.dumps(to_write, ensure_ascii=False, indent=2), encoding="utf-8")
+
+            log(f"Sparade '{var_name}' -> {target.name} ({fmt}{', append' if append else ''})")
+
+        elif t == "load_var":
+            var_name = step["var"]
+            filename = step["filename"]
+            if Path(filename).name != filename:
+                raise FlowError(f"Ogiltigt filnamn: {filename!r} (inga sökvägar tillåtna).")
+            source = config.EXPORT_DIR / filename
+            if not source.exists():
+                raise FlowError(f"Filen finns inte: {source}")
+            if source.suffix.lower() == ".csv":
+                with open(source, newline="", encoding="utf-8") as f:
+                    rows = list(csv.DictReader(f))
+                if rows and set(rows[0].keys()) == {"value"}:
+                    vars_[var_name] = [row["value"] for row in rows]
+                else:
+                    vars_[var_name] = rows
+            else:
+                vars_[var_name] = json.loads(source.read_text(encoding="utf-8"))
+            log(f"Läste in '{var_name}' <- {source.name}")
+
+        elif t == "run_script":
+            script = step.get("path", "")
+            if not script:
+                raise FlowError("run_script kräver 'path'.")
+            script_path = Path(script)
+            if not script_path.is_absolute():
+                script_path = config.ROOT_DIR / script_path
+            if not script_path.exists():
+                raise FlowError(f"Skriptet finns inte: {script_path}")
+            args = shlex.split(step.get("args", ""))
+            timeout_s = float(step.get("timeout", 60))
+            log(f"Kör script: {script_path.name} {' '.join(args)}")
+            result = subprocess.run(
+                [sys.executable, str(script_path), *args],
+                cwd=str(config.ROOT_DIR),
+                capture_output=True,
+                text=True,
+                timeout=timeout_s,
+            )
+            for line in result.stdout.splitlines():
+                log(f"  stdout: {line}")
+            for line in result.stderr.splitlines():
+                log(f"  stderr: {line}")
+            if result.returncode != 0:
+                raise FlowError(f"Script avslutade med felkod {result.returncode}: {script_path.name}")
+            var_name = step.get("var")
+            if var_name:
+                try:
+                    vars_[var_name] = json.loads(result.stdout)
+                except json.JSONDecodeError:
+                    vars_[var_name] = result.stdout.strip()
+
+        elif t == "screenshot":
+            name = step.get("name") or "screenshot"
+            target = config.SCREENSHOT_DIR / f"{name}.png"
+            page.screenshot(path=str(target))
+            log(f"Skärmdump sparad: {target.name}")
+
+        elif t == "list":
+            selector = step["value"]
+            var_name = step["var"]
+            count = page.locator(selector).count()
+            vars_[var_name] = {"selector": selector, "count": count}
+            log(f"Hittade {count} post(er) -> variabel '{var_name}'")
+
+        elif t == "loop":
+            over = vars_.get(step.get("over"))
+            if not over:
+                raise FlowError(
+                    f"Loop refererar okänd variabel '{step.get('over')}' "
+                    "(kör ett 'list'-steg innan loopen och ange samma namn)."
+                )
+            selector = over["selector"]
+            count = over["count"]
+            for i in range(count):
+                log(f"-- Post {i + 1}/{count} --")
+                current_item = page.locator(selector).nth(i)
+                _run_steps(page, step.get("steps", []), log, vars_, current_item, download_dir)
+
+        elif t == "download":
+            loc = _resolve_locator(page, item_locator, step)
+            try:
+                with page.expect_download(timeout=15000) as dl_info:
+                    loc.first.click()
+                download = dl_info.value
+                target = download_dir / download.suggested_filename
+                download.save_as(str(target))
+                log(f"Nedladdad: {target.name}")
+            except Exception as exc:  # noqa: BLE001
+                log(f"Nedladdning misslyckades: {exc}")
+
+        else:
+            raise FlowError(f"Okänd stegtyp: {t!r}")
+
+
+def run_flow(
+    steps: list,
+    log: LogFn = print,
+    download_dir: Path = None,
+    close_mode: str = "always",
+    close_event=None,
+    on_finished: Callable[[Exception | None], None] | None = None,
+    trace_path: Path | None = None,
+) -> None:
+    """Kör ett flöde.
+
+    close_mode styr vad som händer med webbläsaren när stegen är klara:
+      "always"     -- stäng alltid direkt (tidigare/standardbeteende)
+      "on_success" -- stäng bara om inget steg gav fel, håll öppen vid fel
+      "never"      -- håll alltid öppen tills close_event sätts
+
+    on_finished(error) anropas direkt när stegen är klara (innan ev. väntan
+    på close_event), så anroparen kan uppdatera körningens status utan att
+    behöva vänta på att webbläsaren faktiskt stängs.
+
+    Om trace_path anges spelas en Playwright-trace (skärmdumpar, DOM-snapshots,
+    nätverk) in under hela körningen och sparas dit. Öppna den efteråt med
+    `python -m playwright show-trace <trace_path>` (eller `python -m rpa.show_trace`)
+    för att steg-för-steg se exakt vad som klickades och när det gick fel.
+    """
+    download_dir = download_dir or config.DOWNLOAD_DIR
+    with sync_playwright() as p:
+        context = launch_context(p)
+        if trace_path is not None:
+            context.tracing.start(screenshots=True, snapshots=True, sources=True)
+        page = get_page(context)
+        error: Exception | None = None
+        try:
+            _run_steps(page, steps, log, vars_={}, item_locator=None, download_dir=download_dir)
+            log("Flöde klart.")
+        except Exception as exc:  # noqa: BLE001
+            error = exc
+            log(f"FEL: {exc}")
+
+        if on_finished is not None:
+            on_finished(error)
+
+        should_close_now = close_mode == "always" or (close_mode == "on_success" and error is None)
+        if not should_close_now:
+            log('Webbläsaren hålls öppen. Klicka "Stäng webbläsare" i UI:t när du är klar.')
+            if close_event is not None:
+                close_event.wait()
+
+        if trace_path is not None:
+            trace_path.parent.mkdir(parents=True, exist_ok=True)
+            context.tracing.stop(path=str(trace_path))
+            log(f"Trace sparad: {trace_path.name} (öppna med \"Visa trace\")")
+
+        context.close()
diff --git a/rpa/notify.py b/rpa/notify.py
new file mode 100644
index 0000000..51cedcd
--- /dev/null
+++ b/rpa/notify.py
@@ -0,0 +1,18 @@
+def notify_new_items(items: list[dict]) -> None:
+    if not items:
+        print("Inga nya eller ändrade egenremisser.")
+        return
+
+    title = "Egenremiss - uppdatering"
+    message = f"{len(items)} ny(a)/ändrad(e) egenremiss(er) hittades."
+    print(f"{title}: {message}")
+    for item in items:
+        preview = item["text"].splitlines()[0][:120]
+        print(f"  - {preview}")
+
+    try:
+        from plyer import notification
+
+        notification.notify(title=title, message=message, timeout=10)
+    except Exception as exc:  # noqa: BLE001
+        print(f"(Skrivbordsnotis kunde inte visas: {exc})")
diff --git a/rpa/runs.py b/rpa/runs.py
new file mode 100644
index 0000000..a4bad18
--- /dev/null
+++ b/rpa/runs.py
@@ -0,0 +1,90 @@
+import threading
+import uuid
+from datetime import datetime
+from pathlib import Path
+
+from . import config
+from .flow_engine import run_flow
+
+_RUNS: dict[str, dict] = {}
+_LOCK = threading.Lock()
+
+
+def _append_log(run_id: str, message: str) -> None:
+    line = f"[{datetime.now().strftime('%H:%M:%S')}] {message}"
+    with _LOCK:
+        _RUNS[run_id]["logs"].append(line)
+    print(f"[run {run_id}] {message}")
+
+
+def start_run(steps: list, close_mode: str = "always") -> str:
+    run_id = uuid.uuid4().hex[:12]
+    close_event = threading.Event()
+    trace_path = config.TRACE_DIR / f"{run_id}.zip"
+    with _LOCK:
+        _RUNS[run_id] = {
+            "status": "running",
+            "logs": [],
+            "browser_open": True,
+            "close_event": close_event,
+            "trace_path": trace_path,
+            "trace_ready": False,
+        }
+
+    def on_finished(error: Exception | None) -> None:
+        with _LOCK:
+            _RUNS[run_id]["status"] = "error" if error else "done"
+
+    def target() -> None:
+        try:
+            run_flow(
+                steps,
+                log=lambda m: _append_log(run_id, m),
+                download_dir=config.DOWNLOAD_DIR,
+                close_mode=close_mode,
+                close_event=close_event,
+                on_finished=on_finished,
+                trace_path=trace_path,
+            )
+        except Exception as exc:  # noqa: BLE001
+            _append_log(run_id, f"Oväntat fel: {exc}")
+            with _LOCK:
+                _RUNS[run_id]["status"] = "error"
+        finally:
+            with _LOCK:
+                _RUNS[run_id]["browser_open"] = False
+                _RUNS[run_id]["trace_ready"] = trace_path.exists()
+
+    threading.Thread(target=target, daemon=True).start()
+    return run_id
+
+
+def get_run(run_id: str) -> dict | None:
+    with _LOCK:
+        run = _RUNS.get(run_id)
+        if run is None:
+            return None
+        return {
+            "status": run["status"],
+            "logs": list(run["logs"]),
+            "browser_open": run["browser_open"],
+            "trace_ready": run["trace_ready"],
+        }
+
+
+def get_trace_path(run_id: str) -> Path | None:
+    with _LOCK:
+        run = _RUNS.get(run_id)
+        if run is None or not run["trace_ready"]:
+            return None
+        return run["trace_path"]
+
+
+def close_browser(run_id: str) -> bool:
+    """Signalerar till en väntande körning att den får stänga webbläsaren nu."""
+    with _LOCK:
+        run = _RUNS.get(run_id)
+        if run is None:
+            return False
+        run["close_event"].set()
+        return True
diff --git a/rpa/scraper.py b/rpa/scraper.py
new file mode 100644
index 0000000..921fafb
--- /dev/null
+++ b/rpa/scraper.py
@@ -0,0 +1,85 @@
+import hashlib
+import re
+from pathlib import Path
+from typing import Optional
+
+from playwright.sync_api import Page
+
+from . import config
+
+
+def goto_egenremisser(page: Page) -> bool:
+    """Försöker klicka sig fram till egenremiss-sidan via länktext.
+
+    Returnerar True om en matchande länk hittades och klickades.
+    Om detta misslyckas: kör discover-läget, hitta den riktiga länken/
+    rubriken i dumpen, och lägg till exakt text i
+    config.REMISS_LINK_TEXT_CANDIDATES.
+    """
+    for text in config.REMISS_LINK_TEXT_CANDIDATES:
+        link = page.get_by_role("link", name=re.compile(text, re.I))
+        if link.count() > 0:
+            link.first.click()
+            page.wait_for_load_state("domcontentloaded")
+            return True
+    return False
+
+
+def _stable_id(*parts: str) -> str:
+    raw = "|".join(p.strip() for p in parts if p)
+    return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
+
+
+def list_egenremisser(page: Page) -> list[dict]:
+    """Extraherar egenremiss-poster från nuvarande sida.
+
+    Generisk, layout-agnostisk extraktion: letar efter listobjekt/tabellrader
+    eller <article>-liknande block i huvudinnehållet. Varje post sparas med
+    sin text och ev. länkar (för dokument/detaljer). Justera CSS-selectorn
+    "main li, main tr, main article" efter vad --discover visar.
+    """
+    items = []
+    candidates = page.locator("main li, main tr, main article")
+    count = candidates.count()
+    for i in range(count):
+        node = candidates.nth(i)
+        text = node.inner_text().strip()
+        if not text or len(text) < 3:
+            continue
+
+        hrefs = node.locator("a").evaluate_all(
+            "els => els.map(e => e.href)"
+        )
+
+        item = {
+            "id": _stable_id(text),
+            "text": text,
+            "links": hrefs,
+        }
+        items.append(item)
+    return items
+
+
+def download_attachments(page: Page, item: dict, download_dir: Path) -> list[str]:
+    """Laddar ner ev. dokument/PDF-länkar kopplade till en egenremiss-post."""
+    saved_paths = []
+    doc_links = [
+        href
+        for href in item.get("links", [])
+        if re.search(r"\.pdf($|\?)", href, re.I)
+        or re.search(r"dokument|bilaga|attachment", href, re.I)
+    ]
+
+    for href in doc_links:
+        try:
+            with page.expect_download(timeout=15000) as dl_info:
+                page.goto(href)
+            download = dl_info.value
+            filename = f"{item['id']}_{download.suggested_filename}"
+            target = download_dir / filename
+            download.save_as(str(target))
+            saved_paths.append(str(target))
+        except Exception as exc:  # noqa: BLE001
+            print(f"  Kunde inte ladda ner {href}: {exc}")
+
+    return saved_paths
diff --git a/rpa/show_trace.py b/rpa/show_trace.py
new file mode 100644
index 0000000..fee4883
--- /dev/null
+++ b/rpa/show_trace.py
@@ -0,0 +1,42 @@
+"""Öppnar Playwrights Trace Viewer för en sparad körning.
+
+Användning:
+    python -m rpa.show_trace              # senaste sparade trace
+    python -m rpa.show_trace <run_id>      # en specifik körning (id från webui:t)
+    python -m rpa.show_trace <sökväg.zip>  # valfri trace-fil
+"""
+
+import subprocess
+import sys
+from pathlib import Path
+
+from . import config
+
+
+def _resolve_path(arg: str | None) -> Path:
+    if arg is None:
+        traces = sorted(config.TRACE_DIR.glob("*.zip"), key=lambda p: p.stat().st_mtime, reverse=True)
+        if not traces:
+            raise SystemExit(f"Inga sparade traces i {config.TRACE_DIR}")
+        return traces[0]
+
+    direct = Path(arg)
+    if direct.exists():
+        return direct
+
+    by_run_id = config.TRACE_DIR / f"{arg}.zip"
+    if by_run_id.exists():
+        return by_run_id
+
+    raise SystemExit(f"Hittar ingen trace för {arg!r} (sökte {direct} och {by_run_id})")
+
+
+def main() -> None:
+    arg = sys.argv[1] if len(sys.argv) > 1 else None
+    path = _resolve_path(arg)
+    print(f"Öppnar trace: {path}")
+    subprocess.run([sys.executable, "-m", "playwright", "show-trace", str(path)])
+
+
+if __name__ == "__main__":
+    main()
diff --git a/rpa/state.py b/rpa/state.py
new file mode 100644
index 0000000..490f99b
--- /dev/null
+++ b/rpa/state.py
@@ -0,0 +1,27 @@
+import json
+from datetime import datetime
+
+from . import config
+
+
+def load_previous() -> dict:
+    if not config.STATE_FILE.exists():
+        return {"items": {}}
+    with open(config.STATE_FILE, "r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def save_current(items: list[dict]) -> None:
+    payload = {
+        "updated_at": datetime.now().isoformat(timespec="seconds"),
+        "items": {item["id"]: item for item in items},
+    }
+    with open(config.STATE_FILE, "w", encoding="utf-8") as f:
+        json.dump(payload, f, ensure_ascii=False, indent=2)
+
+
+def diff_new_or_changed(previous: dict, current_items: list[dict]) -> list[dict]:
+    # item["id"] är ett hashvärde av dess text (se scraper._stable_id), så
+    # en ändrad text ger automatiskt ett nytt id och räknas som "ny" post.
+    prev_items = previous.get("items", {})
+    return [item for item in current_items if item["id"] not in prev_items]
diff --git a/run_webui.py b/run_webui.py
new file mode 100644
index 0000000..d08720c
--- /dev/null
+++ b/run_webui.py
@@ -0,0 +1,6 @@
+import uvicorn
+
+if __name__ == "__main__":
+    # Endast lokal åtkomst (127.0.0.1) - detta styr en riktig webbläsare mot
+    # din inloggade 1177-session och ska inte exponeras på nätverket.
+    uvicorn.run("webui.app:app", host="127.0.0.1", port=8765, reload=False)
diff --git a/webui/__init__.py b/webui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/webui/app.py b/webui/app.py
new file mode 100644
index 0000000..36a36b0
--- /dev/null
+++ b/webui/app.py
@@ -0,0 +1,103 @@
+import json
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import HTMLResponse
+from pydantic import BaseModel
+
+from rpa import config
+from rpa.runs import close_browser, get_run, get_trace_path, start_run
+
+app = FastAPI(title="Egenremiss RPA - flödesbyggare")
+
+TEMPLATES_DIR = Path(__file__).parent / "templates"
+NAME_RE = re.compile(r"^[a-zA-Z0-9_\-åäöÅÄÖ ]{1,80}$")
+CLOSE_MODES = {"always", "on_success", "never"}
+
+
+class Flow(BaseModel):
+    steps: list = []
+
+
+class RunOptions(BaseModel):
+    close_mode: str = "always"
+
+
+def _flow_path(name: str) -> Path:
+    if not NAME_RE.match(name):
+        raise HTTPException(400, "Ogiltigt flödesnamn")
+    return config.FLOWS_DIR / f"{name}.json"
+
+
+@app.get("/", response_class=HTMLResponse)
+def index() -> str:
+    return (TEMPLATES_DIR / "index.html").read_text(encoding="utf-8")
+
+
+@app.get("/api/flows")
+def list_flows() -> list[str]:
+    return sorted(p.stem for p in config.FLOWS_DIR.glob("*.json"))
+
+
+@app.get("/api/flows/{name}")
+def get_flow(name: str) -> dict:
+    path = _flow_path(name)
+    if not path.exists():
+        return {"steps": []}
+    return json.loads(path.read_text(encoding="utf-8"))
+
+
+@app.put("/api/flows/{name}")
+def save_flow(name: str, flow: Flow) -> dict:
+    path = _flow_path(name)
+    path.write_text(json.dumps(flow.model_dump(), ensure_ascii=False, indent=2), encoding="utf-8")
+    return {"ok": True}
+
+
+@app.delete("/api/flows/{name}")
+def delete_flow(name: str) -> dict:
+    path = _flow_path(name)
+    if path.exists():
+        path.unlink()
+    return {"ok": True}
+
+
+@app.post("/api/flows/{name}/run")
+def run_flow_endpoint(name: str, options: RunOptions = RunOptions()) -> dict:
+    path = _flow_path(name)
+    if not path.exists():
+        raise HTTPException(404, "Flödet finns inte")
+    flow = json.loads(path.read_text(encoding="utf-8"))
+    close_mode = options.close_mode if options.close_mode in CLOSE_MODES else "always"
+    run_id = start_run(flow.get("steps", []), close_mode=close_mode)
+    return {"run_id": run_id}
+
+
+@app.get("/api/runs/{run_id}")
+def run_status(run_id: str) -> dict:
+    run = get_run(run_id)
+    if run is None:
+        raise HTTPException(404, "Okänt run_id")
+    return run
+
+
+@app.post("/api/runs/{run_id}/close-browser")
+def close_browser_endpoint(run_id: str) -> dict:
+    if not close_browser(run_id):
+        raise HTTPException(404, "Okänt run_id")
+    return {"ok": True}
+
+
+@app.post("/api/runs/{run_id}/show-trace")
+def show_trace_endpoint(run_id: str) -> dict:
+    trace_path = get_trace_path(run_id)
+    if trace_path is None:
+        raise HTTPException(404, "Ingen trace sparad för denna körning (än)")
+    # Öppnar Playwrights Trace Viewer i ett eget fönster på samma maskin
+    # som servern körs på - startas som en fristående process så webui:t
+    # inte blockeras.
+    subprocess.Popen([sys.executable, "-m", "playwright", "show-trace", str(trace_path)])
+    return {"ok": True}
diff --git a/webui/templates/index.html b/webui/templates/index.html
new file mode 100644
index 0000000..449925a
--- /dev/null
+++ b/webui/templates/index.html
@@ -0,0 +1,575 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+<meta charset="UTF-8">
+<title>Egenremiss RPA – flödesbyggare</title>
+<style>
+  :root {
+    color-scheme: light dark;
+    --bg: #f5f5f7;
+    --panel: #ffffff;
+    --border: #d8d8dd;
+    --text: #1c1c1e;
+    --muted: #6b6b70;
+    --accent: #2f6fed;
+    --accent-text: #ffffff;
+    --danger: #d1453b;
+    --loop-bg: #eef2fb;
+  }
+  @media (prefers-color-scheme: dark) {
+    :root {
+      --bg: #1c1c1e;
+      --panel: #2a2a2d;
+      --border: #444448;
+      --text: #f2f2f3;
+      --muted: #9a9a9e;
+      --loop-bg: #232b3d;
+    }
+  }
+  * { box-sizing: border-box; }
+  body {
+    margin: 0;
+    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
+    background: var(--bg);
+    color: var(--text);
+  }
+  header {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 14px 20px;
+    background: var(--panel);
+    border-bottom: 1px solid var(--border);
+    flex-wrap: wrap;
+  }
+  header h1 { font-size: 16px; margin: 0 16px 0 0; white-space: nowrap; }
+  select, input, button {
+    font: inherit;
+    padding: 6px 10px;
+    border-radius: 6px;
+    border: 1px solid var(--border);
+    background: var(--panel);
+    color: var(--text);
+  }
+  button {
+    cursor: pointer;
+    background: var(--accent);
+    color: var(--accent-text);
+    border: none;
+  }
+  button.secondary {
+    background: transparent;
+    color: var(--text);
+    border: 1px solid var(--border);
+  }
+  button.danger { background: var(--danger); }
+  button:disabled { opacity: 0.5; cursor: not-allowed; }
+  main {
+    display: grid;
+    grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
+    gap: 16px;
+    padding: 16px 20px;
+    align-items: start;
+  }
+  .panel {
+    background: var(--panel);
+    border: 1px solid var(--border);
+    border-radius: 10px;
+    padding: 14px;
+  }
+  .steps { display: flex; flex-direction: column; gap: 10px; }
+  .step-card {
+    border: 1px solid var(--border);
+    border-radius: 8px;
+    padding: 10px 12px;
+    background: var(--panel);
+  }
+  .step-card.loop { background: var(--loop-bg); }
+  .step-head {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 8px;
+  }
+  .step-head .type-select { font-weight: 600; }
+  .step-head .spacer { flex: 1; }
+  .step-head button { padding: 3px 8px; font-size: 12px; }
+  .fields {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+    gap: 8px;
+  }
+  .field label {
+    display: block;
+    font-size: 11px;
+    color: var(--muted);
+    margin-bottom: 2px;
+  }
+  .field input, .field select { width: 100%; }
+  .nested {
+    margin-top: 10px;
+    padding: 10px;
+    border-left: 3px solid var(--accent);
+    border-radius: 6px;
+  }
+  .nested .steps { margin-bottom: 8px; }
+  .add-step-row { margin-top: 4px; }
+  #log {
+    background: #111114;
+    color: #d6d6d8;
+    border-radius: 8px;
+    padding: 10px;
+    font-family: ui-monospace, Consolas, monospace;
+    font-size: 12px;
+    height: 360px;
+    overflow-y: auto;
+    white-space: pre-wrap;
+  }
+  .status-badge {
+    display: inline-block;
+    padding: 2px 8px;
+    border-radius: 999px;
+    font-size: 12px;
+    margin-left: 8px;
+  }
+  .status-running { background: #f5b942; color: #222; }
+  .status-done { background: #3aa757; color: #fff; }
+  .status-error { background: var(--danger); color: #fff; }
+  .hint { color: var(--muted); font-size: 12px; margin: 0 0 10px; }
+</style>
+</head>
+<body>
+
+<header>
+  <h1>Egenremiss RPA</h1>
+  <select id="flowSelect"></select>
+  <button class="secondary" id="newFlowBtn">Nytt flöde</button>
+  <button class="danger" id="deleteFlowBtn">Ta bort flöde</button>
+  <span class="spacer" style="flex:1"></span>
+  <select id="closeModeSelect" title="Vad ska hända med webbläsaren när flödet är klart?">
+    <option value="always">Stäng webbläsaren</option>
+    <option value="on_success">Håll öppen vid fel</option>
+    <option value="never">Håll alltid öppen</option>
+  </select>
+  <button class="secondary" id="saveBtn">Spara</button>
+  <button id="runBtn">Kör flöde</button>
+  <button class="secondary" id="closeBrowserBtn" style="display:none">Stäng webbläsare</button>
+  <button class="secondary" id="showTraceBtn" style="display:none">Visa trace</button>
+  <span id="statusBadge"></span>
+</header>
+
+<main>
+  <div class="panel">
+    <p class="hint">
+      Bygg flödet steg för steg. "Lista poster" hittar element via en CSS-selector
+      och sparar dem i en variabel; "Loop" itererar över den variabeln. Inuti en
+      loop kan Sökområde sättas till "item" för att bara söka inom aktuell post.
+    </p>
+    <div id="rootSteps" class="steps"></div>
+    <div class="add-step-row">
+      <button class="secondary" id="addRootStepBtn">+ Lägg till steg</button>
+    </div>
+  </div>
+
+  <div class="panel">
+    <strong>Körlogg</strong>
+    <div id="log"></div>
+  </div>
+</main>
+
+<script>
+const STEP_TYPES = [
+  "goto", "wait", "wait_for_load", "wait_for_login", "wait_for_selector",
+  "click", "type", "press_key", "select_option", "check",
+  "extract_text", "set_var", "list_append", "save_var", "load_var", "run_script",
+  "screenshot", "list", "loop", "download",
+];
+
+const STEP_LABELS = {
+  goto: "Gå till URL",
+  wait: "Vänta (sekunder)",
+  wait_for_load: "Vänta på att sidan laddas",
+  wait_for_login: "Vänta på inloggning (BankID)",
+  wait_for_selector: "Vänta på element",
+  click: "Klicka",
+  type: "Skriv i textfält",
+  press_key: "Tryck tangent",
+  select_option: "Välj i dropdown",
+  check: "Kryssa checkbox/radio",
+  extract_text: "Läs text till variabel",
+  set_var: "Sätt variabel",
+  list_append: "Lägg till i lista",
+  save_var: "Spara variabel (JSON/CSV)",
+  load_var: "Läs in variabel (JSON/CSV)",
+  run_script: "Kör Python-script",
+  screenshot: "Ta skärmdump",
+  list: "Lista poster",
+  loop: "Loop (för varje post)",
+  download: "Ladda ner",
+};
+
+const SELECTOR_FIELDS = [
+  { key: "by", label: "Hitta via", type: "select", options: ["text", "css", "role"], default: "text" },
+  { key: "value", label: "Text / CSS-selector / rollnamn", type: "text" },
+  { key: "role", label: "Roll (om by=role, ex. link, button)", type: "text" },
+  { key: "scope", label: "Sökområde", type: "select", options: ["page", "item"], default: "page" },
+  { key: "nth", label: "Index (0 = första matchning)", type: "number", default: 0 },
+];
+
+const FIELD_DEFS = {
+  goto: [{ key: "url", label: "URL", type: "text" }],
+  wait: [{ key: "seconds", label: "Sekunder", type: "number", default: 1 }],
+  wait_for_load: [],
+  wait_for_login: [{ key: "timeout", label: "Timeout (sekunder)", type: "number", default: 180 }],
+  wait_for_selector: [
+    ...SELECTOR_FIELDS,
+    { key: "timeout", label: "Timeout (sekunder)", type: "number", default: 15 },
+  ],
+  click: SELECTOR_FIELDS,
+  type: [...SELECTOR_FIELDS, { key: "text", label: "Text att skriva", type: "text" }],
+  press_key: [
+    { key: "key", label: "Tangent (ex. Enter, Tab, Escape)", type: "text", default: "Enter" },
+    { key: "value", label: "Text / CSS-selector / rollnamn (tomt = hela sidan)", type: "text" },
+    { key: "by", label: "Hitta via (om ovan ifylld)", type: "select", options: ["text", "css", "role"], default: "text" },
+    { key: "role", label: "Roll (om by=role)", type: "text" },
+    { key: "scope", label: "Sökområde", type: "select", options: ["page", "item"], default: "page" },
+    { key: "nth", label: "Index (0 = första matchning)", type: "number", default: 0 },
+  ],
+  select_option: [...SELECTOR_FIELDS, { key: "option", label: "Alternativ (synlig text)", type: "text" }],
+  check: [
+    ...SELECTOR_FIELDS,
+    { key: "checked", label: "Kryssa i (av = kryssa ur)", type: "select", options: ["true", "false"], default: "true" },
+  ],
+  extract_text: [...SELECTOR_FIELDS, { key: "var", label: "Spara som variabel", type: "text" }],
+  set_var: [
+    { key: "var", label: "Variabelnamn", type: "text" },
+    { key: "text", label: "Värde", type: "text" },
+  ],
+  list_append: [
+    { key: "var", label: "Listvariabel", type: "text" },
+    { key: "text", label: "Literalt värde (används om selector nedan är tom)", type: "text" },
+    { key: "value", label: "Eller: Text / CSS-selector / rollnamn (hämtar från sidan)", type: "text" },
+    { key: "by", label: "Hitta via (om ovan ifylld)", type: "select", options: ["text", "css", "role"], default: "text" },
+    { key: "role", label: "Roll (om by=role)", type: "text" },
+    { key: "scope", label: "Sökområde", type: "select", options: ["page", "item"], default: "page" },
+    { key: "nth", label: "Index (0 = första matchning)", type: "number", default: 0 },
+  ],
+  save_var: [
+    { key: "var", label: "Variabel att spara", type: "text" },
+    { key: "filename", label: "Filnamn i data/exports/ (.json eller .csv)", type: "text" },
+    { key: "format", label: "Format (tomt = gissa från filnamn)", type: "select", options: ["", "json", "csv"], default: "" },
+    { key: "mode", label: "Läge", type: "select", options: ["overwrite", "append"], default: "overwrite" },
+  ],
+  load_var: [
+    { key: "var", label: "Spara som variabel", type: "text" },
+    { key: "filename", label: "Filnamn i data/exports/ (.json eller .csv)", type: "text" },
+  ],
+  run_script: [
+    { key: "path", label: "Sökväg till Python-script", type: "text" },
+    { key: "args", label: "Argument (mellanslagsseparerade)", type: "text" },
+    { key: "timeout", label: "Timeout (sekunder)", type: "number", default: 60 },
+    { key: "var", label: "Spara stdout som variabel (valfritt, JSON om möjligt)", type: "text" },
+  ],
+  screenshot: [{ key: "name", label: "Filnamn (utan .png)", type: "text", default: "screenshot" }],
+  list: [
+    { key: "value", label: "CSS-selector", type: "text" },
+    { key: "var", label: "Spara som variabel", type: "text" },
+  ],
+  loop: [{ key: "over", label: "Loopa över variabel", type: "text" }],
+  download: SELECTOR_FIELDS,
+};
+
+let currentFlowName = null;
+let currentRunId = null;
+let flow = { steps: [] };
+
+function newStep(type) {
+  const step = { type };
+  for (const f of FIELD_DEFS[type] || []) {
+    if (f.default !== undefined) step[f.key] = f.default;
+  }
+  if (type === "loop") step.steps = [];
+  return step;
+}
+
+function fieldRow(step, def, onStructuralChange) {
+  const wrap = document.createElement("div");
+  wrap.className = "field";
+  const label = document.createElement("label");
+  label.textContent = def.label;
+  wrap.appendChild(label);
+
+  let input;
+  if (def.type === "select") {
+    input = document.createElement("select");
+    for (const opt of def.options) {
+      const o = document.createElement("option");
+      o.value = opt;
+      o.textContent = opt;
+      input.appendChild(o);
+    }
+    input.value = step[def.key] ?? def.default ?? def.options[0];
+  } else {
+    input = document.createElement("input");
+    input.type = def.type;
+    input.value = step[def.key] ?? def.default ?? "";
+  }
+  input.addEventListener("input", () => {
+    step[def.key] = def.type === "number" ? Number(input.value) : input.value;
+  });
+  wrap.appendChild(input);
+  return wrap;
+}
+
+function renderStepCard(step, arr, idx, isItemScope, onStructuralChange) {
+  const card = document.createElement("div");
+  card.className = "step-card" + (step.type === "loop" ? " loop" : "");
+
+  const head = document.createElement("div");
+  head.className = "step-head";
+
+  const typeSelect = document.createElement("select");
+  typeSelect.className = "type-select";
+  for (const t of STEP_TYPES) {
+    const o = document.createElement("option");
+    o.value = t;
+    o.textContent = STEP_LABELS[t];
+    typeSelect.appendChild(o);
+  }
+  typeSelect.value = step.type;
+  typeSelect.addEventListener("change", () => {
+    const replaced = newStep(typeSelect.value);
+    arr[idx] = replaced;
+    onStructuralChange();
+  });
+  head.appendChild(typeSelect);
+
+  head.appendChild(document.createElement("span")).className = "spacer";
+
+  const upBtn = document.createElement("button");
+  upBtn.className = "secondary";
+  upBtn.textContent = "↑";
+  upBtn.disabled = idx === 0;
+  upBtn.addEventListener("click", () => {
+    [arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
+    onStructuralChange();
+  });
+
+  const downBtn = document.createElement("button");
+  downBtn.className = "secondary";
+  downBtn.textContent = "↓";
+  downBtn.disabled = idx === arr.length - 1;
+  downBtn.addEventListener("click", () => {
+    [arr[idx + 1], arr[idx]] = [arr[idx], arr[idx + 1]];
+    onStructuralChange();
+  });
+
+  const delBtn = document.createElement("button");
+  delBtn.className = "danger";
+  delBtn.textContent = "Ta bort";
+  delBtn.addEventListener("click", () => {
+    arr.splice(idx, 1);
+    onStructuralChange();
+  });
+
+  head.appendChild(upBtn);
+  head.appendChild(downBtn);
+  head.appendChild(delBtn);
+  card.appendChild(head);
+
+  const fields = document.createElement("div");
+  fields.className = "fields";
+  let defs = FIELD_DEFS[step.type] || [];
+  if (!isItemScope) {
+    defs = defs.filter((d) => d.key !== "scope" || step.type === "loop");
+  }
+  for (const def of defs) {
+    fields.appendChild(fieldRow(step, def, onStructuralChange));
+  }
+  card.appendChild(fields);
+
+  if (step.type === "loop") {
+    const nested = document.createElement("div");
+    nested.className = "nested";
+    const nestedSteps = document.createElement("div");
+    nestedSteps.className = "steps";
+    nested.appendChild(nestedSteps);
+    renderStepsInto(nestedSteps, step.steps, true, onStructuralChange);
+
+    const addBtn = document.createElement("button");
+    addBtn.className = "secondary add-step-row";
+    addBtn.textContent = "+ Lägg till steg i loop";
+    addBtn.addEventListener("click", () => {
+      step.steps.push(newStep("click"));
+      onStructuralChange();
+    });
+    nested.appendChild(addBtn);
+    card.appendChild(nested);
+  }
+
+  return card;
+}
+
+function renderStepsInto(container, arr, isItemScope, onStructuralChange) {
+  container.innerHTML = "";
+  arr.forEach((step, idx) => {
+    container.appendChild(renderStepCard(step, arr, idx, isItemScope, onStructuralChange));
+  });
+}
+
+function render() {
+  const root = document.getElementById("rootSteps");
+  renderStepsInto(root, flow.steps, false, render);
+}
+
+document.getElementById("addRootStepBtn").addEventListener("click", () => {
+  flow.steps.push(newStep("goto"));
+  render();
+});
+
+async function loadFlowList() {
+  const res = await fetch("/api/flows");
+  const names = await res.json();
+  const select = document.getElementById("flowSelect");
+  select.innerHTML = "";
+  for (const name of names) {
+    const o = document.createElement("option");
+    o.value = name;
+    o.textContent = name;
+    select.appendChild(o);
+  }
+  if (names.length && !names.includes(currentFlowName)) {
+    currentFlowName = names[0];
+  }
+  if (currentFlowName) select.value = currentFlowName;
+  return names;
+}
+
+async function loadFlow(name) {
+  const res = await fetch(`/api/flows/${encodeURIComponent(name)}`);
+  flow = await res.json();
+  if (!flow.steps) flow.steps = [];
+  currentFlowName = name;
+  render();
+}
+
+async function saveFlow() {
+  if (!currentFlowName) return;
+  await fetch(`/api/flows/${encodeURIComponent(currentFlowName)}`, {
+    method: "PUT",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ steps: flow.steps }),
+  });
+  await loadFlowList();
+}
+
+document.getElementById("flowSelect").addEventListener("change", (e) => {
+  loadFlow(e.target.value);
+});
+
+document.getElementById("newFlowBtn").addEventListener("click", async () => {
+  const name = prompt("Namn på nytt flöde:");
+  if (!name) return;
+  currentFlowName = name;
+  flow = { steps: [] };
+  await saveFlow();
+  await loadFlowList();
+  render();
+});
+
+document.getElementById("deleteFlowBtn").addEventListener("click", async () => {
+  if (!currentFlowName) return;
+  if (!confirm(`Ta bort flödet "${currentFlowName}"?`)) return;
+  await fetch(`/api/flows/${encodeURIComponent(currentFlowName)}`, { method: "DELETE" });
+  currentFlowName = null;
+  const names = await loadFlowList();
+  if (names.length) await loadFlow(names[0]);
+  else { flow = { steps: [] }; render(); }
+});
+
+document.getElementById("saveBtn").addEventListener("click", saveFlow);
+
+const logEl = document.getElementById("log");
+const statusBadge = document.getElementById("statusBadge");
+const runBtn = document.getElementById("runBtn");
+const closeModeSelect = document.getElementById("closeModeSelect");
+const closeBrowserBtn = document.getElementById("closeBrowserBtn");
+const showTraceBtn = document.getElementById("showTraceBtn");
+
+const savedCloseMode = localStorage.getItem("rpa_close_mode");
+if (savedCloseMode) closeModeSelect.value = savedCloseMode;
+closeModeSelect.addEventListener("change", () => {
+  localStorage.setItem("rpa_close_mode", closeModeSelect.value);
+});
+
+function setStatus(status) {
+  statusBadge.className = "status-badge status-" + status;
+  statusBadge.textContent = { running: "Kör...", done: "Klar", error: "Fel" }[status] || "";
+}
+
+async function pollRun(runId) {
+  const res = await fetch(`/api/runs/${runId}`);
+  const data = await res.json();
+  logEl.textContent = data.logs.join("\n");
+  logEl.scrollTop = logEl.scrollHeight;
+
+  if (data.status !== "running") {
+    setStatus(data.status);
+    runBtn.disabled = false;
+  }
+
+  closeBrowserBtn.style.display = data.browser_open ? "inline-block" : "none";
+  closeBrowserBtn.disabled = false;
+
+  showTraceBtn.style.display = data.trace_ready ? "inline-block" : "none";
+
+  if (data.status === "running" || data.browser_open) {
+    setTimeout(() => pollRun(runId), 1000);
+  }
+}
+
+document.getElementById("runBtn").addEventListener("click", async () => {
+  await saveFlow();
+  logEl.textContent = "";
+  runBtn.disabled = true;
+  closeBrowserBtn.style.display = "none";
+  showTraceBtn.style.display = "none";
+  setStatus("running");
+  const res = await fetch(`/api/flows/${encodeURIComponent(currentFlowName)}/run`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ close_mode: closeModeSelect.value }),
+  });
+  const data = await res.json();
+  currentRunId = data.run_id;
+  pollRun(currentRunId);
+});
+
+closeBrowserBtn.addEventListener("click", async () => {
+  closeBrowserBtn.disabled = true;
+  await fetch(`/api/runs/${encodeURIComponent(currentRunId)}/close-browser`, { method: "POST" });
+});
+
+showTraceBtn.addEventListener("click", async () => {
+  await fetch(`/api/runs/${encodeURIComponent(currentRunId)}/show-trace`, { method: "POST" });
+});
+
+(async function init() {
+  const names = await loadFlowList();
+  if (names.length) {
+    await loadFlow(names[0]);
+  } else {
+    currentFlowName = "standard";
+    flow = { steps: [
+      newStep("goto"),
+      newStep("wait_for_login"),
+    ]};
+    flow.steps[0].url = "https://www.1177.se/";
+    await saveFlow();
+    await loadFlowList();
+    render();
+  }
+})();
+</script>
+</body>
+</html>