poit-puller (0.3.0)

Published 2026-10-07 03:32:18 +00:00 by niro

Installation

[registries.forgejo]
index = "sparse+" # Sparse index
# index = "" # Git

[net]
git-fetch-with-cli = true
cargo add poit-puller@0.3.0 --registry forgejo

About this package

Poller for POIT

POIT Puller

A Rust client for listing and fetching Kallelser from Post- och Inrikes Tidningar (POIT).

The service's notice endpoints sit behind a bot defense that only opens to a browser that has run its JavaScript challenge. This crate does not try to solve that challenge: a human logs in, the library takes it from there. You open POIT in a browser, copy one request's headers out of the developer tools, and build the client from them.

Status

The client can:

  • list publications with amnesomradeId=1 for a date range;
  • fetch an individual publication by kungorelseid;
  • decode POIT's version 5 response format into JSON;
  • poll a set of IDs sequentially with caller-defined jitter;
  • optionally render a notice as PDF.

Persistence and scheduled polling still need to be implemented.

Requirements

  • A recent stable Rust toolchain
  • A browser, and a human to use it

Install Rust with rustup if needed.

Supplying a browser session

  1. Open https://poit.bolagsverket.se/poit-app/urval-senaste-publiceringar in Firefox or Chrome and open any notice. This is the step that passes the bot defense's challenge.

    Expect to land on the start page the first time: the challenge reloads the page and the reload drops the link you followed. Open the same link again and it goes through. Worth saying so in any interface that asks for this.

  2. Open the developer tools (F12) and select the Network panel.

  3. Find a request to POIT — HamtaKungorelse is the one the gate protects.

  4. Right-click it and choose Copy → Copy as cURL. "Copy Request Headers" works just as well.

  5. Save what you copied to a file, for example poit-headers.txt, and point $POIT_HEADERS at it.

export POIT_HEADERS=poit-headers.txt

BrowserHeaders accepts either format and figures out which one it got:

use poit_puller::{BrowserHeaders, PoitClient, Result};

fn client() -> Result<PoitClient> {
    // From $POIT_HEADERS:
    let client = PoitClient::from_env()?;
    // Or from a file, a string, or a parsed set:
    let client = PoitClient::new(BrowserHeaders::from_path("poit-headers.txt")?)?;
    let client = PoitClient::parse("curl 'https://poit.bolagsverket.se/…' -H …")?;
    Ok(client)
}

The copied cookies are session credentials. Keep the file out of version control (/poit-headers.txt is in .gitignore) and out of logs; BrowserHeaders and PoitClient print cookie names without their values for that reason.

What is replayed, and what is not

Headers Treatment
Cookie Seeded into the client's own cookie jar, scoped to poit.bolagsverket.se, and updated from POIT's Set-Cookie responses from there on
User-Agent, Accept-Language, sec-ch-ua*, anything else Replayed as copied
Accept, Sec-Fetch-Dest, Sec-Fetch-Mode, Sec-Fetch-Site, Referer Set by the client per request, matching POIT's web application, so headers copied from a page navigation work as well as ones copied from an API call
Accept-Encoding Narrowed to gzip, deflate, br and identity; zstd is dropped because decoding it would mean linking a C library
Host, Content-Length, Connection, TE, If-None-Match, other per-connection or per-response headers Dropped
HTTP/2 pseudo-headers, the request line, panel section titles Ignored

BrowserHeaders::parse fails if the paste carries no headers at all, no cookies, no User-Agent, or a cookie no jar will store — each of which would otherwise surface much later as an unexplained security challenge. Once it returns Ok, building the client cannot fail on the headers.

When the session expires

TSPD's cookies have a lifetime, and POIT caps how many notices one client may open per day. Both arrive as a RefusedError, which refusal_of() digs out of the returned error so callers need no downcast. Refusal::needs_fresh_headers() then separates the refusals a new paste fixes from the daily limit, which no paste escapes before the next day:

match client.fetch_announcement("K621433/26").await {
    Ok(notice) => println!("{notice:#}"),
    Err(error) => match poit_puller::refusal_of(&*error) {
        Some(refusal) if refusal.needs_fresh_headers() => ask_for_a_new_paste(),
        // `Refusal::DailyLimit`, and anything added later that a paste cannot fix.
        Some(refusal) => report(refusal.to_string()),
        None => report(error.to_string()),
    },
}

