<?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: Juanjo</title>
    <description>The latest articles on DEV Community by Juanjo (@josejux).</description>
    <link>https://dev.to/josejux</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%2F4070001%2F6f98d540-bb4b-4008-acbd-dda870ed39dc.jpg</url>
      <title>DEV Community: Juanjo</title>
      <link>https://dev.to/josejux</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/josejux"/>
    <language>en</language>
    <item>
      <title>I scanned the top 1,000 websites for security headers. The median score is 37/100.</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Sun, 30 Aug 2026 06:55:21 +0000</pubDate>
      <link>https://dev.to/josejux/i-scanned-the-top-1000-websites-for-security-headers-the-median-score-is-37100-3h24</link>
      <guid>https://dev.to/josejux/i-scanned-the-top-1000-websites-for-security-headers-the-median-score-is-37100-3h24</guid>
      <description>&lt;p&gt;Security-header advice has been the same for a decade: set HSTS, set a CSP, add &lt;code&gt;X-Frame-Options&lt;/code&gt; and &lt;code&gt;nosniff&lt;/code&gt;, done. I wanted to know how many sites that actually reach the biggest audiences on the web bother. So I took the &lt;a href="https://tranco-list.eu/" rel="noopener noreferrer"&gt;Tranco&lt;/a&gt; top 1,000, fetched every homepage once, and graded six response headers.&lt;/p&gt;

&lt;p&gt;Short version: &lt;strong&gt;the median security-header score is 37 out of 100. Fewer than 1 in 10 of the top sites score 70 or higher, and 13% send no security headers at all.&lt;/strong&gt; The full dataset and scripts are &lt;a href="https://github.com/JosejuX/top-1000-security-headers" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;, there is an &lt;a href="https://josejux.github.io/top-1000-security-headers/" rel="noopener noreferrer"&gt;interactive version&lt;/a&gt; with a searchable table of every domain, and this post is the walk-through.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the scan works
&lt;/h2&gt;

&lt;p&gt;One &lt;code&gt;GET https://&amp;lt;domain&amp;gt;&lt;/code&gt; per site, redirects followed, then six headers each graded &lt;code&gt;missing&lt;/code&gt; / &lt;code&gt;weak&lt;/code&gt; / &lt;code&gt;reasonable&lt;/code&gt; / &lt;code&gt;strong&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Strict-Transport-Security&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Content-Security-Policy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;X-Frame-Options&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;X-Content-Type-Options&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Referrer-Policy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Permissions-Policy&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The score is the mean of the six grades as a percentage. The one nuance worth stating: a CSP containing &lt;code&gt;unsafe-inline&lt;/code&gt;, &lt;code&gt;unsafe-eval&lt;/code&gt; or a wildcard source is graded &lt;code&gt;weak&lt;/code&gt;, not &lt;code&gt;strong&lt;/code&gt;. It parses, but it does not stop injected script from running.&lt;/p&gt;

&lt;p&gt;I did the fetching and grading with an open-source API I maintain (the &lt;a href="https://webmetadataextractor.com" rel="noopener noreferrer"&gt;Web Metadata &amp;amp; Contact Extractor&lt;/a&gt;), because one call to its &lt;code&gt;/api/v1/extract&lt;/code&gt; endpoint returns the header grades, an overall security score, an SEO score and a detected tech stack for a URL. That last part matters for the third finding below.&lt;/p&gt;

&lt;h3&gt;
  
  
  About the denominator
&lt;/h3&gt;

&lt;p&gt;Of the 1,000 domains, only 489 are real, reachable homepages. 225 are infrastructure (DNS-only CDN and cloud domains that serve no site), 209 were unreachable from where I ran the scan (timeouts, geo-blocks, paywalls, apex redirect quirks), 43 returned a bot-challenge or WAF page, and 34 served a placeholder. Every number below is over those 489. The sites I could not read lean toward the heavily bot-protected, which are on average the more security-conscious ones, so the real picture is probably a little worse than this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: most of the top sites are missing most of the headers
&lt;/h2&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%2Fu9yx4ks3e2x2jniz978a.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%2Fu9yx4ks3e2x2jniz978a.png" alt="Security-header adoption across the top 1,000 sites" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Strict-Transport-Security&lt;/code&gt; is the only header on a majority of sites (71%). &lt;code&gt;X-Frame-Options&lt;/code&gt; and &lt;code&gt;X-Content-Type-Options&lt;/code&gt; - each a single static line with no downside - are missing on roughly 45%. &lt;code&gt;Referrer-Policy&lt;/code&gt; is on 29%. &lt;code&gt;Permissions-Policy&lt;/code&gt; is on 16%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: the score distribution is bottom-heavy
&lt;/h2&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%2Fjqkiqjyac1qj883dgvrn.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%2Fjqkiqjyac1qj883dgvrn.png" alt="Security-header score distribution" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Median 37, mean 36, and a long tail into the low scores rather than the high ones. A quarter of the top 1,000 score 17 or below.&lt;/p&gt;

&lt;p&gt;Content-Security-Policy is where it breaks down hardest. 46% of sites have no CSP at all. Of the 54% that do, all but 90 sites carry &lt;code&gt;unsafe-inline&lt;/code&gt; or a wildcard. Put together, &lt;strong&gt;about 82% of the top 1,000 have no browser-enforced CSP that would actually stop an XSS.&lt;/strong&gt; Three sites in the whole set have a CSP I would call strong.&lt;/p&gt;

&lt;p&gt;That is not all negligence. A strict CSP on a large existing site is genuinely hard - third-party tags, inline handlers, A/B tools all fight it, and a bad rollout breaks the page. But "hard" is the explanation for the weak-CSP half, not for the 46% with nothing and the 84% with no &lt;code&gt;Permissions-Policy&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the tech stack predicts the posture
&lt;/h2&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%2Fcqa4v047rvgoz446c7w8.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%2Fcqa4v047rvgoz446c7w8.png" alt="Median security score by platform" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bucketing the 489 sites by detected platform, WordPress (n=44) sits about 10 points below the field: median score 27, and 61% ship no CSP versus 46% overall. Next.js and similar framework/edge stacks land near or just above the median, mostly because their hosting (Vercel, Netlify, Cloudflare Pages) sets a decent HSTS by default. The only groups with a median above 42 were Astro (n=8, too small to lean on) and Drupal (n=10).&lt;/p&gt;

&lt;p&gt;The interesting part is what happens inside the WordPress bucket. Those 44 sites have a median &lt;em&gt;SEO&lt;/em&gt; score of 83, well above the overall 75. They are not neglected sites. They are sites where SEO got attention and headers did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4: SEO effort and security effort barely correlate
&lt;/h2&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%2Fjhxgy4j9wvswq809kvtf.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%2Fjhxgy4j9wvswq809kvtf.png" alt="SEO score vs security score" width="800" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Across all 489 sites, Pearson r between the SEO score and the security-header score is 0.17. The top of the web has largely solved SEO (median 75) and largely skipped security headers (median 37), and knowing how much a team invested in one tells you almost nothing about the other.&lt;/p&gt;

&lt;p&gt;My read: SEO has a tight, visible feedback loop (rankings, traffic, revenue). Security headers have none until an incident. So on a busy roadmap the headers lose, every sprint, at almost every company - including ones with the resources to know better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own site
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;httpx matplotlib
curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tranco.zip https://tranco-list.eu/top-1m.csv.zip
python scan.py 1000    &lt;span class="c"&gt;# -&amp;gt; results.jsonl&lt;/span&gt;
python analyze.py      &lt;span class="c"&gt;# -&amp;gt; summary.json + top1000-web-report.csv&lt;/span&gt;
python make_charts.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything - the scan script, the analysis, the raw JSONL, the flattened CSV - is in the &lt;a href="https://github.com/JosejuX/top-1000-security-headers" rel="noopener noreferrer"&gt;repo&lt;/a&gt; under MIT. If you just want your own site graded, the &lt;a href="https://webmetadataextractor.com" rel="noopener noreferrer"&gt;API&lt;/a&gt; has a free tier and &lt;code&gt;curl ".../api/v1/security?url=https://yoursite.com"&lt;/code&gt; gives you the same per-header grades used here.&lt;/p&gt;

&lt;p&gt;If you find a bug in the grading or want a rank band or a platform I did not break out, open an issue with the dataset row and I will take a look.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>http</category>
      <category>data</category>
    </item>
    <item>
      <title>This API vs ScrapingBee vs Firecrawl vs BuiltWith: an honest comparison</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Tue, 25 Aug 2026 18:09:23 +0000</pubDate>
      <link>https://dev.to/josejux/this-api-vs-scrapingbee-vs-firecrawl-vs-builtwith-an-honest-comparison-5a94</link>
      <guid>https://dev.to/josejux/this-api-vs-scrapingbee-vs-firecrawl-vs-builtwith-an-honest-comparison-5a94</guid>
      <description>&lt;p&gt;Hello DEV Community! 👋&lt;/p&gt;

