<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Python &#8211; Pascua Theus</title>
	<atom:link href="https://theus.name/category/python/feed/" rel="self" type="application/rss+xml" />
	<link>https://theus.name</link>
	<description>Informatik, IT-Sicherheit, Web</description>
	<lastBuildDate>Wed, 08 Jul 2026 13:19:50 +0000</lastBuildDate>
	<language>de</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.4</generator>

<image>
	<url>https://theus.name/wordpress/wp-content/uploads/2017/02/cropped-t-icon-32x32.png</url>
	<title>Python &#8211; Pascua Theus</title>
	<link>https://theus.name</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Fährräder zählen – seit 7 Jahren</title>
		<link>https://theus.name/2026/07/08/faehrraeder-zaehlen-seit-7-jahren/</link>
					<comments>https://theus.name/2026/07/08/faehrraeder-zaehlen-seit-7-jahren/#respond</comments>
		
		<dc:creator><![CDATA[pascua]]></dc:creator>
		<pubDate>Wed, 08 Jul 2026 13:42:00 +0000</pubDate>
				<category><![CDATA[Programmierung]]></category>
		<category><![CDATA[Python]]></category>
		<guid isPermaLink="false">https://theus.name/?p=476</guid>

					<description><![CDATA[Seit sieben Jahren zähle ich Fahrräder. Das erledige ich natürlich nicht von Hand, sondern das machen 15 Zählstellen im Bonner Stadtgebiet verteilt. Und die Werte der Zählstellen ruft ein Python-Skript für mich ab. Bisher über eine REST-API, die ich reverse-engineered habe. Seit zwei Tagen kommen aber keine Antworten mehr, eine neue Lösung muss her. Die &#8230; <a href="https://theus.name/2026/07/08/faehrraeder-zaehlen-seit-7-jahren/" class="more-link"><span class="screen-reader-text">„Fährräder zählen – seit 7 Jahren“ </span>weiterlesen</a>]]></description>
										<content:encoded><![CDATA[
<p>Seit sieben Jahren <a href="https://theus.name/2019/06/01/fahrraeder-zaehlen/" data-type="post" data-id="321">zähle ich Fahrräder</a>.  Das erledige ich natürlich nicht von Hand, sondern das machen 15 Zählstellen im Bonner Stadtgebiet verteilt. Und die Werte der Zählstellen ruft ein Python-Skript für mich ab. Bisher über eine REST-API, die ich reverse-engineered habe. Seit zwei Tagen kommen aber keine Antworten mehr, eine neue Lösung muss her.</p>



<span id="more-476"></span>



<p>Die Daten aller Fahrradzählstellen kann man über die <a href="https://opendata.bonn.de/dataset/cfc2a3cf-e585-472a-b70d-679aa3070f41/resource/053f5572-7f88-4156-b456-21c573750d32" target="_blank" rel="noreferrer noopener">Open-Data-Plattform der Stadt Bonn</a> als CSV abrufen. Allerdings sind das wirklich nur die Daten des Vortags und ich benötige auch die Daten der Vorwoche – und manchmal mache ich auch Analysen über größere Zeiträume hinweg. Daten doppelt zu halten (und das als CSV) ist mir zu umständlich. Daher gehe ich den direkten Weg über die API des Herstellers, doch die alte REST-API ist seit zwei Tagen offline und <a href="https://bonn.social/@bonnbike/116876793108102564" target="_blank" rel="noreferrer noopener">mein Mastodon-Account liefert keine Daten</a> mehr.</p>



<p>Um herauszufinden, wie ich jetzt noch an die Daten herankomme, öffne ich die <a href="https://stadtbonn.eco-counter.com/">Zählstellen-Website des Herstellers für die Stadt Bonn</a> in meinem Browser. Wenn man sich den Datenverkehr der Website in seinem Browser genauer anschaut, fällt einem schnell auf, dass es sich um eine <a href="https://nextjs.org/" target="_blank" rel="noreferrer noopener nofollow">Next.js</a>-Applikation handeln muss. Man kann dies an <code>TURBOPACK</code> in den Daten der übertragenen JavaScript-Dateien erkennen oder aber auch an dem Präfix der CSS-Variablen <code>next-</code>.</p>



<p>Diese Website stellt jedoch keine JSON-API mehr bereit; stattdessen sind die Tageszahlen in den React Server Components (RSC) der Seite eingebettet. Ich zeige euch hier ein Beispiel, wie man diesen Stream abfragt und daraus die aggregierten Tageswerte, die ich für meine Toots benötige, extrahiert. Ich habe den Code extra etwas vereinfacht. Bitte achtet auf die Validierung der Input-Daten.</p>



<p>Die Abfrage der Daten geschieht in drei Schritten:</p>



<ol class="wp-block-list">
<li>den RSC-Stream abrufen</li>



<li>den gewünschten JSON-Wert aus dem Stream extrahieren</li>



<li>das extrahierte JSON in eine Liste der täglichen Zahlen umwandeln</li>
</ol>



<p>Das komplette Skript findet ihr ganz unten. Ich gehe hier nur die wichtigsten Teile durch.</p>



<h2 class="wp-block-heading">RSC-Stream abrufen</h2>



<p>Ich selbst habe noch nie eine Website mit Next.js gebaut. Das ist also auch neu für mich. Schreibt mir gern in die Kommentare, wenn etwas nicht ganz korrekt erklärt ist. Fakt ist, die Next.js-Anwendung verwendet RSCs. Eine URL wie <code>/site/100021143?…</code> kann je nach Art der Anfrage zwei völlig unterschiedliche Ergebnisse liefern:</p>



<ul class="wp-block-list">
<li>Initiale Browseranfrage: Der Server gibt eine vollständige HTML-Seite zurück. React läuft im Browser und die eigentlichen Zähldaten werden nach dem Laden via JavaScript abgerufen und im DOM gerendert.</li>



<li>Anfrage mit gesetztem HTTP-Header <code>RSC: 1</code>: Next.js gibt stattdessen den RSC-Payload zurück: einen Stream, der den DOM und die Daten beschreibt. Das ist, was der Browser selbst anfordert, wenn du innerhalb der App navigierst – React ruft die neue Seite als RSC-Daten ab, anstatt das vollständige HTML erneut zu laden.</li>
</ul>



<pre class="wp-block-code"><code>def fetch_rsc_payload(counter_id: int, year: int, month: int) -> str:
    """Return the raw RSC stream text for one site and month."""
    url = f"{SITE_BASE}/site/{counter_id}"
    params = {"year": year, "month": month, "granularity": "P1D"}
    headers = {
        "RSC": "1",
        "Accept": "text/x-component",
        "User-Agent": "Mozilla/5.0",
        "Referer": f"{SITE_BASE}/site/{counter_id}",
    }
    resp = requests.get(url, params=params, headers=headers, timeout=30)
    resp.raise_for_status()
    return resp.text</code></pre>



<p>Was wir von dieser Funktion erhalten, ist etwa folgender Payload (hier deutlich gekürzt):</p>



<pre class="wp-block-code"><code>1:"$Sreact.fragment"
3:I&#91;97367,&#91;"/_next/static/chunks/0s2yzmf9j.2c6.js", ...]]
4:"$Sreact.suspense"
...</code></pre>



<p>Wie du schnell erkennst, ist das nicht einfach JSON. Es ist Next.js&#8216; zeilenbasiertes RSC Wire Format (hier ein <a href="https://sachinsharma.dev/blogs/rsc-wire-protocol-custom-parser-2026" target="_blank" rel="noreferrer noopener">Blogartikel von Sachin</a> zum Deep Dive). Jede Zeile ist durchnummeriert. Aber – und das ist das Wichtige – die eigentlichen Daten, die uns interessieren, sind als gewöhnliches JSON-Array gespeichert und befinden sich direkt in einer dieser Zeilen:</p>



<pre class="wp-block-code"><code>22:&#91;"$","$L25",null,{"granularity":"P1D","site":"$2:2:props:...","chartData":&#91;{"travelMode":"bike","data":&#91;{"timestamp":"2026-07-01T00:00:00+02:00","traffic":{"counts":19014}}, ... ]}], "directionGraphData":&#91;...], ...}]</code></pre>



<h2 class="wp-block-heading">JSON aus dem Stream extrahieren</h2>



<p>In den erhaltenen Daten suchen wir jetzt nach <code>chartData</code> oder <code>graphData</code> (ich hab je nach Monat der Daten diese in dem einen oder in dem anderen Key gefunden, eventuell hat der Betreiber einmal sein Schema geändert?). Wir suchen also nach diesen beiden Schlüsseln und merken uns die Zeile.</p>



<pre class="wp-block-code"><code>SITE_BASE = "https://stadtbonn.eco-counter.com"

def _extract_keyed_value(body: str, key: str) -> object:
    """Find `"key":` in the stream and json.loads the line."""
    needle = f'"{key}":'
    for line in body.split("\n"):
        if needle in line:
            try:
                return json.loads(_extract_json(line))&#91;3]&#91;key]
            except (IndexError, KeyError):
                continue

    raise RSCParseError(f'Key "{key}" not found')</code></pre>



<p>Mit dieser Zeile rufen wir dann eine weitere Funktion auf, welche das JSON aus der Zeile herauslöst (es ist ja durch die Zeilennummer gepräfixt, wir trennen daher am ersten Doppelpunkt).</p>



<pre class="wp-block-code"><code>def _extract_json(line: str) -> str:
    """Return the json from a line of the RSCP."""
    try:
        json_data = line.split(":", 1)&#91;1]
        return json_data
    except IndexError:
        raise RSCParseError("Unexpected format of RSC payload.")</code></pre>



<p>Aus dem erhaltenen JSON lassen wir uns jetzt in <code>_extract_keyed_value</code> das vierte Element der Liste ausgeben und aus diesem Objekt dann den Value für unseren gesuchten Key. Warum? Naja, das habe ich halt einfach so aus den erhaltenen Daten abgelesen. Und ob es morgen noch funktioniert, sehen wir dann morgen.</p>



<p class="has-yellow-background-color has-background">Das ist ein sehr fragiler Teil des Codes und überhaupt ist das Parsen von Daten Dritter eine sehr gefährliche Angelegenheit. Dieser Beispielcode ist auch nicht mein Produktivcode! Mit Libraries wie <a href="https://github.com/pydantic/pydantic/">Pydantic</a> könnt ihr euren Code auf wesentlich sichere Beine stellen. Hier aber möchte ich euch den grundsätzlichen Ansatz zeigen.</p>



<h2 class="wp-block-heading">Liste der täglichen Zahlen</h2>



<p>Jetzt bauen wir uns eine <a href="https://docs.python.org/3/library/dataclasses.html" target="_blank" rel="noreferrer noopener">Dataclass</a>, welche die täglichen Daten halten soll.</p>



<pre class="wp-block-code"><code>@dataclass(frozen=True)
class DayCount:
    """One day's total count. `count` is None when no data exists for that day."""

    date: dt.date
    count: int | None</code></pre>



<p>Jetzt bauen wir uns eine Funktion, um aus der Liste von Tageswerten eine Liste von <code>DayCount</code>-Objekten zu erhalten.</p>



<pre class="wp-block-code"><code>DATA_KEYS = ("chartData", "graphData")

def parse_daily_counts(body: str) -> list&#91;DayCount]:
    """Extract the aggregate daily counts from an RSC payload."""
    series = None
    for key in DATA_KEYS:
        try:
            series = _extract_keyed_value(body, key)
            break
        except RSCParseError:
            continue
    if series is None:
        raise RSCParseError(
            f"none of {DATA_KEYS} found; the page structure may have changed "
            "or the RSC header is no longer honored"
        )

    # series looks like: &#91;{"travelMode": "bike", "data": &#91; {timestamp, traffic}, ... ]}]
    rows = series&#91;0].get("data")

    counts = &#91;
        DayCount(
            date=dt.date.fromisoformat(row&#91;"timestamp"]&#91;:10]),
            count=row&#91;"traffic"].get("counts"),
        )
        for row in rows
    ]
    return sorted(counts, key=lambda d: d.date)</code></pre>



<p class="has-yellow-background-color has-background">Auch dieser Beispielcode ist nicht reif für Produktiv!</p>



<p>In dem kompletten Beispielcode, der jetzt folgt, könnt ihr dann auch sehen – und gerne auch ausprobieren – wie alles zusammengestöpselt wird. Dazu erstellt ihr euch am besten ein <a href="https://docs.python.org/3/library/venv.html" target="_blank" rel="noreferrer noopener">virtuelles Environment</a> und installiert euch requests mittels <code>pip install requests</code>.</p>



<pre class="wp-block-code"><code>import datetime as dt
import json
from dataclasses import dataclass

import requests

SITE_BASE = "https://stadtbonn.eco-counter.com"
DATA_KEYS = ("chartData", "graphData")


@dataclass(frozen=True)
class DayCount:
    """One day's total count. `count` is None when no data exists for that day."""

    date: dt.date
    count: int | None


class RSCParseError(ValueError):
    """Raised when the expected count data can't be located or parsed."""


# --------------------------------------------------------------------------- #
# Step 1: fetch the RSC stream
# --------------------------------------------------------------------------- #
def fetch_rsc_payload(counter_id: int, year: int, month: int) -> str:
    """Return the raw RSC stream text for one site and month."""
    url = f"{SITE_BASE}/site/{counter_id}"
    params = {"year": year, "month": month, "granularity": "P1D"}
    headers = {
        "RSC": "1",  # &lt;- the key that makes Next.js return the data stream
        "Accept": "text/x-component",
        "User-Agent": "Mozilla/5.0",
        "Referer": f"{SITE_BASE}/site/{counter_id}",
    }
    resp = requests.get(url, params=params, headers=headers, timeout=30)
    resp.raise_for_status()
    return resp.text


# --------------------------------------------------------------------------- #
# Step 2: extract exactly the JSON value we want from the stream
# --------------------------------------------------------------------------- #
def _extract_json(line: str) -> str:
    """Return the json from a line of the RSCP."""
    try:
        json_data = line.split(":", 1)&#91;1]
        return json_data
    except IndexError:
        raise RSCParseError("Unexpected format of RSC payload.")


def _extract_keyed_value(body: str, key: str) -> object:
    """Find `"key":` in the stream and json.loads the line."""
    needle = f'"{key}":'
    for line in body.split("\n"):
        if needle in line:
            try:
                return json.loads(_extract_json(line))&#91;3]&#91;key]
            except (IndexError, KeyError):
                continue

    raise RSCParseError(f'Key "{key}" not found')


# --------------------------------------------------------------------------- #
# Step 3: turn the extracted JSON into a clean list of daily counts
# --------------------------------------------------------------------------- #
def parse_daily_counts(body: str) -> list&#91;DayCount]:
    """Extract the aggregate daily counts from an RSC payload."""
    series = None
    for key in DATA_KEYS:
        try:
            series = _extract_keyed_value(body, key)
            break
        except RSCParseError:
            continue
    if series is None:
        raise RSCParseError(
            f"none of {DATA_KEYS} found; the page structure may have changed "
            "or the RSC header is no longer honored"
        )

    # series looks like: &#91;{"travelMode": "bike", "data": &#91; {timestamp, traffic}, ... ]}]
    rows = series&#91;0].get("data")

    counts = &#91;
        DayCount(
            date=dt.date.fromisoformat(row&#91;"timestamp"]&#91;:10]),
            count=row&#91;"traffic"].get("counts"),
        )
        for row in rows
    ]
    return sorted(counts, key=lambda d: d.date)


# --------------------------------------------------------------------------- #
# Convenience: put the three steps together
# --------------------------------------------------------------------------- #
def get_daily_counts(counter_id: int, year: int, month: int) -> list&#91;DayCount]:
    """Fetch and parse the daily counts for one site and month."""
    return parse_daily_counts(fetch_rsc_payload(counter_id, year, month))


if __name__ == "__main__":
    # Example: the Kennedybrücke counter in Bonn
    # It's an honour to be a Bonner! :)
    KENNEDYBRUECKE = 100021143
    today = dt.date.today()

    counts = get_daily_counts(KENNEDYBRUECKE, today.year, today.month)

    print(f"Daily counts for site {KENNEDYBRUECKE}, {today:%B %Y}:")
    for day in counts:
        value = "—" if day.count is None else f"{day.count:,}"
        print(f"  {day.date:%Y-%m-%d}: {value}")

    # Pick out a single day (e.g. yesterday).
    yesterday = today - dt.timedelta(days=1)
    match = next((d for d in counts if d.date == yesterday), None)
    if match and match.count is not None:
        print(f"\nYesterday ({yesterday}): {match.count:,}")
    else:
        print(f"\nNo data for yesterday ({yesterday}) yet.")</code></pre>
]]></content:encoded>
					
					<wfw:commentRss>https://theus.name/2026/07/08/faehrraeder-zaehlen-seit-7-jahren/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
