<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Salaheldinaz</title><link>https://salaheldinaz.com/</link><description>OSINT · Intelligence · Cyber Security · Automation · Technologist · Investigator</description><language>en-US</language><lastBuildDate>Thu, 22 Oct 2026 08:33:33 +0000</lastBuildDate><atom:link href="https://salaheldinaz.com/tags/geospatial/" rel="self" type="application/rss+xml"/><item><title>Scraping and mapping a public alerts feed</title><link>https://salaheldinaz.com/blog/scraping-and-mapping-public-alerts/</link><guid isPermaLink="true">https://salaheldinaz.com/blog/scraping-and-mapping-public-alerts/</guid><pubDate>Thu, 22 Oct 2026 00:00:00 +0000</pubDate><description>A public feed with no export button and a rate limit. Walking it politely with rotating exits, collapsing 37 spellings of city names into one, and turning the result into map layers by city and by area plus category counts over time.</description><category>osint</category><category>automation</category><category>geospatial</category><category>python</category><category>project</category><content:encoded><![CDATA[<span class="hero-label not-prose">The Analyst&#39;s Stack · Showcase</span>

<p>A public authority publishes every alert it issues, and offers no way to ask anything of
the 12 months behind it. This walks that feed one record at a time and turns it into
2 map layers and a set of charts.</p>
<h2 id="why-i-built-it">Why I built it</h2>
<p>A newsroom verification desk had a question about a public alerts feed — the kind a
public authority publishes so anyone can see what has just been issued, as it happens.
Not about this week: over the past 12 months, how many alerts, in which cities, of
which category, and did the pattern move.</p>
<p>The feed answers none of that. It shows you the newest item and then forgets. No date
filter, no export, no bulk download, and a rate limit that ends the conversation if you
ask too quickly. What it does have is one useful property: every record ever filed sits
at its own sequential number, and you can ask for one at a time. That turns a research
question into a collection problem, which is an easier kind of problem to have.</p>
<h2 id="what-it-solves">What it solves</h2>
<p>One run turns a range of record numbers into the raw records on disk, one flat table,
2 map layers — counts by city, and the same counts dissolved into the larger areas
those cities sit inside — and charts of each category month by month.</p>
<p>The number worth quoting is the unglamorous one: <strong>37</strong>, how many
variant spellings of city names the normalize step collapses before any of the mapping
works. The feed and the boundary file disagree about hyphens, prefixes and plain typos,
and a join <em>(matching rows in one table to shapes in another by a shared key)</em> does not
fail loudly on a name that does not match. It drops the row.</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/scraping-and-mapping-public-alerts/images/c1-namejoin_light.svg" width="840" height="330" alt="Three invented city names from the feed joined to a boundary file. Lakeview matches Lakeview exactly and is counted on the map, highlighted. Harbourton against Harborton, and St. Oren against Saint Oren, do not match, so both rows are dropped with no error and no warning. A note says the names are invented and that the real fix table holds 37 entries." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/scraping-and-mapping-public-alerts/images/c1-namejoin_dark.svg" width="840" height="330" alt="Three invented city names from the feed joined to a boundary file. Lakeview matches Lakeview exactly and is counted on the map, highlighted. Harbourton against Harborton, and St. Oren against Saint Oren, do not match, so both rows are dropped with no error and no warning. A note says the names are invented and that the real fix table holds 37 entries." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>A join drops what it cannot match.</strong> One letter or one abbreviation is enough, and nothing on the finished map shows the gap.</figcaption>
</figure>

<p>Collection has its own 2 numbers: 50 records in flight at once, 3 requests per
exit before that exit rotates out.</p>
<h2 id="how-it-works">How it works</h2>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/scraping-and-mapping-public-alerts/images/c1-pipeline_light.svg" width="968" height="190" alt="Five boxes left to right: Collect walks the id range, Normalize gives one name per city, Store writes JSON then CSV, then Map and Chart, both labelled Analyze plus Present. Map is highlighted." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/scraping-and-mapping-public-alerts/images/c1-pipeline_dark.svg" width="968" height="190" alt="Five boxes left to right: Collect walks the id range, Normalize gives one name per city, Store writes JSON then CSV, then Map and Chart, both labelled Analyze plus Present. Map is highlighted." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The same spine, split at the end.</strong> Analyze has no box of its own because it is not a step here — the grouping and counting happen inside the mapping and charting code, with no model anywhere near them. Those two are drawn apart because they answer different questions from one table.</figcaption>
</figure>

