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>