A long-running process can store BrowserHeaders::to_header_block() — the paste in canonical form, which parse reads back unchanged — and rebuild the client later instead of asking again, for as long as the cookies live. PoitClient::cookie_header() gives the jar's current cookies, including any POIT rotated during the run.

Driving this from a user interface

Asking a person to paste headers is the intended way in, so the parse result is built for being shown rather than logged. Every failure is a HeaderError variant and every usable-but-doubtful paste a HeaderWarning variant, each with a message() naming the problem and a guidance() saying what to do; match on the variant to translate, or show the pair as it is.

use poit_puller::{BrowserHeaders, HeaderError, PoitClient};

fn on_submit(pasted: &str) -> Result<PoitClient, Vec<String>> {
    let headers = match BrowserHeaders::parse(pasted) {
        Ok(headers) => headers,
        // `HeaderError::NoCookies { looks_like_response: true }` and friends
        // can be matched instead, to phrase it in the interface's own words.
        Err(problem) => return Err(vec![problem.message(), problem.guidance().to_owned()]),
    };

    // Amber, not red: these say the paste is fine but POIT will likely refuse it.
    let warnings: Vec<String> = headers.warnings().iter().map(|w| w.message()).collect();

    // Safe to show next to the field — cookie values are never printed.
    println!("{headers}");               // "9 headers from Mozilla/5.0 …, cookies: TSPD_101, …"
    println!("{:?}", headers.host());    // Some("poit.bolagsverket.se")

    PoitClient::new(headers).map_err(|error| vec![error.to_string()])
}
What the interface needs What to call
Validate a paste as it is submitted BrowserHeaders::parse
Two separate fields instead of one paste BrowserHeaders::from_cookie_header(cookies, user_agent)
Explain a rejected paste HeaderError::message and ::guidance
Warn about a paste that will not get past the gate BrowserHeaders::warnings
Summarize an accepted paste Display, host, cookie_names, user_agent, has_challenge_cookie
Store the session between runs BrowserHeaders::to_header_block, re-read with parse
Share one session across tasks PoitClient is Clone; clones share the connection pool and cookie jar
Decide whether to ask for a new paste refusal_of(&*error) and Refusal::needs_fresh_headers

Cookie values are credentials: Debug and Display on BrowserHeaders and PoitClient print cookie names only, and to_header_block is the one method that writes the values out — treat its result as a secret.

Library usage

use poit_puller::{PoitClient, Result};

async fn fetch() -> Result<()> {
    let client = PoitClient::from_env()?;
    let listings = client
        .list_kallelser("2026-09-14", "2026-09-14")
        .await?;
    let notice = client.fetch_announcement(&listings[0].kungorelseid).await?;
    println!("{notice:#}");
    Ok(())
}

Complete listings and result limits

list_kallelser(from, to) accepts an inclusive range of valid YYYY-MM-DD dates. The saved POIT web client uses SokKungorelse without page/offset parameters: this endpoint returns an array, INGA_TRAFFAR (no matches), or FOR_MANGA_TRAFFAR (too many matches). The separate annual-reports endpoint's pagination parameters do not apply to Kallelser.

The library returns empty listings for no matches. On too many matches, it bisects the range into non-overlapping date intervals until each succeeds, fetching sequentially with a one-second delay between requests. Results are combined in traversal order (earlier intervals first), preserving each response's order and retaining only the first occurrence of each kungorelseid.

If a single day still exceeds the limit, or any request fails, the whole call returns an error rather than a partial listing. HTTP errors and security challenges are not retried. Broad ranges may require many requests; prefer narrow ranges and comply with POIT's terms. Separate requests are not a consistent snapshot if the underlying notices change during retrieval.

The listing endpoints are not gated by the bot defense, but the client asks for copied headers all the same: one session, one set of headers, and POIT still expects requests to look like a browser's.

Rate-limited polling

Fetch an iterator of IDs sequentially with caller-defined random spacing:

use std::time::Duration;

let notices = client
    .poll_announcements(
        ids,
        Duration::from_secs(10)..=Duration::from_secs(30),
    )
    .await?;