<p><strong>Collect</strong> walks a range of record numbers, 50 at a time, each request leaving
through a rotating exit. A <code>404</code> means nothing was filed there.</p>
<p><strong>Normalize</strong> flattens each record into rows — one row per city named in one alert —
applying the name-fix table on the way through.</p>
<p><strong>Store</strong> writes each raw record to disk as JSON before anything touches it, then the
flat table as CSV. The raw copy makes a normalize bug cost a re-run, not a
re-collection.</p>
<p><strong>Map</strong> joins the counts to a boundary file, repairs whatever geometry needs repairing,
and writes 2 GeoJSON layers <em>(plain text holding shapes and their attributes; every
mapping tool reads it)</em> — one at city level, one dissolved to area level.</p>
<p><strong>Chart</strong> groups the same table by month and by category, and renders bar and line
charts as images.</p>
<p>The collection loop from further down, run against a synthetic feed on my own machine:
300 record numbers, 8 of them withdrawn, and one rate limit planted to see what happens.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ python3 walk_demo.py 1000 1299
</span></span><span class="line"><span class="cl">walking ids 1000-1299 on a local synthetic feed: 50 in flight, 3 requests per exit
</span></span><span class="line"><span class="cl">id 1037  404  nothing filed, walk continues
</span></span><span class="line"><span class="cl">id 1048  404  nothing filed, walk continues
</span></span><span class="line"><span class="cl">id 1077  404  nothing filed, walk continues
</span></span><span class="line"><span class="cl">id 1150  429  rate limited: wait 5 s, next exit
</span></span><span class="line"><span class="cl">id 1165  404  nothing filed, walk continues
</span></span><span class="line"><span class="cl">id 1187  404  nothing filed, walk continues
</span></span><span class="line"><span class="cl">id 1202  404  nothing filed, walk continues
</span></span><span class="line"><span class="cl">id 1274  404  nothing filed, walk continues
</span></span><span class="line"><span class="cl">id 1024  404  nothing filed, walk continues
</span></span><span class="line"><span class="cl">done in 5.2 s
</span></span><span class="line"><span class="cl">  records saved          292
</span></span><span class="line"><span class="cl">  not filed (404)          8
</span></span><span class="line"><span class="cl">  rate limited, retried    1
</span></span><span class="line"><span class="cl">  requests per exit     exit-a 75  exit-b 76  exit-c 75  exit-d 75
</span></span></code></pre></div><div class="post-caption not-prose"><strong>Fifty in flight, 3 per exit.</strong> The 4 exits here are stand-ins, all on one machine,
but the arithmetic is the real loop&rsquo;s: 301 requests spread almost evenly. The rotation
is not stealth. It is a politeness budget, made visible.</div>

<h2 id="how-i-built-it">How I built it</h2>
<p>Python, because the mapping half decides the language: <code>geopandas</code> and <code>shapely</code> for
geometry, <code>pandas</code> for the table, <code>matplotlib</code> for the charts, <code>httpx</code> and <code>asyncio</code>
for fetching.</p>
<p>The first version fetched one record at a time with a polite sleep between requests. It
worked, and for a few thousand records it would have run overnight. Async fixed that
and brought the real difficulty with it: a semaphore <em>(a counter that stops more than N
things running at once)</em> caps you at 50 in flight, and 50 in flight from one
address is exactly the traffic shape a rate limiter exists to catch. So the exits
rotate on a budget — 3 requests each, then move on.</p>
<p>The stop condition was the other nuisance. Nothing tells you where the range ends, so a
<code>404</code> is the signal — and a <code>404</code> also happens mid-range, where a record was withdrawn.
The version that treats the first one as the end is wrong in a way that looks like
success.</p>
<p>Then the geometry. Boundary files from public sources are full of polygons that are
almost valid: a ring that crosses itself, a sliver with zero area, points stored in the
wrong winding order. None of it is visible on a map. All of it makes <code>geopandas</code> refuse
to dissolve, measure or join.</p>
<p>What the 2 map layers look like, with invented shapes and counts standing in for real ones:</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/scraping-and-mapping-public-alerts/images/c1-choropleth_light.svg" width="1160" height="480" alt="Two invented maps side by side, each shaded by alert count with a legend beneath it. On the left, 18 city polygons: one city has 95 alerts and carries the darkest shade, and the rest range from 4 to 40. On the right, the same polygons dissolved into 4 areas. Area C, 6 cities of 26 to 35 alerts each, sums to 180 and is now the darkest. Area A totals 105, and a note says 95 of those are the one busy city. A line under both maps says the shapes and counts are invented." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/scraping-and-mapping-public-alerts/images/c1-choropleth_dark.svg" width="1160" height="480" alt="Two invented maps side by side, each shaded by alert count with a legend beneath it. On the left, 18 city polygons: one city has 95 alerts and carries the darkest shade, and the rest range from 4 to 40. On the right, the same polygons dissolved into 4 areas. Area C, 6 cities of 26 to 35 alerts each, sums to 180 and is now the darkest. Area A totals 105, and a note says 95 of those are the one busy city. A line under both maps says the shapes and counts are invented." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The same numbers, one level up.</strong> Dissolving is a sum, not a smoothing. The busiest city vanishes into its area&rsquo;s total, and the darkest area turns out to be 6 ordinary cities added together.</figcaption>
</figure>

