<?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: Juraj Madzunkov</title>
    <description>The latest articles on DEV Community by Juraj Madzunkov (@madareyou).</description>
    <link>https://dev.to/madareyou</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%2F4081334%2Fc07fa61e-e050-4adc-9eb5-1fb2e65a1571.jpg</url>
      <title>DEV Community: Juraj Madzunkov</title>
      <link>https://dev.to/madareyou</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/madareyou"/>
    <language>en</language>
    <item>
      <title>Your code is tested. Your measurements are not.</title>
      <dc:creator>Juraj Madzunkov</dc:creator>
      <pubDate>Sun, 30 Aug 2026 14:15:24 +0000</pubDate>
      <link>https://dev.to/madareyou/your-code-is-tested-your-measurements-are-not-2il9</link>
      <guid>https://dev.to/madareyou/your-code-is-tested-your-measurements-are-not-2il9</guid>
      <description>&lt;p&gt;I keep a ledger of every change I ship that changes behaviour. Each entry carries a written prediction and a pre-ship baseline, and a review date is set the day it ships, usually five days out. On that date I have to come back and read the real numbers against what I said would happen.&lt;/p&gt;

&lt;p&gt;Twelve changes have now been through that review. None has been reverted.&lt;/p&gt;

&lt;p&gt;That is not the interesting number. The interesting number is that &lt;strong&gt;eight of the twelve reviews found something genuinely broken - and in all eight it was the measurement, not the change.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What an entry looks like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adaptive-source-cap&lt;/span&gt;
  &lt;span class="na"&gt;metric&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;digest_fill_rate&lt;/span&gt;
  &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;Sunday 2026-08-09 and Monday 2026-08-10 fill rates return toward&lt;/span&gt;
    &lt;span class="s"&gt;their pre-2026-07-29 level (91.7-97.6%) and clearly beat the&lt;/span&gt;
    &lt;span class="s"&gt;76.7%/59.0% measured after the cap change [...]&lt;/span&gt;
  &lt;span class="na"&gt;baseline&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;[...] A/B on 2026-08-03's real pool for the reporting subscriber:&lt;/span&gt;
    &lt;span class="s"&gt;cap1 -&amp;gt; 11, cap2 -&amp;gt; 13, cap3 -&amp;gt; 14, against 20 requested -- so the&lt;/span&gt;
    &lt;span class="s"&gt;relaxation is expected to recover roughly 2-3 stories, not all 8.&lt;/span&gt;
    &lt;span class="s"&gt;The rest is genuine weekend thinness and should stay unfixed.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the real entry, trimmed only where marked. Read the two fields together.&lt;/p&gt;

&lt;p&gt;The baseline contains an A/B on real data: relaxing the cap takes the subscriber from 11 stories to 13 or 14, against the 20 they asked for. That is a ceiling of 65-70% fill, and the baseline says so in words - &lt;em&gt;recover roughly 2-3 stories, not all 8&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The target, written in the same sitting, a few lines below, demands 91.7-97.6%.&lt;/p&gt;

&lt;p&gt;The fix did exactly what its own experiment predicted it would do, and failed its target for it. Verdict recorded as &lt;em&gt;"Worked, against a target it could never have reached."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The three failure modes, in order of how hard they were to see
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The target contradicts its own baseline
&lt;/h3&gt;

&lt;p&gt;The one above. Easy to catch once you look, and the fix is mechanical: &lt;strong&gt;if an entry contains an experiment bounding the effect, the target may not exceed that bound.&lt;/strong&gt; That is now a rule.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The baseline and the metric are in different units
&lt;/h3&gt;

&lt;p&gt;An entry recorded a baseline of "286 collected, 71 delivered" over 30 days, described as computed with the metric's own logic. It was not. Those were &lt;em&gt;distinct stories&lt;/em&gt;; the live metric counted &lt;em&gt;slots across digests&lt;/em&gt;. Same nouns, different denominators.&lt;/p&gt;

&lt;p&gt;The review came out &lt;strong&gt;wrong in a flattering direction&lt;/strong&gt;, which is the dangerous kind. A result that disappoints you gets re-checked. One that confirms you does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The metric moves when your population does
&lt;/h3&gt;

