<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nitish Mane</title>
    <description>The latest articles on DEV Community by Nitish Mane (@nitish_mane_2c399ba312e53).</description>
    <link>https://dev.to/nitish_mane_2c399ba312e53</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3941301%2F492cc1e7-f3a4-4fc5-bc07-32ff7f30d98e.png</url>
      <title>DEV Community: Nitish Mane</title>
      <link>https://dev.to/nitish_mane_2c399ba312e53</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nitish_mane_2c399ba312e53"/>
    <language>en</language>
    <item>
      <title>I Built Scrapers That Heal Themselves (and Won an NVIDIA DGX Spark)</title>
      <dc:creator>Nitish Mane</dc:creator>
      <pubDate>Mon, 12 Oct 2026 18:39:15 +0000</pubDate>
      <link>https://dev.to/nitish_mane_2c399ba312e53/i-built-scrapers-that-heal-themselves-and-won-an-nvidia-dgx-spark-45g8</link>
      <guid>https://dev.to/nitish_mane_2c399ba312e53/i-built-scrapers-that-heal-themselves-and-won-an-nvidia-dgx-spark-45g8</guid>
      <description>&lt;p&gt;Scraper Factory took first place at the WeMakeDevs Zero Downtime Hackathon in San Francisco. On the surface it is a small product. It scrapes about twelve days of TV guide data for Philo, YouTube TV and Sling, ranks the top ten upcoming shows and the top ten upcoming movies, and gives you one click to record each title on Philo.&lt;/p&gt;

&lt;p&gt;The interesting part is underneath. I wanted scrapers that notice when they break, attempt their own repair, and only accept that repair once it is proven correct. Three tools carried the project, each doing what it is best at: &lt;strong&gt;Bright Data&lt;/strong&gt; for scraping and self-healing, &lt;strong&gt;Port&lt;/strong&gt; as the control plane, and &lt;strong&gt;SigNoz&lt;/strong&gt; for the audit trail. This post walks through how they fit together, with the code and config that make each piece work, so you can follow along or run it yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try the product:&lt;/strong&gt; &lt;a href="https://scraper-factory-top20.vercel.app" rel="noopener noreferrer"&gt;scraper-factory-top20.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/Nitishmane/scraper-factory" rel="noopener noreferrer"&gt;github.com/Nitishmane/scraper-factory&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F687ys3l2jo2ugusu22g2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F687ys3l2jo2ugusu22g2.jpg" alt="The product: the top 10 shows and top 10 movies, with a one-click Record on Philo button where a direct link exists." width="799" height="443"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The product: the top 10 shows and top 10 movies, with a one-click Record on Philo button where a direct link exists.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: scrapers rot without anyone noticing
&lt;/h2&gt;

&lt;p&gt;Every scraper I have built eventually fails. A site ships a redesign, a selector stops matching, and the data either stops arriving or, worse, keeps arriving slightly wrong. Times land in the wrong column, titles swap with descriptions, rows go missing. You usually find out a week later when something downstream looks odd.&lt;/p&gt;

&lt;p&gt;Most teams treat scrapers as scripts. Scripts don't have owners, health states or an on-call rotation. I wanted each scraper treated like any other production service: state you can see, automation that reacts to that state, and a clear record of every repair.&lt;/p&gt;

&lt;p&gt;The one rule that ties the project together: &lt;strong&gt;judgment goes to the agents, proof stays deterministic.&lt;/strong&gt; A model decides what broke and how to repair it. Plain code, diffing against a frozen fixture, decides whether the repair is real.&lt;/p&gt;
&lt;h2&gt;
  
  
  How the pieces fit
&lt;/h2&gt;

&lt;p&gt;Here is the production data path. Port triggers the scrape, Bright Data extracts the guide, the pipeline ranks titles with TMDB data, and the results land back in Port's Context Lake, where the webapp reads them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhoxdyhoze6roavti5lro.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhoxdyhoze6roavti5lro.png" alt="The data pipeline, from Port's scheduled trigger to the published top 10 lists." width="799" height="291"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The data pipeline, from Port's scheduled trigger to the published top 10 lists.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In production this runs weekly without a laptop. Port's &lt;code&gt;wf_scheduled_scrape&lt;/code&gt; workflow fires every Monday at 08:00 UTC and dispatches a GitHub Actions job that runs scrape, heal (if needed), catalog refresh, rank and publish, then exits. The same job can be started on demand from a Port button.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bright Data: scrapers that repair themselves
&lt;/h2&gt;

