Salaheldinaz

Search posts

Table of contents
AI-generated illustration: A reel-to-reel tape recorder on a small table by a window at night.

The Analyst's Stack Part 12 of 22

Recording a source that has no API

10 min read osint automation python data project
The Analyst's Stack · Showcase

A recorder for a source that offers no way to take anything with you: capture what it renders, normalise it, replay it on your own map.

Why I built it

Some sources hand you a download link. Most do not, and a few are built so that they never will. The data is on your screen, nowhere else you can reach, and the polite request for an export has no recipient.

Mine was a live tracking site with no export. You pick an area, a start time and a duration, and it plays the movement back at you. You can watch it. You cannot keep it. Come back in a month and the window you wanted has aged out of what it will serve.

By hand that means screenshotting at intervals and transcribing positions off pictures to a precision of roughly “somewhere near there, at about that minute”. For one afternoon, fine. For several weeks of a busy shipping lane you will get it wrong.

Then the obvious thought, which took me embarrassingly long. Whatever that map draws, it first had to fetch. The data has already reached my machine or there would be nothing on the screen. It is not unavailable. It is unexported.

What it solves

I define the window once — an area, a start time, a duration — and the recorder walks it, writing everything it gets into a SQLite file on my disk. From there the source is not involved: the same movement replays offline, at any speed, next year, with filters and exports the original never offered.

The window can be far longer than the source will serve at once. Anything over the source’s per-request limit is split into consecutive segments, up to 20 of them, so one pasted window can cover months.

The recorder’s replay map part-way through a playback, in its dark interface. At the top, the playback panel: a Play button, the clock reading 2026-06-27 12:00:00 UTC, a speed slider set to +1.00×, and the time scrubber halfway along a 7-day window. Below, a street map of the Strait of Hormuz and the water west of it, with the Iranian coast, Qeshm island and the Musandam peninsula, and the shipping lanes drawn as pale red bands. Markers for 275 vessels and their coloured track lines crowd the lanes, the anchorages off the ports and the strait itself. A counts panel in the corner reads Tug & Special Craft 100, Cargo 99, Tanker 50, Fishing 8, Passenger 8, Unspecified 8 and Navigation Aid 2, with High-Speed Craft and Pleasure Craft at 0.

The dataset the source never published. Replayed from my own disk, months after the source stopped serving that window: the Strait of Hormuz and its western approach, in the first week of a recording that ran from 24 June to 30 July 2026. The counts panel shows only the vessels present at the scrubber’s time, here 275. Real data from my own recording, shown one weekly segment at a time, because the 36-day whole is more than the browser map will load at once.

How it works

Six stages left to right: Collect replays the window, Normalize makes a vessel row and a fix row, Store is SQLite keyed by ship, Analyze classifies on land or on water, Alert is drawn as not built, Present is a chart rather than a table. Collect is highlighted. Six stages left to right: Collect replays the window, Normalize makes a vessel row and a fix row, Store is SQLite keyed by ship, Analyze classifies on land or on water, Alert is drawn as not built, Present is a chart rather than a table. Collect is highlighted.
Five of the six stages. This is a recorder, not a watcher.

Collect. The window arrives as the map’s own playback request, copied out of the browser: the corners of the view, a start time, a duration, a chunk size. The recorder rebuilds it once per chunk and fetches 5 at a time, spacing the launches so the pace stays close to a person clicking. Every raw response goes to disk before anything parses it. That order matters — a parser bug is fixable tomorrow, a chunk you did not keep is gone.

Normalize. Each response is a bundle of objects carrying lists of fixes. It becomes 2 flat shapes: a vessel row, and one position row per fix with longitude, latitude, speed, course, heading and a timestamp.

Store. SQLite. Vessels are keyed on the identifier the source already assigns; a position is unique on the triple of job, ship and timestamp. When a vessel’s attributes change the old row is archived before the live row is overwritten, so a renamed object cannot quietly rewrite its own past.

Analyze. Positions are classified as on land or on water when asked — a first hint of a jammed signal. Tracks are also tested against polygons you draw, which turns “who crossed this line, and when” into a query.

Present. A local map: time scrubber, variable speed, reverse, live per-type counts, polygon filters, and CSV, JSON and GeoJSON export over whatever is on screen.

Pressing play on a week. The first of the 6 segments, 24 June to 1 July 2026, at the recorder’s normal speed, which plays any window in 24 seconds. Real data from my own recording, stitched from screenshots taken 2 a second; a background browser tab does not animate, so the replay was stepped one frame at a time.