&lt;p&gt;This is the one worth the article.&lt;/p&gt;

&lt;p&gt;The metric pooled a per-slot share across every subscriber who received a digest that day:&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="c1"&gt;# what it did, in effect
&lt;/span&gt;&lt;span class="n"&gt;share&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;matching_slots_across_everyone&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total_slots_across_everyone&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That number moves when the &lt;strong&gt;subscriber mix&lt;/strong&gt; moves, even when nothing in the pipeline has changed. And it did move: two subscribers stopped receiving partway through the review window.&lt;/p&gt;

&lt;p&gt;Same five days, same rows, three defensible readings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;reading&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pooled across everyone&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+1.45&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;late-leaving subscribers dropped&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+4.26&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;per-subscriber, each against themselves&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+3.84&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the third is composition-free. It is also the only one that showed what actually happened: &lt;strong&gt;6 of 6 subscribers improved, at +0.59 slots per digest.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had already drafted the verdict "failed target, not failed fix" off the pooled number.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two defensible readings disagreeing by 3x on identical data is not an inconvenience to resolve by picking one. It &lt;em&gt;is&lt;/em&gt; the finding.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The related trap: the open day
&lt;/h2&gt;

&lt;p&gt;One more, because it is the cheapest to fix and the easiest to ship.&lt;/p&gt;

&lt;p&gt;My subscribers choose their own delivery hour, so the archive table gains rows all day - the send window runs 05:04 to 12:00 UTC. Every "since date X" metric treated today as a finished day.&lt;/p&gt;

&lt;p&gt;A partial day does not read as missing data. &lt;strong&gt;It reads as a data point.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And the error is directional, not random: late digests skew one way, so an early read is always wrong in the same direction, and always on the newest point - exactly where you look to decide whether something is trending. It manufactured a four-day decline that was not there.&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;def&lt;/span&gt; &lt;span class="nf"&gt;_last_complete_day&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    The most recent date whose archive rows are final: yesterday, UTC.

    Yesterday is provably safe rather than merely conservative: every row
    was created AND last updated within its own digest_date, checked
    across all 90 days of retention, zero exceptions.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;today_str&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that docstring matter more than the one-liner under them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is not a time-of-day check.&lt;/strong&gt; The first version of this guard hardcoded a cutoff derived from the current subscriber list, and it was wrong immediately - it assumed a single late subscriber at 11:00, because the other two happened to have that send day switched off on the day I measured. Dropping the whole open day is correct no matter who sends when.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is deliberately not applied everywhere.&lt;/strong&gt; The pool-backed metrics read a file the 05:00 pipeline writes once, so today's file is either absent and skipped, or already complete. Clamping those too would throw away a good data point to fix a problem they do not have. A guard applied uniformly because uniformity feels tidy is just a different way of being wrong.&lt;/p&gt;

&lt;p&gt;Ten call sites route through it. A random error adds visible noise. A directional error on your most recent point manufactures a slope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;We have a whole discipline for testing code and essentially none for testing the things we measure code with. A broken function throws. A broken measurement returns a number, and the number looks exactly like an answer.&lt;/p&gt;

&lt;p&gt;If you keep any kind of before-and-after metric, three questions worth asking today:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does my target contradict a bound recorded in the same entry?&lt;/li&gt;
&lt;li&gt;Are my baseline and my live metric counting the same unit?&lt;/li&gt;
&lt;li&gt;Does my denominator move when my population moves rather than when my system does?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I build &lt;a href="https://devdigest.io/?ref=devto" rel="noopener noreferrer"&gt;devdigest&lt;/a&gt;, a daily tech and AI digest that shows you why it picked each story - the ledger above is how I keep myself honest about whether any of it works. It launched on Product Hunt this weekend: &lt;a href="https://www.producthunt.com/products/devdigest-io" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/devdigest-io&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>testing</category>
      <category>buildinpublic</category>
      <category>python</category>
    </item>
    <item>
      <title>A directional error on your newest data point manufactures a trend</title>
      <dc:creator>Juraj Madzunkov</dc:creator>
      <pubDate>Thu, 27 Aug 2026 16:20:59 +0000</pubDate>
      <link>https://dev.to/madareyou/a-directional-error-on-your-newest-data-point-manufactures-a-trend-42kj</link>
      <guid>https://dev.to/madareyou/a-directional-error-on-your-newest-data-point-manufactures-a-trend-42kj</guid>
      <description>&lt;p&gt;My dashboard had a column called "today". Today wasn't finished.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://devdigest.io/?ref=devto" rel="noopener noreferrer"&gt;devdigest&lt;/a&gt;, a daily tech digest. Every change that changes behaviour gets reviewed against real data five days later, and I write the numbers down before I let myself have an opinion about them. It is the only thing standing between me and believing whatever I was hoping for.&lt;/p&gt;