&lt;p&gt;The usual approach to scraping is structural: you find that the airtime lives in &lt;code&gt;div.card &amp;gt; span:nth-child(3)&lt;/code&gt; and write that down. That selector says where the data sits, not what it means. Rename a CSS class and it breaks. Reorder the cards and it breaks.&lt;/p&gt;

&lt;p&gt;Bright Data's Scraper Studio builds a collector from a natural language description of the data instead of selectors. A description of meaning survives a redesign far better than a selector does. And the prompt becomes the thing you heal from: when a scraper drifts, the &lt;code&gt;scraper heal&lt;/code&gt; command reasons over that same description plus a plain language report of what went wrong.&lt;/p&gt;
&lt;h3&gt;
  
  
  A prompt about meaning
&lt;/h3&gt;

&lt;p&gt;This is the full prompt behind the channel schedule collector, from &lt;code&gt;src/factory/pipeline.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;This page lists a TV channel&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s upcoming schedule as program cards. Extract every &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;program card. For each: the program title as title, the episode title if shown as &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;episode_title, the airing date exactly as displayed (like Sat, Aug 22) as date_raw, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;the start time exactly as displayed (like 2:00 PM) as start_raw, the time range text &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;if shown (like Sat, Aug 22 2:00 PM - 2:30 PM) as time_range_raw, the category text &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;as category. Include every card.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;There are no class names, positions or DOM paths in it. Notice also &lt;code&gt;date_raw&lt;/code&gt; and &lt;code&gt;start_raw&lt;/code&gt;. I always capture the string exactly as displayed next to any parsed value. If &lt;code&gt;start_raw&lt;/code&gt; says "8:00 PM" but the parsed UTC time is null, that is a drift signal, and one of the detection rules checks for exactly that.&lt;/p&gt;

&lt;p&gt;Everything goes through the Bright Data CLI, which made it easy to script from Python. Creating a collector is one command:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brightdata login &lt;span class="nt"&gt;--api-key&lt;/span&gt; &lt;span class="nv"&gt;$BRIGHTDATA_API_KEY&lt;/span&gt;
brightdata scraper create &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FIXTURE_BASE_URL&lt;/span&gt;&lt;span class="s2"&gt;/channel.html"&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;PROMPT&amp;gt;"&lt;/span&gt;
brightdata scraper run &amp;lt;collector_id&amp;gt; &lt;span class="nt"&gt;--urls&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;channel page url&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;--pretty&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  One collector, three providers
&lt;/h3&gt;

&lt;p&gt;The guide data comes from a third party site where every channel has its own page: roughly 455 program cards covering about twelve days. Because every channel page has the same structure, one shared collector serves all three providers (137 Philo channels, 175 YouTube TV channels and 119 Sling channels). A second collector reads Philo's browse pages, about 739 shows and movies, so the ranked list can link straight to a record button.&lt;/p&gt;

&lt;p&gt;One practical tip: generate the collector against a small, clean sample. I trimmed a real channel page down to 30 program cards, removed links the generator might try to follow, served it through a tunnel, and created the collector from that. That same trimmed page later became my frozen test fixture.&lt;/p&gt;
&lt;h3&gt;
  
  
  Splitting fetch and extract
&lt;/h3&gt;

&lt;p&gt;Bright Data's usage policy does not allow collector runs directly against streaming media domains. After checking with their support team, I split the job across two Bright Data products. The Web Unlocker fetches the live page and archives it. The Scraper Studio collector then extracts from that archived copy, served through a tunnel.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd1ee7z8qfaut6u7umm33.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd1ee7z8qfaut6u7umm33.png" alt="Fetch with the Web Unlocker, extract with a Scraper Studio collector." width="800" height="204"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Fetch with the Web Unlocker, extract with a Scraper Studio collector.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The code still tries a direct collector run first, so if the policy ever changes the relay step drops away on its own. A side benefit: every scrape's input HTML is archived, so any drift investigation can be replayed byte for byte.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbmexgu4nq4tvnfyqf84j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbmexgu4nq4tvnfyqf84j.png" alt="The two Scraper Studio collectors: channel-schedule-v3 for every guide page, and philo-catalog for record links." width="800" height="225"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The two Scraper Studio collectors: channel-schedule-v3 for every guide page, and philo-catalog for record links.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The repair loop
&lt;/h3&gt;

