<?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: Perufitlife</title>
    <description>The latest articles on DEV Community by Perufitlife (@perufitlife).</description>
    <link>https://dev.to/perufitlife</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%2F3897417%2F545b848b-bfb9-4725-95f7-29d6af2e1bc7.png</url>
      <title>DEV Community: Perufitlife</title>
      <link>https://dev.to/perufitlife</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/perufitlife"/>
    <language>en</language>
    <item>
      <title>Free open-source security auditors for Supabase, Strapi, Hasura, Convex, Ollama &amp; more</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 23 Jun 2026 01:14:20 +0000</pubDate>
      <link>https://dev.to/perufitlife/free-open-source-security-auditors-for-supabase-strapi-hasura-convex-ollama-more-4jbl</link>
      <guid>https://dev.to/perufitlife/free-open-source-security-auditors-for-supabase-strapi-hasura-convex-ollama-more-4jbl</guid>
      <description>&lt;p&gt;Most backend data leaks aren't clever hacks. They're a database, CMS or API left readable by the &lt;strong&gt;anonymous / public role&lt;/strong&gt; — a default someone forgot to lock down before going to production.&lt;/p&gt;

&lt;p&gt;So I built a family of open-source auditors (MIT, zero dependencies) that check for exactly that, and &lt;strong&gt;confirm each leak with a read-only anonymous probe&lt;/strong&gt; — the same request any visitor's browser makes. Nothing is downloaded, nothing is changed. You get the bytes that are actually exposed, not a guess from a config file.&lt;/p&gt;

&lt;p&gt;One command each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx strapi-security   &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-strapi.example.com
npx directus-security &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-directus.example.com
npx hasura-security   &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-hasura.example.com
npx convex-security   &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-app.convex.cloud
npx ollama-security   &lt;span class="nt"&gt;--url&lt;/span&gt; http://your-host:11434
npx payload-security  &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-payload.example.com
npx n8n-security      &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-n8n.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus auditors for Supabase, Firebase, PocketBase, Appwrite and Nhost, and tools for served secret files (.env, .git, source maps) and Claude Code .claude/ config footguns.&lt;/p&gt;

&lt;p&gt;Full collection, all MIT:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/Perufitlife/awesome-backend-security" rel="noopener noreferrer"&gt;https://github.com/Perufitlife/awesome-backend-security&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Want me to run one for you — free?
&lt;/h2&gt;

&lt;p&gt;If you'd rather not install anything, drop your backend URL and I'll run the matching auditor and post the findings + the exact fixes back to you, &lt;strong&gt;free&lt;/strong&gt;. Read-only, nothing downloaded.&lt;/p&gt;

&lt;p&gt;Request a free audit: &lt;a href="https://github.com/Perufitlife/awesome-backend-security/issues/new?template=free-audit.yml" rel="noopener noreferrer"&gt;https://github.com/Perufitlife/awesome-backend-security/issues/new?template=free-audit.yml&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If it turns up something and you'd like the fixes done for you, there's a fixed-scope $99 option — but the tools and the audit are free, and that's the point: most of these holes take five minutes to close once you know they're there.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I gave my AI agent live aviation weather — building a free Aviation MCP server</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Sun, 14 Jun 2026 02:26:02 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-gave-my-ai-agent-live-aviation-weather-building-a-free-aviation-mcp-server-2cc8</link>
      <guid>https://dev.to/perufitlife/i-gave-my-ai-agent-live-aviation-weather-building-a-free-aviation-mcp-server-2cc8</guid>
      <description>&lt;p&gt;I'm a commercial pilot who builds software. Last week I noticed something: ask any AI assistant "what's the weather at JFK right now and is it VFR?" and it either guesses, hallucinates a METAR, or tells you to go check a website. LLMs have no live aviation data.&lt;/p&gt;

&lt;p&gt;So I built an MCP server that fixes that. It gives Claude, ChatGPT, Cursor — any MCP client — six aviation tools that return &lt;strong&gt;real&lt;/strong&gt; data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;get_metar&lt;/code&gt; — current decoded METAR for any ICAO airport (flight category, wind, visibility, temp, dewpoint), optional TAF&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_airport&lt;/code&gt; — airport info by ICAO (name, IATA, city, coordinates, elevation, runways)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_aircraft&lt;/code&gt; — aircraft specs by slug (engines, range, cruise, ceiling, MTOW, type rating)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_glossary_term&lt;/code&gt; — definitions from an aviation glossary&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;practice_questions&lt;/code&gt; — FAA-style exam questions with answers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;quiz_of_the_day&lt;/code&gt; — a daily aviation question&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No API key. No signup. It's a thin MCP wrapper over a free aviation API I maintain (&lt;a href="https://rotatepilot.com/developers" rel="noopener noreferrer"&gt;Rotate Pilot&lt;/a&gt;), so the tools are just typed HTTP calls — the hard part is the data, not the protocol.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why MCP, and why this was easy
&lt;/h3&gt;

&lt;p&gt;The Model Context Protocol is becoming the default way to hand tools to LLMs in 2026 — Claude, Cursor, Cline, Continue and Windsurf all speak it. If you have any API, wrapping it as an MCP server drops it into every AI client at once. That's a free distribution channel most API owners are sleeping on.&lt;/p&gt;

&lt;p&gt;The server runs as an Apify Standby Actor (Streamable HTTP &lt;code&gt;/mcp&lt;/code&gt; + legacy SSE), using the official &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt;. Each tool definition is ~10 lines: a name, a JSON schema, and a &lt;code&gt;buildPath(args)&lt;/code&gt; that returns the API path. The server fetches it and returns the JSON. That's the whole thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connect it
&lt;/h3&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;"mcpServers"&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;"aviation"&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;"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://renzomacar--aviation-mcp.apify.actor/mcp?token=YOUR_APIFY_TOKEN"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="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;Then ask your agent: &lt;em&gt;"Pull the METAR for KSFK and EGLL, tell me which is VFR, and compare a Cessna 172 to a Piper Warrior on cruise speed."&lt;/em&gt; It will call the tools and answer from real data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it / source
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;GitHub (MIT): &lt;a href="https://github.com/Perufitlife/aviation-mcp" rel="noopener noreferrer"&gt;https://github.com/Perufitlife/aviation-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Apify Store: &lt;a href="https://apify.com/renzomacar/aviation-mcp" rel="noopener noreferrer"&gt;https://apify.com/renzomacar/aviation-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The underlying free API + OpenAPI spec: &lt;a href="https://rotatepilot.com/developers" rel="noopener noreferrer"&gt;https://rotatepilot.com/developers&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curious what other niche data verticals are still missing from the MCP ecosystem — aviation felt like an obvious gap for a pilot. What's yours?&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>aviation</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Build a Lead-Gen Automation in n8n: Scrape, Enrich, Export</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:45:42 +0000</pubDate>
      <link>https://dev.to/perufitlife/build-a-lead-gen-automation-in-n8n-scrape-enrich-export-1l51</link>
      <guid>https://dev.to/perufitlife/build-a-lead-gen-automation-in-n8n-scrape-enrich-export-1l51</guid>
      <description>&lt;p&gt;Most "lead generation" tutorials stop at scraping a list of business names. But a name and a phone number isn't a lead - a name, a decision-maker email, and a verified contact channel is. In this tutorial we'll build a complete lead-gen pipeline in &lt;a href="https://n8n.io" rel="noopener noreferrer"&gt;n8n&lt;/a&gt; that does all three stages: &lt;strong&gt;scrape&lt;/strong&gt; businesses from Google Maps, &lt;strong&gt;enrich&lt;/strong&gt; them with emails crawled straight off their websites, and &lt;strong&gt;export&lt;/strong&gt; a clean list to Google Sheets.&lt;/p&gt;

&lt;p&gt;No code, no scraper maintenance. We lean on n8n's generic &lt;strong&gt;Apify&lt;/strong&gt; node to run two ready-made actors and chain them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline at a glance
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Trigger -&amp;gt; Scrape (Google Maps) -&amp;gt; Enrich (Website Contact Finder) -&amp;gt; Export (Sheets)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scrape&lt;/strong&gt; - pull businesses for a niche + city from Google Maps (name, phone, website, rating).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enrich&lt;/strong&gt; - take each business website and crawl it for emails, phones, and social links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export&lt;/strong&gt; - write the merged record to Google Sheets as one row per lead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stage 1 gives you reach. Stage 2 gives you the email that actually makes the lead actionable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A running n8n instance (cloud or self-hosted).&lt;/li&gt;
&lt;li&gt;A free &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; account. Grab your &lt;strong&gt;Personal API token&lt;/strong&gt; from &lt;strong&gt;Settings -&amp;gt; Integrations&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. Both actors we use are on the Apify Store and run through the same generic Apify node, so you only configure one credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 - Add the Apify credential
&lt;/h2&gt;

&lt;p&gt;Add a node in n8n, search &lt;strong&gt;Apify&lt;/strong&gt;, choose the generic Apify node. When it asks for credentials, select &lt;strong&gt;Apify API&lt;/strong&gt; and paste your token. Save. You'll reuse this credential for both the scrape and enrich steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 - Scrape businesses from Google Maps
&lt;/h2&gt;

