<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>Tue, 20 Oct 2026 08:27:51 +0000</lastBuildDate><atom:link href="https://salaheldinaz.com/tags/monitoring/" rel="self" type="application/rss+xml"/><item><title>A leak-site watcher: the whole pipeline in one script</title><link>https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/</link><guid isPermaLink="true">https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/</guid><pubDate>Tue, 20 Oct 2026 00:00:00 +0000</pubDate><description>Poll a source every 15 minutes, hash the path for a stable ID, keep the state in SQLite, archive what you find, say one thing in a chat channel. Six stages, no platform.</description><category>osint</category><category>automation</category><category>python</category><category>monitoring</category><category>project</category><content:encoded><![CDATA[<span class="hero-label not-prose">The Analyst&#39;s Stack · Showcase</span>

<p>The smallest complete thing I have built: every stage of the pipeline present, none of
them elaborate.</p>
<h2 id="why-i-built-it">Why I built it</h2>
<p>There is a leak site that posts new entries. They appear at hours nobody sensible is
awake for, they are sometimes gone within a day, and the files attached to them
disappear faster than the pages do.</p>
<p>What that costs you is not analysis time. It is the 2 things you cannot get back:
knowing early, and having your own copy. By the time someone forwards you a link, you
are reading their account of a page you can no longer open.</p>
<p>Doing it by hand looks like this. Open the listing. Compare it against your memory of
yesterday. Open anything new. Screenshot it, save the images, download the attachments,
notice that 3 of them failed, write yourself a note to retry those 3 tomorrow,
and then tomorrow fail to find the note.</p>
<p>I was doing that several times a day and still arriving late.</p>
<h2 id="what-it-solves">What it solves</h2>
<p>The gap between an entry appearing and me knowing about it is now at most 15
minutes — the poll interval — and usually less. That is the whole headline.</p>
<p>Underneath it: each new entry produces one chat message with the title, the time and up to 10 of
the links inside it, a folder on my own disk holding a full-page screenshot and every
image and download, and a second message saying what landed and what did not. Failed
downloads are retried on later runs, up to 15 attempts per file, because a file
that times out at three in the morning is usually there at four.</p>
<p><img src="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/images/d0-1-chat-alert_hu_99c616354759e728.webp" width="745" height="646" alt="A chat channel showing messages from a bot named watcher. At the top, an earlier entry finishes: “Post complete: Pellow Graith bundle”, 1 image and 2 files OK, then “Scrape cycle complete, 1 new”. Below, a new entry: a “New post” message with the title Brannick Solvey release, its time, a short post ID and 4 links, then its own “Post complete” message reporting 1 image and 4 files OK, and a closing “Scrape cycle complete, 1 new”." loading="lazy" decoding="async"></p>
<div class="post-caption not-prose"><strong>The whole interface.</strong> No dashboard and no login: the tool speaks twice per entry and is
otherwise silent. Captured from the real tool pointed at a local demo site, so every entry
is invented.</div>

<h2 id="how-it-works">How it works</h2>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/images/d0-pipeline_light.svg" width="1160" height="190" alt="The same 6 stages as the series spine, left to right: Collect polls every 15 minutes, Normalize uses the path as the ID, Store keeps one SQLite row per post, Analyze parses, fetches and archives, Alert sends 2 chat messages, Present is a status command. Store is highlighted." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/images/d0-pipeline_dark.svg" width="1160" height="190" alt="The same 6 stages as the series spine, left to right: Collect polls every 15 minutes, Normalize uses the path as the ID, Store keeps one SQLite row per post, Analyze parses, fetches and archives, Alert sends 2 chat messages, Present is a status command. Store is highlighted." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>The spine, at the size of one script.</strong> Same 6 stages as the <a href="https://salaheldinaz.com/blog/what-automation-means-for-an-osint-analyst/">foundation post</a>, one weekend instead of a platform.</figcaption>
</figure>

<p><strong>Collect.</strong> Two discovery routes: the feed if the source is serving one, the listing
pages if it is not, walked 5 pages deep. A feed can quietly stop; the listing page is
the version that keeps working.</p>
<p><strong>Normalize.</strong> Every candidate becomes the same 4 fields — URL, title, timestamp,
attached links — before anything downstream touches it.</p>
<p><strong>Store.</strong> A SQLite file with 2 tables: one row per entry, one row per file. Every
entry gets a 12-character identifier derived from its path. This is the stage that
separates a tool from a script that shouts every 15 minutes.</p>
<p><strong>Analyze.</strong> For a genuinely new entry: fetch the page, pull out the images and the
downloadable files, check each link is reachable before promising it, then download. A
headless browser takes one full-page screenshot at 1280×720 and does nothing else.</p>
<p><strong>Alert.</strong> Two messages, not one. The first goes out the moment an entry is detected,
before any downloading, because the link is the urgent part. It lists at most 10 of
the links inside the entry so the message stays readable. The second reports the result:
how many images and files landed.</p>
<p><strong>Present.</strong> A read-only command that queries the same database: what has been seen,
what is pending, what failed and why.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ python3 status.py
</span></span><span class="line"><span class="cl">  ────────────────────────────────────────────────────────────────────────────────────────────────────────────
</span></span><span class="line"><span class="cl">  Title                                         Imgs  Files  File names                            Status
</span></span><span class="line"><span class="cl">  ────────────────────────────────────────────────────────────────────────────────────────────────────────────
</span></span><span class="line"><span class="cl">  Brannick Solvey release                          1     4  solvey_part1.zip, solvey_part2.zip,…  ok
</span></span><span class="line"><span class="cl">  Varrow Teklund dump                              1     3  teklund_ledger_q3.zip, varrow_minut…  ok
</span></span><span class="line"><span class="cl">  Quillmere Ostrava archive                        1     2  ostrava_index.pdf, quillmere_notes.…  ok
</span></span><span class="line"><span class="cl">  ────────────────────────────────────────────────────────────────────────────────────────────────────────────
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">=== Summary ===
</span></span><span class="line"><span class="cl">  Posts: 3 total  |  ok: 3
</span></span><span class="line"><span class="cl">  Files: 9 total  |  ok: 9
</span></span><span class="line"><span class="cl">  Pending/Downloading now: 0
</span></span></code></pre></div><div class="post-caption not-prose"><strong>Stage 6, honestly sized.</strong> The presentation layer is one command that reads the
database nothing else is allowed to write to. Real output against the demo database.</div>

<h2 id="how-i-built-it">How I built it</h2>
<p>Python, because the parsing libraries are there and because I wanted to be able to read
it a year later. An async HTTP client, a feed parser, BeautifulSoup for the HTML, SQLite
from the standard library, and a headless browser used solely for the screenshot.
Optionally <code>aria2c</code> <em>(a command-line downloader that resumes interrupted transfers)</em> for
the large attachments, falling back to plain HTTP when it is absent. It runs as a loop on
a small machine and the whole thing is under 2,000 lines.</p>
<p>Three choices, and two of them are there because the first version got it wrong.</p>
<p><strong>The identifier ignores the domain.</strong> Sites like this move. Had the identifier been the
full URL, a host change would have made every existing entry look new and the tool would
have re-announced and re-downloaded its entire history. Deriving it from the path alone
means a domain change costs one configuration line.</p>
<p><strong>Retry state lives in the database, not in memory.</strong> Each file row carries its own
attempt counter and status, so a run that dies halfway through loses nothing; the next
run picks up whatever is still pending. This sounds obvious and I did not do it the
first time.</p>
<p><strong>Two messages instead of one.</strong> The first version waited until the downloads finished
before saying anything, so a slow attachment delayed the alert. Splitting detection from
completion fixed the only failure that actually mattered.</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/images/d0-alerts_light.svg" width="920" height="250" alt="Two versions of the alert step, one row each. First version: Detect a new entry, Download every file, then One message, which waits for the slowest file and is highlighted as the problem. Now: Detect, Message 1 with the link at once, highlighted, then Download files and screenshot, then Message 2 saying what landed and what failed." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/images/d0-alerts_dark.svg" width="920" height="250" alt="Two versions of the alert step, one row each. First version: Detect a new entry, Download every file, then One message, which waits for the slowest file and is highlighted as the problem. Now: Detect, Message 1 with the link at once, highlighted, then Download files and screenshot, then Message 2 saying what landed and what failed." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>Say the urgent part first.</strong> The link goes out before a single byte is downloaded; the slow part reports when it is done.</figcaption>
</figure>

<p>The hard part was none of that. It was failure taxonomy: a download can fail because the
network blinked, because the server is briefly overloaded, or because the file is gone —
and the first two want retrying, up to 15 attempts in all, while the third wants
abandoning at once. Treat all three the same and you get either a queue that never drains or a tool that
gives up on a file that was going to arrive.</p>
<figure class="diagram not-prose">
  <img class="diagram-light" src="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/images/d0-retry_light.svg" width="980" height="320" alt="A failed download, left to right. Why it failed is read from the status code. A 401, 403 or 404 is abandoned at once. Anything else, a network blip or a busy server, counts one attempt: under 15 attempts the file is marked retry_later and the next run tries again, highlighted; the 15th failure marks it failed_max_retries." loading="lazy" decoding="async">
  <img class="diagram-dark" src="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/images/d0-retry_dark.svg" width="980" height="320" alt="A failed download, left to right. Why it failed is read from the status code. A 401, 403 or 404 is abandoned at once. Anything else, a network blip or a busy server, counts one attempt: under 15 attempts the file is marked retry_later and the next run tries again, highlighted; the 15th failure marks it failed_max_retries." loading="lazy" decoding="async">
  <figcaption class="post-caption"><strong>Two kinds of failure, two different answers.</strong> A missing file is given up on immediately; a flaky one gets 15 attempts in all.</figcaption>
</figure>

<p>What I would change: the timeouts are hardcoded, and the listing parser reads only the
HTML that arrives. Neither has bitten yet. Both are the kind of thing that bites once,
silently.</p>
<p>What I would add is a better copy of the page itself: raw HTML opened a month later is
missing its images, styling and fonts. <a href="https://github.com/gildas-lormeau/SingleFile">SingleFile</a>,
through its <a href="https://github.com/gildas-lormeau/single-file-cli">command-line version</a>,
saves an entry page as one self-contained HTML file that is easy to hash and archive beside
the downloads; the hash proves your copy has not changed since you took it. It drives a
real browser against the source, so it carries the same OPSEC concerns as the polling.</p>
<h2 id="a-look-at-the-code">A look at the code</h2>
<p>The identifier, rewritten to stand alone:</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">hashlib</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">urllib.parse</span> <span class="kn">import</span> <span class="n">urlparse</span><span class="p">,</span> <span class="n">unquote</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">post_identity</span><span class="p">(</span><span class="n">url</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;The part of a URL that identifies the post, ignoring where it is hosted.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">parts</span> <span class="o">=</span> <span class="n">urlparse</span><span class="p">(</span><span class="n">url</span><span class="o">.</span><span class="n">strip</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">    <span class="n">path</span> <span class="o">=</span> <span class="n">unquote</span><span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">path</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">rstrip</span><span class="p">(</span><span class="s2">&#34;/&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">lower</span><span class="p">()</span> <span class="ow">or</span> <span class="s2">&#34;/&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">query</span> <span class="o">=</span> <span class="p">(</span><span class="n">parts</span><span class="o">.</span><span class="n">query</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">path</span><span class="si">}</span><span class="s2">?</span><span class="si">{</span><span class="n">query</span><span class="si">}</span><span class="s2">&#34;</span> <span class="k">if</span> <span class="n">query</span> <span class="k">else</span> <span class="n">path</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">post_id</span><span class="p">(</span><span class="n">url</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;A stable 12-character key. Same entry, same key, whatever the domain.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">post_identity</span><span class="p">(</span><span class="n">url</span><span class="p">)</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">()[:</span><span class="mi">12</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># One entry, reachable at two different hosts, is one row:</span>
</span></span><span class="line"><span class="cl"><span class="n">post_id</span><span class="p">(</span><span class="s2">&#34;https://example.com/posts/2026/an-entry&#34;</span><span class="p">)</span>    <span class="c1"># &#39;34510e6e9281&#39;</span>
</span></span><span class="line"><span class="cl"><span class="n">post_id</span><span class="p">(</span><span class="s2">&#34;https://example.net/posts/2026/an-entry/&#34;</span><span class="p">)</span>   <span class="c1"># &#39;34510e6e9281&#39;</span>
</span></span></code></pre></div><p>And the failure taxonomy, which is the part I would keep if I had to throw the rest away:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">MAX_ATTEMPTS</span> <span class="o">=</span> <span class="mi">15</span>
</span></span><span class="line"><span class="cl"><span class="n">GIVE_UP_ON</span> <span class="o">=</span> <span class="p">{</span><span class="mi">401</span><span class="p">,</span> <span class="mi">403</span><span class="p">,</span> <span class="mi">404</span><span class="p">}</span>   <span class="c1"># asking again will not change the answer</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">record_failure</span><span class="p">(</span><span class="n">db</span><span class="p">,</span> <span class="n">entry</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">status_code</span><span class="p">,</span> <span class="n">message</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Count one failed download. `db` is an open sqlite3 connection.
</span></span></span><span class="line"><span class="cl"><span class="s2">       Returns the file row&#39;s new status.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">status_code</span> <span class="ow">in</span> <span class="n">GIVE_UP_ON</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">db</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;UPDATE files SET status = ?, error = ? WHERE entry = ? AND url = ?&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="p">(</span><span class="sa">f</span><span class="s2">&#34;failed_</span><span class="si">{</span><span class="n">status_code</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">message</span><span class="p">,</span> <span class="n">entry</span><span class="p">,</span> <span class="n">url</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">db</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="s2">&#34;&#34;&#34;UPDATE files
</span></span></span><span class="line"><span class="cl"><span class="s2">                  SET attempts = attempts + 1,
</span></span></span><span class="line"><span class="cl"><span class="s2">                      error    = ?,
</span></span></span><span class="line"><span class="cl"><span class="s2">                      status   = CASE WHEN attempts + 1 &gt;= ?
</span></span></span><span class="line"><span class="cl"><span class="s2">                                      THEN &#39;failed_max_retries&#39;
</span></span></span><span class="line"><span class="cl"><span class="s2">                                      ELSE &#39;retry_later&#39; END
</span></span></span><span class="line"><span class="cl"><span class="s2">                WHERE entry = ? AND url = ?&#34;&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="p">(</span><span class="n">message</span><span class="p">,</span> <span class="n">MAX_ATTEMPTS</span><span class="p">,</span> <span class="n">entry</span><span class="p">,</span> <span class="n">url</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">db</span><span class="o">.</span><span class="n">commit</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">row</span> <span class="o">=</span> <span class="n">db</span><span class="o">.</span><span class="n">execute</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;SELECT status FROM files WHERE entry = ? AND url = ?&#34;</span><span class="p">,</span> <span class="p">(</span><span class="n">entry</span><span class="p">,</span> <span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">fetchone</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">row</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</span></span></code></pre></div><p>Everything the next run needs is in that one row. No queue, no job runner, and no state
anywhere a power cut could take with it.</p>
<figure class="post-video not-prose">
  <video class="post-video-el" controls muted loop playsinline preload="none"
         width="1622" height="662" poster="/blog/leak-site-watcher-the-whole-pipeline-in-one-script/video/demo-d0-poster.jpg" aria-label="A silent screen recording, about 18 seconds. On the right a terminal runs one watcher cycle against a local demo site: it fetches the feed, finds 1 new entry called Brannick Solvey release, downloads its 4 files and 1 image line by line, and reports the entry done. On the left the chat channel then shows the two messages for that entry, a New post alert with its links and a Post complete message with the counts.">
    <source src="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/video/demo-d0.mp4" type="video/mp4">
    <p class="post-video-fallback">
      Your browser cannot play this clip.
      <a href="https://salaheldinaz.com/blog/leak-site-watcher-the-whole-pipeline-in-one-script/video/demo-d0.mp4" download>Download the MP4</a> — A silent screen recording, about 18 seconds. On the right a terminal runs one watcher cycle against a local demo site: it fetches the feed, finds 1 new entry called Brannick Solvey release, downloads its 4 files and 1 image line by line, and reports the entry done. On the left the chat channel then shows the two messages for that entry, a New post alert with its links and a Post complete message with the counts.
    </p>
  </video>
  <figcaption class="post-caption"><strong>One cycle, start to finish.</strong> The silence between runs is the tool working. Recorded against a local demo site with invented entries; a page reload in the chat window is cut.</figcaption>
</figure>

<h2 id="what-it-cant-do">What it can&rsquo;t do</h2>
<div class="limitation not-prose"><p class="limitation-title">The edges of a small tool</p><div class="limitation-body">It reads only the HTML a source hands over, so a listing built in the browser after the
page loads is invisible to it — and invisible the quiet way, reporting nothing new rather
than failing. It does not follow long redirect chains, so a file behind 2 hops is
recorded as unreachable when it is merely relocated. Timeouts are hardcoded, which means
a source that gets slower cannot be accommodated without editing code. There is no
download progress, only a per-file status, so a stalled large transfer and a healthy one
look identical until one of them finishes. And it polls on a fixed interval
without rate limiting of its own — a regular, predictable pattern in somebody&rsquo;s server
logs, which is a different kind of problem and the subject of the OPSEC post.</div>
</div>

<p>The bigger limitation is conceptual. There is no intelligence in stage 4 at all:
nothing translates, classifies or scores. It tells you something exists and hands you a
copy. Every judgement about what that means happens in a human head, which for this
source is exactly where I want it.</p>
<p>What that leaves behind is a folder. One entry, from a demo run against a local page,
listed with <code>tree</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ tree -h --dirsfirst archive/
</span></span><span class="line"><span class="cl">[   60]  archive/
</span></span><span class="line"><span class="cl">└── [  140]  34510e6e9281
</span></span><span class="line"><span class="cl">    ├── [  100]  files
</span></span><span class="line"><span class="cl">    │   ├── [ 2.3M]  bundle-a.7z
</span></span><span class="line"><span class="cl">    │   ├── [ 889K]  bundle-b.7z
</span></span><span class="line"><span class="cl">    │   └── [  62K]  readme.txt
</span></span><span class="line"><span class="cl">    ├── [  100]  images
</span></span><span class="line"><span class="cl">    │   ├── [  41K]  image-01.png
</span></span><span class="line"><span class="cl">    │   ├── [  85K]  image-02.png
</span></span><span class="line"><span class="cl">    │   └── [  12K]  image-03.png
</span></span><span class="line"><span class="cl">    ├── [ 3.0K]  entry.json
</span></span><span class="line"><span class="cl">    ├── [  60K]  listing.html
</span></span><span class="line"><span class="cl">    └── [ 145K]  screenshot.png
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">4 directories, 9 files
</span></span></code></pre></div><div class="post-caption not-prose"><strong>What survives.</strong> The page is gone in a week; the folder is not. The folder name is the
12-character identifier from the code above, so the row in the database and the bytes on
disk find each other without a lookup table.</div>

<h2 id="where-this-sits-in-the-series">Where this sits in the series</h2>
<p>This is the <a href="https://salaheldinaz.com/blog/what-automation-means-for-an-osint-analyst/">6-stage spine</a> with
every stage present and none of them ambitious. The version you can type in an evening is
<a href="https://salaheldinaz.com/blog/building-your-first-automation-workflow/">the first workflow post</a> — the same
collect, identify, store and notify loop, minus the archiving and the retry ladder.</p>
<p>The gap between those 60 lines and this is not cleverness. It is the accumulated
weight of things that went wrong once.</p>
<p>The hash-as-identity trick here is the smallest possible version of a much larger
problem, and <span class="link-pending" title="Coming soon in this series">storing and searching what you collect</span> is where that problem gets
taken seriously.</p>
]]></content:encoded></item></channel></rss>