&lt;p&gt;This week's series, the share of each digest landing in the four categories a fix I had just shipped was meant to feed:&lt;/p&gt;

&lt;p&gt;69.2, 67.6, 60.0, 53.0&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%2F8r8w9i0eecg6ml4p7ug8.gif" 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%2F8r8w9i0eecg6ml4p7ug8.gif" alt="The same five-day series read on day four and on day five" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four days, straight down. I had the write-up half composed in my head.&lt;/p&gt;

&lt;p&gt;Then I noticed the last number was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Subscribers choose when their digest arrives. The first goes out just after five in the morning, the last at midday. My measurement treated the current day as a finished one, so reading it in the morning meant measuring a day that had not happened yet.&lt;/p&gt;

&lt;p&gt;The real figure was 53.9, not 53.0. Nine tenths of a point. Not exciting. Here is why it is worth writing down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a small error mattered
&lt;/h2&gt;

&lt;p&gt;The error is directional, not random. The late digests skew one way, so an early reading is always wrong in the same direction. And it is always wrong on the newest point, which is exactly where you look when you are deciding whether something is trending.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A random error adds noise you can see. A directional error on the most recent point manufactures a slope.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The part I got wrong twice
&lt;/h2&gt;

&lt;p&gt;Day five came in at 61.9 and did not continue the decline.&lt;/p&gt;

&lt;p&gt;My first instinct was to write that up as the decline never having been real. That instinct is wrong, and it is wrong in the same way as the one it replaced. Four points looked like a trend. Five points look like a recovery. Neither is something five points can tell you, and day six could resume the fall.&lt;/p&gt;

&lt;p&gt;What I can say is narrower:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Observed four-day drop&lt;/td&gt;
&lt;td&gt;15.3 points&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest four-day drop in the 22 days before it&lt;/td&gt;
&lt;td&gt;8.7 points&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So something real happened, and waving it away as noise would have been the mirror image of the mistake I had just caught myself making.&lt;/p&gt;

&lt;p&gt;The measurement error and the reading error turn out to be the same error wearing different clothes: wanting a small number of days to settle a question they cannot settle.&lt;/p&gt;

&lt;p&gt;So I am not calling it. There is a date in my notes to read the same series over eleven days instead of five, and I will post what it says.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The guard is small. Metrics now stop at the last day that is provably closed:&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;def&lt;/span&gt; &lt;span class="nf"&gt;_last_complete_day&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;The most recent date whose rows are final: yesterday, UTC.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromisoformat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;today_str&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yesterday is provably safe here rather than merely conservative, and I checked before choosing it: every row in the table was created and last updated within its own date, across the whole retention window, so once the date has passed nothing further is written to it.&lt;/p&gt;

&lt;p&gt;A partial day does not look like missing data. It looks like a data point, and you will believe it.&lt;/p&gt;