&lt;p&gt;Add your first Apify node. Configure it to run the &lt;a href="https://apify.com/renzomacar/google-maps-businesses" rel="noopener noreferrer"&gt;Google Maps Email Extractor&lt;/a&gt; actor (&lt;code&gt;renzomacar/google-maps-businesses&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operation&lt;/strong&gt;: &lt;code&gt;Run actor and get dataset&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actor&lt;/strong&gt;: &lt;code&gt;renzomacar/google-maps-businesses&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input (JSON)&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&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;"searchQueries"&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="s2"&gt;"marketing agencies in Denver CO"&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;"maxResultsPerQuery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeWebsite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;Notice &lt;code&gt;includeWebsite&lt;/code&gt; is &lt;strong&gt;false&lt;/strong&gt; here. We deliberately keep this stage fast and cheap - we just want the business list and their website URLs. The deep email crawl happens in the next step with a dedicated tool that does it better.&lt;/p&gt;

&lt;p&gt;Each output item looks like:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summit Digital"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 720-555-0142"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"website"&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://summitdigital.co"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewsCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;96&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;h2&gt;
  
  
  Step 3 - Enrich each business with emails
&lt;/h2&gt;

&lt;p&gt;Now the enrichment step. Add a second Apify node, this time running the &lt;a href="https://apify.com/renzomacar/website-contact-finder" rel="noopener noreferrer"&gt;Email &amp;amp; Contact Finder&lt;/a&gt; actor (&lt;code&gt;renzomacar/website-contact-finder&lt;/code&gt;). This actor crawls a website's contact, about, and team pages and extracts emails, phone numbers, social profiles, and even the tech stack.&lt;/p&gt;

&lt;p&gt;The trick is feeding it the website URLs from Step 2. The actor takes a &lt;code&gt;domains&lt;/code&gt; array, so collect the websites from the previous node and pass them in. A simple way: drop a &lt;strong&gt;Code&lt;/strong&gt; node between the two Apify nodes to gather the URLs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Code node: collect website URLs from the Google Maps results&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&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="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;domains&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;Then configure the second Apify node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operation&lt;/strong&gt;: &lt;code&gt;Run actor and get dataset&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actor&lt;/strong&gt;: &lt;code&gt;renzomacar/website-contact-finder&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input (JSON)&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&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;"domains"&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="err"&gt;$json.domains&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;"maxPagesPerDomain"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeGenericEmails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detectTechStack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;domains&lt;/code&gt; value is mapped from the Code node, so it scales automatically with however many businesses you scraped. Each enriched result comes back like:&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;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"summitdigital.co"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hello@summitdigital.co"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jobs@summitdigital.co"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phones"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"+1 720-555-0142"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"socialLinks"&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;"linkedin"&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://linkedin.com/company/summit-digital"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"instagram"&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://instagram.com/summitdigital"&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;"techStack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"WordPress"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HubSpot"&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;Now you have an email and a social channel for outreach - the difference between a raw list and a usable pipeline. Bonus: the detected tech stack lets you segment ("everyone on HubSpot," "everyone still on WordPress") for sharper messaging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 - Merge and export to Google Sheets
&lt;/h2&gt;

&lt;p&gt;You've got two datasets: businesses (Step 2) and contacts (Step 3). Use n8n's &lt;strong&gt;Merge&lt;/strong&gt; node set to &lt;strong&gt;Combine -&amp;gt; Merge By Key&lt;/strong&gt;, keying on the website/domain so each business lines up with its scraped emails. Then add a &lt;strong&gt;Google Sheets&lt;/strong&gt; node with operation &lt;strong&gt;Append Row&lt;/strong&gt; and map the columns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sheet column&lt;/th&gt;
&lt;th&gt;Expression&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.name }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.emails[0] }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.phone }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.website }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.socialLinks.linkedin }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tech&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.techStack.join(", ") }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run it. Each row is now a real, enriched, ready-to-contact lead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 - Put it on a schedule
&lt;/h2&gt;

&lt;p&gt;Swap the manual trigger for a &lt;strong&gt;Schedule Trigger&lt;/strong&gt; and rotate your &lt;code&gt;searchQueries&lt;/code&gt; across cities and niches. Every run adds fresh, enriched leads to the sheet with no manual effort. Add a dedupe step (key on email or domain) if you run overlapping searches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why split scrape and enrich into two actors?
&lt;/h2&gt;

&lt;p&gt;You could ask the Google Maps actor to visit websites itself (&lt;code&gt;includeWebsite: true&lt;/code&gt;) and call it a day. That's fine for small jobs. But splitting the stages gives you two wins: the &lt;strong&gt;scrape stays fast and cheap&lt;/strong&gt;, and the &lt;strong&gt;enrichment is far more thorough&lt;/strong&gt; - the contact-finder actor crawls multiple pages per domain (contact, about, team) rather than just the homepage, so it surfaces emails the quick pass misses. For serious outreach lists, the two-stage pipeline pulls noticeably more verified emails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;That's a full lead-gen machine in n8n with zero scraping code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scrape&lt;/strong&gt; with &lt;a href="https://apify.com/renzomacar/google-maps-businesses" rel="noopener noreferrer"&gt;Google Maps Email Extractor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enrich&lt;/strong&gt; with &lt;a href="https://apify.com/renzomacar/website-contact-finder" rel="noopener noreferrer"&gt;Email &amp;amp; Contact Finder&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export&lt;/strong&gt; to Sheets, on a schedule&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both actors run through the same generic Apify node, so once your token is in, you can recombine them into any pipeline you like - reviews monitoring, competitor research, recruiting, you name it.&lt;/p&gt;

&lt;p&gt;Now go fill that sheet.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>leadgeneration</category>
      <category>automation</category>
      <category>nocode</category>
    </item>
    <item>
      <title>How to Scrape Google Maps Leads in n8n Without Code (Emails + Phones)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:44:28 +0000</pubDate>
      <link>https://dev.to/perufitlife/how-to-scrape-google-maps-leads-in-n8n-without-code-emails-phones-1j1g</link>
      <guid>https://dev.to/perufitlife/how-to-scrape-google-maps-leads-in-n8n-without-code-emails-phones-1j1g</guid>
      <description>&lt;p&gt;Lead generation usually means one of two painful things: paying for an expensive SaaS seat, or hand-copying business names and phone numbers off Google Maps one card at a time. If you already run &lt;a href="https://n8n.io" rel="noopener noreferrer"&gt;n8n&lt;/a&gt; for your automations, there is a third option that takes about ten minutes to set up and needs zero code.&lt;/p&gt;

&lt;p&gt;n8n ships with a generic &lt;strong&gt;Apify&lt;/strong&gt; node. That node can run any actor on the Apify Store and hand you back structured JSON you can pipe into Sheets, a CRM, or an email step. So instead of building a scraper, you point the node at a ready-made one. In this tutorial we will use a Google Maps scraper that pulls business &lt;strong&gt;names, phone numbers, websites, ratings, and emails&lt;/strong&gt;, then drop the results into Google Sheets.&lt;/p&gt;

&lt;p&gt;No browser automation to maintain, no proxies to rotate, no captcha headaches. Let's build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll build
&lt;/h2&gt;

&lt;p&gt;A 3-node n8n workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Manual / Schedule trigger&lt;/strong&gt; - kick the run off on demand or nightly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apify node&lt;/strong&gt; - run a Google Maps scraper actor with your search terms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Sheets node&lt;/strong&gt; - append every business as a new row&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The output is a clean lead list: business name, address, phone, website, rating, review count, and (optionally) the email scraped from the business website.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 - Get an Apify token
&lt;/h2&gt;

&lt;p&gt;You need a free &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; account. After signing up, go to &lt;strong&gt;Settings -&amp;gt; Integrations&lt;/strong&gt; and copy your &lt;strong&gt;Personal API token&lt;/strong&gt;. The free tier comes with monthly platform credit, which is plenty for testing and small lead lists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 - Add the Apify credential in n8n
&lt;/h2&gt;

&lt;p&gt;In n8n, open any workflow and add a new node. Search for &lt;strong&gt;Apify&lt;/strong&gt; and pick the generic Apify node (it talks to the Apify API directly - no custom community node required).&lt;/p&gt;

&lt;p&gt;When prompted for credentials, choose &lt;strong&gt;Apify API&lt;/strong&gt; and paste the token from Step 1. Save it. That credential is now reusable across every Apify-powered workflow you build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 - Point the node at the Google Maps actor
&lt;/h2&gt;

&lt;p&gt;The Apify node asks for an &lt;strong&gt;Actor&lt;/strong&gt; to run. We'll use the &lt;a href="https://apify.com/renzomacar/google-maps-businesses" rel="noopener noreferrer"&gt;Google Maps Email Extractor&lt;/a&gt; actor (&lt;code&gt;renzomacar/google-maps-businesses&lt;/code&gt;). It scrapes Google Maps search results and returns structured business data, and it can optionally visit each business website to grab an email - which is exactly what makes it useful for outreach.&lt;/p&gt;

&lt;p&gt;In the node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operation&lt;/strong&gt;: &lt;code&gt;Run actor and get dataset&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actor&lt;/strong&gt;: &lt;code&gt;renzomacar/google-maps-businesses&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input (JSON)&lt;/strong&gt;: paste the config below
&lt;/li&gt;
&lt;/ul&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;"searchQueries"&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="s2"&gt;"dentists in Miami FL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"coffee shops in Austin TX"&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;"maxResultsPerQuery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeWebsite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes on these fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;searchQueries&lt;/code&gt; is an array, so you can batch several searches in one run. Use the same phrasing you would type into Google Maps: &lt;code&gt;"&amp;lt;business type&amp;gt; in &amp;lt;city&amp;gt;"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maxResultsPerQuery&lt;/code&gt; caps how many businesses per query. Google Maps tops out around 120 per search, so anything up to that is realistic.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;includeWebsite: true&lt;/code&gt; tells the actor to open each business website and extract emails and social links. This is the magic toggle for outreach - leave it off if you only need phone numbers and want a faster, cheaper run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the node once. You should get an array of business objects back, each looking roughly like this:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bright Smile Dental"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123 Biscayne Blvd, Miami, FL 33132"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 305-555-0199"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"website"&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://brightsmilemiami.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;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hello@brightsmilemiami.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;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.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;"reviewsCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;214&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dental clinic"&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;h2&gt;
  
  
  Step 4 - Send the leads to Google Sheets
&lt;/h2&gt;