&lt;p&gt;When drift is detected, the healer calls &lt;code&gt;scraper heal&lt;/code&gt; with the collector ID and a plain language symptom description. Bright Data returns a proposal in an &lt;code&gt;awaiting_approval&lt;/code&gt; state, with a &lt;code&gt;preview_result&lt;/code&gt; of what the repaired scraper would extract. Nothing changes until my code decides. Here is the core of &lt;code&gt;src/factory/agents/healer.py&lt;/code&gt;, trimmed for length:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;proposal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;brightdata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collector_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;drift_description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# CLI: brightdata scraper heal &amp;lt;collector_id&amp;gt; "&amp;lt;symptom&amp;gt;"
#   -&amp;gt; {"status": "awaiting_approval", "preview_result": [...]}
&lt;/span&gt;
&lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;channel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preview_result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# no model
&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;brightdata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collector_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# scraper approve &amp;lt;id&amp;gt;
&lt;/span&gt;    &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_scraper_health&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collector_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;healthy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verify_status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pass&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;brightdata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;approve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collector_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reject&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# approve &amp;lt;id&amp;gt; --reject
&lt;/span&gt;    &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_scraper_health&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;collector_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;broken&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verify_status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# heal_event recorded as rolled_back; a person takes it from here
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;awaiting_approval&lt;/code&gt; state is what makes this safe. The model proposes, my code checks, and only then does anything change.&lt;/p&gt;

&lt;p&gt;Because every run goes through the CLI, the Bright Data control panel doubles as a run log. Each weekly scrape shows up as a batch of collector runs with inputs, records and success rate:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqli9m9xoqb8eh81q5seo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqli9m9xoqb8eh81q5seo.png" alt="Run history for the channel schedule collector. The Monday batches are the weekly production scrape." width="799" height="505"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Run history for the channel schedule collector. The Monday batches are the weekly production scrape.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The gate: detection and verification in plain code
&lt;/h2&gt;

&lt;p&gt;Two parts of the system never involve a model: deciding that something is wrong, and deciding that a fix is right.&lt;/p&gt;
&lt;h3&gt;
  
  
  Seven drift rules
&lt;/h3&gt;

&lt;p&gt;Every run passes through seven deterministic checks in &lt;code&gt;src/factory/detect.py&lt;/code&gt;: rows per page out of band, required field null rate, raw time present but parsed time null, channel collapse, empty page rate, a change in the schema hash of returned keys, and lineup disappearance. The thresholds are plain constants:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;REQUIRED_FIELDS&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;channel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start_utc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;NULL_RATE_LIMIT&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;
&lt;span class="n"&gt;EMPTY_PAGE_LIMIT&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;gt;20% of channel pages returning nothing is breakage
&lt;/span&gt;&lt;span class="n"&gt;CHANNEL_COLLAPSE&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.80&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;80% of requested channels yielding rows is breakage
&lt;/span&gt;&lt;span class="n"&gt;LINEUP_DELTA_LIMIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;gt;10% channel-set churn needs two confirming runs
&lt;/span&gt;&lt;span class="n"&gt;PAGE_ROW_BANDS&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A broken scraper and a real change at the source look identical in one sample. If 15 percent of channels vanish, that could be breakage or a real carriage change. So lineup churn needs two consecutive runs agreeing before it is accepted, and drift state is keyed by &lt;code&gt;collector:provider&lt;/code&gt; so one provider's lineup is never compared against another's.&lt;/p&gt;
&lt;h3&gt;
  
  
  The verifier
&lt;/h3&gt;

&lt;p&gt;The verifier normalises the heal preview and the hand-checked expected output for the frozen fixture, then diffs them. It is the only thing allowed to promote a repair:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;COMPARE_FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start_raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nf"&gt;_normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;row count &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, expected &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;want&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unexpected &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Verdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The fixture is three files: a real channel page trimmed to 30 cards, its hand-checked expected output, and a mutated copy with every structural handle moved (classes renamed, cards reordered, airtimes wrapped in new elements). The mutated copy lets me inject drift on demand with &lt;code&gt;make demo-drift&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31xjayd5cbs4r4uwy1oo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F31xjayd5cbs4r4uwy1oo.png" alt="The self-heal loop: three ways to detect a problem, one repair path, and a deterministic gate before anything is promoted." width="800" height="383"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The self-heal loop: three ways to detect a problem, one repair path, and a deterministic gate before anything is promoted.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Port: the control plane
&lt;/h2&gt;

