Skip to main content

powerbi

Reverse-engineer public Power BI dashboards (app.powerbi.com/view embeds) — scripts/powerbi.py primitives: token/key, query replay, DSR decompression.

Source facts

Repository
jokull/icelandic-data
Last source activity
September 10, 2026 at 15:02
Detected SKILL.md language
English
Stars
54
Forks
4

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
powerbi
description
Reverse-engineer public Power BI dashboards (app.powerbi.com/view embeds) — scripts/powerbi.py primitives: token/key, query replay, DSR decompression.
# Power BI dashboard reverse-engineering **Requires:** Tier 2 (browser) — token capture needs Chromium; query replay afterwards is core. Icelandic public bodies publish a lot of data only as embedded **Power BI** dashboards (`https://app.powerbi.com/view?r=<token>`, often inside an `<iframe>` on the agency's site). They all speak one protocol, so `scripts/powerbi.py` factors out the generalizable parts. A per-source script supplies only the SPA URL, which section to open, and the slicer/column names. Sources in this repo that are Power BI underneath: **samgongustofa** (fully migrated — the reference implementation), plus landlaeknir, vernd, farsaeld_barna, ferdamalastofa, tekjusagan, vinnumalastofnun, maelabord_nautgripa, sedlabanki_rates. The last group still only *captures whatever fires on page load*; migrate them to `powerbi.py` when they need parameterized queries (change a slicer, pull another year). ## The protocol (what's identical everywhere) 1. **Embed token** — `?r=<base64({"k":resourceKey,"t":tenant,"c":cluster})>`. `pb.embed_url(key, tenant)` builds it; `pb.key_of(url)` / `pb.token_of(url)` decode it. Keys **rotate** — never hardcode; read them live. 2. **Query endpoint** — `https://wabi-<region>-<x>-api.analysis.windows.net/ public/reports/querydata?synchronous=true`, header `x-powerbi-resourcekey: <key>`, **no bearer** for public reports. 3. **Auth reality** — the anonymous grant is **session-, origin- and rate-bound**. A cold `httpx` client works for a few requests then 401s (`PowerBINotAuthorizedException`; the API even exposes `retry-after`), and a POST from any origin other than the app.powerbi.com iframe is rejected. So the robust move is to **replay queries with `fetch()` inside the iframe**. 4. **Request body** — a `SemanticQueryDataShapeCommand`. Capture the report's own request as a template and rewrite its `Where`; the body must keep its top-level `modelId`/`version`/`cancelQueries` or you get `400 "ModelId must be between 1 and 9.2e18"`. 5. **Response** — a DSR (DataShapeResult): a *compressed* columnar format with `ValueDicts` (int→string), an `R` repeat-bitmask (carry the previous row's value) and a `Ø` null-bitmask. **Decompress it or you silently undercount** — the naive `row["C"][0], row["C"][1]` reader drops every compressed row. ## The recipe ```python import asyncio, sys; sys.path.insert(0, "scripts") import powerbi as pb from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: b = await p.chromium.launch(headless=True) page = await b.new_page() # 1. open the dashboard, get the active report's iframe + key + templates d = await pb.discover(page, "https://<host>/", anchor="#section") # anchor optional # 2. clone a visual's captured request and set filters body = pb.where_in(d.templates["<GroupCol>"], "<YearCol>", ["2026L"], text=False) body = pb.where_in(body, "<OtherCol>", ["SomeValue"]) # text literal # 3. replay inside the iframe (retries the transient 401/429) and decode counts = pb.group_counts(await pb.replay(d.frame, d.key, body, retries=1)) await b.close() print(sorted(counts.items(), key=lambda kv: -kv[1])[:10]) asyncio.run(main()) ``` ## Helper reference (`scripts/powerbi.py`) | helper | does | |---|---| | `embed_url(key, tenant, *, cluster=8, page=None)` | build an `app.powerbi.com/view?r=` URL | | `token_of(url)` / `key_of(url)` | decode the `?r=` token / its resource key | | `capture_requests(page, sink)` | attach a listener collecting `(key, body)` per querydata POST | | `discover(page, spa_url, *, anchor=None)` | → `Discovery(frame, key, templates, requests)`; `templates` keyed by each visual's first group-by column, filtered to the active iframe's key | | `replay(frame, key, payload, *, url=None, retries=1)` | POST `payload` inside the iframe → parsed JSON; retries transient 401/429 | | `query_of(body)` | the `SemanticQuery` (Select/Where/OrderBy/Binding) inside a request body | | `in_condition(col, values)` | build one `In` filter (values are literals: `'text'` or `2023L`) | | `where_in(body, col, values, *, replace=True, text=True)` | clone + add/replace an `In` filter | | `where_drop(body, *cols)` | clone + remove filters on those columns | | `group_counts(body, *, measure=0)` | decode a `[dimension, measure]` visual → `{label: value}`; handles both DM0 shapes + compression | | `parse_dsr(body)` | every DM0 row as a decoded value list (Select order) — for multi-measure / time-series visuals | ## Literal formats (the fiddly bit) `In` values are sent **verbatim as literals**. Get the form right or the query 400s / returns empty: - integer / date-key columns → `"2023L"` (note the `L`), pass `text=False`. - text columns → `"'08-ágúst'"`, `"'Rafmagn'"` — `where_in(..., text=True)` (default) wraps them. Discover a column's exact name and literal format by driving the slicer/visual once with `pb.capture_requests` and reading the `post_data` it fires. That is how every constant in the Power BI sources here was found. ## Onboarding a new Power BI source 1. `pb.discover(page, url)` (with `page.on("request")` via `capture_requests`) to enumerate the report's visuals, key, and the slicer columns. 2. Note the SPA URL, section anchors, group-by columns, slicer columns and their literal formats — the only source-specific facts. 3. Write a thin `scripts/<source>.py` CLI (copy `samgongustofa.py`'s shape: a `REPORTS`/dims config + `_slicer_payload` + a `fetch`/`list`). 4. Add a health probe and a Quick Command (see `new-data-source`). ## Caveats - **Geo-fencing** is per-source, not a Power BI trait: e.g. Samgöngustofa's host only answers Icelandic IPs. Run from the right country. - `group_counts` covers single-group visuals; genuinely hierarchical results (secondary `SH`/`DM1` groupings, e.g. a wide time-series matrix) need `parse_dsr` + custom shaping (see `sedlabanki_rates.parse_pbi_timeseries`). - Pace replays (~2 s apart) — the anonymous grant rate-limits.
View on GitHub