&lt;p&gt;Add a &lt;strong&gt;Google Sheets&lt;/strong&gt; node after the Apify node. Authenticate with your Google account, pick (or create) a spreadsheet, and set the operation to &lt;strong&gt;Append Row&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Map the columns to the fields coming out of the Apify node:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sheet column&lt;/th&gt;
&lt;th&gt;n8n expression&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.name }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.phone }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.email }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.website }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rating&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.rating }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Address&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.address }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because the Apify node outputs one item per business, n8n loops automatically - every business becomes its own row. Run the workflow and watch the sheet fill up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 (optional) - Email the list to yourself
&lt;/h2&gt;

&lt;p&gt;Want the lead list in your inbox instead? Swap (or add) an &lt;strong&gt;email / Gmail&lt;/strong&gt; node at the end. Pipe the dataset through a small &lt;strong&gt;Code&lt;/strong&gt; or &lt;strong&gt;Set&lt;/strong&gt; node to format it as an HTML table, then send. Now you have a nightly "fresh leads" email with zero manual work.&lt;/p&gt;

&lt;p&gt;You can also flip the Apify actor's &lt;code&gt;outputFormat&lt;/code&gt; to &lt;code&gt;html-report&lt;/code&gt; and it returns a polished, scored lead-list report you can attach directly - handy if you're delivering these to a client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it run on autopilot
&lt;/h2&gt;

&lt;p&gt;Replace the manual trigger with a &lt;strong&gt;Schedule Trigger&lt;/strong&gt; (say, every Monday at 8am) and rotate your &lt;code&gt;searchQueries&lt;/code&gt; - different cities, different niches - so each run brings in net-new leads. That's a self-refilling pipeline without writing a single line of scraping code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the node-based approach beats DIY scraping
&lt;/h2&gt;

&lt;p&gt;If you tried to scrape Google Maps yourself inside n8n with an HTTP Request node, you'd immediately hit dynamic JS rendering, rate limits, and layout changes that break your selectors every few weeks. Offloading that to a maintained actor means the brittle part is someone else's problem - you just consume clean JSON. That's the whole point of the Apify node: scraping becomes a single configured step in your automation, not a project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;In a handful of nodes you've got a no-code lead-gen pipeline: search Google Maps -&amp;gt; extract names, phones, websites and emails -&amp;gt; append to Sheets or email yourself -&amp;gt; schedule it. The same pattern works for any niche and any city.&lt;/p&gt;

&lt;p&gt;If you want to go deeper on the contact-enrichment side, the &lt;a href="https://apify.com/renzomacar/google-maps-businesses" rel="noopener noreferrer"&gt;Google Maps Email Extractor&lt;/a&gt; actor and its companion contact-finder are both on the Apify Store and run inside n8n exactly the way shown here.&lt;/p&gt;

&lt;p&gt;Happy automating.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>nocode</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>Healthcare Lead Generation With the Free NPI Registry (and How to Add the Missing Emails)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:23:03 +0000</pubDate>
      <link>https://dev.to/perufitlife/healthcare-lead-generation-with-the-free-npi-registry-and-how-to-add-the-missing-emails-116f</link>
      <guid>https://dev.to/perufitlife/healthcare-lead-generation-with-the-free-npi-registry-and-how-to-add-the-missing-emails-116f</guid>
      <description>&lt;p&gt;If you sell to clinics, doctors or dentists in the US, you're sitting on top of one of the cleanest free B2B datasets that exists and most people ignore it. The NPPES NPI registry is a government database of every healthcare provider in the country, it has a public JSON API, and there's no key, no scraping and no terms-of-service grey area. The catch: it gives you &lt;em&gt;who&lt;/em&gt; and &lt;em&gt;where&lt;/em&gt;, but not the email. This post is about closing that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the NPI registry actually is
&lt;/h2&gt;

&lt;p&gt;Every US provider that bills insurance has an NPI (National Provider Identifier). The Centers for Medicare &amp;amp; Medicaid Services publish the whole thing through NPPES, and there's a documented public API: &lt;a href="https://npiregistry.cms.hhs.gov/api-page" rel="noopener noreferrer"&gt;npiregistry.cms.hhs.gov/api-page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;You can query by name, specialty (taxonomy), city, state or postal code. Each record gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provider or organization name&lt;/li&gt;
&lt;li&gt;NPI number (so every lead is verifiably real)&lt;/li&gt;
&lt;li&gt;Primary taxonomy / specialty&lt;/li&gt;
&lt;li&gt;Practice address and phone&lt;/li&gt;
&lt;li&gt;Whether it's an individual (NPI-1) or organization (NPI-2)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's already more structured and more trustworthy than most paid lead lists, because every row maps to a government-verified identifier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling a target segment
&lt;/h2&gt;

