<?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: MariusKa01</title>
    <description>The latest articles on DEV Community by MariusKa01 (@mariuska01).</description>
    <link>https://dev.to/mariuska01</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%2F4038058%2F82953bf2-6877-431c-98f9-e4384109072c.png</url>
      <title>DEV Community: MariusKa01</title>
      <link>https://dev.to/mariuska01</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mariuska01"/>
    <language>en</language>
    <item>
      <title>Three checks that lied to me while building a content extraction API</title>
      <dc:creator>MariusKa01</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:42:56 +0000</pubDate>
      <link>https://dev.to/mariuska01/three-checks-that-lied-to-me-while-building-a-content-extraction-api-4n0p</link>
      <guid>https://dev.to/mariuska01/three-checks-that-lied-to-me-while-building-a-content-extraction-api-4n0p</guid>
      <description>&lt;p&gt;Three times while building a web content extractor, a passing check told me&lt;br&gt;
something that wasn't true. Not a flaky test, not a missed edge case — a green&lt;br&gt;
result that actively asserted the opposite of reality. All three failed the same&lt;br&gt;
way, and once I saw the pattern I couldn't unsee it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The benchmark said 90% of pages extracted successfully.&lt;/strong&gt; The success test&lt;br&gt;
asked whether the extractor returned at least 200 &lt;em&gt;characters&lt;/em&gt;. A section landing&lt;br&gt;
page — a site homepage, a &lt;code&gt;/technology&lt;/code&gt; index — extracts as concatenated&lt;br&gt;
navigation: "HomePoliticsWorldBusiness…". That clears 200 characters comfortably&lt;br&gt;
while amounting to about a dozen words. So pages with no article on them at all&lt;br&gt;
counted as successful extractions. The number was measuring "trafilatura returned&lt;br&gt;
a string", not "we got an article." Re-scoring the same recorded run against a&lt;br&gt;
100-word floor drops it from 90 to 72, and the smallest "successful" body in it&lt;br&gt;
was &lt;strong&gt;six words&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tests said the browser was sandboxed.&lt;/strong&gt; I moved the service off root and&lt;br&gt;
removed &lt;code&gt;--no-sandbox&lt;/code&gt; from the Chromium launch arguments. Tests passed, pages&lt;br&gt;
rendered, everything looked right. But Playwright disables the Chromium sandbox&lt;br&gt;
&lt;em&gt;by default&lt;/em&gt; and injects that flag itself, so deleting it from my own argument&lt;br&gt;
list changed nothing. The verification script I wrote to prove the sandbox was on&lt;br&gt;
called &lt;code&gt;launch()&lt;/code&gt; exactly the way the application did — inheriting the same&lt;br&gt;
default — and cheerfully confirmed a sandbox that had never once been enabled.&lt;br&gt;
What exposed it was reading the live process list: every running renderer still&lt;br&gt;
carried &lt;code&gt;--no-sandbox&lt;/code&gt;, flatly contradicting both the code and the green tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reproduction said CI's failure wasn't real.&lt;/strong&gt; CI was red while everything&lt;br&gt;
passed locally, so I rebuilt the environment to match: fresh virtualenv, only&lt;br&gt;
pinned requirements, same Python version. 68 tests passed. I concluded the suite&lt;br&gt;
was fine and started removing CI steps to find the culprit. Wrong. Playwright's&lt;br&gt;
browser binaries live in &lt;code&gt;~/.cache/ms-playwright&lt;/code&gt;, shared across virtualenvs — so&lt;br&gt;
my "clean" environment silently reused a Chromium the CI runner didn't have. The&lt;br&gt;
fix was to point &lt;code&gt;PLAYWRIGHT_BROWSERS_PATH&lt;/code&gt; at an empty directory and &lt;em&gt;prove the&lt;br&gt;
browser was unavailable&lt;/em&gt; before running anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A check that shares an assumption with the thing it checks cannot detect a&lt;br&gt;
failure in that assumption.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The character threshold shared the extractor's own idea of what counts as&lt;br&gt;
content. The sandbox script shared the application's launch defaults. The "clean"&lt;br&gt;
reproduction shared the machine's browser cache. Each was a mirror held up to the&lt;br&gt;
code, faithfully reporting the code's own opinion back — and I read that&lt;br&gt;
reflection as independent confirmation.&lt;/p&gt;