The job page for one collection split into 6 sub-jobs. Its status reads success, with a full progress bar and the period 2026-06-24 00:00 to 2026-07-30 00:00, 36 days. Combined totals for the whole group: 2504 vessels and 1610317 positions, then a statistics line and a count per vessel type, led by Cargo 971, Tug & Special Craft 505 and Tanker 465. Below, the sub-job list: 12-1 to 12-5 start on 24 June and on 1, 8, 15 and 22 July, each +168h with 1008 of 1008 chunks; 12-6 starts on 29 July, +24h, with 144 of 144. All 6 read success.

One window, 6 requests. Why a 36-day question is not asked 6 times by hand: the Strait of Hormuz and its western approach from 24 June to 30 July 2026, cut into 5 weeks and a last day. The segments ran one at a time and all 6 finished, with 2,504 vessels and 1,610,317 positions between them. The page shows the combined totals and the per-segment list on 2 tabs; both are open together here, with the small map and the export links hidden. Real data from my own recording.

How I built it

Python 3.12, Flask, SQLite from the standard library, shapely for the geometry, Leaflet in the browser with no build step. It ships as a container that publishes only to 127.0.0.1 — a tool holding a copy of my own logged-in session has no business listening on a network.

The interface is deliberately not a generic dashboard: dark and low-glare around a plain street-map base, laid out like a bridge instrument, because it gets stared at in the dark and a chart should read like a chart.

Two decisions mattered; both were about storage.

Raw first, database second. Every chunk lands on disk as it arrives. That file is the checkpoint a job resumes from when it dies two-thirds of the way through, and the bytes a parser change is re-run against.

The source’s own identifier is the key, and I never mint my own. This is the unglamorous stage 3 of the spine, and it decides whether anything downstream works. Two records with the same id are the same object; the same id in a window from March and one from September is one vessel with a longer history. Key on a name instead and you get blanks, duplicates, and 2 objects merged because they share a name.

Invented records. Three records: id 7301 in March named Vessel A, id 7301 in September named Vessel B after a rename, and id 5520 in September named Vessel A, a different vessel. Keyed on the id, as the recorder does, id 7301 becomes one vessel with one history across March and September despite the rename, and id 5520 stays a separate vessel. Keyed on the name, Vessel A merges 2 different vessels into one, and Vessel B holds only the September half of the renamed vessel's history. The inputs are identical in both rows; only the key changed. Invented records. Three records: id 7301 in March named Vessel A, id 7301 in September named Vessel B after a rename, and id 5520 in September named Vessel A, a different vessel. Keyed on the id, as the recorder does, id 7301 becomes one vessel with one history across March and September despite the rename, and id 5520 stays a separate vessel. Keyed on the name, Vessel A merges 2 different vessels into one, and Vessel B holds only the September half of the renamed vessel's history. The inputs are identical in both rows; only the key changed.
Same records, different key, different vessels. Key on the id and a rename changes nothing; key on the name and a rename splits one history while a shared name merges 2 vessels.

The hard part was neither. The requests have to look like the browser that would have made them, down to the shape of the TLS handshake (the layer that puts the ’s’ in https) — the fine detail OPSEC for automation describes, which gives away the library that opened the connection. Get the shape wrong and the request is refused before it reaches the application, and that is the most fragile thing here.

One decision came before any code. Recording a source’s own playback sits close to the line its terms of use draw, and sometimes over it. I read them first, kept the request rate below what my own clicking would generate, and stored only what I had already been shown. You may reach a different answer. What should not differ is that the question gets asked before the editor is open.

A look at the code

The splitter, rewritten to stand alone:

from datetime import datetime, timedelta

MAX_HOURS = 168          # an example cap; set it to what your source serves per request

def split_window(start, duration_hours, max_hours=MAX_HOURS):
    """Cut a long window into consecutive segments the source will actually serve.
       `start` is 'YYYY-MM-DD HH:MM'. Segments are contiguous: no gap, no overlap."""
    if duration_hours <= max_hours:
        return [{"start": start, "duration_hours": duration_hours}]
    base = datetime.strptime(start, "%Y-%m-%d %H:%M")
    segments, remaining, offset = [], float(duration_hours), 0.0
    while remaining > 1e-9:
        step = min(max_hours, remaining)
        segments.append({
            "start": (base + timedelta(hours=offset)).strftime("%Y-%m-%d %H:%M"),
            "duration_hours": step,
        })
        remaining -= step
        offset += step
    return segments