&lt;p&gt;Scrapers needed to be entities with state, not scripts. Port's data model made that possible. Seven blueprints model the system: &lt;code&gt;data_source&lt;/code&gt;, &lt;code&gt;scraper&lt;/code&gt;, &lt;code&gt;scrape_run&lt;/code&gt;, &lt;code&gt;heal_event&lt;/code&gt;, &lt;code&gt;ranked_title&lt;/code&gt;, &lt;code&gt;requirement&lt;/code&gt; and &lt;code&gt;factory_deployment&lt;/code&gt;. Each scraper has a &lt;code&gt;health&lt;/code&gt; property that moves between healthy, drifting and broken. Every run is a &lt;code&gt;scrape_run&lt;/code&gt; entity. Every repair attempt is a &lt;code&gt;heal_event&lt;/code&gt; recording its trigger, the drift description, the outcome and a link to the trace.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwf62tlcqvsr42euazvrh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwf62tlcqvsr42euazvrh.png" alt="Both scrapers as Port entities, with collector ID, verify status and health." width="799" height="166"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Both scrapers as Port entities, with collector ID, verify status and health.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Automations that react to state
&lt;/h3&gt;

&lt;p&gt;The fastest repair path is a Port automation on the scraper blueprint. A JQ condition on the updated entity means it only fires when health flips to drifting:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invoke_healer_on_drift"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trigger"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"automation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ENTITY_UPDATED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"blueprintIdentifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scraper"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JQ"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"expressions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;".diff.after.properties.health == &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;drifting&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"combinator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"and"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"invocationMethod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WEBHOOK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;factory&amp;gt;/heal?token=..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"publish"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Workflows with an AI agent in the middle
&lt;/h3&gt;

&lt;p&gt;When a run fails outright, the &lt;code&gt;wf_heal_on_bad_run&lt;/code&gt; workflow takes over. It is triggered when a &lt;code&gt;scrape_run&lt;/code&gt; is created with status error or with drift detected. The middle node is Port's Triage agent, which reads the scraper's run and heal history and writes a plain language symptom report. The last node passes that report to the heal endpoint:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;trigger  -&amp;gt; EVENT_TRIGGER on scrape_run created
            (.status == "error") or (.drift_detected == true)
triage   -&amp;gt; AI_AGENT "triage"
            "Describe the observed SYMPTOM (what changed in the data),
             never a guess at the fix. Under 400 characters."
heal     -&amp;gt; WEBHOOK POST /heal
            { provider, collector_id, drift_description: {{ .triage.output }} }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffgemzcad5t447i5rmuu9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffgemzcad5t447i5rmuu9.png" alt="The wf_heal_on_bad_run workflow in Port: the trigger, the Triage agent, then the call to the healer." width="800" height="75"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The wf_heal_on_bad_run workflow in Port: the trigger, the Triage agent, then the call to the healer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The agent's job stays narrow on purpose. Triage writes the symptom report but never approves its own fix. That decision stays with the verifier.&lt;/p&gt;

&lt;p&gt;The other workflows are simpler. &lt;code&gt;wf_scheduled_scrape&lt;/code&gt; runs on a cron (&lt;code&gt;0 8 * * 1&lt;/code&gt;) and dispatches the GitHub Actions job. &lt;code&gt;wf_publish_on_success&lt;/code&gt; publishes the rankings whenever a clean run lands. Three self-service actions, &lt;code&gt;run_scrape_now&lt;/code&gt;, &lt;code&gt;refresh_catalog_now&lt;/code&gt; and &lt;code&gt;publish_now&lt;/code&gt;, let an operator force a cycle from the Port UI.&lt;/p&gt;
&lt;h3&gt;
  
  
  Agents, scorecards and the Context Lake
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Four AI agents.&lt;/strong&gt; Triage (approval required), Heal Explainer (explains why a repair was approved or rejected), On-Call Assistant (answers operator questions) and Catalog Coverage (reports how many ranked titles have a direct record link).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A reliability scorecard.&lt;/strong&gt; &lt;code&gt;scraper_reliability&lt;/code&gt; grades each scraper Basic, Bronze (verified today), Silver (verification passing) or Gold (healthy and verified).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Context Lake as the data layer.&lt;/strong&gt; The 20 &lt;code&gt;ranked_title&lt;/code&gt; entities are the product. The Next.js webapp has no database of its own; it reads them straight from Port. Publishing is just an upsert.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjpcmtyrq357hld9grndt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjpcmtyrq357hld9grndt.png" alt="The Factory Operator dashboard in Port: scrapers by health, the architecture note and recent heal events, including the rolled back repairs." width="800" height="511"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The Factory Operator dashboard in Port: scrapers by health, the architecture note and recent heal events, including the rolled back repairs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everything above is provisioned through Port's API by one idempotent script, &lt;code&gt;scripts/bootstrap_port.py&lt;/code&gt;, so the whole setup can be rebuilt at any time.&lt;/p&gt;
&lt;h2&gt;
  
  
  SigNoz: the audit trail
&lt;/h2&gt;