&lt;p&gt;"Just use ScrapingBee" or "just use Firecrawl" comes up a lot when someone asks how to pull structured data from a URL — but those are full scraping platforms with headless-browser pricing to match, and sometimes what you actually need is a lot simpler: SEO tags, detected tech stack, public contact signals, and a security-header grade for a normal, server-rendered page.&lt;/p&gt;

&lt;p&gt;I maintain the &lt;strong&gt;Web Metadata &amp;amp; Contact Extractor API&lt;/strong&gt;, and I kept getting asked "how is this different from ScrapingBee / Firecrawl / BuiltWith?" — so instead of a vague answer, I actually checked pricing pages and wrote down the honest comparison. No "we're better at everything" spin: these four tools solve overlapping but genuinely different problems.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Prices/limits below checked live in August 2026. Re-verify before relying on exact numbers — pricing pages change.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  The one-line version
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it actually is&lt;/th&gt;
&lt;th&gt;JS rendering?&lt;/th&gt;
&lt;th&gt;Crawls a whole site?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;This API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fast metadata/SEO/contact/tech-stack extractor for one URL at a time&lt;/td&gt;
&lt;td&gt;❌ No — reads server HTML only&lt;/td&gt;
&lt;td&gt;❌ No — one URL in, one JSON out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ScrapingBee&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;General-purpose scraping API with a real headless browser&lt;/td&gt;
&lt;td&gt;✅ Yes — full Chrome, clicks/scrolls/forms&lt;/td&gt;
&lt;td&gt;❌ No built-in crawler, but handles single complex pages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Firecrawl&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scraping + crawling API built for feeding LLMs/RAG pipelines&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Yes — follows links, crawls domains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BuiltWith&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Technology-profiling database + lead/contact export tool&lt;/td&gt;
&lt;td&gt;N/A — it's a lookup database, not a live fetcher&lt;/td&gt;
&lt;td&gt;N/A — queries its own crawled index&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you need to click through a login form, scroll infinite-scroll content, or get past a JS challenge: &lt;strong&gt;this API is the wrong tool&lt;/strong&gt; — go use ScrapingBee or Firecrawl. My own docs say so directly, &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor#-honest-limitations" rel="noopener noreferrer"&gt;see "Honest Limitations" in the README&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you need one fast, structured answer for an ordinary page — without paying for a headless browser you don't need — that's the actual use case here.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pricing, side by side
&lt;/h3&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;Free tier&lt;/th&gt;
&lt;th&gt;Cheapest paid plan&lt;/th&gt;
&lt;th&gt;What it buys&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;This API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,000 requests/mo&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$5.99/mo&lt;/strong&gt; (Pro)&lt;/td&gt;
&lt;td&gt;50,000 requests/mo + email support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ScrapingBee&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,000 credits (trial, no card)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$49/mo&lt;/strong&gt; (Freelance)&lt;/td&gt;
&lt;td&gt;250,000 credits/mo, 50 concurrent requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Firecrawl&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,000 credits/mo (forever)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$16/mo&lt;/strong&gt; (Hobby, billed yearly)&lt;/td&gt;
&lt;td&gt;5,000 credits/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BuiltWith&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$295/mo&lt;/strong&gt; (Basic)&lt;/td&gt;
&lt;td&gt;2 technology/keyword/retail lookups, 1 login&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two takeaways, not marketing spin:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This API is dramatically cheaper per request than ScrapingBee or BuiltWith — &lt;strong&gt;because it does less&lt;/strong&gt;. No JS execution, no headless browser, no crawler. Comparing $5.99/mo to $49/mo only makes sense once you've confirmed your target pages don't need JS rendering.&lt;/li&gt;
&lt;li&gt;BuiltWith isn't really a live-extraction competitor at all — it's a historical technology database you query ($295/mo minimum, no free tier), not a "fetch this URL right now" tool.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When each one actually wins
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This API&lt;/strong&gt; — normal server-rendered pages (blogs, marketing sites, product pages, GitHub, Wikipedia-style sites), when you want SEO metadata + tech stack + public contacts + security headers + SEO score in one ~150-300ms call. &lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;Free tier, 1,000 req/mo, no card required.&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ScrapingBee&lt;/strong&gt; — pages that need real browser interaction: logins, infinite scroll, clicks, screenshots, JS-scenario scripting. $49/mo floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firecrawl&lt;/strong&gt; — crawling a whole site (not just one URL) into clean Markdown for an LLM/RAG pipeline, with a real always-free tier for testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BuiltWith&lt;/strong&gt; — historical tech-stack data across large domain lists, plus exportable contact/lead lists, if $295/mo+ fits the budget for that specific use case.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The gap none of us solve for free
&lt;/h3&gt;

&lt;p&gt;None of these turn "public email/phone found on a page" into verified company/people intelligence. My &lt;code&gt;/api/v1/contacts&lt;/code&gt; endpoint is deliberately named "Contact Discovery," not "Lead Enrichment" — it returns raw signals, you still build the verification layer on top. That's true regardless of which extraction tool you start from.&lt;/p&gt;




&lt;p&gt;Full write-up with sources lives in the repo: &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor/blob/main/COMPETITOR_COMPARISON.md" rel="noopener noreferrer"&gt;COMPETITOR_COMPARISON.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;Try the free tier on RapidAPI&lt;/a&gt;&lt;br&gt;
👉 &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Curious what you all think — especially if you've hit the JS-rendering wall with a similar "lightweight extractor" approach. Happy to be told I'm missing an angle here.&lt;/p&gt;

</description>
      <category>api</category>
      <category>webscraping</category>
      <category>seo</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building a B2B Prospecting Agent in 15 Minutes with CrewAI and a Single API Call</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Mon, 24 Aug 2026 14:59:38 +0000</pubDate>
      <link>https://dev.to/josejux/building-a-b2b-prospecting-agent-in-15-minutes-with-crewai-and-a-single-api-call-18gn</link>
      <guid>https://dev.to/josejux/building-a-b2b-prospecting-agent-in-15-minutes-with-crewai-and-a-single-api-call-18gn</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;A B2B prospecting workflow usually looks the same regardless of industry: you have a list of target company domains, and for each one you need three things before you can write a useful outreach email — a way to &lt;em&gt;contact&lt;/em&gt; them, a read on what they're &lt;em&gt;running&lt;/em&gt; (so you can tailor the pitch), and a quick judgment call on whether they're even worth reaching out to.&lt;/p&gt;

&lt;p&gt;Doing this by hand doesn't scale past a handful of leads. Doing it with a scraper you wrote yourself means dealing with SSRF-safe fetching, HTML parsing, and rate limits before you've written a single line of actual agent logic.&lt;/p&gt;

&lt;p&gt;This walks through building a two-agent &lt;a href="https://www.crewai.com/" rel="noopener noreferrer"&gt;CrewAI&lt;/a&gt; crew that takes a list of company URLs and produces a prioritized, reasoned shortlist — using &lt;a href="https://pypi.org/project/crewai-webmetadata-extractor/" rel="noopener noreferrer"&gt;&lt;code&gt;crewai-webmetadata-extractor&lt;/code&gt;&lt;/a&gt; to handle the extraction side, so the agents can focus on judgment instead of parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;crewai crewai-webmetadata-extractor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need two keys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A free RapidAPI key for the &lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;Web Metadata &amp;amp; Contact Extractor API&lt;/a&gt; (1,000 requests/month, no card required)&lt;/li&gt;
&lt;li&gt;An API key for whatever LLM provider you're running CrewAI's agents on (OpenAI, Anthropic, etc. — CrewAI handles the provider abstraction, not covered here)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;WEBMETADATA_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-rapidapi-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The tools
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;crewai-webmetadata-extractor&lt;/code&gt; ships four ready-made &lt;code&gt;BaseTool&lt;/code&gt; subclasses. For a prospecting crew, two matter most:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it returns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WebContactsTool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Emails, phone numbers, and social links found on the page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WebMetadataExtractTool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full picture: SEO/OpenGraph data, detected tech stack, security-headers grade, Schema.org data, links&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every tool returns a JSON string, and API errors come back as &lt;code&gt;{"error": true, ...}&lt;/code&gt; instead of raising — so a bad URL in your lead list doesn't crash the whole crew run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the crew
&lt;/h2&gt;