&lt;p&gt;This is not the same as a test being wrong. A wrong test fails when it shouldn't,&lt;br&gt;
and you notice. These passed. They were load-bearing, they were green, and they&lt;br&gt;
were lying.&lt;/p&gt;

&lt;p&gt;The rule is cheap to apply: &lt;strong&gt;before trusting a check, make it fail on purpose.&lt;/strong&gt;&lt;br&gt;
Unload the AppArmor profile and confirm the browser stops launching. Empty the&lt;br&gt;
browser cache and confirm the suite breaks. Feed the success test a page of&lt;br&gt;
navigation soup and confirm it says no. If you cannot make a check fail, you have&lt;br&gt;
no evidence that it can — and a check that cannot fail is not measuring anything.&lt;/p&gt;

&lt;p&gt;The corollary is what makes it worth writing down: freezing your inputs does not&lt;br&gt;
save you. I had a rigorously frozen benchmark, and it produced a stable, precise,&lt;br&gt;
wrong answer for weeks.&lt;/p&gt;




&lt;p&gt;The rest of this is the project those checks were attached to, and what the&lt;br&gt;
numbers look like once they're honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the thing does
&lt;/h2&gt;

&lt;p&gt;Give it a URL, get back clean article text and metadata. That sounds solved —&lt;br&gt;
&lt;code&gt;readability&lt;/code&gt; has existed for over a decade — but a realistic spread of the web&lt;br&gt;
breaks it three ways. Content is buried in navigation, ads, cookie banners and&lt;br&gt;
comment widgets. Some pages render their body with JavaScript, so an HTTP fetch&lt;br&gt;
returns an empty shell. And a growing slice sits behind commercial anti-bot&lt;br&gt;
systems that block automated clients outright.&lt;/p&gt;

&lt;p&gt;The pipeline tries a fast static extraction first (&lt;code&gt;trafilatura&lt;/code&gt; →&lt;br&gt;
&lt;code&gt;readability&lt;/code&gt;), and escalates to a real browser (Playwright) only when static&lt;br&gt;
comes back under the floor or the fetch was blocked. Static handles most of it at&lt;br&gt;
a median of ~0.4s; the browser costs ~5s a page, so spending one on a page that&lt;br&gt;
didn't need it is pure waste.&lt;/p&gt;

&lt;h2&gt;
  
  
  Freezing the benchmark wasn't enough
&lt;/h2&gt;

&lt;p&gt;Before building the browser layer I froze a set of 100 real URLs — news in two&lt;br&gt;
languages, blogs, docs, newsletters, e-commerce, reference — and committed it.&lt;br&gt;
The set never changes. Only the number moves.&lt;/p&gt;

&lt;p&gt;That instinct was right. A moving benchmark measures nothing: when the number&lt;br&gt;
shifts you can't tell whether your code improved or the web moved under you. But&lt;br&gt;
freezing the inputs controls exactly one variable, and I had two others loose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My own code moved.&lt;/strong&gt; Static-only scored 78%, static-plus-browser 90%, and I&lt;br&gt;
wanted to call that a 12-point win for the browser layer. Going through the runs&lt;br&gt;
URL by URL says otherwise: of the 22 URLs that failed the first run, six were&lt;br&gt;
rescued by the browser — and six more started passing on the &lt;em&gt;static&lt;/em&gt; path,&lt;br&gt;
because the static extractor had been refactored in between. Same URLs, different&lt;br&gt;
code. The browser's honest contribution is &lt;strong&gt;six points, not twelve&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I never classified what was in the set.&lt;/strong&gt; I built it carefully and never asked&lt;br&gt;
what each URL actually &lt;em&gt;was&lt;/em&gt;. When I finally labelled them, &lt;strong&gt;40 of the 100 were&lt;br&gt;
site homepages or section indexes&lt;/strong&gt; — &lt;code&gt;theverge.com/&lt;/code&gt;, &lt;code&gt;bbc.com/travel&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;etsy.com/&lt;/code&gt;. Not articles. A homepage has no article on it, so an article&lt;br&gt;
extractor can neither succeed nor fail on one in any meaningful sense.&lt;/p&gt;