The first request runs immediately; subsequent requests wait for a random duration in the inclusive range. Polling stops on the first error so a security challenge or throttling response is not repeatedly retried. Random spacing cannot guarantee that POIT will allow the traffic—callers remain responsible for choosing a conservative rate and complying with the service's terms.

PDF support

Enable the optional pdf feature to convert kungorelseText to PDF:

let pdf: Vec<u8> = client.fetch_announcement_pdf("K621433/26").await?;
std::fs::write("K621433-26.pdf", pdf)?;

The targeted renderer supports the HTML elements emitted by POIT: paragraphs, bold text, line breaks, and ordered or unordered lists. It uses built-in PDF fonts and an all-Rust dependency stack—no browser, wkhtmltopdf, system font, or native library is required.

Examples

Every example reads its headers from $POIT_HEADERS and fails with instructions if it is unset.

List Kallelser for one day or a date range:

export POIT_HEADERS=poit-headers.txt
cargo run --example list -- 2026-09-14
cargo run --example list -- 2026-09-14 2026-09-16

Fetch and decode an individual publication:

cargo run --example fetch -- 'K621433/26'

The examples write pretty-printed JSON to standard output. Poll all IDs listed for a date, using required minimum and maximum delays in seconds:

cargo run --example poll -- 2026-09-14 10 30

Generate a PDF with:

cargo run --features pdf --example pdf -- 'K621433/26' output.pdf

List one day and fetch, render, and save every notice PDF sequentially:

cargo run --features pdf --example poll-pdf -- 2026-09-14

The example writes PDFs to output-pdfs/<date>/, using <kungorelseid-with-slashes-replaced>.pdf filenames. It waits for a random inclusive 10–30-second interval between notices and stops on the first error. PDFs completed before an error remain in the output directory.

How individual publications are decoded

The client follows the current POIT web application:

  1. Request a coupling token from HamtaKoppling using the same HTTP session.
  2. Request HamtaKungorelse with the publication ID, coupling token, and version=5.
  3. Reverse POIT's letter-case and digit transformation.
  4. Base64-decode the response.
  5. Derive a 32-byte state from SHA-256(kungorelseid + koppling).
  6. Apply POIT's mutable XOR routine and parse the resulting JSON.

Coupling token

HamtaKoppling returns a short-lived token that links a detail request to the current HTTP session. POIT also uses it when encoding the response: the decoder key state is derived from SHA-256(kungorelseid + koppling). The token is therefore fetched by the library for every notice — it is never copied from the browser and never hard-coded.

Why the login is left to a human

POIT runs behind F5 BIG-IP ASM's TSPD bot defense. The gate is per path: SokKungorelse and HamtaKoppling answer any client, while HamtaKungorelse answers 403 and /poit-app/… serves a challenge interstitial instead of the application, until a TSPD_101 cookie proves the challenge was solved. That cookie is computed by the challenge's own JavaScript and never handed out in a response header, so no combination of user agent, cookie jar, header set or timing produces it. Running the challenge is a browser's job, and a browser has a human in front of it.

A solved challenge is not even sufficient on its own: driving a real headless Chrome through the challenge from a datacenter IP got 200 from every open endpoint and 403 from the gated one in the browser itself, and the current web application screens for Selenium, Cypress, headless user agents and newer Chrome builds before it will fetch a notice at all. AGENTS.md records those measurements and what else was ruled out.

Development

cargo fmt --check
cargo test
cargo clippy --all-targets --all-features

The test suite is offline: header parsing, listing behavior and the request shape are checked against local servers and captured fixtures, so no POIT traffic and no copied headers are needed to run it.

Dependencies

Dependencies

ID Version
base64 ^0.23.1
html-escape ^0.2.15
printpdf ^0.12.8
rand ^0.10.2
reqwest ^0.13.5
serde ^1.0.229
serde_json ^1.0.151
sha2 ^0.11.0
time ^0.3
tokio ^1.53.1
Details
Cargo
2026-10-07 03:32:18 +00:00
3
MIT
64 KiB
Assets (1)
Versions (3) View all
0.3.0 2026-10-07
0.2.0 2026-09-14
0.1.0 2026-09-14