&lt;p&gt;Say you want dentists in Austin, TX. The API takes simple query params:&lt;br&gt;
&lt;/p&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;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;taxonomy_description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Dentist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Austin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;200&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;res&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="s2"&gt;`https://npiregistry.cms.hhs.gov/api/?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="p"&gt;}&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;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;leads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;npi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;organization_name&lt;/span&gt;
        &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;first_name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last_name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;taxonomy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taxonomies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address_purpose&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LOCATION&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;limit&lt;/code&gt; caps at 200 per call and results are paginated with &lt;code&gt;skip&lt;/code&gt;, so you loop in pages until you've covered the segment. Be gentle with request rate — it's a public good, not a private API you're paying to abuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The missing piece: emails
&lt;/h2&gt;

&lt;p&gt;NPPES deliberately does not publish email addresses. So a raw NPI pull is a list of verified practices with phone + address but no inbox. To turn it into something you can actually run a cold email campaign against, you enrich each record with the practice's own website and the email published on it.&lt;/p&gt;

&lt;p&gt;The enrichment chain per lead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the website.&lt;/strong&gt; The NPI address + name is usually enough to resolve the practice site (a places lookup, or a constrained web search by name + city).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crawl the contact pages.&lt;/strong&gt; Same trick as any contact scraper — fetch &lt;code&gt;/contact&lt;/code&gt;, &lt;code&gt;/about&lt;/code&gt;, &lt;code&gt;/appointments&lt;/code&gt; etc. first, decode obfuscated &lt;code&gt;info [at] clinic [dot] com&lt;/code&gt; addresses, and filter image/asset false positives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the NPI as the join key.&lt;/strong&gt; Because every record has a unique NPI, you can dedupe and re-enrich cleanly later without guessing whether two rows are the same practice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the part that's tedious to maintain by hand: resolving the site, handling the 10-20% of practices with no site or a Facebook-only presence, retrying flaky requests, and not getting rate-limited across thousands of small clinic sites.&lt;/p&gt;

&lt;p&gt;I packaged the whole chain — NPI pull plus website resolution plus contact-page crawl — as a hosted, pay-per-lead scraper: &lt;a href="https://apify.com/renzomacar/healthcare-provider-leads" rel="noopener noreferrer"&gt;Healthcare Provider Leads&lt;/a&gt;. You give it a specialty + location, it returns NPI-verified providers with name, specialty, address and phone, &lt;em&gt;plus&lt;/em&gt; the emails and socials enriched from their sites. No API key, and you pay per lead delivered rather than per month.&lt;/p&gt;

&lt;p&gt;If you want to enrich a list of arbitrary practice websites you already have (not NPI-sourced), the generic &lt;a href="https://apify.com/renzomacar/website-contact-finder" rel="noopener noreferrer"&gt;Email Scraper &amp;amp; Contact Finder&lt;/a&gt; does just the crawl-and-extract step on any URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats buying a list
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verifiable.&lt;/strong&gt; Every lead has an NPI you can check against the public registry. Bought lists are full of dead and duplicated rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free at the source.&lt;/strong&gt; The provider data is taxpayer-funded and public; you only pay for the enrichment work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segmentable.&lt;/strong&gt; Taxonomy + geography filtering means you can target "pediatric dentists in Florida" exactly, instead of a generic "healthcare" dump.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The NPI registry solves the "is this a real, licensed provider?" problem for free and at scale. The only thing it's missing is the email — and that's a solvable enrichment step (resolve site, crawl contact pages, keep NPI as the key), not a reason to go buy a stale list. Start from the government data, enrich on top, and every lead in your CRM is one you can trace back to a real identifier.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>leadgeneration</category>
      <category>healthcare</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Scrape Emails and Contacts From Any Website (No API Key)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:22:23 +0000</pubDate>
      <link>https://dev.to/perufitlife/how-to-scrape-emails-and-contacts-from-any-website-no-api-key-54mo</link>
      <guid>https://dev.to/perufitlife/how-to-scrape-emails-and-contacts-from-any-website-no-api-key-54mo</guid>
      <description>&lt;p&gt;Most "find emails on a website" tutorials reach for a paid API on the second paragraph. You don't need one. Email addresses, phone numbers and social links are sitting in the public HTML of almost every business site. The hard parts are knowing &lt;em&gt;which&lt;/em&gt; pages to fetch, parsing the matches without drowning in false positives, and doing it politely enough that you don't get blocked. This post walks through how to build a no-API-key contact scraper, and where the same logic falls apart at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you don't need an API key
&lt;/h2&gt;

&lt;p&gt;A "contact enrichment API" is mostly doing three things on your behalf:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetching a handful of pages from the target domain.&lt;/li&gt;
&lt;li&gt;Running regex/heuristics over the HTML.&lt;/li&gt;
&lt;li&gt;De-duplicating and scoring the results.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three are things you can do yourself with &lt;code&gt;fetch&lt;/code&gt; and a parser. The API's real value is the &lt;em&gt;database&lt;/em&gt; it has pre-crawled, plus deliverability verification. For finding the emails a company actually publishes on its own site, you're paying for steps you can run locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: hit the right pages first, not the homepage
&lt;/h2&gt;

&lt;p&gt;The single biggest mistake is scraping only the homepage. Companies almost never put their real contact email on &lt;code&gt;/&lt;/code&gt;. They put it on &lt;code&gt;/contact&lt;/code&gt;, &lt;code&gt;/about&lt;/code&gt;, &lt;code&gt;/team&lt;/code&gt;, &lt;code&gt;/imprint&lt;/code&gt;, &lt;code&gt;/support&lt;/code&gt;, or a footer that links to those.&lt;/p&gt;

&lt;p&gt;So the crawl order matters more than the crawl depth. A good heuristic: fetch the homepage, extract internal links, then prioritize any link whose URL or anchor text matches a contact-intent pattern.&lt;br&gt;
&lt;/p&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;CONTACT_HINTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/contact|about|team|imprint|impressum|support|help|kontakt|nosotros|equipo/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rankLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;links&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;links&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;sameHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// contact-intent pages first&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;CONTACT_HINTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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;Crawling 3-4 &lt;em&gt;ranked&lt;/em&gt; pages beats crawling 30 random ones, both for hit-rate and for not hammering the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: extract without the false-positive swamp
&lt;/h2&gt;

&lt;p&gt;The naive email regex matches a lot of junk: image filenames like &lt;code&gt;logo@2x.png&lt;/code&gt;, Sentry/analytics keys, version strings. Tighten it and then filter.&lt;br&gt;
&lt;/p&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;EMAIL_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9._%+&lt;/span&gt;&lt;span class="se"&gt;\-]&lt;/span&gt;&lt;span class="sr"&gt;+@&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9.&lt;/span&gt;&lt;span class="se"&gt;\-]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.[&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;]{2,}&lt;/span&gt;&lt;span class="sr"&gt;/gi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractEmails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EMAIL_RE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;png|jpe&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;g|gif|webp|svg&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;     &lt;span class="c1"&gt;// image @2x assets&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{8,}&lt;/span&gt;&lt;span class="sr"&gt;@/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                  &lt;span class="c1"&gt;// hashed analytics ids&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;sentry|wixpress|example|domain&lt;/span&gt;&lt;span class="se"&gt;)\.&lt;/span&gt;&lt;span class="sr"&gt;com$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;Don't forget obfuscated addresses. Many sites write &lt;code&gt;hello [at] company [dot] com&lt;/code&gt; or hide the address behind &lt;code&gt;mailto:&lt;/code&gt; only, or split it with &lt;code&gt;&amp;amp;#64;&lt;/code&gt; HTML entities. Decode entities before you run the regex, and add a second pass for the &lt;code&gt;[at]&lt;/code&gt;/&lt;code&gt;[dot]&lt;/code&gt; pattern.&lt;/p&gt;

&lt;p&gt;Phones and socials are the same idea: a permissive regex plus a denylist. For socials, match &lt;code&gt;linkedin.com/company/&lt;/code&gt;, &lt;code&gt;twitter.com/&lt;/code&gt;, &lt;code&gt;instagram.com/&lt;/code&gt; etc. and strip share-intent URLs (&lt;code&gt;/share?&lt;/code&gt;, &lt;code&gt;/sharer&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: be polite or get blocked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Set a real &lt;code&gt;User-Agent&lt;/code&gt;. Default fetch agents get filtered.&lt;/li&gt;
&lt;li&gt;Respect a small concurrency cap per host (2-3) and add jitter.&lt;/li&gt;
&lt;li&gt;Honor &lt;code&gt;robots.txt&lt;/code&gt; for the paths you crawl.&lt;/li&gt;
&lt;li&gt;Cache by host so you don't refetch the homepage for every email you want.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most "the scraper stopped working" reports are really "the scraper got rate-limited because it fired 50 parallel requests with a python-requests UA."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the DIY version breaks down
&lt;/h2&gt;

&lt;p&gt;The script above is great for tens of sites. Past that you hit the operational tail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency + proxies&lt;/strong&gt; so one IP doesn't get blocked across thousands of domains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retries with backoff&lt;/strong&gt; for the 10-15% of sites that flake on first request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JS-rendered contact widgets&lt;/strong&gt; (some sites inject the email via JavaScript), which need a headless browser only sometimes — running one for every site is wasteful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tech-stack detection&lt;/strong&gt; if you want to segment leads (e.g. "all Shopify stores in this list").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's exactly the boundary where I stopped maintaining a local script and moved the logic onto a hosted runner. I publish two of them as pay-per-result scrapers on Apify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://apify.com/renzomacar/website-contact-finder" rel="noopener noreferrer"&gt;Email Scraper &amp;amp; Contact Finder&lt;/a&gt; — feed it a list of websites, it does the ranked-crawl + extraction + tech-stack detection described above and returns emails, phones and socials. No API key, no login.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/renzomacar/google-maps-businesses" rel="noopener noreferrer"&gt;Google Maps Email Extractor&lt;/a&gt; — when you don't even have the list of websites yet. Give it a search term + location, it pulls the local businesses from Google Maps (name, address, phone, website, rating) and then runs the contact crawl on each site to get the email. No Google API key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both run on Apify's free tier to try, and you pay per result rather than a monthly seat — which is the right shape for lead-gen work that's bursty rather than constant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;For finding the contact details a business publishes about itself, the API-key requirement is mostly artificial. Ranked crawling (contact pages first), a tightened regex with a denylist, entity/obfuscation decoding, and basic politeness get you most of the way. Reach for a hosted runner only when concurrency, proxy rotation and retries become the actual job — which is later than most tutorials would have you believe.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>leadgeneration</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Build a Healthcare Lead List From the Public NPI Registry (NPPES API)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Fri, 12 Jun 2026 09:52:35 +0000</pubDate>
      <link>https://dev.to/perufitlife/build-a-healthcare-lead-list-from-the-public-npi-registry-nppes-api-2n1f</link>
      <guid>https://dev.to/perufitlife/build-a-healthcare-lead-list-from-the-public-npi-registry-nppes-api-2n1f</guid>
      <description>&lt;p&gt;If you run a marketing agency that serves dentists, dermatologists, med spas, or any medical vertical, you've probably paid way too much for a lead list — and half the rows were stale. Here's the thing almost nobody outside the data world knows: there is a &lt;strong&gt;free, official, public registry of every healthcare provider in the United States&lt;/strong&gt;, and it has a clean API.&lt;/p&gt;

&lt;p&gt;It's called NPPES — the National Plan and Provider Enumeration System — and it backs the NPI Registry that CMS (Medicare/Medicaid) maintains. Every provider who bills insurance has an NPI (National Provider Identifier), and the registry is public record.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free NPPES API
&lt;/h2&gt;

&lt;p&gt;No key, no signup. You hit one endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://npiregistry.cms.hhs.gov/api/?version=2.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It supports filtering by taxonomy (specialty), city, state, and more. Here's a Node example pulling every dentist in Austin, TX:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;got&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;got&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;searchProviders&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;taxonomy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&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;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="c1"&gt;// API caps each call at 200 results; page with skip&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;200&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;}&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;got&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://npiregistry.cms.hhs.gov/api/&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="na"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;taxonomy_description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;taxonomy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "Dentist"&lt;/span&gt;
        &lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&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;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;for &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;p&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;page&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;addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addresses&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address_purpose&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LOCATION&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
      &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;npi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;providerName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;organization_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
          &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;first_name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basic&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;last_name&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="na"&gt;specialty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taxonomies&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nx"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;telephone_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;fullAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address_1&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postal_code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;results&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;leads&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;searchProviders&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;taxonomy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Dentist&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Austin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TX&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;leads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;providers&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;p&gt;In a few seconds you have name, NPI, specialty, phone, and full address for every dentist in a city — straight from the source of truth, updated as providers re-enumerate with CMS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The missing piece: email enrichment
&lt;/h2&gt;

&lt;p&gt;The registry gives you a phone and address but &lt;strong&gt;no email&lt;/strong&gt; — that's the gap that makes the raw API only half useful for cold outreach. The fix is a second pass: for each provider, find their practice website (Google/Bing the name + city, or use the listed org URL), then crawl the site's contact and about pages to extract emails, additional phone numbers, social links, and even the tech stack.&lt;/p&gt;

&lt;p&gt;That enrichment loop is where the value is. A practice's &lt;code&gt;info@&lt;/code&gt; or front-desk email plus a verified website turns a registry row into an actual lead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a finished lead looks like
&lt;/h2&gt;

&lt;p&gt;After the registry pull + website enrichment, each row carries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;npi&lt;/code&gt;, &lt;code&gt;providerName&lt;/code&gt;, &lt;code&gt;providerType&lt;/code&gt; (individual vs organization)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;specialty&lt;/code&gt;, &lt;code&gt;credential&lt;/code&gt;, &lt;code&gt;licenseNumber&lt;/code&gt;, &lt;code&gt;licenseState&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fullAddress&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;fax&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;website&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt; / &lt;code&gt;emails&lt;/code&gt; (all discovered), &lt;code&gt;websitePhones&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;socialLinks&lt;/code&gt; (Facebook, Instagram), &lt;code&gt;techStack&lt;/code&gt;, &lt;code&gt;contactPageUrl&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;techStack&lt;/code&gt; field is a sneaky-good qualifier for agencies: a dentist still on a 2014 template website is a far warmer lead for a web-redesign or paid-ads pitch than one already running a modern stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on doing this responsibly
&lt;/h2&gt;

&lt;p&gt;The NPI registry is public business-contact data — practice addresses and front-desk lines, not patient data, and nothing HIPAA-covered. Still: respect CAN-SPAM, honor unsubscribes, and target the &lt;em&gt;practice&lt;/em&gt;, not individuals' personal info. This is B2B outreach to businesses, full stop.&lt;/p&gt;

&lt;p&gt;If you'd rather not build and maintain the registry pagination + website-enrichment crawler yourself, I packaged the whole pipeline into a &lt;a href="https://apify.com/renzomacar/healthcare-provider-leads" rel="noopener noreferrer"&gt;Healthcare Provider Leads&lt;/a&gt; actor — you give it a &lt;code&gt;specialty&lt;/code&gt;, &lt;code&gt;city&lt;/code&gt;, and &lt;code&gt;state&lt;/code&gt;, toggle &lt;code&gt;enrichEmails&lt;/code&gt;, optionally set &lt;code&gt;onlyWithEmail&lt;/code&gt;, and it returns the enriched rows above, one per provider. But the NPPES API is free and public, so even the DIY version above will get an agency a clean, current vertical list today.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>leadgeneration</category>
      <category>healthcare</category>
      <category>api</category>
    </item>
    <item>
      <title>How to Scrape the Facebook Ad Library for Competitor Ad Intelligence (No Login)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Fri, 12 Jun 2026 09:51:36 +0000</pubDate>
      <link>https://dev.to/perufitlife/how-to-scrape-the-facebook-ad-library-for-competitor-ad-intelligence-no-login-18ce</link>
      <guid>https://dev.to/perufitlife/how-to-scrape-the-facebook-ad-library-for-competitor-ad-intelligence-no-login-18ce</guid>
      <description>&lt;p&gt;The Facebook (Meta) Ad Library is one of the most underrated datasets in marketing. Because of ad-transparency regulation, Meta is &lt;strong&gt;legally required&lt;/strong&gt; to publish every ad running across Facebook, Instagram, Messenger, and Audience Network — searchable by advertiser, keyword, and country, by anyone, with no account.&lt;/p&gt;

&lt;p&gt;That means every competitor's live creative strategy is sitting in a public endpoint. The problem is getting it out cleanly. Let me walk through how the Ad Library actually serves its data and how to scrape it without a Facebook login.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ad Library is public — but the data is in XHR, not HTML
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;https://www.facebook.com/ads/library/&lt;/code&gt; and search a brand. The visible page is a React app; the ad cards you see are &lt;strong&gt;not&lt;/strong&gt; in the initial HTML. They arrive via background GraphQL calls (&lt;code&gt;/api/graphql/&lt;/code&gt;) that the page fires after load. So a naive &lt;code&gt;fetch&lt;/code&gt; + HTML parse gets you almost nothing.&lt;/p&gt;

&lt;p&gt;The robust approach is &lt;strong&gt;browser-intercept&lt;/strong&gt;: drive a headless browser to the search URL, let the page make its own signed GraphQL requests, and capture the JSON responses as they come back. The page signs its own requests (tokens, doc IDs, session params), so you ride along instead of trying to forge them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Intercepting the GraphQL responses
&lt;/h2&gt;

&lt;p&gt;With Playwright, you hook the network layer and grab the responses whose payload contains ad nodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&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;ads&lt;/span&gt; &lt;span class="o"&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;browser&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;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&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;page&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;response&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/graphql/&lt;/span&gt;&lt;span class="dl"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&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="k"&gt;try&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;json&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;res&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="c1"&gt;// ad nodes live under search results edges in the GraphQL payload&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nx"&gt;json&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;ad_library_main&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;search_results_connection&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;edges&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;for &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;edge&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;results&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;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;collated_results&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="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;snap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
      &lt;span class="nx"&gt;ads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;adArchiveId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ad_archive_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;pageName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;snap&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;ctaText&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cta_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;ctaType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cta_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;linkUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;link_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;platforms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publisher_platform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* not the payload we want */&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Nike&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`https://www.facebook.com/ads/library/?active_status=all&amp;amp;ad_type=all&amp;amp;country=US&amp;amp;q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;networkidle&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="c1"&gt;// scroll to trigger pagination, then read `ads`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scroll the page to trigger more GraphQL pages, dedupe by &lt;code&gt;adArchiveId&lt;/code&gt;, and you've got a structured feed of a competitor's entire active ad set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you actually get out