&lt;p&gt;That unlabelled 40% is what let the broken success test survive. Navigation text&lt;br&gt;
from a homepage cleared the character bar, counted as a win, and nothing in the&lt;br&gt;
set said "this one isn't supposed to produce an article." The two errors propped&lt;br&gt;
each other up: neither was visible while the other held.&lt;/p&gt;

&lt;p&gt;The fix wasn't to change the set — it's frozen, that's the point — but to tag the&lt;br&gt;
homepages and report three numbers instead of one. A single blended percentage&lt;br&gt;
was always going to be wrong in both directions: it treats homepages as&lt;br&gt;
extraction failures while counting their menus as successes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spike before you build
&lt;/h2&gt;

&lt;p&gt;Before committing to a browser pool — process management, memory limits, a queue,&lt;br&gt;
a circuit breaker, all of it — I ran a 30-minute spike: take 8 of the 22 URLs&lt;br&gt;
that failed static extraction, throw a plain Playwright at them, and &lt;em&gt;measure&lt;/em&gt;&lt;br&gt;
how many come back.&lt;/p&gt;

&lt;p&gt;Three of eight. Those three were plain JavaScript rendering. Of the other five,&lt;br&gt;
four were commercial anti-bot and one was a plain nginx 429 rate-limit. That set&lt;br&gt;
the ceiling for the whole sprint: the browser layer's job is the JS-rendering&lt;br&gt;
class, not the anti-bot class. Building the pool was justified; building anti-bot&lt;br&gt;
evasion was not.&lt;/p&gt;

&lt;p&gt;Spikes like this are cheap, and they keep you honest about what a feature can&lt;br&gt;
actually deliver before you've sunk a week into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell which anti-bot system blocked you
&lt;/h2&gt;

&lt;p&gt;When a page fails, &lt;em&gt;why&lt;/em&gt; it failed is diagnosable from the response:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DataDome&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;x-datadome&lt;/code&gt; header, &lt;code&gt;datadome&lt;/code&gt; cookie&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare challenge&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cf-mitigated: challenge&lt;/code&gt; header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Akamai Bot Manager&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AkamaiGHost&lt;/code&gt; server header, &lt;code&gt;_abck&lt;/code&gt; cookie&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerimeterX / HUMAN&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_px*&lt;/code&gt; cookies&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The subtlety that cost me a debugging session: &lt;strong&gt;a Cloudflare cookie is not a&lt;br&gt;
Cloudflare block.&lt;/strong&gt; Most of the web sits behind a CDN, so &lt;code&gt;__cf_bm&lt;/code&gt; shows up on&lt;br&gt;
pages that serve their content perfectly. My first detector flagged a Substack&lt;br&gt;
post as "protected" while the rendered page carried 22,924 words of real content&lt;br&gt;
(whole-page text, as the spike harness measured it; the extractor's cleaned&lt;br&gt;
article is 5,310). The fix was to make detection content-first: if the page&lt;br&gt;
returned real content, the anti-bot signal is irrelevant. Only a strong signal&lt;br&gt;
(&lt;code&gt;cf-mitigated: challenge&lt;/code&gt;) counts before extraction. Get this wrong and you flag&lt;br&gt;
half the internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I don't bypass anti-bot
&lt;/h2&gt;

&lt;p&gt;I could have reached for stealth plugins, residential proxies, or&lt;br&gt;
&lt;code&gt;undetected-chromedriver&lt;/code&gt;. I didn't, for two reasons.&lt;/p&gt;

&lt;p&gt;Honesty of the product: returning &lt;code&gt;ANTIBOT_PROTECTED&lt;/code&gt; with the provider name —&lt;br&gt;
"this site uses DataDome, we don't support it" — is more useful to a caller than&lt;br&gt;
a vague timeout. They know exactly where the boundary is.&lt;/p&gt;