&lt;p&gt;Two agents: one that gathers raw signal per company, one that turns that signal into a ranked, reasoned shortlist.&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;crewai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Crew&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Process&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;crewai_webmetadata_extractor&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WebContactsTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WebMetadataExtractTool&lt;/span&gt;

&lt;span class="n"&gt;researcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B2B Lead Researcher&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;goal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Extract contact information and technical footprint for a list of company websites&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;backstory&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;You investigate company websites to surface contact points and technology &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signals that a sales team can act on. You report facts, not conclusions.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;WebContactsTool&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;WebMetadataExtractTool&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
    &lt;span class="n"&gt;verbose&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="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;qualifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sales Development Rep&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;goal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Turn raw research into a prioritized, reasoned outreach shortlist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;backstory&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;You review research on prospective companies and decide who&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s worth &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contacting first, based on how good a fit their tech stack and public &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presence make them for our product.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;verbose&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="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;target_urls&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;https://example-company-one.com&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;https://example-company-two.com&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;https://example-company-three.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;research_task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&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;For each of these URLs, extract public contact info and the detected tech &lt;/span&gt;&lt;span class="sh"&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;stack: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_urls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. List what you found per company, including &lt;/span&gt;&lt;span class="sh"&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;any URL that returned no usable contact info.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A per-company breakdown of contacts found and tech stack detected.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;researcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;qualify_task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;description&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;Using the research above, rank the companies from most to least worth &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;contacting. Justify each ranking with a specific signal from the research &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(a detected technology, a missing security header, presence or absence of &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a direct contact channel) — not a generic guess.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;expected_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A ranked list of companies with a one-line justification each.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;qualifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;research_task&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;crew&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Crew&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;researcher&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qualifier&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;research_task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qualify_task&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crew&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kickoff&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the qualifier actually has to work with
&lt;/h2&gt;

&lt;p&gt;The researcher's tool calls return structured JSON, not free text — which is what lets the qualifier reason about specific fields instead of vibes. A &lt;code&gt;WebMetadataExtractTool&lt;/code&gt; call includes a &lt;code&gt;tech_stack&lt;/code&gt; array (CMS, analytics, frameworks detected) and a graded &lt;code&gt;security_headers&lt;/code&gt; object, so "rank by fit" can turn into something like &lt;em&gt;"runs WordPress with no CMS-specific caching layer detected and a missing CSP header — a plausible fit for a dev/security retainer"&lt;/em&gt; instead of a made-up reason.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WebContactsTool&lt;/code&gt;'s output is deliberately scoped to what's actually on the page (emails, phones, social links) — it's a raw signal for the qualifier to weigh, not a claim about verified company or people data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes from here
&lt;/h2&gt;

&lt;p&gt;The same two tools compose into other shapes: a &lt;code&gt;WebSEOAuditTool&lt;/code&gt; pass turns this into an SEO-focused sales angle ("your homepage is missing H1 structure, here's a report"); dropping in &lt;code&gt;WebMarkdownTool&lt;/code&gt; on the same URL list turns the researcher into the ingestion step for a RAG pipeline over your leads' own public content, instead of a prospecting crew.&lt;/p&gt;

&lt;p&gt;The point of separating "extraction" from "judgment" into two agents (rather than one agent doing both) is that the researcher's output stays inspectable — you can log or cache the raw JSON independent of whatever the qualifier concludes from it, and swap the qualifier's prompt without re-running extraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://pypi.org/project/crewai-webmetadata-extractor/" rel="noopener noreferrer"&gt;&lt;code&gt;crewai-webmetadata-extractor&lt;/code&gt; on PyPI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pypi.org/project/webmetadata-extractor/" rel="noopener noreferrer"&gt;Underlying Python SDK&lt;/a&gt; (&lt;code&gt;webmetadata-extractor&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pypi.org/project/langchain-webmetadata-extractor/" rel="noopener noreferrer"&gt;Also available for LangChain&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;Full API + free tier&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor" rel="noopener noreferrer"&gt;Source&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>crewai</category>
      <category>ai</category>
      <category>python</category>
      <category>agents</category>
    </item>
    <item>
      <title>Firecrawl vs. Jina Reader vs. Tavily: Picking the Right Web-to-LLM Tool (and When You Need None of Them)</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Sun, 23 Aug 2026 15:26:36 +0000</pubDate>
      <link>https://dev.to/josejux/firecrawl-vs-jina-reader-vs-tavily-picking-the-right-web-to-llm-tool-and-when-you-need-none-of-21ck</link>
      <guid>https://dev.to/josejux/firecrawl-vs-jina-reader-vs-tavily-picking-the-right-web-to-llm-tool-and-when-you-need-none-of-21ck</guid>
      <description>&lt;p&gt;If you're building a RAG pipeline or an autonomous agent, at some point you need to turn a URL into something an LLM can actually use. Over the last year a handful of APIs have grown up specifically to solve this — Firecrawl, Jina Reader, Tavily, and Crawl4AI are the four that come up constantly in agent-framework docs and Show HN threads. They get lumped together a lot, but they're not interchangeable, and none of them is really built for the narrower job of "get me this page's SEO metadata, contact info, and security posture." Here's an honest breakdown of what each one is actually for, what it costs, and where a purpose-built API still makes more sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each one is actually optimized for
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Firecrawl&lt;/strong&gt; is a crawler first, a scraper second. Its core primitives are Scrape (one page), Crawl (a whole site, following links), and Map (site structure discovery), all with JS rendering. If your job is "ingest this entire documentation site into my vector store," Firecrawl's Crawl endpoint is the right shape of tool — nothing else here does multi-page traversal out of the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Jina Reader&lt;/strong&gt; (&lt;code&gt;r.jina.ai&lt;/code&gt;) is the opposite end of the spectrum: the simplest possible interface. Prepend &lt;code&gt;https://r.jina.ai/&lt;/code&gt; to any URL and you get clean Markdown back, no request body, no SDK required. It's genuinely the fastest way to paste "the content of this page" into a prompt. It doesn't crawl, and it doesn't extract structured fields beyond the content itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tavily&lt;/strong&gt; is built around search, not single-URL extraction. Its bread and butter is the search endpoint (query in, ranked results with summaries out) for research-style agent loops; it has an extract endpoint too, but the product is designed around "search the web and synthesize an answer," not "give me every structured field on this specific page."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crawl4AI&lt;/strong&gt; is a different kind of answer entirely: it's not a hosted API, it's an open-source (Apache 2.0), self-hosted Python library and Docker service — you run it yourself instead of calling someone else's endpoint. It gives you full Playwright/Chromium control for JS-heavy pages, LLM-ready Markdown output, and both LLM-driven and CSS-selector-based structured extraction. The tradeoff for that control is that you own the infrastructure — proxies, browser pool, scaling. A commercial "Crawl4AI Cloud API" is in closed beta as of this writing, with no public pricing yet.&lt;/p&gt;

&lt;p&gt;None of the four is trying to be an SEO auditor, a security-headers grader, or a contact-discovery tool — that's a different job, and it's the one I've been building an open-source API for: &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor" rel="noopener noreferrer"&gt;https://github.com/JosejuX/rapidapi-metadata-extractor&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing and limits, compared
&lt;/h2&gt;

&lt;p&gt;This is where the practical differences show up fastest. Three of the four meter usage through a credit or token system; Crawl4AI's core is free because you're paying for your own compute instead of theirs, and the metadata-focused API I maintain doesn't meter by credits either — flat per-request.&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;Free tier&lt;/th&gt;
&lt;th&gt;Paid entry point&lt;/th&gt;
&lt;th&gt;Metering&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Firecrawl&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,000 credits/mo (10 scrapes/min, 1 crawl/min)&lt;/td&gt;
&lt;td&gt;$16/mo (Hobby, 5,000 credits)&lt;/td&gt;
&lt;td&gt;Credits: 1/page (scrape/crawl/map), 2/10 results (search), 5/page (stealth mode)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Jina Reader&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~20 req/min with no key; 100 RPM / 2 concurrent with a free key&lt;/td&gt;
&lt;td&gt;Paid tier: 500 RPM, $0.02 per 1M output tokens&lt;/td&gt;
&lt;td&gt;Token-based billing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tavily&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,000 credits/mo, no card&lt;/td&gt;
&lt;td&gt;$30/mo (4,000 credits) or $0.008/credit PAYG&lt;/td&gt;
&lt;td&gt;Credits: 1 (basic search) to 2 (advanced search)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Crawl4AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unlimited — self-hosted, Apache 2.0, you cover your own compute&lt;/td&gt;
&lt;td&gt;Cloud API in closed beta, pricing not yet public&lt;/td&gt;
&lt;td&gt;No metering; you run it, so cost is your infra bill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Web Metadata &amp;amp; Contact Extractor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,000 requests/mo, no card&lt;/td&gt;
&lt;td&gt;$5.99/mo (Pro)&lt;/td&gt;
&lt;td&gt;Flat per-request count, no credit/token math&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical difference isn't "cheaper" so much as "simpler to reason about." With a flat request count, if you're doing 1,000 lookups a month you know exactly where you stand — there's no separate accounting for how many credits a "stealth" scrape burned versus a plain one, or converting a token bill back into "how many pages was that."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the narrower tool wins
&lt;/h2&gt;