&lt;/h2&gt;

&lt;p&gt;The interesting fields for ad spying / competitor research:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Creative&lt;/strong&gt;: &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;body&lt;/code&gt;, &lt;code&gt;caption&lt;/code&gt;, plus &lt;code&gt;imageUrls&lt;/code&gt; / &lt;code&gt;videoUrls&lt;/code&gt; for the actual assets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carousel cards&lt;/strong&gt; parsed out individually — each card's image, headline, and link&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call-to-action&lt;/strong&gt;: &lt;code&gt;ctaText&lt;/code&gt; ("Shop Now", "Sign Up") and &lt;code&gt;ctaType&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Targeting signals&lt;/strong&gt;: &lt;code&gt;platforms&lt;/code&gt; (which of FB/IG/Messenger it runs on), &lt;code&gt;startDate&lt;/code&gt; / &lt;code&gt;endDate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advertiser context&lt;/strong&gt;: &lt;code&gt;pageName&lt;/code&gt;, &lt;code&gt;pageLikeCount&lt;/code&gt;, &lt;code&gt;pageCategories&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For political/issue ads only&lt;/strong&gt; (extra transparency): &lt;code&gt;spend&lt;/code&gt;, &lt;code&gt;impressions&lt;/code&gt;, &lt;code&gt;currency&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gold here is &lt;strong&gt;duration + variant count&lt;/strong&gt;. If a competitor has been running the same creative for three months across five variants, that ad is &lt;em&gt;working&lt;/em&gt; — they don't burn budget on losers. You just reverse-engineered their winning hook for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;strong&gt;residential proxies&lt;/strong&gt; keyed to the country you're querying — the Ad Library is geo-partitioned and datacenter IPs get throttled fast.&lt;/li&gt;
&lt;li&gt;GraphQL doc IDs change; that's why intercepting the page's own requests beats hardcoding the query. Let Meta sign it.&lt;/li&gt;
&lt;li&gt;Respect the obvious: this is public transparency data, not private user data. Scrape creatives and CTAs, not people.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you'd rather not maintain the browser-intercept plumbing yourself, I packaged this exact approach into a &lt;a href="https://apify.com/renzomacar/facebook-ads-library-scraper" rel="noopener noreferrer"&gt;Facebook Ad Library Scraper&lt;/a&gt; — you pass a &lt;code&gt;searchQuery&lt;/code&gt;, &lt;code&gt;country&lt;/code&gt;, and &lt;code&gt;adType&lt;/code&gt;, and it returns the 20+ fields above (including separated carousel cards and &lt;code&gt;spend&lt;/code&gt;/&lt;code&gt;impressions&lt;/code&gt; for political ads) without any login. Either way, the lesson is the same: the best competitor-ad dataset on the internet is public by law, and you reach it by riding the page's own GraphQL calls.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>marketing</category>
      <category>facebook</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to Scrape Public Telegram Channels Without the API, Login, or MTProto</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Fri, 12 Jun 2026 09:51:24 +0000</pubDate>
      <link>https://dev.to/perufitlife/how-to-scrape-public-telegram-channels-without-the-api-login-or-mtproto-4438</link>
      <guid>https://dev.to/perufitlife/how-to-scrape-public-telegram-channels-without-the-api-login-or-mtproto-4438</guid>
      <description>&lt;p&gt;Most tutorials on scraping Telegram start the same way: register an app at my.telegram.org, get an &lt;code&gt;api_id&lt;/code&gt; and &lt;code&gt;api_hash&lt;/code&gt;, install a giant MTProto client like Telethon or GramJS, and authenticate with your &lt;strong&gt;own phone number&lt;/strong&gt;. That works, but it has a nasty cost: you are putting a real account on the line. Telegram bans MTProto sessions that look automated, and tying your personal number to a scraper is a great way to lose it.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;public&lt;/strong&gt; channels, you don't need any of that. There's a much simpler door, and it's been hiding in plain sight: &lt;code&gt;t.me/s/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: &lt;code&gt;t.me/s/&amp;lt;channel&amp;gt;&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When you open a public channel in a browser, Telegram normally serves a JS app. But there's a special preview route — the &lt;code&gt;/s/&lt;/code&gt; (for "slug" / preview) path — that returns &lt;strong&gt;server-rendered HTML&lt;/strong&gt; of the channel feed. No JavaScript execution, no login wall, no token.&lt;/p&gt;

&lt;p&gt;Try it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://t.me/s/durov
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That page contains the last ~20 messages as plain HTML, with view counts, timestamps, media URLs, link previews, and forwarded-from info baked right into the markup. You can paginate backwards in time with a &lt;code&gt;?before=&amp;lt;messageId&amp;gt;&lt;/code&gt; query parameter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing it
&lt;/h2&gt;

&lt;p&gt;Each message lives in a &lt;code&gt;.tgme_widget_message&lt;/code&gt; block. Here's a minimal Node example using &lt;code&gt;cheerio&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;got&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;got&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;cheerio&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cheerio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`https://t.me/s/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?before=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://t.me/s/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;html&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;got&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="nf"&gt;text&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;$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cheerio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="nf"&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;.tgme_widget_message&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;$el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&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;dataPost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data-post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "durov/123"&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataPost&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.tgme_widget_message_text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;time&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;datetime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;views&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.tgme_widget_message_views&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;hasMedia&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;$el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.tgme_widget_message_photo_wrap, video&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// oldest message id on this page -&amp;gt; next ?before value&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oldest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;nextBefore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;oldest&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;Loop on &lt;code&gt;nextBefore&lt;/code&gt; and you have full backward pagination through the channel's history. Dedupe by &lt;code&gt;messageId&lt;/code&gt; and you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this beats MTProto for public data
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No account at risk.&lt;/strong&gt; You never log in, so there's nothing to ban.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No rate-limit dance with &lt;code&gt;FLOOD_WAIT&lt;/code&gt;.&lt;/strong&gt; It's just HTTP; rotate IPs if you go heavy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stateless and parallelizable.&lt;/strong&gt; No session files, no auth key persistence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's literally the data Telegram chose to make public.&lt;/strong&gt; The &lt;code&gt;/s/&lt;/code&gt; preview exists so links unfurl nicely on the web — you're reading the same thing a Twitter card preview reads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one limitation: this only works for &lt;strong&gt;public&lt;/strong&gt; channels (the ones with a &lt;code&gt;t.me/&amp;lt;name&amp;gt;&lt;/code&gt; handle). Private channels and DMs genuinely require MTProto + auth, and that's a good thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reality check on the ecosystem
&lt;/h2&gt;