&lt;p&gt;Any system that changes itself needs a clear record of what it did and why. SigNoz carried every trace, metric and log, and its alerting acted as the safety net for the repair loop. It runs self-hosted with Docker Compose, so there was no API key or trial to manage during the hackathon.&lt;/p&gt;
&lt;h3&gt;
  
  
  Auto-instrumentation first
&lt;/h3&gt;

&lt;p&gt;The FastAPI service runs under OpenTelemetry auto-instrumentation and exports OTLP to SigNoz:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;-b&lt;/span&gt; main https://github.com/SigNoz/signoz.git ../signoz
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; ../signoz/deploy/docker/docker-compose.yaml up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# the factory service (this is what `make serve` runs)&lt;/span&gt;
&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_PROTOCOL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http/protobuf &lt;span class="se"&gt;\&lt;/span&gt;
opentelemetry-instrument python &lt;span class="nt"&gt;-m&lt;/span&gt; uvicorn factory.app:app &lt;span class="nt"&gt;--host&lt;/span&gt; 0.0.0.0 &lt;span class="nt"&gt;--port&lt;/span&gt; 8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Because the web layer is instrumented, even Port's automation calling &lt;code&gt;/heal&lt;/code&gt; shows up as a span. On top of that I added custom spans for each pipeline step, with the attributes I would want during an incident:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;start_as_current_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scrape.heal.reprompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target.provider&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scraper.id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collector_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heal.preview_rows&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preview_result&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heal requested for %s: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;drift_description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify.status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pass&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;passed&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;A scrape produces one &lt;code&gt;scrape.run&lt;/code&gt; trace with a &lt;code&gt;scrape.fetch&lt;/code&gt; span per channel page (tagged with provider, channel ID, row count and fetch mode), then &lt;code&gt;scrape.extract&lt;/code&gt;, &lt;code&gt;scrape.validate&lt;/code&gt;, and when a repair runs, &lt;code&gt;scrape.heal.reprompt&lt;/code&gt; and &lt;code&gt;scrape.verify.fixture&lt;/code&gt;. Logs carry trace and span IDs, and the drift description, heal prompt and verifier diff are all logged inside the relevant span.&lt;/p&gt;
&lt;h3&gt;
  
  
  Two detection paths, one endpoint
&lt;/h3&gt;

&lt;p&gt;The inline detector marks the scraper as drifting in Port within seconds. SigNoz is the safety net: an alert rule on &lt;code&gt;scraper.drift.detected &amp;gt; 0&lt;/code&gt; over a five minute window, evaluated every minute, calls the same &lt;code&gt;/heal&lt;/code&gt; endpoint through a webhook channel. Whichever arrives first does the work; the second finds the scraper already healthy and exits. A second rule fires when data stops arriving altogether, which is the only signal that catches a pipeline that died before it could run its own detector.&lt;/p&gt;
&lt;h3&gt;
  
  
  Pulling in what isn't pushed
&lt;/h3&gt;

&lt;p&gt;Port's action and workflow runs and Bright Data's remaining budget are not emitted as telemetry by default. A small background watcher polls both and re-emits them as metrics (&lt;code&gt;port.action.runs&lt;/code&gt;, &lt;code&gt;port.workflow.runs&lt;/code&gt; and &lt;code&gt;brightdata.budget.remaining&lt;/code&gt;), de-duplicated in SQLite so a restart never double counts. The dashboard, the alert and the webhook channel are all created by &lt;code&gt;scripts/bootstrap_signoz.py&lt;/code&gt;, and the dashboard is embedded in Port's operator view.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsi4r4bqg499qlnzwkf4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsi4r4bqg499qlnzwkf4q.png" alt="The three correlated signals sent to SigNoz, plus the dashboard and alert." width="800" height="206"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The three correlated signals sent to SigNoz, plus the dashboard and alert.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The unhappy path, on purpose
&lt;/h2&gt;

&lt;p&gt;The clearest proof came from a fault I injected during the hackathon. A scrape run landed with an error, the heal workflow fired, the Triage agent wrote up the symptom, and the repair endpoint ran a real cycle against Bright Data. The proposal came back with a preview of 2 rows where the fixture expected 30. My verifier rejected it. Port recorded the heal event as rolled back, set the scraper to broken, and left it for a person. Nothing bad reached the product. The next clean scheduled run moved health back to healthy on its own.&lt;/p&gt;

&lt;p&gt;Opening the SigNoz trace told the whole story: the fetch spans with their row counts, the heal span holding the symptom description, and the verify span whose log read "row count 2, expected 30". No guessing, no digging through separate log files.&lt;/p&gt;