&lt;p&gt;If anything you rely on has a "today" column, go and find out what time today finishes.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>python</category>
      <category>datascience</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>I read the Terms of Service for every source in my news aggregator</title>
      <dc:creator>Juraj Madzunkov</dc:creator>
      <pubDate>Mon, 17 Aug 2026 12:01:31 +0000</pubDate>
      <link>https://dev.to/madareyou/i-read-the-terms-of-service-for-every-source-in-my-news-aggregator-14j0</link>
      <guid>https://dev.to/madareyou/i-read-the-terms-of-service-for-every-source-in-my-news-aggregator-14j0</guid>
      <description>&lt;p&gt;I build &lt;a href="https://devdigest.io/?ref=hn" rel="noopener noreferrer"&gt;devdigest&lt;/a&gt;, a daily tech digest. It pulls from RSS feeds, has a model read each excerpt to categorise and score it, writes an original summary, and emails subscribers a list of titles, summaries and links back to the original articles. Full article text is never reproduced or stored beyond a short-lived per-run cache. To read anything, you click through to the publisher.&lt;/p&gt;

&lt;p&gt;Before charging money for that, I wanted to know whether any of my sources explicitly banned it. So I read the actual Terms of Service for all of them. Not the privacy policy, not a summary, the real current terms, quoting the clause or explicitly recording "no ToS found."&lt;/p&gt;

&lt;p&gt;I expected the answer to track company size. Big corporate sites run their own RSS feeds, press APIs and developer-relations teams, so I assumed they would be relaxed. Small scrappy publications felt like the ones who would care.&lt;/p&gt;

&lt;p&gt;I had it exactly backwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split
&lt;/h2&gt;

&lt;p&gt;The most restrictive terms came from VC-data companies and large corporate infrastructure blogs. Verbatim:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crunchbase News&lt;/strong&gt; bans using content "to train models (including generative artificial intelligence technologies)," and separately bans anything that "'Crawls,' 'scrapes,' or 'spiders' any page, data, or portion of" the content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HPCwire/AIwire&lt;/strong&gt; bans any "robot, spider, or other automatic device" without prior written permission, plus "any form of data extraction or data mining, or other commercial exploitation of any kind." The most specifically targeted restriction I found anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TechRadar&lt;/strong&gt; (Future plc): "You shall not conduct, facilitate, authorise or permit any text or data mining or web scraping... for any purpose, including the development, training, fine-tuning or validation of AI systems or models."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare's blog&lt;/strong&gt; bars automated bots from "scrape, data mine, copy, or use the materials or content on this website for developing, training, fine-tuning, or otherwise contributing to or improving a machine learning model or artificial intelligence (AI) system."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sifted (§7.6)&lt;/strong&gt;, the European startup publication: "You must not conduct, facilitate, authorise or permit any text or data mining or web scraping in relation to our Platform... for any purpose, including the development, training, fine-tuning or validation of AI systems or models."&lt;/p&gt;

&lt;p&gt;Now the other direction, from the sites I assumed would be precious:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;InfoQ&lt;/strong&gt;: "We permit the posting of a summary and then a link back to the InfoQ landing page." That is my exact pipeline, described approvingly, in their terms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MIT News&lt;/strong&gt;: its Terms of Use state it "offers RSS feeds for syndication purposes."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub&lt;/strong&gt;: "these Terms do not restrict lawful access to or use of the contents of public repositories by third parties."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;arXiv&lt;/strong&gt;: the API Terms of Use explicitly names RSS-based discovery and notification tools as a permitted use case, in those words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TechCrunch&lt;/strong&gt; maintains dedicated RSS terms, separate from its general ToS, explicitly permitting display of feed content with attribution and a link to the full article.&lt;/p&gt;

&lt;p&gt;Engineering blogs, university news offices and syndication-native sites were mostly fine with it. The well-lawyered commercial sites were not.&lt;/p&gt;

&lt;p&gt;My best guess at why: the restrictive ones have their own data-licensing business to protect, or expect to. A clause banning AI training is an asset if you intend to sell that access later. The permissive ones live on distribution. Being read and linked to is the product, so the feed is a front door rather than a leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually matters: three ways this goes wrong
&lt;/h2&gt;

&lt;p&gt;The size heuristic being backwards was interesting. What was useful was discovering that the obvious check is wrong in three different directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The ToS is clean and robots.txt bans you.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I originally cleared Towards Data Science. It is Medium-hosted, and Medium's Terms of Service contain no RSS, scraping, commercial-use or AI-training restriction. Clean read.&lt;/p&gt;