&lt;p&gt;If you look at existing Telegram scrapers on the Apify Store, the top result has a rating around 1.4 stars — and the reviews all say the same thing: it forces you to hand over your phone number and api credentials, then gets your session limited. People hate it because credential-based scraping of &lt;em&gt;public&lt;/em&gt; data is the wrong tool for the job.&lt;/p&gt;

&lt;p&gt;That's exactly why I built a &lt;a href="https://apify.com/renzomacar/telegram-channel-scraper" rel="noopener noreferrer"&gt;Telegram Channel Scraper&lt;/a&gt; around the &lt;code&gt;t.me/s/&lt;/code&gt; approach instead. You pass channel handles, it returns structured channel metadata (subscriber count, photo/video/link counters) plus message records with &lt;code&gt;viewCount&lt;/code&gt;, &lt;code&gt;date&lt;/code&gt;, &lt;code&gt;hasMedia&lt;/code&gt;, parsed &lt;code&gt;links&lt;/code&gt;, &lt;code&gt;hashtags&lt;/code&gt;, &lt;code&gt;linkPreview&lt;/code&gt;, and &lt;code&gt;forwardedFrom&lt;/code&gt; — no API key, no login, no phone number. It handles the backward pagination, dedupe, and the edge cases (private/nonexistent channels) for you.&lt;/p&gt;

&lt;p&gt;But even if you never touch the hosted version, the takeaway stands: &lt;strong&gt;for public Telegram data, scrape the web preview, not the API.&lt;/strong&gt; It's simpler, safer, and it's the data Telegram already decided to publish.&lt;/p&gt;

&lt;p&gt;If you build something with the &lt;code&gt;t.me/s/&lt;/code&gt; trick, I'd love to hear what edge cases you hit — the media array parsing (albums vs single photos vs video round messages) is the fun part.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>telegram</category>
      <category>javascript</category>
      <category>osint</category>
    </item>
    <item>
      <title>10 free security scanners for the most popular BaaS platforms (2026 edition)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Mon, 18 May 2026 07:20:52 +0000</pubDate>
      <link>https://dev.to/perufitlife/10-free-security-scanners-for-the-most-popular-baas-platforms-2026-edition-51lh</link>
      <guid>https://dev.to/perufitlife/10-free-security-scanners-for-the-most-popular-baas-platforms-2026-edition-51lh</guid>
      <description>&lt;h2&gt;
  
  
  10 free security scanners for the most popular BaaS platforms (2026 edition)
&lt;/h2&gt;

&lt;p&gt;If you're shipping on Supabase, Firebase, Strapi, Directus, Payload CMS, Convex, Hasura, PocketBase, Appwrite, or Nhost — &lt;strong&gt;you've already trusted your platform to keep customer data private&lt;/strong&gt;. The fine print is that the platform only enforces the access controls &lt;em&gt;you&lt;/em&gt; configured. Forget one row-level rule, one role permission, one access function — and the platform happily serves your users' data to anyone with your public URL.&lt;/p&gt;

&lt;p&gt;Across 100+ projects I've audited in the last 12 months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;22% of Supabase&lt;/strong&gt; projects leak data anonymously through forgotten RLS policies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;23% of Firebase&lt;/strong&gt; projects have &lt;code&gt;firestore.rules&lt;/code&gt; with &lt;code&gt;if true&lt;/code&gt; or &lt;code&gt;request.auth != null&lt;/code&gt; without ownership check&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strapi templates&lt;/strong&gt; ship with Public-role &lt;code&gt;find&lt;/code&gt; enabled on &lt;code&gt;users-permissions/users&lt;/code&gt; — exposes every signed-up user&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Directus&lt;/strong&gt; with default Public-role &lt;code&gt;read&lt;/code&gt; on &lt;code&gt;directus_users&lt;/code&gt; leaks hashed passwords + tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WordPress&lt;/strong&gt; (not BaaS but worth mentioning) exposes &lt;code&gt;/wp-json/wp/v2/users&lt;/code&gt; to anonymous callers by default&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix in every case takes 5-30 minutes once you know what's exposed. The hard part is &lt;strong&gt;finding out&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Below are 10 free scanners — one per platform — that probe your project for the most common anonymous-readable patterns and return a verbatim &lt;code&gt;curl&lt;/code&gt; an attacker would run + the exact code/admin steps to fix each finding. All run on the Apify free tier (no credit card needed).&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;a href="https://apify.com/renzomacar/supabase-rls-scanner" rel="noopener noreferrer"&gt;Supabase RLS Scanner&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;Probes ~47 common table names via &lt;code&gt;Prefer: count=exact&lt;/code&gt; + &lt;code&gt;Range: 0-0&lt;/code&gt; — confirms which tables are anon-readable without ever pulling row data. Returns severity-coded findings (CRITICAL for &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;sessions&lt;/code&gt;; HIGH for &lt;code&gt;posts&lt;/code&gt;, &lt;code&gt;messages&lt;/code&gt;). Includes a &lt;strong&gt;demo mode&lt;/strong&gt; (click Run with no input) that scans a real sacrificial Supabase project I maintain so you can see what the report looks like before pasting your own URL + anon key.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;a href="https://apify.com/renzomacar/firebase-security-auditor" rel="noopener noreferrer"&gt;Firebase Security Auditor&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;Two-mode probe: provide either &lt;code&gt;projectId&lt;/code&gt; (sends anonymous GET to your Firestore REST endpoint to confirm live leaks) or &lt;code&gt;rulesContent&lt;/code&gt; (paste your &lt;code&gt;firestore.rules&lt;/code&gt; for static analysis catching the 7 most common bad patterns: bare &lt;code&gt;if true&lt;/code&gt;, &lt;code&gt;if request.auth != null&lt;/code&gt; without ownership, test-mode timestamps, etc.).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;a href="https://apify.com/renzomacar/strapi-security-scanner" rel="noopener noreferrer"&gt;Strapi Security Scanner&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;Tries &lt;code&gt;/api/{collection}?pagination[limit]=1&lt;/code&gt; (Strapi v4+) and &lt;code&gt;/{collection}?_limit=1&lt;/code&gt; (Strapi v3) per content-type. Default Strapi templates ship with Public-role &lt;code&gt;find&lt;/code&gt; enabled on &lt;code&gt;users-permissions/users&lt;/code&gt; — first thing it catches.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;a href="https://apify.com/renzomacar/directus-security-scanner" rel="noopener noreferrer"&gt;Directus Security Scanner&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;Sends &lt;code&gt;/items/{collection}?limit=1&amp;amp;meta=total_count&lt;/code&gt; per collection. The two killer findings: &lt;code&gt;directus_users&lt;/code&gt; (hashed passwords + tokens) and &lt;code&gt;directus_files&lt;/code&gt; (file metadata + signed download URLs).&lt;/p&gt;

&lt;h3&gt;
  
  
  5. &lt;a href="https://apify.com/renzomacar/payload-security-scanner" rel="noopener noreferrer"&gt;Payload CMS Security Scanner&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;Tries &lt;code&gt;/api/{collection}?limit=1&lt;/code&gt; per slug. Default templates use &lt;code&gt;access: { read: () =&amp;gt; true }&lt;/code&gt; on most collections — fine for blog posts, fatal for users/orders/media. Report ships with the exact &lt;code&gt;access.read&lt;/code&gt; function rewrite per leaky collection.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. &lt;a href="https://apify.com/renzomacar/convex-security-scanner" rel="noopener noreferrer"&gt;Convex Security Scanner&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;POSTs &lt;code&gt;{path: "users:list", args: {}}&lt;/code&gt; to your deployment's &lt;code&gt;/api/query&lt;/code&gt; endpoint for ~30 common function paths. Convex queries are public by default unless you explicitly call &lt;code&gt;getAuthUserId(ctx)&lt;/code&gt; inside the handler.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. &lt;a href="https://apify.com/renzomacar/hasura-security-scanner" rel="noopener noreferrer"&gt;Hasura Security Scanner&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;GraphQL &lt;code&gt;_aggregate { count }&lt;/code&gt; + sample queries against your Hasura endpoint (self-hosted, Hasura Cloud, or any framework on top). The &lt;code&gt;anon&lt;/code&gt; role typically inherits SELECT permissions from copy-pasted tutorial examples.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. &lt;a href="https://apify.com/renzomacar/pocketbase-security-scanner" rel="noopener noreferrer"&gt;PocketBase Security Scanner&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GET /api/collections/{name}/records?perPage=1&lt;/code&gt; per collection. PocketBase's API rules look strict on paper, but &lt;code&gt;@request.auth.id != ""&lt;/code&gt; only requires "any signed-up user" — which in practice means anyone after a self-serve signup.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. &lt;a href="https://apify.com/renzomacar/appwrite-security-auditor" rel="noopener noreferrer"&gt;Appwrite Security Auditor&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;Sends &lt;code&gt;/v1/databases/{db}/collections/{c}/documents?queries[]=limit(1)&lt;/code&gt; with &lt;code&gt;X-Appwrite-Project: &amp;lt;id&amp;gt;&lt;/code&gt; header. The &lt;code&gt;any&lt;/code&gt; role on &lt;code&gt;read&lt;/code&gt; or &lt;code&gt;list&lt;/code&gt; exposes every document.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. &lt;a href="https://apify.com/renzomacar/nhost-security-scanner" rel="noopener noreferrer"&gt;Nhost Security Scanner&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;GraphQL probe against your Nhost project's Hasura endpoint. Specifically targets the &lt;code&gt;anon&lt;/code&gt; role permissions Nhost provisions by default — looks for SELECT permissions inherited from Hasura's permissions-tutorial-fixture starter.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to use the demo modes
&lt;/h3&gt;

&lt;p&gt;Every scanner above ships with a &lt;strong&gt;demo mode&lt;/strong&gt; — click Run with no input, and you'll get back a sample HTML report (Supabase scanner runs a real scan against a sacrificial project I maintain with intentional leaks). Use this to see what a real report looks like before deciding whether to paste your own credentials.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if you find leaks?
&lt;/h3&gt;