&lt;p&gt;If I had let a model approve its own repair, that two row extraction would have been promoted and the product would have shown a nearly empty guide with no alarm going off.&lt;/p&gt;
&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;You need Python 3.11+, Node 20+, Docker, a Bright Data API key, a Port account and a SigNoz instance. A free TMDB key improves the ranking.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Nitishmane/scraper-factory &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;scraper-factory
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env                  &lt;span class="c"&gt;# fill in credentials&lt;/span&gt;

make setup                            &lt;span class="c"&gt;# dependencies + OpenTelemetry instrumentation&lt;/span&gt;
make bootstrap                        &lt;span class="c"&gt;# Port: blueprints, workflows, agents&lt;/span&gt;
python3 scripts/bootstrap_signoz.py   &lt;span class="c"&gt;# SigNoz: dashboard, drift alert, webhook channel&lt;/span&gt;
make serve                            &lt;span class="c"&gt;# factory API + demo site on :8000&lt;/span&gt;

make run &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make catalog &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make rank &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make publish
make demo-drift                       &lt;span class="c"&gt;# inject drift and watch the repair loop&lt;/span&gt;
make verify                           &lt;span class="c"&gt;# the fixture gate; non-zero exit on mismatch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Bright Data's collectors run in the cloud and can't reach localhost, so the fixtures and the factory's webhooks are exposed through &lt;code&gt;cloudflared&lt;/code&gt; tunnels during development. The README covers the full setup.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I would pass on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write prompts about meaning, not layout.&lt;/strong&gt; The prompt is what the healer works from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capture the raw string next to every parsed value.&lt;/strong&gt; Raw present but parsed null turned out to be one of my most reliable drift signals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a heal preview as a candidate.&lt;/strong&gt; Check it against known good output before approving it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model the unusual things.&lt;/strong&gt; Treating scrapers, runs and repairs as entities is what made every other feature useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the agent's job narrow.&lt;/strong&gt; Triage writes the symptom report; it does not approve its own fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the repair endpoint idempotent.&lt;/strong&gt; With more than one trigger path, they will race.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on a metric, not a log line.&lt;/strong&gt; And add an alert for silence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the setup in scripts.&lt;/strong&gt; Re-running one idempotent bootstrap beats clicking through settings at 2 AM during a hackathon.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  About the project
&lt;/h2&gt;

&lt;p&gt;Scraper Factory was built for the WeMakeDevs Zero Downtime Hackathon, where it won first place and an NVIDIA DGX Spark. Thanks to Kunal Kushwaha and the WeMakeDevs team for putting on a great event, and to Port, Bright Data and SigNoz for the tools that made it possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live webapp:&lt;/strong&gt; &lt;a href="https://scraper-factory-top20.vercel.app" rel="noopener noreferrer"&gt;scraper-factory-top20.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/Nitishmane/scraper-factory" rel="noopener noreferrer"&gt;github.com/Nitishmane/scraper-factory&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/Nitishmane" rel="noopener noreferrer"&gt;
        Nitishmane
      &lt;/a&gt; / &lt;a href="https://github.com/Nitishmane/scraper-factory" rel="noopener noreferrer"&gt;
        scraper-factory
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;The Scraper Factory&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;What should I record this week?&lt;/strong&gt; The Scraper Factory scrapes ~12 days of TV-guide data per
channel for Philo, YouTube TV, and Sling, ranks the &lt;strong&gt;top 10 upcoming shows and top 10
upcoming movies&lt;/strong&gt; by TMDB popularity, and gives you one click to record each title on Philo.&lt;/p&gt;
&lt;p&gt;That is the product. The engineering thesis underneath it is the real point: &lt;strong&gt;web scrapers
rot.&lt;/strong&gt; A guide site ships a redesign, selectors stop matching, and the data either stops
arriving or — worse — arrives subtly wrong. This project treats that as a reliability problem
with an on-call rotation. Scrapers are catalogued services with health states. Every run is
traced. Drift is an alert. Repair is an automated loop that must prove itself against a frozen
fixture before its output is trusted, and escalates to a human when it can't.&lt;/p&gt;
&lt;p&gt;The same doctrine now runs the whole factory…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/Nitishmane/scraper-factory" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>automation</category>
      <category>tools</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>I Built Scrapers That Heal Themselves (and Won an NVIDIA DGX Spark)</title>
      <dc:creator>Nitish Mane</dc:creator>
      <pubDate>Fri, 09 Oct 2026 04:24:11 +0000</pubDate>
      <link>https://dev.to/nitish_mane_2c399ba312e53/i-built-scrapers-that-heal-themselves-and-won-an-nvidia-dgx-spark-k13</link>
      <guid>https://dev.to/nitish_mane_2c399ba312e53/i-built-scrapers-that-heal-themselves-and-won-an-nvidia-dgx-spark-k13</guid>
      <description>&lt;h1&gt;
  
  
  I Built Scrapers That Heal Themselves (and Won an NVIDIA DGX Spark)