&lt;p&gt;If what you actually need is: "pull the SEO metadata, OpenGraph tags, public contact signals, tech stack, and a security-headers grade from this one URL," none of the three general-purpose tools return that as structured, named fields — you'd get raw Markdown or HTML back and have to parse it yourself. That's the gap this API fills:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;webmetadata_extractor&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;WebMetadataClient&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;WebMetadataClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YOUR_RAPIDAPI_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Structured fields, not raw markdown you have to re-parse
&lt;/span&gt;&lt;span class="n"&gt;seo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seo_audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seo_score_percentage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;seo&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warnings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;contacts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;emails&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;contacts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;social_links&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;security&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;security&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;security_headers&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;Compare that to getting Markdown back from Jina Reader, Firecrawl's Scrape, or your own Crawl4AI instance, and then writing your own regex/BeautifulSoup pass to pull out emails, security headers, or a 14-point SEO checklist — doable, but it's work these tools weren't designed to save you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it genuinely doesn't compete
&lt;/h2&gt;

&lt;p&gt;To be fair about the other side: this API has no crawler — one URL in, one page's data out (see the "Honest Limitations" section in the README: &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor#-honest-limitations" rel="noopener noreferrer"&gt;https://github.com/JosejuX/rapidapi-metadata-extractor#-honest-limitations&lt;/a&gt;). If you need to crawl an entire site, Firecrawl is the right tool, full stop. If you just want the fastest possible "read this page for me" for a prompt and don't care about structured fields, Jina Reader's one-line interface is hard to beat. If your agent's job is genuinely "search the web and answer a question," Tavily's search-plus-synthesis loop is built for exactly that in a way this API isn't trying to be. And if you need full rendering control and don't mind operating your own crawler infrastructure, Crawl4AI's self-hosted flexibility is a real advantage — this API is a managed service, not a framework you deploy and own.&lt;/p&gt;

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

&lt;p&gt;These aren't five competing products so much as five answers to five different questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Crawl this whole site" → Firecrawl&lt;/li&gt;
&lt;li&gt;"Give me this page as clean text, fastest way possible" → Jina Reader&lt;/li&gt;
&lt;li&gt;"Search the web and summarize" → Tavily&lt;/li&gt;
&lt;li&gt;"Full control, self-hosted, don't mind running infrastructure" → Crawl4AI&lt;/li&gt;
&lt;li&gt;"Give me structured SEO/contact/security/tech-stack data on this specific URL, without token or credit accounting" → this API: &lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick based on the shape of the job, not the hype cycle. If you want to try the structured-extraction angle, there's a free interactive demo (no signup) at &lt;a href="https://rapidapi-metadata-extractor.onrender.com" rel="noopener noreferrer"&gt;https://rapidapi-metadata-extractor.onrender.com&lt;/a&gt;, a Python SDK at &lt;a href="https://pypi.org/project/webmetadata-extractor/" rel="noopener noreferrer"&gt;https://pypi.org/project/webmetadata-extractor/&lt;/a&gt;, and now LangChain (&lt;a href="https://pypi.org/project/langchain-webmetadata-extractor/" rel="noopener noreferrer"&gt;https://pypi.org/project/langchain-webmetadata-extractor/&lt;/a&gt;) and CrewAI (&lt;a href="https://pypi.org/project/crewai-webmetadata-extractor/" rel="noopener noreferrer"&gt;https://pypi.org/project/crewai-webmetadata-extractor/&lt;/a&gt;) tool packages if you want to hand it directly to an agent.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>Grading Security Headers Isn't the Full Picture</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Wed, 19 Aug 2026 21:01:39 +0000</pubDate>
      <link>https://dev.to/josejux/grading-security-headers-isnt-the-full-picture-1gpm</link>
      <guid>https://dev.to/josejux/grading-security-headers-isnt-the-full-picture-1gpm</guid>
      <description>&lt;p&gt;A few days ago someone left a comment on my last post about grading security headers automatically:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Security-header grading is most useful when it explains tradeoffs, not only pass/fail. CSP especially needs context because a stricter policy can be correct technically and still break the product if rollout is blind."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fair hit. My original grader read &lt;code&gt;Content-Security-Policy&lt;/code&gt; and &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; as the same thing — a strict policy still in report-only mode (i.e., not actually blocking anything yet) scored identically to one fully enforced. That's not a rounding error, it's a real blind spot: a team mid-rollout with a report-only CSP gets the same green checkmark as a team that shipped a broken policy and never noticed.&lt;/p&gt;

&lt;p&gt;Fixing that made me look harder at what grading security from &lt;em&gt;headers alone&lt;/em&gt; actually misses — and the biggest gap isn't in the headers at all. It's in the TLS layer underneath them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Headers tell you intent. TLS tells you reality.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Strict-Transport-Security&lt;/code&gt; says "browsers should only ever load me over HTTPS." It says nothing about whether the certificate serving that HTTPS connection is valid right now, how many days until it expires, which TLS version actually got negotiated (a server can advertise HSTS and still fall back to TLS 1.0 for older clients), or who issued it. A site can have a perfect header score and be running on a cert that expires in 6 days. Headers audit configuration; a live handshake audits ground truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing the handshake for real
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_cert_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostname&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;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;3.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_default_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_connection&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timeout&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;sock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wrap_socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;server_hostname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;hostname&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;ssock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;cert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getpeercert&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ssock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;version&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;not_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strptime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notAfter&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;%b %d %H:%M:%S %Y %Z&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="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issuer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;issuer&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;valid_until&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;not_after&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;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tls_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_expired&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&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;not_after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter beyond the happy path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's blocking I/O.&lt;/strong&gt; In an async service, wrap it: &lt;code&gt;await loop.run_in_executor(None, get_cert_info, hostname)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It has to fail soft.&lt;/strong&gt; Self-signed certs, non-TLS ports, and slow/unreachable hosts are all normal inputs here, not exceptions to crash on — catch broadly and return &lt;code&gt;None&lt;/code&gt;, don't let a cert-inspection add-on take down a response that would otherwise be fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a second round-trip you didn't need before.&lt;/strong&gt; If your header audit only reads response headers you already have in memory, bolting on a live handshake by default silently doubles your latency for every caller, most of whom never asked for it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Make it opt-in, not default
&lt;/h2&gt;

&lt;p&gt;That third point is why I didn't fold this into the existing security-audit endpoint's default behavior — I gated it behind a query flag (&lt;code&gt;include_tls_details=true&lt;/code&gt;) that's off unless explicitly requested. The base audit stays header-only and fast; anyone who wants the deeper cert check asks for it and pays the extra round-trip knowingly.&lt;/p&gt;

&lt;p&gt;I ended up shipping both fixes — Report-Only-aware header context and opt-in TLS inspection — in the API from my last post:&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor" rel="noopener noreferrer"&gt;https://github.com/JosejuX/rapidapi-metadata-extractor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks again to the commenter who pushed on this — pass/fail without the &lt;em&gt;why&lt;/em&gt; is exactly the kind of audit tool that trains people to chase a green checkmark instead of understanding the header.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Grading a Website's Security Headers Automatically (HSTS, CSP, X-Frame-Options) in One API Call</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Sun, 16 Aug 2026 09:50:11 +0000</pubDate>
      <link>https://dev.to/josejux/grading-a-websites-security-headers-automatically-hsts-csp-x-frame-options-in-one-api-call-5e23</link>
      <guid>https://dev.to/josejux/grading-a-websites-security-headers-automatically-hsts-csp-x-frame-options-in-one-api-call-5e23</guid>
      <description>&lt;p&gt;If you've ever run a site through Mozilla Observatory or securityheaders.com and gotten a mediocre grade with no idea what to actually change, this post walks through what each header does, why the common advice ("just add CSP") is more nuanced than it sounds, and how to check this programmatically instead of one URL at a time in a browser tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why security headers matter more than they look like they should
&lt;/h2&gt;