# 40 days becomes 6 requests, in order, with nothing missing between them:
[s["start"] for s in split_window("2026-03-01 00:00", 24 * 40)]
# ['2026-03-01 00:00', '2026-03-08 00:00', '2026-03-15 00:00',
#  '2026-03-22 00:00', '2026-03-29 00:00', '2026-04-05 00:00']
A single requested window of 40 days starting on 1 March is split at an example cap of 7 days, or 168 hours, into 6 consecutive segments on a day axis from 0 to 40: 5 segments of 7 days starting on 1, 8, 15, 22 and 29 March, and a last segment of 5 days starting on 5 April. The segments touch with no gap and no overlap. One window is cut into at most 20 segments. A single requested window of 40 days starting on 1 March is split at an example cap of 7 days, or 168 hours, into 6 consecutive segments on a day axis from 0 to 40: 5 segments of 7 days starting on 1, 8, 15, 22 and 29 March, and a last segment of 5 days starting on 5 April. The segments touch with no gap and no overlap. One window is cut into at most 20 segments.
The same call, drawn. Six requests the source will serve, laid end to end so nothing falls between them.

And the fold that turns overlapping chunks into one record per object:

def merge_chunk(acc, chunk):
    """Fold one fetched chunk into the accumulator. `chunk` maps the source's own
       object id to that object's attributes plus a list of timestamped fixes."""
    for object_id, payload in chunk.items():
        entry = acc.setdefault(object_id, {"attrs": None, "fixes": {}})
        entry["attrs"] = payload["attrs"]
        for fix in payload["fixes"]:
            entry["fixes"][int(fix["timestamp"])] = fix   # same timestamp, one fix

def finalize(acc):
    """One record per object, fixes in time order, duplicates already collapsed."""
    return {oid: {**e["attrs"], "fixes": [e["fixes"][t] for t in sorted(e["fixes"])]}
            for oid, e in acc.items()}

Chunks overlap at their edges, and the same window fetched twice returns the same fixes. Keying the inner dictionary on the timestamp makes neither a problem. The database repeats the rule, so re-running a job is a no-op, not a second copy.

What it can’t do

The on-land flag is a heuristic, not a finding

A position that falls on land is a useful hint that a signal was jammed or faked. It is checked against a bundled coastline that is static and simplified at build time, so a vessel legitimately alongside a quay, or inside a narrow harbour, falls on the land side of a smoothed line and reads as on land. Higher-resolution coastline can be pulled in for one area, which narrows the error without closing it. The flag is a sort order, never the finding itself.

It depends on a session it does not own

Collection runs on my own logged-in session, captured from my browser, and pauses when that session expires until I hand it a fresh one. The source can change its requests, or the way it checks them, in any given week. The one mercy is that the failure is loud: a job goes to paused or failed rather than returning a quietly empty window.

Five stages, not six

No alerting of any kind: it will not tell you something entered an area overnight. The optional per-vessel enrichment has no retry ladder either, so a partly enriched set looks exactly like a complete one unless you read the coverage count.

The replay map with the On-land only filter ticked. A note under the filters reads “Showing vessels reported on land (5144/354392 positions) — often GPS jamming or false AIS.” The clock reads 2026-06-25 03:50:00 UTC. A filtered-vessels panel reads 174 vessels in full playback, led by Cargo 67 and Tug & Special Craft 61. The map shows the Iranian coast around Bandar Abbas and the land behind it. One track runs in a straight line from Hormuz Island north-west over the coast and inland, ending at a vessel marker well inland; a second marker sits on the shore further west. The vessel table below has the columns Flag, Name, Type, Positions, Speed, Course and Last seen (UTC), with 3 rows: GADERI .BOYA FISHIG, unspecified, 87 positions, 0 knots; HOWEYZEH, tug and special craft, 2872 positions, 0.6 knots; YES, cargo, 68 positions, 20 knots on a course of 320°.

A hint, filtered. Every row still needs a person to decide whether it means interference, a bad fix, or a coastline drawn too coarsely. In the first week of the recording the recorder’s own classifier put 5,144 of 354,392 fixes on land, across 174 vessels, many of them stationary at a few points along the shore. The cargo ship YES is different: on 25 June its reported track leaves Hormuz Island and runs inland past Bandar Abbas for over 3 hours, in a straight line at a steady 20 knots. Real data from my own recording.

Where this sits in the series

This is stage 1 of the 6-stage spine against a source that does not want a stage 1 to exist. The first workflow post builds the same identity rule from nothing by hashing a URL; here the source assigns the identifier and the job is to keep using it consistently.

Most readers will never record a vessel track. Nearly every reader has a source like this one — a portal, a viewer, a dashboard that renders exactly what you need and offers no way to take it with you. The answer is the same every time: record what it renders, give each object a key that will still be true next year, keep your own copy.

The stable object key starts a longer argument. Storing and searching what you collect covers the rest: why the recorded observation and your interpretation of it belong in separate layers.