&lt;/h1&gt;

&lt;p&gt;Scraper Factory took first place at the WeMakeDevs Zero Downtime Hackathon in San Francisco. On the surface it is a small product: it scrapes about twelve days of TV guide data for Philo, YouTube TV and Sling, ranks the top ten upcoming shows and the top ten upcoming movies, and gives you one click to record each title on Philo.&lt;/p&gt;

&lt;p&gt;The interesting part is underneath. I wanted scrapers that notice when they break, attempt their own repair, and only accept that repair once it is proven correct. Three tools carried the project, each doing what it is best at: Bright Data for scraping and self-healing, Port as the control plane, and SigNoz for the audit trail. This post walks through how they fit together.&lt;/p&gt;

&lt;p&gt;Live webapp: &lt;a href="https://scraper-factory-top20.vercel.app" rel="noopener noreferrer"&gt;scraper-factory-top20.vercel.app&lt;/a&gt;&lt;br&gt;
Source code: &lt;a href="https://github.com/Nitishmane/scraper-factory" rel="noopener noreferrer"&gt;github.com/Nitishmane/scraper-factory&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: scrapers rot without anyone noticing
&lt;/h2&gt;

&lt;p&gt;Every scraper I have built eventually fails. A site ships a redesign, a selector stops matching, and the data either stops arriving or, worse, keeps arriving slightly wrong. Times land in the wrong column, titles swap with descriptions, rows go missing. You usually find out a week later when something downstream looks odd.&lt;/p&gt;

&lt;p&gt;Most teams treat scrapers as scripts. Scripts don't have owners, health states or an on-call rotation. I wanted each scraper treated like any other production service: state you can see, automation that reacts to that state, and a clear record of every repair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bright Data: scrapers that repair themselves
&lt;/h2&gt;

&lt;p&gt;The usual approach to scraping is structural: you find that the airtime lives in &lt;code&gt;div.card &amp;gt; span:nth-child(3)&lt;/code&gt; and write that down. That selector says where the data sits, not what it means. Rename a CSS class and it breaks. Reorder the cards and it breaks. The worse case is when it doesn't fail loudly and keeps returning data that is slightly wrong.&lt;/p&gt;

&lt;p&gt;Bright Data's Scraper Studio builds a collector from a natural language description of the data instead of selectors. That changes what a scraper is: a short paragraph about meaning. A description of meaning survives a redesign far better than a selector does. And the prompt becomes the thing you heal from: when a scraper drifts, the &lt;code&gt;scraper heal&lt;/code&gt; command reasons over that same description plus a plain-language report of what went wrong.&lt;/p&gt;

&lt;p&gt;The guide data comes from a third-party site where every channel has its own page: roughly 455 program cards covering about twelve days. Because every channel page has the same structure, one shared collector serves all three providers (137 Philo channels, 175 YouTube TV channels, 119 Sling channels). A second collector reads Philo's browse pages so the ranked list can link straight to a record button.&lt;/p&gt;

&lt;p&gt;The repair loop works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The factory calls &lt;code&gt;scraper heal&lt;/code&gt; with the collector ID and a symptom description written by a triage agent.&lt;/li&gt;
&lt;li&gt;Bright Data reasons over the original prompt and returns a proposal in an &lt;code&gt;awaiting_approval&lt;/code&gt; state, with a preview of what the repaired scraper would extract.&lt;/li&gt;
&lt;li&gt;A deterministic verifier (plain Python, no model) diffs that preview against a hand-checked expected output for a frozen fixture.&lt;/li&gt;
&lt;li&gt;If the diff passes, the factory approves the proposal. If it fails, it rejects it, marks the scraper broken, and sends it to a person.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;awaiting_approval&lt;/code&gt; state is what makes this safe. The model proposes, my code checks, and only then does anything change.&lt;/p&gt;

&lt;p&gt;One practical tip: generate the collector against a small, clean sample. I trimmed a real channel page down to 30 program cards and created the collector from that. That same trimmed page later became my frozen test fixture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Port: the control plane
&lt;/h2&gt;

&lt;p&gt;Scrapers needed to be entities with state, not scripts. Port's data model made that possible: seven blueprints model the system (data_source, scraper, scrape_run, heal_event, ranked_title, requirement, factory_deployment). Each scraper has a health property that moves between healthy, drifting and broken. Every run is a scrape_run entity. Every repair attempt is a heal_event recording its trigger, the drift description and the outcome.&lt;/p&gt;