&lt;p&gt;Security headers don't stop a determined attacker with a zero-day. What they do is close off entire classes of &lt;em&gt;cheap, automatable&lt;/em&gt; attacks: clickjacking, MIME-sniffing exploits, protocol-downgrade attacks, and a large chunk of XSS. A site with good headers isn't unhackable — it's just not hanging fruit for the scanners and bots that make up the overwhelming majority of real-world probing traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headers that actually matter, and what "correct" looks like
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Strict-Transport-Security (HSTS)&lt;/strong&gt; — tells the browser to never downgrade to plain HTTP for this domain, even if a link or bookmark points there. The common mistake: setting a short &lt;code&gt;max-age&lt;/code&gt; (or none at all), which means the protection lapses the moment a user hasn't visited in a while. &lt;code&gt;max-age=31536000; includeSubDomains&lt;/code&gt; is the baseline serious sites use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content-Security-Policy (CSP)&lt;/strong&gt; — the header with the worst reputation, because a naive CSP breaks half the page (inline scripts, third-party widgets, analytics). The honest advice: don't reach for &lt;code&gt;unsafe-inline&lt;/code&gt; and &lt;code&gt;unsafe-eval&lt;/code&gt; as your first move just to make errors go away — that defeats most of what CSP protects against. Start with &lt;code&gt;default-src 'self'&lt;/code&gt; and add specific sources as things break, rather than starting permissive and never tightening it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;X-Frame-Options / frame-ancestors&lt;/strong&gt; — prevents clickjacking by controlling whether your page can be embedded in an &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; on someone else's site. &lt;code&gt;SAMEORIGIN&lt;/code&gt; is right for almost everyone; &lt;code&gt;DENY&lt;/code&gt; if you never embed your own pages either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;X-Content-Type-Options: nosniff&lt;/strong&gt; — stops the browser from guessing a file's type based on content instead of the declared &lt;code&gt;Content-Type&lt;/code&gt;. Without it, a file uploaded as an "image" that's actually JavaScript can get executed in some edge cases. One line, no downside, frequently missing anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Referrer-Policy&lt;/strong&gt; — controls how much of your URL gets leaked to third parties (analytics scripts, external links) via the &lt;code&gt;Referer&lt;/code&gt; header. &lt;code&gt;strict-origin-when-cross-origin&lt;/code&gt; is a reasonable default: full URL on same-origin requests, just the origin cross-origin, nothing over plain HTTP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a grading system should actually check
&lt;/h2&gt;

&lt;p&gt;A binary "present/absent" check per header is a start, but it misses the cases that matter most: an &lt;code&gt;HSTS&lt;/code&gt; header with &lt;code&gt;max-age=0&lt;/code&gt; (present, but functionally disabled), a &lt;code&gt;CSP&lt;/code&gt; that's technically set but allows &lt;code&gt;unsafe-inline&lt;/code&gt; for scripts (present, but not doing its job), or a &lt;code&gt;Referrer-Policy&lt;/code&gt; set to &lt;code&gt;unsafe-url&lt;/code&gt; (present, actively leaking more than having no policy at all in some browsers' default behavior). Grading each header's &lt;em&gt;value&lt;/em&gt;, not just its presence, is what separates a useful audit from a checkbox exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing this programmatically instead of one tab at a time
&lt;/h2&gt;

&lt;p&gt;If you're auditing more than a handful of pages — a competitor teardown, a pre-launch checklist across a multi-page site, or a recurring monitor — checking headers by hand in browser dev tools doesn't scale. A single API call can fetch the page, read the response headers, grade each one, and return a percentage score plus per-header grades:&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;"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;"https://example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"security_score_percentage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;61.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"security_headers"&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;"strict_transport_security"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"max-age=300;includeSubdomains"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content_security_policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default-src 'self'; script-src 'self' 'unsafe-inline'..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"x_frame_options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SAMEORIGIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"x_content_type_options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nosniff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"referrer_policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"no-referrer-when-downgrade"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"permissions_policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"autoplay=(), camera=(), ..."&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;"security_header_grades"&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;"strict_transport_security"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"weak"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content_security_policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"weak"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"x_frame_options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"strong"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;max-age=300&lt;/code&gt; on HSTS in that example — technically present, graded "weak" because 5 minutes is far too short to matter in practice. That's the kind of nuance a presence-only check would miss entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  A real comparison: 61.7% vs. 93.3%
&lt;/h2&gt;

&lt;p&gt;To see the scoring actually differentiate between sites, here's the same audit run against two real, live pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;techcrunch.com — 61.7%&lt;/strong&gt;: strong on &lt;code&gt;X-Frame-Options&lt;/code&gt; and &lt;code&gt;X-Content-Type-Options&lt;/code&gt;, reasonable on &lt;code&gt;Referrer-Policy&lt;/code&gt; and &lt;code&gt;Permissions-Policy&lt;/code&gt;, but weak on both &lt;code&gt;Strict-Transport-Security&lt;/code&gt; (&lt;code&gt;max-age=300&lt;/code&gt; — five minutes) and &lt;code&gt;Content-Security-Policy&lt;/code&gt; (contains &lt;code&gt;unsafe-inline&lt;/code&gt;). Two present-but-weak headers are enough to drag an otherwise decent setup down by nearly 40 points.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This API's own demo site — 93.3%&lt;/strong&gt;: strong across &lt;code&gt;Strict-Transport-Security&lt;/code&gt; (&lt;code&gt;max-age=63072000; includeSubDomains&lt;/code&gt;), a nonce-based &lt;code&gt;Content-Security-Policy&lt;/code&gt; (no &lt;code&gt;unsafe-inline&lt;/code&gt;, no wildcard sources), &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt;, &lt;code&gt;X-Content-Type-Options: nosniff&lt;/code&gt;, and &lt;code&gt;Referrer-Policy: strict-origin-when-cross-origin&lt;/code&gt; — only &lt;code&gt;Permissions-Policy&lt;/code&gt; lands on "reasonable" instead of "strong", because the grading logic currently treats any non-empty policy as reasonable and doesn't have a "strong" tier for it. That's effectively the ceiling under the current heuristic, not a header that's actually missing anything meaningful.&lt;/p&gt;

&lt;p&gt;The gap between those two scores is the entire point of grading &lt;em&gt;values&lt;/em&gt;, not just presence — both sites &lt;em&gt;have&lt;/em&gt; most of these headers, but one of them actually enforces what the header is supposed to do.&lt;/p&gt;

&lt;p&gt;This is what the &lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;Web Metadata &amp;amp; Contact Extractor API&lt;/a&gt;'s &lt;code&gt;/api/v1/security&lt;/code&gt; endpoint returns — one call, real per-header grading instead of a pass/fail, and it composes with the same anti-SSRF-protected fetch layer as the rest of the API, so it's safe to point at user-submitted URLs. It's &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor" rel="noopener noreferrer"&gt;MIT-licensed on GitHub&lt;/a&gt; if you'd rather read the grading logic or self-host it, and there's a &lt;a href="https://rapidapi-metadata-extractor.onrender.com" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; if you just want to check a URL right now.&lt;/p&gt;

</description>
      <category>security</category>
      <category>api</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Daily Competitor SEO &amp; Tech-Stack Monitor with n8n and a Single API Call</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Sat, 15 Aug 2026 18:08:17 +0000</pubDate>
      <link>https://dev.to/josejux/building-a-daily-competitor-seo-tech-stack-monitor-with-n8n-and-a-single-api-call-h8n</link>
      <guid>https://dev.to/josejux/building-a-daily-competitor-seo-tech-stack-monitor-with-n8n-and-a-single-api-call-h8n</guid>
      <description>&lt;p&gt;Manually checking a handful of competitor sites every week for SEO regressions or a tech-stack change (a new CMS, a new analytics tool, a new payment provider showing up) doesn't scale, and it's exactly the kind of repetitive check that should just run itself. Here's how to automate it end-to-end with n8n, without writing a scraper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "monitoring a competitor" actually requires
&lt;/h2&gt;

&lt;p&gt;Two signals matter most for competitive/SEO monitoring, and they're both annoying to get reliably on your own:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An SEO health score&lt;/strong&gt; — are they missing meta descriptions, is their robots.txt blocking something it shouldn't, do they have broken canonical tags. This normally means building (or paying for) an auditing tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detected tech stack&lt;/strong&gt; — did they switch from Shopify to a custom storefront, did they add a new analytics or A/B testing tool, did they start using a new CMS. This normally means either manually inspecting page source/headers, or maintaining your own library of technology fingerprints (script src patterns, meta generator tags, cookie names) — which is a genuinely large, constantly-changing dataset to keep current.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both of these are solved problems as external APIs, which means the actual automation work is just: read a list of URLs, call an API, compare to yesterday's result, alert on meaningful changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The n8n workflow
&lt;/h2&gt;