&lt;p&gt;And scope: bypassing commercial anti-bot is an arms race with real legal and&lt;br&gt;
terms-of-service implications. It is a fundamentally different product from clean&lt;br&gt;
text extraction, and mixing them would compromise both. &lt;strong&gt;Seven of the 100 URLs&lt;br&gt;
sit behind commercial anti-bot — two article URLs and five homepages&lt;/strong&gt; — and they&lt;br&gt;
are excluded from the target on purpose.&lt;/p&gt;

&lt;p&gt;An explicit boundary is a feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two bugs the resilience test found
&lt;/h2&gt;

&lt;p&gt;I almost shipped without a resilience test. It found two things that would have&lt;br&gt;
bitten in production, weeks later, with nobody watching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lazy watchdog.&lt;/strong&gt; The browser had a watchdog that restarted it when it died —&lt;br&gt;
but only inside the request path. If the browser died and no request came in, it&lt;br&gt;
stayed dead. Worse, restarting &lt;em&gt;on&lt;/em&gt; the request path meant every caller ate the&lt;br&gt;
restart latency and, under load, piled up behind a lock. Now a dead browser is a&lt;br&gt;
fast static fallback for callers and a background job for the watchdog.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;page.content()&lt;/code&gt; hang.&lt;/strong&gt; I had a 15-second timeout on page navigation. I did&lt;br&gt;
not have one on &lt;code&gt;page.content()&lt;/code&gt;. One e-commerce page passed navigation and then&lt;br&gt;
hung &lt;code&gt;content()&lt;/code&gt; for 120+ seconds — and &lt;code&gt;asyncio.wait_for&lt;/code&gt; couldn't cancel it,&lt;br&gt;
because Playwright's call was blocked at the C level below Python's event loop.&lt;br&gt;
The only reliable fix was a hard 35-second ceiling in the request handler itself.&lt;br&gt;
No single page can block the server longer than that.&lt;/p&gt;

&lt;p&gt;Both rhyme with the rest of this: timeouts you didn't write are timeouts you&lt;br&gt;
don't have, and a component that recovers "eventually, when poked" doesn't&lt;br&gt;
recover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result, honestly
&lt;/h2&gt;

&lt;p&gt;Of the &lt;strong&gt;60 URLs that point at an actual article, 54 return a body of at least&lt;br&gt;
100 words&lt;/strong&gt; — 90%, or 93% setting aside the two behind anti-bot. The other four:&lt;br&gt;
two extract nothing usable (a Rust book chapter, an Amazon product page) and two&lt;br&gt;
time out.&lt;/p&gt;

&lt;p&gt;Of the &lt;strong&gt;40 homepages, 11 correctly return nothing&lt;/strong&gt; — the right answer, not a&lt;br&gt;
failure — and 19 carry enough real prose to extract anyway. &lt;strong&gt;Seven URLs across&lt;br&gt;
the set are behind commercial anti-bot&lt;/strong&gt;, out of scope by design.&lt;/p&gt;

&lt;p&gt;The old headline for the same set was also "90%", and it meant something much&lt;br&gt;
weaker: any extraction at all, across all 100 URLs, counting homepage navigation&lt;br&gt;
as success. Same digits, entirely different claim. The smallest body behind&lt;br&gt;
today's number is 103 words. Behind the old one it was six.&lt;/p&gt;

&lt;p&gt;I could chase the anti-bot sites with proxies and stealth and maybe claw back a&lt;br&gt;
few points, at the cost of an arms race, legal exposure, and a murkier product.&lt;br&gt;
I'd rather have a number I can defend line by line than a bigger one I can't —&lt;br&gt;
which is, in the end, the same lesson as the three checks.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code, benchmark, and the full decision log:&lt;/em&gt;&lt;br&gt;
&lt;a href="https://github.com/MariusKa01/article-extractor-api" rel="noopener noreferrer"&gt;https://github.com/MariusKa01/article-extractor-api&lt;/a&gt; &lt;em&gt;· live demo:&lt;/em&gt;&lt;br&gt;
&lt;code&gt;curl "https://api.lobbyx.net/v1/extract?url=&amp;lt;URL&amp;gt;"&lt;/code&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>webdev</category>
      <category>testing</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