&lt;p&gt;Port drives the daily loop with no person involved:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A scheduled workflow refreshes the Philo catalog at 07:30 UTC.&lt;/li&gt;
&lt;li&gt;A second scheduled workflow starts the guide scrape at 08:00 UTC.&lt;/li&gt;
&lt;li&gt;When a scrape_run lands with status success, a workflow publishes the ranked titles back to the Context Lake.&lt;/li&gt;
&lt;li&gt;When a run lands in error or drift, a heal workflow fires.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Four AI agents sit next to the catalog. Triage reads run and heal history and writes the drift description used for repair (approval required). A Heal Explainer describes in plain words why a repair was approved or rejected. An On-Call Assistant answers operator questions. A Catalog Coverage agent reports how many ranked titles have a direct Philo record link. The agent's job stays narrow on purpose: Triage writes the symptom report but never approves its own fix. That decision stays with plain code.&lt;/p&gt;

&lt;p&gt;The 20 ranked_title entities are the product itself. The public Next.js webapp has no database of its own; it reads those entities straight from Port's Context Lake. Publishing is just an upsert.&lt;/p&gt;

&lt;p&gt;Everything was provisioned through the API by one idempotent bootstrap script, so the whole setup could be rebuilt at any time.&lt;/p&gt;

&lt;h2&gt;
  
  
  SigNoz: the audit trail
&lt;/h2&gt;

&lt;p&gt;Any system that changes itself needs a clear record of what it did and why. SigNoz carried every trace, metric and log, and its alerting acted as the safety net for the repair loop.&lt;/p&gt;

&lt;p&gt;A scrape run produces one trace with spans for each step: fetch per channel page, extract, validate, and when a repair runs, heal-reprompt and verify spans. Because the web layer is auto-instrumented, even Port's automation calling the heal endpoint shows up as a span. Logs carry trace and span IDs, so I could jump from a log line to the exact span it came from.&lt;/p&gt;

&lt;p&gt;Detection has two paths calling the same idempotent heal endpoint. The fast path: the inline detector marks the scraper as drifting in Port within seconds. The safety net: a SigNoz alert fires when drift is detected over a five-minute window and calls the heal endpoint through a webhook about five minutes later. Whichever arrives first does the work. A second rule fires when data stops arriving altogether, which is the only signal that catches a pipeline that died before it could run its own detector. A pipeline that never runs never reports drift either.&lt;/p&gt;

&lt;p&gt;The dashboard (overview stats, run failures, control-plane activity, Bright Data budget) was created by a script and embedded in Port's operator view, so the on-call screen has everything in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unhappy path, on purpose
&lt;/h2&gt;

&lt;p&gt;The clearest proof came from a fault I injected during the hackathon. A scrape run landed with an error, the heal workflow fired, the Triage agent wrote up the symptom, and the repair endpoint ran a real cycle against Bright Data. The proposal came back with a preview of 2 rows where the fixture expected 30. My verifier rejected it. Port recorded the heal event as rolled back, set the scraper to broken, and left it for a person. Nothing bad reached the product. The next clean scheduled run moved health back to healthy on its own.&lt;/p&gt;

&lt;p&gt;Opening the SigNoz trace told the whole story: the fetch spans with their row counts, the heal span holding the symptom description, and the verify span whose log read "row count 2, expected 30." No guessing, no digging through separate log files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would pass on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Write prompts about meaning, not layout. The prompt is what the healer works from.&lt;/li&gt;
&lt;li&gt;Capture the raw string next to every parsed value. Raw present but parsed null turned out to be one of my most reliable drift signals.&lt;/li&gt;
&lt;li&gt;Treat a heal preview as a candidate. Check it against known good output before approving it.&lt;/li&gt;
&lt;li&gt;Model the unusual things. Treating scrapers, runs and repairs as entities is what made every other feature useful.&lt;/li&gt;
&lt;li&gt;Keep the agent's job narrow. Triage writes the symptom report; it does not approve its own fix.&lt;/li&gt;
&lt;li&gt;Alert on a metric, not a log line. And add an alert for silence.&lt;/li&gt;
&lt;li&gt;Keep the setup in scripts. Re-running one idempotent bootstrap beats clicking through settings at 2 AM during a hackathon.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  About the project
&lt;/h2&gt;

&lt;p&gt;Scraper Factory was built for the WeMakeDevs Zero Downtime Hackathon, where it won first place and an NVIDIA DGX Spark. Thanks to Kunal Kushwaha and the WeMakeDevs team for putting on a great event, and to Port, Bright Data and SigNoz for the tools that made it possible.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