&lt;p&gt;The shape of this workflow is simple enough to build in an afternoon:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Schedule Trigger&lt;/strong&gt; — once a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Sheets (Read)&lt;/strong&gt; — a &lt;code&gt;Competitors&lt;/code&gt; tab with columns &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;last_score&lt;/code&gt;, &lt;code&gt;last_stack&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loop over each row.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP Request (SEO audit)&lt;/strong&gt; and &lt;strong&gt;HTTP Request (tech stack)&lt;/strong&gt; — two parallel calls per URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge&lt;/strong&gt; the two responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare&lt;/strong&gt; the new score/stack against &lt;code&gt;last_score&lt;/code&gt;/&lt;code&gt;last_stack&lt;/code&gt; from the sheet — flag if the score dropped more than a threshold (5 points is a reasonable default) or the detected stack changed at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IF node&lt;/strong&gt; — only continue if something actually changed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack/email alert&lt;/strong&gt; (optional) — post the diff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Sheets (Update)&lt;/strong&gt; — write the new score/stack back for tomorrow's comparison.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the entire scenario. No scraper code, no maintaining your own tech-stack fingerprint database, no parsing HTML yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people usually get wrong: comparing state correctly
&lt;/h2&gt;

&lt;p&gt;The easy mistake is comparing the &lt;em&gt;current&lt;/em&gt; result to some fixed baseline instead of the &lt;em&gt;previous day's&lt;/em&gt; result — which either misses gradual regressions (a score that drops 2 points a day for a week is a 14-point drop nobody notices) or floods you with noise on volatile pages. Reading &lt;code&gt;last_score&lt;/code&gt;/&lt;code&gt;last_stack&lt;/code&gt; from the same row you're about to update, comparing, &lt;em&gt;then&lt;/em&gt; overwriting, is what makes this a rolling day-over-day diff instead of a one-time snapshot.&lt;/p&gt;

&lt;p&gt;For the tech-stack comparison specifically, sort the detected technology list before comparing (&lt;code&gt;sorted(stack).join(',')&lt;/code&gt; or equivalent) — an API returning the same technologies in a different order between runs will otherwise look like a "change" when nothing actually changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the API calls come from
&lt;/h2&gt;

&lt;p&gt;For the two HTTP Request nodes above, you need something that returns both an SEO audit score and a tech-stack fingerprint from a single URL — the &lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;Web Metadata &amp;amp; Contact Extractor API&lt;/a&gt; covers both endpoints (&lt;code&gt;/api/v1/seo-audit&lt;/code&gt; and &lt;code&gt;/api/v1/tech-stack&lt;/code&gt;) as part of its free tier (1,000 requests/month), which is enough headroom to monitor a meaningful watchlist of competitors daily without hitting limits.&lt;/p&gt;

&lt;p&gt;I've published this exact workflow — Google Sheets read, parallel API calls, day-over-day comparison, Slack alert, sheet update — as a ready-to-import &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor/tree/main/n8n-templates" rel="noopener noreferrer"&gt;n8n template&lt;/a&gt;, so you don't have to wire the nodes up from the description above. Drop in your own RapidAPI key and Google Sheet, point it at a &lt;code&gt;Competitors&lt;/code&gt; tab with a &lt;code&gt;url&lt;/code&gt; column, and it runs daily on its own.&lt;/p&gt;

&lt;p&gt;The API itself is &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor" rel="noopener noreferrer"&gt;open source (MIT license)&lt;/a&gt; if you'd rather self-host the extraction logic instead of using the hosted version — the n8n workflow works the same either way, you'd just point the HTTP Request nodes at your own instance.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>n8n</category>
      <category>seo</category>
      <category>api</category>
    </item>
    <item>
      <title>Turning Any Webpage into Clean, LLM-Ready Markdown for RAG Pipelines</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Sat, 15 Aug 2026 18:01:19 +0000</pubDate>
      <link>https://dev.to/josejux/turning-any-webpage-into-clean-llm-ready-markdown-for-rag-pipelines-a9c</link>
      <guid>https://dev.to/josejux/turning-any-webpage-into-clean-llm-ready-markdown-for-rag-pipelines-a9c</guid>
      <description>&lt;p&gt;If you're feeding web content into an LLM — for RAG, for an agent's context window, for a summarization pipeline — raw HTML is the wrong input. This post covers why, and the specific extraction problems you'll hit once you try to fix it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why raw HTML wastes your context window (and your accuracy)
&lt;/h2&gt;

&lt;p&gt;A typical webpage's HTML is 70-90% markup, navigation, ads, cookie banners, and scripts that have nothing to do with the actual content. Dump that into an LLM prompt and you're paying token cost for &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt; boilerplate, burning context budget that could hold more actual retrieved content, and — worse — sometimes confusing the model with off-topic sidebar links and "related articles" widgets that get treated as part of the main content.&lt;/p&gt;

&lt;p&gt;The fix is converting the page to clean Markdown: headings, paragraphs, lists, and links, with the chrome stripped out. This is a solved problem in principle (Readability-style content extraction has existed for years), but there are a few specific failure modes worth knowing about before you build it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: content-extraction heuristics fail on modern SPA sites
&lt;/h2&gt;

&lt;p&gt;Libraries like &lt;code&gt;readability-lxml&lt;/code&gt; or &lt;code&gt;newspaper3k&lt;/code&gt; work by looking at the &lt;em&gt;static&lt;/em&gt; HTML and guessing which &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; holds the "main content" based on text density heuristics. That works fine for a WordPress blog. It fails completely on a React/Next.js/Vue app where the initial HTML response is a nearly empty shell (&lt;code&gt;&amp;lt;div id="root"&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt;) and the actual content only exists after client-side JavaScript runs.&lt;/p&gt;

&lt;p&gt;If you're scraping server-side (no headless browser), you need to either detect this case and expand your fetch — reading more bytes to catch server-side-rendered (SSR) content that a naive byte-limited fetch would truncate — or fall back to a headless browser, which is much slower and heavier. A reasonable middle ground: detect known SPA framework signatures (React/Next.js/Vue/Angular/Svelte hydration markers) in the first chunk of HTML and adaptively increase how much you read before giving up, rather than always paying headless-browser cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: "clean" shouldn't mean "lossy"
&lt;/h2&gt;

&lt;p&gt;A lot of Markdown converters strip links entirely, or collapse them to plain text — which is bad for RAG, because the source URL of a claim is often exactly the citation info you want preserved for the agent to reference later. You want a converter that keeps &lt;code&gt;[text](url)&lt;/code&gt; link structure intact, keeps heading hierarchy (so a model can tell "this was an H2 under this H1"), and drops navigation/footer/ad content without dropping legitimate body links.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: word count and reading time aren't cosmetic — they're routing signals
&lt;/h2&gt;

&lt;p&gt;If you're chunking content for a RAG pipeline, knowing the Markdown's word count &lt;em&gt;before&lt;/em&gt; you decide how to chunk it (single chunk vs. split by heading vs. split by fixed token windows) saves a wasted round trip. Most raw-HTML-to-Markdown tools don't return this — you end up computing it yourself downstream, which is fine, but it's one more thing to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal example of what "good" output looks like
&lt;/h2&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;"markdown_content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"# Article Title&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;Actual paragraph content with [preserved links](https://example.com) and structure...&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;## A subheading&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;More content..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"word_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;842&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reading_time_minutes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&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;p&gt;Versus what you get from naive HTML stripping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Home About Contact Subscribe Article Title Actual paragraph content with preserved
links and structure More content Copyright 2026 Privacy Policy Terms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference matters more than it looks like on paper — the second version has already lost the information a downstream LLM needs to understand document structure, and it's mixed navigation text directly into what looks like body content.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combining this with metadata extraction in one call
&lt;/h2&gt;

&lt;p&gt;If your pipeline needs more than just the Markdown — OpenGraph title/description for a citation card, the page's detected tech stack for competitive research, contact info for lead enrichment — doing all of that as separate scraping steps means separate fetches, separate parsing logic, and separate places for SSRF bugs to creep in (see my post on SSRF-safe URL fetching for why that matters).&lt;/p&gt;

&lt;p&gt;This is the exact gap the &lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;Web Metadata &amp;amp; Contact Extractor API&lt;/a&gt; is built for: one GET request returns clean, link-preserving Markdown (with word count and reading time) &lt;em&gt;alongside&lt;/em&gt; SEO/OpenGraph metadata, contacts, social links, and a 40+ signature tech-stack detector — so a RAG ingestion step doesn't need five different tools glued together. It handles the SPA-detection problem with an adaptive byte-limit fetch, and every fetch goes through the same SSRF/DNS-rebinding-safe layer regardless of what data you're asking for.&lt;/p&gt;

