poit-puller (0.3.0)
Installation
[registries.forgejo]
index = "sparse+ " # Sparse index
# index = " " # Git
[net]
git-fetch-with-cli = truecargo add poit-puller@0.3.0 --registry forgejoAbout this package
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=1for 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
-
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.
-
Open the developer tools (F12) and select the Network panel.
-
Find a request to POIT —
HamtaKungorelseis the one the gate protects. -
Right-click it and choose Copy → Copy as cURL. "Copy Request Headers" works just as well.
-
Save what you copied to a file, for example
poit-headers.txt, and point$POIT_HEADERSat 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:
- Request a coupling token from
HamtaKopplingusing the same HTTP session. - Request
HamtaKungorelsewith the publication ID, coupling token, andversion=5. - Reverse POIT's letter-case and digit transformation.
- Base64-decode the response.
- Derive a 32-byte state from
SHA-256(kungorelseid + koppling). - 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
tokiofor the asynchronous runtimetimefor calendar-safe listing date rangesreqwestfor HTTP requests, the cookie jar, andgzip/deflate/brdecodingserdeandserde_jsonfor POIT responsesbase64andsha2for publication decoding- Optional
printpdfandhtml-escapefor pure-Rust PDF output
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 |