&lt;p&gt;I had not checked robots.txt. Medium's explicitly disallows ClaudeBot, GPTBot and every other major AI crawler by name. My pipeline runs on OpenAI's model, so GPTBot's presence in that list was not an academic detail. I removed the source.&lt;/p&gt;

&lt;p&gt;Checking the terms and not robots.txt gave me a clean answer to the wrong question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The site publishes an RSS feed and its ToS bans scraping anyway.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sifted surfaces an RSS feed link on its own homepage. Its Terms of Use, quoted above, ban text and data mining and separately define and prohibit any "'robot', 'bot', 'spider', 'scraper' or other automated device."&lt;/p&gt;

&lt;p&gt;A published feed is not a licence. It is extremely easy to read the existence of /feed as permission, because for most of the web's history that is roughly what it meant. It does not mean that now, and the terms are the binding document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The scraping clause exists but is scoped somewhere else entirely.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;TechCrunch has a "robots/spiders/scrapers" clause. On a keyword search it looks like a red flag. Reading it in context, it is scoped to user-submitted content, comments and forums, not published articles, and the separate dedicated RSS terms permit exactly what I was doing.&lt;/p&gt;

&lt;p&gt;If I had grepped for "scraper" and moved on, I would have dropped a source that explicitly allows this. False positives cost you real sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boilerplate is not signal
&lt;/h2&gt;

&lt;p&gt;The single most useful methodology note, and the one that took longest to arrive at.&lt;/p&gt;

&lt;p&gt;Nearly every commercial media ToS carries some version of "all rights reserved, no reproduction without permission." It is close to universal. Treated as a prohibition, it bans what every RSS reader, every feed aggregator and every newsletter on earth does daily.&lt;/p&gt;

&lt;p&gt;So it cannot be the test. If your rule flags everything, it has told you nothing.&lt;/p&gt;

&lt;p&gt;What I ended up looking for is a clause that specifically names automated access, bots, scraping, RSS or feed reuse, or (increasingly common since 2024) AI training and summarisation. Those describe the actual pipeline. Generic anti-plagiarism language does not.&lt;/p&gt;

&lt;p&gt;The rule I settled on: exclude only on an explicit stated prohibition. Never infer a ban from silence. Several sources had no discoverable terms at all - Meta's AI blog has no ToS page whatsoever, Simon Willison's blog has a bare copyright symbol and nothing else. Silence is not permission, but it is also not a prohibition, and inventing one costs a good source for nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did about it
&lt;/h2&gt;

&lt;p&gt;Eight sources came out over the audits: Crunchbase News, HPCwire/AIwire, TechRadar and Cloudflare's blog on explicit clauses; Ars Technica and Wired on Condé Nast's posture, including active litigation against an AI company over scraping, where I could never get a verbatim read; Towards Data Science on the robots.txt finding; Sifted once §7.6 turned up in a later pass.&lt;/p&gt;

&lt;p&gt;The list has since grown past 120 sources and the audit runs on a schedule rather than once, because terms change. The 2024-onward AI-training clauses are mostly new language. A source cleared in 2023 is not cleared now, and several of the clauses above carry recent dates.&lt;/p&gt;

&lt;p&gt;None of these sites did anything wrong. Writing a restrictive ToS is entirely a publisher's prerogative, and given how much unlicensed scraping has funded model training, the defensive drafting is easy to understand. I quote them here because the terms are public and because "check for yourself" is useless advice without showing what the check actually looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;p&gt;I am not a lawyer and this is not legal advice. This is one developer reading terms carefully before shipping, which is a lower bar than a real IP review and a considerably higher one than not looking.&lt;/p&gt;

&lt;p&gt;Two things I would tell anyone building something that reads other people's content automatically:&lt;/p&gt;

&lt;p&gt;Check the terms and robots.txt, separately, and treat them as independent. They disagree, in both directions.&lt;/p&gt;

&lt;p&gt;And check the sites you assume are fine. My entire mental model of who would object was wrong, and the only reason I know that is that I read all of them instead of the ones I expected to be a problem.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>rss</category>
      <category>startup</category>
    </item>
  </channel>
</rss>