&lt;p&gt;It's &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor" rel="noopener noreferrer"&gt;MIT-licensed and open source&lt;/a&gt; if you want to see exactly how the Markdown conversion and SPA detection work, or &lt;a href="https://rapidapi-metadata-extractor.onrender.com" rel="noopener noreferrer"&gt;try the live demo&lt;/a&gt; with any URL, no signup required.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>How to Safely Fetch Metadata from User-Submitted URLs (Without Getting Hit by SSRF or DNS Rebinding)</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Sat, 15 Aug 2026 17:53:33 +0000</pubDate>
      <link>https://dev.to/josejux/how-to-safely-fetch-metadata-from-user-submitted-urls-without-getting-hit-by-ssrf-or-dns-rebinding-1nfo</link>
      <guid>https://dev.to/josejux/how-to-safely-fetch-metadata-from-user-submitted-urls-without-getting-hit-by-ssrf-or-dns-rebinding-1nfo</guid>
      <description>&lt;p&gt;If your app lets a user paste in a URL — a link preview, a "import from website" button, an AI agent that browses the web on someone's behalf — you have a Server-Side Request Forgery (SSRF) problem whether you've thought about it or not. This post walks through exactly why naive URL fetching is dangerous, why the obvious fixes don't work, and how to actually close the hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach (and why it's broken)
&lt;/h2&gt;

&lt;p&gt;Most "fetch a URL and extract data from it" code looks like this:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first instinct to secure this is a blocklist check on the hostname or IP before fetching:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_safe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gethostbyname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_private&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_loopback&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_link_local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks reasonable and will pass casual testing. It is still exploitable.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS rebinding: the gap the blocklist misses
&lt;/h2&gt;

&lt;p&gt;The check above resolves the hostname, validates the IP, and then — separately — &lt;code&gt;requests.get()&lt;/code&gt; resolves the hostname &lt;em&gt;again&lt;/em&gt; when it actually opens the connection. Those are two different DNS lookups, at two different points in time, and nothing guarantees they return the same IP.&lt;/p&gt;

&lt;p&gt;An attacker controlling the DNS for &lt;code&gt;evil.example.com&lt;/code&gt; can configure a very short TTL and serve a legitimate public IP on the first lookup (the one your safety check sees) and then flip the DNS record to &lt;code&gt;169.254.169.254&lt;/code&gt; (the AWS/GCP/Azure cloud metadata endpoint) or &lt;code&gt;127.0.0.1&lt;/code&gt; for the second lookup (the one that actually happens when the HTTP client connects). Your check passes. Your fetch hits internal infrastructure anyway.&lt;/p&gt;

&lt;p&gt;This is DNS rebinding, and it's not a theoretical attack — it's been used in real SSRF exploits against exactly this pattern: "validate URL, then fetch URL" as two separate steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redirects are the second gap
&lt;/h2&gt;

&lt;p&gt;Even if you solve DNS rebinding, a URL that resolves cleanly to a public IP can still respond with an HTTP redirect to &lt;code&gt;http://169.254.169.254/latest/meta-data/&lt;/code&gt;. If your HTTP client follows redirects automatically (most do, by default) and you only validated the &lt;em&gt;original&lt;/em&gt; URL, the redirect target never gets checked at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual fix: resolve once, pin the connection, recheck every hop
&lt;/h2&gt;

&lt;p&gt;The only approach that closes both gaps is to make the IP validation and the connection use &lt;em&gt;the same resolved address&lt;/em&gt;, and to repeat that validation on every redirect hop, not just the first request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resolve the hostname's DNS &lt;strong&gt;once&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Validate that specific IP against private/loopback/link-local/cloud-metadata ranges.&lt;/li&gt;
&lt;li&gt;Open the HTTP connection &lt;strong&gt;directly to that IP&lt;/strong&gt; (not the hostname) — while still sending the correct &lt;code&gt;Host&lt;/code&gt; header and TLS SNI so the target server routes and certificate-validates correctly.&lt;/li&gt;
&lt;li&gt;If the response is a redirect, repeat steps 1–3 for the new location before following it. Never let a redirect chain sneak past validation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Python, this means using &lt;code&gt;requests&lt;/code&gt;' &lt;code&gt;HTTPAdapter&lt;/code&gt; with a custom connection pool (or &lt;code&gt;httpx&lt;/code&gt;'s transport hooks) to force the socket connection to the pre-validated IP instead of letting the library re-resolve the hostname at connect time. It's more code than a hostname blocklist, and it's the only version that's actually correct.&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;# Simplified sketch — the real implementation needs to handle
# IPv6, connection pooling, and TLS SNI carefully.
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;BLOCKED_RANGES&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;127.0.0.0/8&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;10.0.0.0/8&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;172.16.0.0/12&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;192.168.0.0/16&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;169.254.0.0/16&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# link-local + cloud metadata (169.254.169.254)
&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;resolve_and_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&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="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="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gethostbyname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_address&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&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;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addr&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ipaddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ip_network&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;BLOCKED_RANGES&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&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;Blocked IP range: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ip&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="n"&gt;ip&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;safe_fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;max_redirects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&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;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_redirects&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_and_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# re-validated on every hop
&lt;/span&gt;        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&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;Host&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;extensions&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;sni_hostname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="c1"&gt;# pin the socket to `ip` via a custom transport — omitted here for brevity
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Too many redirects&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;h2&gt;
  
  
  When this matters most: AI agents fetching arbitrary URLs
&lt;/h2&gt;

&lt;p&gt;This whole class of bug gets a lot more dangerous the moment an LLM agent is the one deciding what URL to fetch — from a prompt, from a tool call, from a webpage it just scraped. A user (or a malicious webpage the agent visited earlier) can steer the agent toward fetching &lt;code&gt;http://169.254.169.254/latest/meta-data/iam/security-credentials/&lt;/code&gt; and exfiltrate your cloud provider's IAM credentials through the agent's own output. If you're building a RAG pipeline, a browsing tool, or any MCP server that accepts a URL parameter from a model, this isn't optional hardening — treat every URL as attacker-controlled input.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you'd rather not build this yourself
&lt;/h2&gt;

&lt;p&gt;This exact resolve-once/pin-connection/recheck-every-hop pattern is what powers the URL-fetching layer of the &lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;Web Metadata &amp;amp; Contact Extractor API&lt;/a&gt; — it's a small REST API (and MCP server) that takes any URL and returns metadata, contacts, tech stack, and clean Markdown, with this SSRF/DNS-rebinding protection built in by default. The code is &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor" rel="noopener noreferrer"&gt;MIT-licensed on GitHub&lt;/a&gt; if you want to read the real implementation or self-host it — there's also a &lt;a href="https://rapidapi-metadata-extractor.onrender.com" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; you can try with no signup.&lt;/p&gt;

&lt;p&gt;Either way — build it yourself with the pattern above, or use something that already has it — the important part is not shipping the naive version.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Two Security Gaps Every RapidAPI Provider Forgets to Fix</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Tue, 11 Aug 2026 20:02:16 +0000</pubDate>
      <link>https://dev.to/josejux/the-two-security-gaps-every-rapidapi-provider-forgets-to-fix-15h2</link>
      <guid>https://dev.to/josejux/the-two-security-gaps-every-rapidapi-provider-forgets-to-fix-15h2</guid>
      <description>&lt;p&gt;If you've published an API on RapidAPI, there's a good chance it has one (or both) of these two holes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your real backend URL isn't actually secret
&lt;/h2&gt;

&lt;p&gt;RapidAPI's gateway is supposed to be the only way to reach your API. In practice, your real URL leaks constantly — a log line, a &lt;code&gt;Host&lt;/code&gt; header, a curious subscriber poking around. Once someone has it, they call your backend directly and skip RapidAPI's billing entirely.&lt;/p&gt;

&lt;p&gt;The fix is one dependency:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_rapidapi_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x_rapidapi_proxy_secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&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;x_rapidapi_proxy_secret&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;RAPIDAPI_PROXY_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Forbidden&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;Add &lt;code&gt;Depends(verify_rapidapi_secret)&lt;/code&gt; to a route, configure the same secret in RapidAPI Studio's header-injection settings, and that route only answers to traffic that actually came through the gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. If your API fetches a URL, it's an SSRF vector
&lt;/h2&gt;