<p>Two things I would change. The backoff is a set of fixed sleeps, when the server is
already saying how long to wait in its own response headers. And the 37 name
fixes live in a dictionary inside the code, invisible to anyone not reading it; they
belong in a data file beside the boundary file they exist to agree with.</p>
<p>The 2 chart shapes the last step writes, drawn here with invented numbers in them:</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/scraping-and-mapping-public-alerts/images/c1-charts_light.svg" width="1160" height="400" alt="Two charts side by side, both from an invented dataset. On the left, total alerts per category as 5 bars: Weather 412, Traffic 268, Flood 190, Power 144, Fire 96. On the right, Weather alerts month by month as a line rising from 18 in January to 52 in November and 50 in December. A note under both says every figure is invented." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/scraping-and-mapping-public-alerts/images/c1-charts_dark.svg" width="1160" height="400" alt="Two charts side by side, both from an invented dataset. On the left, total alerts per category as 5 bars: Weather 412, Traffic 268, Flood 190, Power 144, Fire 96. On the right, Weather alerts month by month as a line rising from 18 in January to 52 in November and 50 in December. A note under both says every figure is invented." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>Counts over time.</strong> The line chart is the one that gets argued about, because a change in slope is a claim and a bar is only a total.</figcaption>
</figure>

<h2 id="a-look-at-the-code">A look at the code</h2>
<p>The collection loop, standing alone. One pool of exits, one semaphore, and a per-exit
budget that rotates the pool instead of sleeping.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">asyncio</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">collections</span> <span class="kn">import</span> <span class="n">deque</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">httpx</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">FEED</span> <span class="o">=</span> <span class="s2">&#34;https://example.test/alerts-history/id&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">PER_EXIT</span> <span class="o">=</span> <span class="mi">3</span>       <span class="c1"># requests an exit serves before it is rotated out</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">fetch</span><span class="p">(</span><span class="n">record_id</span><span class="p">,</span> <span class="n">pool</span><span class="p">,</span> <span class="n">gate</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;One record as a dict, or None if nothing is filed under that id.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">async</span> <span class="k">with</span> <span class="n">gate</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">3</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">client</span><span class="p">,</span> <span class="n">used</span> <span class="o">=</span> <span class="n">pool</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">            <span class="n">pool</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span><span class="n">client</span><span class="p">,</span> <span class="n">used</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)</span> <span class="k">if</span> <span class="n">used</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&lt;</span> <span class="n">PER_EXIT</span> <span class="k">else</span> <span class="p">(</span><span class="n">client</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">used</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&gt;=</span> <span class="n">PER_EXIT</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">pool</span><span class="o">.</span><span class="n">rotate</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span>         <span class="c1"># this exit has had its turn</span>
</span></span><span class="line"><span class="cl">            <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">r</span> <span class="o">=</span> <span class="k">await</span> <span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">FEED</span><span class="si">}</span><span class="s2">/</span><span class="si">{</span><span class="n">record_id</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">except</span> <span class="n">httpx</span><span class="o">.</span><span class="n">TransportError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="n">pool</span><span class="o">.</span><span class="n">rotate</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">status_code</span> <span class="ow">in</span> <span class="p">(</span><span class="mi">200</span><span class="p">,</span> <span class="mi">404</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">                <span class="k">return</span> <span class="n">r</span><span class="o">.</span><span class="n">json</span><span class="p">()</span> <span class="k">if</span> <span class="n">r</span><span class="o">.</span><span class="n">status_code</span> <span class="o">==</span> <span class="mi">200</span> <span class="k">else</span> <span class="kc">None</span>  <span class="c1"># 404: nothing filed</span>
</span></span><span class="line"><span class="cl">            <span class="k">await</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>      <span class="c1"># rate limited, or a server error</span>
</span></span><span class="line"><span class="cl">            <span class="n">pool</span><span class="o">.</span><span class="n">rotate</span><span class="p">(</span><span class="o">-</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">walk</span><span class="p">(</span><span class="n">first</span><span class="p">,</span> <span class="n">last</span><span class="p">,</span> <span class="n">exits</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">pool</span> <span class="o">=</span> <span class="n">deque</span><span class="p">((</span><span class="n">httpx</span><span class="o">.</span><span class="n">AsyncClient</span><span class="p">(</span><span class="n">proxy</span><span class="o">=</span><span class="n">u</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">20</span><span class="p">),</span> <span class="mi">0</span><span class="p">)</span> <span class="k">for</span> <span class="n">u</span> <span class="ow">in</span> <span class="n">exits</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">gate</span> <span class="o">=</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Semaphore</span><span class="p">(</span><span class="mi">50</span><span class="p">)</span>        <span class="c1"># records in flight at once</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="k">await</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">gather</span><span class="p">(</span><span class="o">*</span><span class="p">(</span><span class="n">fetch</span><span class="p">(</span><span class="n">i</span><span class="p">,</span> <span class="n">pool</span><span class="p">,</span> <span class="n">gate</span><span class="p">)</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">first</span><span class="p">,</span> <span class="n">last</span> <span class="o">+</span> <span class="mi">1</span><span class="p">)))</span>
</span></span></code></pre></div><p>And the mapping step. <code>counts</code> is a 2-column table of city name and alert count; the
rest is repair and 2 writes.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">geopandas</span> <span class="k">as</span> <span class="nn">gpd</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">repaired</span><span class="p">(</span><span class="n">gdf</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Drop empty shapes, rebuild the broken ones, say how many.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">gdf</span> <span class="o">=</span> <span class="n">gdf</span><span class="p">[</span><span class="n">gdf</span><span class="o">.</span><span class="n">geometry</span><span class="o">.</span><span class="n">notna</span><span class="p">()</span> <span class="o">&amp;</span> <span class="o">~</span><span class="n">gdf</span><span class="o">.</span><span class="n">geometry</span><span class="o">.</span><span class="n">is_empty</span><span class="p">]</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">broken</span> <span class="o">=</span> <span class="o">~</span><span class="n">gdf</span><span class="o">.</span><span class="n">geometry</span><span class="o">.</span><span class="n">is_valid</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">broken</span><span class="o">.</span><span class="n">any</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># buffer(0) rebuilds a polygon from its own edges. It fixes most</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># self-intersections and silently reshapes the rest — so count them.</span>
</span></span><span class="line"><span class="cl">        <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;repairing </span><span class="si">{</span><span class="nb">int</span><span class="p">(</span><span class="n">broken</span><span class="o">.</span><span class="n">sum</span><span class="p">())</span><span class="si">}</span><span class="s2"> invalid geometries&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">gdf</span><span class="o">.</span><span class="n">loc</span><span class="p">[</span><span class="n">broken</span><span class="p">,</span> <span class="s2">&#34;geometry&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">gdf</span><span class="o">.</span><span class="n">loc</span><span class="p">[</span><span class="n">broken</span><span class="p">,</span> <span class="s2">&#34;geometry&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">buffer</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">gdf</span><span class="p">[</span><span class="n">gdf</span><span class="o">.</span><span class="n">geometry</span><span class="o">.</span><span class="n">is_valid</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">cities</span> <span class="o">=</span> <span class="n">repaired</span><span class="p">(</span><span class="n">gpd</span><span class="o">.</span><span class="n">read_file</span><span class="p">(</span><span class="s2">&#34;city_boundaries.geojson&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><span class="n">layer</span> <span class="o">=</span> <span class="n">cities</span><span class="o">.</span><span class="n">merge</span><span class="p">(</span><span class="n">counts</span><span class="p">,</span> <span class="n">on</span><span class="o">=</span><span class="s2">&#34;city&#34;</span><span class="p">,</span> <span class="n">how</span><span class="o">=</span><span class="s2">&#34;left&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">fillna</span><span class="p">({</span><span class="s2">&#34;alerts&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">unmatched</span> <span class="o">=</span> <span class="nb">set</span><span class="p">(</span><span class="n">counts</span><span class="p">[</span><span class="s2">&#34;city&#34;</span><span class="p">])</span> <span class="o">-</span> <span class="nb">set</span><span class="p">(</span><span class="n">cities</span><span class="p">[</span><span class="s2">&#34;city&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">unmatched</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">unmatched</span><span class="p">)</span><span class="si">}</span><span class="s2"> names have no polygon: </span><span class="si">{</span><span class="nb">sorted</span><span class="p">(</span><span class="n">unmatched</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">layer</span><span class="o">.</span><span class="n">to_file</span><span class="p">(</span><span class="s2">&#34;alerts_by_city.geojson&#34;</span><span class="p">,</span> <span class="n">driver</span><span class="o">=</span><span class="s2">&#34;GeoJSON&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">layer</span><span class="o">.</span><span class="n">dissolve</span><span class="p">(</span><span class="n">by</span><span class="o">=</span><span class="s2">&#34;area&#34;</span><span class="p">,</span> <span class="n">aggfunc</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;alerts&#34;</span><span class="p">:</span> <span class="s2">&#34;sum&#34;</span><span class="p">})</span><span class="o">.</span><span class="n">to_file</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;alerts_by_area.geojson&#34;</span><span class="p">,</span> <span class="n">driver</span><span class="o">=</span><span class="s2">&#34;GeoJSON&#34;</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span></code></pre></div><p>The lines that print <code>unmatched</code> are what I would keep if I had to delete everything
else. A silent left join is how you publish a map with 37 holes in it.</p>
<h2 id="what-it-cant-do">What it can&rsquo;t do</h2>
<div class="limitation not-prose"><p class="limitation-title">It is welded to one feed</p><div class="limitation-body">The address of the feed is written into the code, with no configuration and no
fallback, and the record structure is assumed rather than checked. A feed that renames
a field does not raise an error. It produces an empty column, and the map drawn from an
empty column is the map of a month in which nothing happened. And a record that is
rate-limited 3 times in a row is dropped as if nothing were filed there.</div>
</div>

<div class="limitation not-prose"><p class="limitation-title">The name table is a list of mistakes already seen</p><div class="limitation-body">The table&rsquo;s 37 fixes cover only the variants already seen. A new city, a new spelling
or a fresh typo falls straight through the join and off the map. There is no fuzzy
matching — only the count printed at the end, so the map is as honest as the person
reading that line.</div>
</div>

<div class="limitation not-prose"><p class="limitation-title">Repaired geometry is changed geometry</p><div class="limitation-body"><code>buffer(0)</code> is the standard repair and it is not a guarantee. It fixes most
self-intersections, fails on some, and on the rest returns a valid polygon that is not
quite the one you started with. For counting alerts inside administrative boundaries
that is fine. Anything measuring distance or area inherits an error you have to state
rather than absorb.</div>
</div>

<p>There is no alerting stage here at all, and that is deliberate. This answers a question
about the past; it does not watch the present.</p>
<h2 id="where-this-sits-in-the-series">Where this sits in the series</h2>
<p>This is the spine from
<a href="https://salaheldinaz.com/blog/what-automation-means-for-an-osint-analyst/">what automation actually means for an OSINT
analyst</a>,
with the Analyze stage kept deliberately dumb — no model, no classification, just
grouping and counting — and the Present stage doing all the work.</p>
<p>The rotation and the 3-requests-per-exit budget are the practical version of
<a href="https://salaheldinaz.com/blog/opsec-for-automation/">OPSEC for automation</a>. Rotation here is not about hiding.
It is about not being the traffic that makes a public feed decide to stop being public.</p>
<p>Two later posts pick up threads from this one. <span class="link-pending" title="Coming soon in this series">Storing and searching what you
collect</span> covers what to do when the same place arrives under 3 spellings from
2 sources, which is the half of normalization this post only gestures at. <span class="link-pending" title="Coming soon in this series">Does your AI
step actually work?</span> is the one to read before
trusting the 37: a hand-built fix table is a recall problem, and recall is
measurable rather than assumed.</p>
]]></content:encoded></item></channel></rss>