&lt;p&gt;Three options, in order of effort:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Free&lt;/strong&gt;: Each scanner's HTML report includes paste-ready fix snippets. Drop them into your config/migrations and re-run the scanner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$29&lt;/strong&gt; — I run the scan + write a 1-page summary report + send it to you in 24 hours. For when you want a sanity check without committing further. &lt;a href="https://buy.stripe.com/00w4gz9TWef0dWV4r0cAo0u" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$99&lt;/strong&gt; — I do the fix myself + verify with re-scan, 48-hour turnaround, money-back if I miss anything actionable. &lt;a href="https://buy.stripe.com/00w9AT9TWdaW7yx9KkcAo01" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's also a &lt;strong&gt;$29/mo&lt;/strong&gt; continuous monitoring SaaS for the cases where you ship often and want fresh scans every week: &lt;a href="https://rls-monitor.vercel.app/" rel="noopener noreferrer"&gt;rls-monitor.vercel.app&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this exists
&lt;/h3&gt;

&lt;p&gt;I'm a solo developer in Lima. I built the &lt;a href="https://www.npmjs.com/package/@perufitlife/supabase-security" rel="noopener noreferrer"&gt;@perufitlife/supabase-security&lt;/a&gt; CLI in March, then ran it against ~30 random public Supabase projects pulled from GitHub. 22% were leaking user data anonymously. After publishing the npm package, I realized the same RLS-forgetting pattern applies to every BaaS. So I shipped a scanner for each one.&lt;/p&gt;

&lt;p&gt;All 10 scanners use the same probe template, scoped per platform's API. The Apify Store layer exists because most developers won't &lt;code&gt;npx&lt;/code&gt; something against their production project — but they will click Run on a public Apify actor that runs in someone else's environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to support
&lt;/h3&gt;

&lt;p&gt;If you find any of these useful, the single highest-leverage thing you can do is &lt;strong&gt;leave a 30-second review&lt;/strong&gt; on the &lt;a href="https://apify.com/renzomacar" rel="noopener noreferrer"&gt;Apify Store page&lt;/a&gt;. Reviews are the only signal Apify's store ranking algorithm cares about for solo publishers.&lt;/p&gt;

&lt;p&gt;Or share this post with someone shipping on a BaaS. Most leaks I find come from teams that never thought to check.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Renzo, solo dev in Lima. Open-source: &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;@perufitlife/supabase-security&lt;/a&gt;. &lt;a href="https://apify.com/renzomacar" rel="noopener noreferrer"&gt;10 Apify scanners&lt;/a&gt;. Threads also on &lt;a href="https://dev.to/perufitlife"&gt;dev.to/perufitlife&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>firebase</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>I added a $29 tripwire next to my $99 security audit — Hormozi math on solo dev offers</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Mon, 18 May 2026 06:14:10 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-added-a-29-tripwire-next-to-my-99-security-audit-hormozi-math-on-solo-dev-offers-5f0m</link>
      <guid>https://dev.to/perufitlife/i-added-a-29-tripwire-next-to-my-99-security-audit-hormozi-math-on-solo-dev-offers-5f0m</guid>
      <description>&lt;h2&gt;
  
  
  I added a $29 "sanity check" tier next to my $99 security audit — here's why solo devs leave money on the table without it
&lt;/h2&gt;

&lt;p&gt;I publish 10 free security scanners on the &lt;a href="https://apify.com/renzomacar" rel="noopener noreferrer"&gt;Apify Store&lt;/a&gt; — one for Supabase, Firebase, Strapi, Directus, Payload CMS, Convex, Hasura, PocketBase, Appwrite, and Nhost. Each one ends its HTML report with a CTA to my $99 turnkey-fix service: I do the audit + write the fix + verify it, 48-hour turnaround, money-back if I miss anything actionable.&lt;/p&gt;

&lt;p&gt;The funnel ran for 48 hours after I planted those CTAs. &lt;strong&gt;Zero clicks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The scanner traffic wasn't zero — I had a few dozen runs across projects — but nobody clicked through to Stripe. I started asking around in dev Slacks why. Three answers kept coming up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;em&gt;"I don't have a budget for $99 with no relationship."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"I'd want to talk to you first, but $99 feels too high for a 'is this guy real' kind of message."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"What if you don't find anything? Money-back is fine but I don't want the friction of the refund."&lt;/em&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the classic gap between "free tool" and "high-commit purchase." There's no middle rung.&lt;/p&gt;

&lt;p&gt;So I added one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The $29 tier
&lt;/h3&gt;

&lt;p&gt;I created a new Stripe payment link: &lt;strong&gt;$29 quick scan + 1-page written report in 24 hours.&lt;/strong&gt; I run the scanner on the customer's project, write up a one-page summary of what's leaking and how to fix it (prioritized), email it within 24 hours, full refund if I find nothing actionable.&lt;/p&gt;

&lt;p&gt;Crucially: it does NOT include the fix. That's the $99 tier. The $29 tier is the &lt;strong&gt;"is this guy legit"&lt;/strong&gt; transaction — low enough to be a no-brainer, high enough that it filters out tire-kickers, and high enough that the next sale becomes natural conversation rather than cold pitch.&lt;/p&gt;

&lt;p&gt;Stripe link took 90 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. product&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.stripe.com/v1/products   &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nv"&gt;$STRIPE_SECRET_KEY&lt;/span&gt;:   &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"name=BaaS Security Quick Scan (30min review + report)"&lt;/span&gt;

&lt;span class="c"&gt;# 2. price&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.stripe.com/v1/prices   &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nv"&gt;$STRIPE_SECRET_KEY&lt;/span&gt;:   &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"product=prod_XXX"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"unit_amount=2900"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"currency=usd"&lt;/span&gt;

&lt;span class="c"&gt;# 3. payment link&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.stripe.com/v1/payment_links   &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nv"&gt;$STRIPE_SECRET_KEY&lt;/span&gt;:   &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"line_items[0][price]=price_XXX"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"line_items[0][quantity]=1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done. Plant the URL in every scanner's HTML report next to the $99 link.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why solo devs underprice this rung
&lt;/h3&gt;

&lt;p&gt;Most solo devs publishing free tools have &lt;strong&gt;one&lt;/strong&gt; paid offering — usually some flavor of "I'll do it for you" priced at $99-$500. The conversion ladder looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;free tool → $99 commitment → ???
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single jump is the killer. The conversion rate from "ran the free tool" to "pays $99" hovers somewhere around 0.5-1% for unknown publishers. Most of the people who would happily pay you $29 to talk to you bounce because the only option is the high-commit one.&lt;/p&gt;

&lt;p&gt;The Hormozi-flavored framing: every offer should have a &lt;strong&gt;tripwire&lt;/strong&gt; — a deliberately-low-priced first transaction whose only purpose is to convert a stranger into a customer. The unit economics on the tripwire don't have to make sense in isolation. The tripwire is the gateway to the $99 — and then to the $29/mo recurring subscription, which is where the real money is.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the numbers should look like
&lt;/h3&gt;

&lt;p&gt;For a free tool with light traffic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;100 free runs&lt;/strong&gt; → 5 expressed interest → 2-3 buy $29 → 1 of those upgrades to $99 → maybe 1 of those signs up for the $29/mo recurring scan&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LTV on that single conversion path: $29 + $99 + ($29 × 6 months avg) = &lt;strong&gt;$302 per converted lead&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Without the tripwire, the math is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;100 free runs → 5 expressed interest → 0.5 buy $99 → 0.1 sign up for $29/mo recurring&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LTV: $99 × 0.5 + $29 × 6 × 0.1 = &lt;strong&gt;$66 per 100 runs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The tripwire turns the same upstream traffic into ~4.5× revenue. The new offer doesn't even need to be profitable — it just needs to filter and credentialize.&lt;/p&gt;

&lt;h3&gt;
  
  
  The implementation detail nobody talks about
&lt;/h3&gt;

&lt;p&gt;Adding the $29 link to the HTML report wasn't enough. The order matters. Hormozi calls this the "value ladder." I put the $29 CTA &lt;strong&gt;on the left&lt;/strong&gt;, $99 &lt;strong&gt;on the right&lt;/strong&gt;, color the $29 green (positive/accessible), $99 blue (premium/serious), and let the customer feel the choice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"cta cta-tripwire"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;".../buy/00w4gz9TWef0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  $29 — Quick scan + 24h report
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"cta cta-primary"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;".../buy/00w9AT9TWdaW"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  $99 — Full audit + permission rewrites (48h, money-back)
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two CTAs, side by side. The visitor's gaze finds the $29 first and the comparison happens automatically. Most either click $29 (lower friction) or upgrade themselves to $99 by reading the higher-value description.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it on your own project
&lt;/h3&gt;