&lt;p&gt;Any endpoint that takes a URL and fetches it server-side (link previews, "extract data from this page," scrapers) is a pivot point into your own private network or your cloud provider's metadata endpoint (&lt;code&gt;169.254.169.254&lt;/code&gt;). This is the single most common way a "fetch this for me" API turns into a real security incident.&lt;/p&gt;

&lt;p&gt;The naive fix — checking the hostname before fetching — doesn't work, because of DNS rebinding: the name resolves to something safe at check time and something private at connect time. The fix has to resolve the DNS once, pin the IP, and connect to that exact IP:&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;resolve_and_validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# blocks private/loopback/reserved/metadata ranges
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&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;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extensions&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;sni_hostname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And every redirect hop needs the same check re-run from scratch — a public URL that redirects to &lt;code&gt;http://169.254.169.254/&lt;/code&gt; is a very old trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm writing this
&lt;/h2&gt;

&lt;p&gt;I built both of these (plus rate limiting, a circuit breaker, caching, and observability) for a RapidAPI-published API I run in production. After doing it once, I didn't want to rebuild the same plumbing for the next one, so I extracted it into a standalone FastAPI starter kit — tested (39 tests, including a full SSRF attack-matrix suite), with CI, and a hardened Docker image.&lt;/p&gt;

&lt;p&gt;If you're publishing on RapidAPI and don't want to rebuild this yourself: &lt;a href="https://josecoder.gumroad.com/l/dxrfou" rel="noopener noreferrer"&gt;FastAPI + RapidAPI Starter Kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the SSRF/DNS-rebinding part in the comments — it's a more interesting problem than it looks.&lt;/p&gt;

</description>
      <category>fastapi</category>
      <category>python</category>
      <category>security</category>
      <category>api</category>
    </item>
    <item>
      <title>Building an Enterprise-Grade Web Metadata &amp; Contact Extractor API (&lt;200ms) with Python, FastAPI &amp; C-Lexbor</title>
      <dc:creator>Juanjo</dc:creator>
      <pubDate>Sun, 09 Aug 2026 15:03:15 +0000</pubDate>
      <link>https://dev.to/josejux/building-an-enterprise-grade-web-metadata-email-extractor-api-200ms-100-powered-by-gemini-ai-2k4a</link>
      <guid>https://dev.to/josejux/building-an-enterprise-grade-web-metadata-email-extractor-api-200ms-100-powered-by-gemini-ai-2k4a</guid>
      <description>&lt;p&gt;Hello DEV Community! 👋&lt;/p&gt;

&lt;p&gt;When building link preview cards (like WhatsApp or Slack), public contact-discovery pipelines, or web scrapers for AI/LLM RAG agents, fetching webpage metadata reliably and fast is surprisingly difficult.&lt;/p&gt;

&lt;p&gt;Traditional scrapers suffer from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🐢 &lt;strong&gt;High Latency&lt;/strong&gt;: Heavy DOM parsers taking 1-3 seconds per request.&lt;/li&gt;
&lt;li&gt;🚨 &lt;strong&gt;SSRF Vulnerabilities&lt;/strong&gt;: DNS Rebinding &amp;amp; redirect attacks accessing internal cloud metadata.&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;Broken SPA Rendering&lt;/strong&gt;: Downloading tiny empty HTML shells from React/Next.js apps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To solve this, I built the &lt;strong&gt;Web Metadata, OpenGraph &amp;amp; Contact Extractor API (v4.1.0)&lt;/strong&gt; — an ultra-fast REST API designed for high-concurrency production workloads.&lt;/p&gt;




&lt;h3&gt;
  
  
  ⚡ Key Tech Stack &amp;amp; Performance Architecture
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;C-Lexbor HTML Engine (&lt;code&gt;selectolax&lt;/code&gt;)&lt;/strong&gt;: Parses HTML DOM tree 10x faster than BeautifulSoup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rust &lt;code&gt;ORJSON&lt;/code&gt; Engine&lt;/strong&gt;: Ultra-fast JSON response serialization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-Blocking Async DNS Resolution&lt;/strong&gt;: Uses &lt;code&gt;asyncio.to_thread&lt;/code&gt; with an in-memory 5-minute DNS TTL cache to eliminate event-loop blocking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adaptive SPA Byte Limit (64 KB ➔ 256 KB)&lt;/strong&gt;: Automatically detects React, Next.js, Vue, Angular, Svelte, or Astro and dynamically expands the streaming buffer to capture server-side rendered data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP-Pinned Anti-SSRF Shield&lt;/strong&gt;: Resolves DNS once, validates target IP against private/loopback/cloud metadata ranges (&lt;code&gt;169.254.169.254&lt;/code&gt;), and pins the HTTP connection directly with TLS SNI headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Normalized 15-Min TTL In-Memory Cache&lt;/strong&gt;: Repeat URL extractions complete in &lt;strong&gt;&amp;lt; 0.01 ms&lt;/strong&gt; internal server time.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  💎 What Data Does It Extract?
&lt;/h3&gt;

&lt;p&gt;In a single GET request, the API returns a structured JSON payload containing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SEO &amp;amp; OpenGraph Metadata&lt;/strong&gt;: Title, Description, OG Image, OG Type, OG URL, OG Video, Favicon, Canonical URL, Language, Author, Theme Color, Robots directive (&lt;code&gt;index, follow&lt;/code&gt;), and &lt;code&gt;hreflang&lt;/code&gt; internationalization tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🛍️ E-Commerce Product Data&lt;/strong&gt;: Automatically extracts &lt;code&gt;Product&lt;/code&gt; Schema.org JSON-LD (Name, Price, Currency, Availability, Brand, Ratings &amp;amp; Reviews).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;📧 Public Contacts&lt;/strong&gt;: Public emails and telephone numbers with smart DOM script/style cleaning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;📲 Social Profiles&lt;/strong&gt;: Twitter/X, LinkedIn, Facebook, Instagram, GitHub, YouTube, Telegram, TikTok.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🛠️ 40+ Tech Stack Detector&lt;/strong&gt;: Identifies WordPress, Shopify, WooCommerce, Webflow, React, Next.js, Vue, Angular, TailwindCSS, Stripe, GA4, etc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🤖 AI &amp;amp; LLM Clean Markdown&lt;/strong&gt;: Converts web articles into clean Markdown for ChatGPT, Claude, and RAG pipelines (includes word count &amp;amp; reading time).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;📊 SEO Audit Score&lt;/strong&gt;: 14-point automated diagnostic score (0-100%) with actionable warnings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;🔒 Security Headers Audit&lt;/strong&gt;: HSTS, CSP, X-Frame-Options, Referrer Policy score.&lt;/li&gt;
&lt;/ol&gt;




&lt;h3&gt;
  
  
  🚀 Quick Code Examples
&lt;/h3&gt;

&lt;h4&gt;
  
  
  JavaScript / Node.js
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://web-metadata-and-contact-extractor.p.rapidapi.com/api/v1/extract?url=https%3A%2F%2Fgithub.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-RapidAPI-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;YOUR_RAPIDAPI_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-RapidAPI-Host&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;web-metadata-and-contact-extractor.p.rapidapi.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Product:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product_data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tech Stack:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detected_technologies&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Execution Time:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;execution_time_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Python
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://web-metadata-and-contact-extractor.p.rapidapi.com/api/v1/extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;headers&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;X-RapidAPI-Key&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;YOUR_RAPIDAPI_KEY&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;X-RapidAPI-Host&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;web-metadata-and-contact-extractor.p.rapidapi.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&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;url&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;https://stripe.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  🔑 Try It Free on RapidAPI
&lt;/h3&gt;

&lt;p&gt;The API is live and available on RapidAPI with &lt;strong&gt;1,000 free requests/month&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://rapidapi.com/josejuanjocoding/api/web-metadata-and-contact-extractor" rel="noopener noreferrer"&gt;Get your free API Key on RapidAPI&lt;/a&gt;&lt;br&gt;
👉 &lt;a href="https://github.com/JosejuX/rapidapi-metadata-extractor" rel="noopener noreferrer"&gt;GitHub Repository&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd love to hear your feedback, thoughts on the architecture, or feature requests!&lt;/p&gt;

&lt;p&gt;This piece is also cross-posted on &lt;a href="https://josejux.hashnode.dev/building-an-enterprise-grade-web-metadata-contact-extractor-api-200ms-with-python-fastapi-c-lexbor" rel="noopener noreferrer"&gt;Hashnode&lt;/a&gt; and, as a shorter companion version, on &lt;a href="https://medium.com/@josejuanjo.coding/building-an-enterprise-grade-web-metadata-contact-extractor-api-200ms-with-python-fastapi-4cf042dda28e" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;.&lt;/p&gt;

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