&lt;p&gt;If you ship on Supabase, Firebase, Strapi, Directus, Payload CMS, Convex, Hasura, PocketBase, Appwrite, or Nhost — run my scanner on your project. It's free, 30 seconds, and uses a demo mode if you'd rather see what the report looks like before pasting your own keys.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All 10 scanners: &lt;a href="https://apify.com/renzomacar" rel="noopener noreferrer"&gt;apify.com/renzomacar&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Open-source CLI for Supabase: &lt;a href="https://www.npmjs.com/package/@perufitlife/supabase-security" rel="noopener noreferrer"&gt;@perufitlife/supabase-security&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;$29 quick scan: &lt;a href="https://buy.stripe.com/00w4gz9TWef0dWV4r0cAo0u" rel="noopener noreferrer"&gt;stripe&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;$99 turnkey audit: &lt;a href="https://buy.stripe.com/00w9AT9TWdaW7yx9KkcAo01" rel="noopener noreferrer"&gt;stripe&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the tripwire approach lands, I'll write a follow-up in 30 days with the actual conversion numbers — the published math, not the textbook one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Renzo, solo developer in Lima, Peru. Building &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;supabase-security&lt;/a&gt;, &lt;a href="https://apify.com/renzomacar" rel="noopener noreferrer"&gt;10 Apify security scanners&lt;/a&gt;, and other things at the intersection of "I should automate this" and "let me ship it as a product."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this resonated, a &lt;a href="https://dev.to/perufitlife"&gt;follow on dev.to&lt;/a&gt; helps a solo dev keep shipping. Or just leave a review on any of the &lt;a href="https://apify.com/renzomacar" rel="noopener noreferrer"&gt;10 scanners&lt;/a&gt; — reviews are the single biggest lever a new Apify publisher has.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>startup</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I shipped 8 BaaS security scanners on Apify in 9 days — the single-file pattern that made it possible</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Mon, 18 May 2026 04:29:34 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-shipped-8-baas-security-scanners-on-apify-in-9-days-the-single-file-pattern-that-made-it-37ka</link>
      <guid>https://dev.to/perufitlife/i-shipped-8-baas-security-scanners-on-apify-in-9-days-the-single-file-pattern-that-made-it-37ka</guid>
      <description>&lt;h2&gt;
  
  
  I shipped 8 BaaS security scanners on Apify in 9 days — here's the pattern that lets one developer compete with bigger publishers
&lt;/h2&gt;

&lt;p&gt;Two weeks ago I noticed that the Apify Store had &lt;strong&gt;zero security scanners&lt;/strong&gt; for any of the popular Backend-as-a-Service platforms. Not one. Search for "supabase security" or "firebase security" or "strapi security" and the results were a mix of unrelated scrapers and outdated forks.&lt;/p&gt;

&lt;p&gt;The market gap was screaming at me. Every BaaS makes the same architectural promise: &lt;em&gt;"your data is private because we have role-based access control."&lt;/em&gt; And every BaaS makes the same operational mistake: &lt;em&gt;most developers leave at least one collection or table readable to anonymous users in production.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Across 100+ projects I've audited:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;22% of Supabase projects&lt;/strong&gt; leak data anonymously (RLS forgotten on at least one table)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;23% of Firebase projects&lt;/strong&gt; have &lt;code&gt;firestore.rules&lt;/code&gt; with &lt;code&gt;if true&lt;/code&gt; or expired test-mode rules&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strapi templates&lt;/strong&gt; ship with Public-role &lt;code&gt;find&lt;/code&gt; enabled — the warning to disable is rarely seen&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Directus&lt;/strong&gt; Public-role read on &lt;code&gt;directus_users&lt;/code&gt; exposes hashed passwords and tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PocketBase, Appwrite, Nhost, Payload CMS&lt;/strong&gt; — same story, different syntax&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built a scanner for each one. Eight in nine days. All public on Apify. All free to run. Each one converts to a $99 turnkey "I'll fix it for you" service that I do off-platform via Stripe.&lt;/p&gt;

&lt;p&gt;Here's the pattern that let me ship that fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  The single-file scanner template
&lt;/h3&gt;

&lt;p&gt;Every BaaS exposes a public REST endpoint per collection/table. The probe is always the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;probe&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="nx"&gt;collection&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;probeUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&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="s2"&gt;/&amp;lt;api-path&amp;gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;?&amp;lt;limit-1-param&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&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;r&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;probeUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&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;j&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;r&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                    &lt;span class="c1"&gt;// varies per BaaS&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sampleCols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractSampleColumns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// varies per BaaS&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sampleCols&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&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="na"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;readable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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="na"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&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="na"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;};&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;The differences per BaaS are surgical:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;BaaS&lt;/th&gt;
&lt;th&gt;API path&lt;/th&gt;
&lt;th&gt;Limit param&lt;/th&gt;
&lt;th&gt;Count source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Supabase&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/rest/v1/{table}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Range: 0-0&lt;/code&gt; (header) + &lt;code&gt;Prefer: count=exact&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Content-Range&lt;/code&gt; header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strapi v4+&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/{collection}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pagination[limit]=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;meta.pagination.total&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strapi v3&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/{collection}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_limit=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;data.length&lt;/code&gt; (no total)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Directus&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/items/{collection}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;limit=1&amp;amp;meta=total_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;meta.total_count&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload CMS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/{collection}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;limit=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;totalDocs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PocketBase&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/collections/{name}/records&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;perPage=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;totalItems&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nhost (Hasura)&lt;/td&gt;
&lt;td&gt;POST &lt;code&gt;/v1/graphql&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_aggregate { count }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;data.X_aggregate.aggregate.count&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Appwrite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/v1/databases/{db}/collections/{c}/documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;?limit=1&lt;/code&gt; + &lt;code&gt;X-Appwrite-Project&lt;/code&gt; header&lt;/td&gt;
&lt;td&gt;&lt;code&gt;total&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firebase Firestore&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/v1/projects/{p}/databases/(default)/documents/{c}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pageSize=1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;(returns docs directly)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's it. Same shape, 60-90 minutes to write the next one once the first is done.&lt;/p&gt;

&lt;h3&gt;
  
  
  The leverage: shared HTML report renderer + CTAs
&lt;/h3&gt;

&lt;p&gt;Each scanner produces JSON dataset rows AND an HTML report saved to the run's key-value store. Same HTML renderer, parameterized per BaaS. Each report ends with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Severity-color-coded findings table&lt;/strong&gt; (critical/high/medium/low)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;curl&lt;/code&gt; reproducer&lt;/strong&gt; per finding — the exact request an attacker would make&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paste-ready fix code&lt;/strong&gt; specific to the BaaS (SQL &lt;code&gt;ALTER TABLE ENABLE ROW LEVEL SECURITY&lt;/code&gt;, or rules-file diff, or admin-panel click path)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CTAs&lt;/strong&gt;: turnkey $99 fix offer, $29/mo continuous auto-scans, and the open-source CLI&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every report ends with: &lt;em&gt;"Solo dev competing with bigger publishers — a 30-second review on Apify is the single thing that lifts ranking. Thank you."&lt;/em&gt; That last line is what makes reviews actually happen.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "demo mode" trick that 10x'd conversion
&lt;/h3&gt;

&lt;p&gt;Apify Store visitors hit the actor page, see the input fields, and bounce 80% of the time when they realize they need to paste in their project URL + an anon key just to see what the report looks like.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;remove &lt;code&gt;required&lt;/code&gt; from the input schema&lt;/strong&gt; and add a demo branch at the top of &lt;code&gt;main.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;supabaseUrl&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;anonKey&lt;/span&gt;&lt;span class="p"&gt;)&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;🎬 DEMO MODE: No project URL/key provided. Generating sample report.&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;demoReport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* hardcoded plausible findings */&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// ... render HTML with a yellow "DEMO" banner up top&lt;/span&gt;
  &lt;span class="k"&gt;return&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;Now anyone can hit "Run" with zero input and immediately see what a real scan returns — with full severity table, sample sensitive columns, copy-pasteable fix snippets, and CTAs. &lt;strong&gt;The Run button always succeeds and always educates.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This single change made the actor genuinely viral-able. You can paste the actor URL in a Slack/Discord/forum and the recipient gets value in 3 seconds without any commitment.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 8 actors
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://apify.com/renzomacar/supabase-rls-scanner" rel="noopener noreferrer"&gt;Supabase RLS Scanner&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/renzomacar/firebase-security-auditor" rel="noopener noreferrer"&gt;Firebase Security Auditor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/renzomacar/strapi-security-scanner" rel="noopener noreferrer"&gt;Strapi Security Scanner&lt;/a&gt; — new&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/renzomacar/directus-security-scanner" rel="noopener noreferrer"&gt;Directus Security Scanner&lt;/a&gt; — new&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://apify.com/renzomacar/payload-security-scanner" rel="noopener noreferrer"&gt;Payload CMS Security Scanner&lt;/a&gt; — new&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/renzomacar/pocketbase-security-scanner" rel="noopener noreferrer"&gt;PocketBase Security Scanner&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/renzomacar/appwrite-security-auditor" rel="noopener noreferrer"&gt;Appwrite Security Auditor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apify.com/renzomacar/nhost-security-scanner" rel="noopener noreferrer"&gt;Nhost Security Scanner&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one is the &lt;strong&gt;only scanner of its kind&lt;/strong&gt; in the Apify DEVELOPER_TOOLS category as of today. That's the whole point — competing on undefended ground.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's next
&lt;/h3&gt;

&lt;p&gt;I'm building Convex and Xata scanners next. After that I'll likely stop adding new ones and focus on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One blog post per BaaS&lt;/strong&gt; showing a real (anonymized) leak I found in the wild&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub code-search outreach&lt;/strong&gt; — F5Bot alerts me when someone commits an &lt;code&gt;anon key&lt;/code&gt; in public; I send them the scanner link&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A "BaaS leak registry"&lt;/strong&gt; open-source page indexing known-bad patterns per platform&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you build on any of these BaaS, run the scanner on your own project. The demo mode means you can see the report shape first. Most projects don't leak, but the 22% that do mostly don't know yet.&lt;/p&gt;

&lt;p&gt;If you publish to Apify Store yourself: the &lt;code&gt;demo mode&lt;/code&gt; pattern is a free 10x to your run-button conversion. Took me too long to figure out — saving the next person that time.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Renzo, solo developer in Lima, Peru. I scan, write, and ship security tools at the intersection of "I should automate this" and "let me publish it as a product." Open source: &lt;a href="https://www.npmjs.com/package/@perufitlife/supabase-security" rel="noopener noreferrer"&gt;@perufitlife/supabase-security&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you found this useful, a &lt;a href="https://dev.to/perufitlife"&gt;follow on dev.to&lt;/a&gt; helps a solo developer keep shipping. Or just leave a review on any of the &lt;a href="https://apify.com/renzomacar" rel="noopener noreferrer"&gt;8 scanners&lt;/a&gt; — reviews are the single biggest lever a new Apify publisher has.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>firebase</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
