<?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: Jeffrey Turov</title>
    <description>The latest articles on DEV Community by Jeffrey Turov (@jeffreyturov).</description>
    <link>https://dev.to/jeffreyturov</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%2F4050944%2Fabfb8306-248c-4a94-8471-d954f41366c3.png</url>
      <title>DEV Community: Jeffrey Turov</title>
      <link>https://dev.to/jeffreyturov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jeffreyturov"/>
    <language>en</language>
    <item>
      <title>Your scraper forgets everything between runs. Here's a review monitor that doesn't.</title>
      <dc:creator>Jeffrey Turov</dc:creator>
      <pubDate>Tue, 01 Sep 2026 17:55:54 +0000</pubDate>
      <link>https://dev.to/apify/your-scraper-forgets-everything-between-runs-heres-a-review-monitor-that-doesnt-4ddb</link>
      <guid>https://dev.to/apify/your-scraper-forgets-everything-between-runs-heres-a-review-monitor-that-doesnt-4ddb</guid>
      <description>&lt;p&gt;Your scraper forgets everything between runs. Here's a review monitor that doesn't.&lt;/p&gt;

&lt;p&gt;A scraper that pulls Google Maps reviews once is a toy. What businesses actually pay for is a monitor: "tell me the moment a bad review lands." The difference between the two is one unglamorous feature — remembering what you've already seen.&lt;/p&gt;

&lt;p&gt;I built Review Radar, an Apify Actor that watches a list of Google Maps places, scrapes recent reviews on a schedule, and posts a Slack alert the moment a new review at or below your star threshold appears. The Slack side uses Apify's MCP connectors, so the Actor never touches a token. Everything below comes from real runs — including the two state bugs that almost shipped.&lt;/p&gt;

&lt;p&gt;What we're building&lt;/p&gt;

&lt;p&gt;Review Radar:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;takes a list of Google Maps place URLs (or a search query),&lt;/li&gt;
&lt;li&gt;scrapes the latest reviews of each place with Playwright,&lt;/li&gt;
&lt;li&gt;diffs them against a persistent store of already-seen review IDs,&lt;/li&gt;
&lt;li&gt;pushes only genuinely new reviews to the dataset, flagged &lt;code&gt;isNew: true&lt;/code&gt;, and&lt;/li&gt;
&lt;li&gt;posts a Slack alert for each new review at or below your threshold — via an MCP connector, so no Slack token ever enters the Actor's code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why Google Maps reviews? Because for hotels, restaurants, and local agencies, a 1-star review answered within an hour is recoverable; the same review discovered three weeks later is a lost customer. Review monitoring is a product businesses already pay monthly for — and the official Google Business API only covers businesses you own, not your competitors or your clients' portfolios.&lt;/p&gt;

&lt;p&gt;The three parts that actually matter&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scraping the reviews panel&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On a Maps place page, reviews live behind the "Avis"/"Reviews" tab. The extraction selectors that survived contact with production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Review blocks: &lt;code&gt;div[data-review-id]&lt;/code&gt; — with a trap I detail below&lt;/li&gt;
&lt;li&gt;Author: &lt;code&gt;div.d4r55&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Rating: &lt;code&gt;span.kvMYJc[role="img"]&lt;/code&gt; — parse the number from the &lt;code&gt;aria-label&lt;/code&gt; ("4 étoiles" / "4 stars")&lt;/li&gt;
&lt;li&gt;Text: &lt;code&gt;span.wiI7pd&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Relative date: &lt;code&gt;span.rsqaWe&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If no review block is visible on load, click the tab first: &lt;code&gt;button[aria-label*="Avis"]&lt;/code&gt; or &lt;code&gt;button[aria-label*="Reviews"]&lt;/code&gt;. Then scroll the panel (&lt;code&gt;div.m6QErb.DxyBCb.kA9KIf.dS8AEf&lt;/code&gt;) until you have enough blocks. Google Maps requires a residential proxy — datacenter IPs get consent-walled or blocked outright.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;State: the difference between a scraper and a monitor&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Actor keeps a named key-value store (&lt;code&gt;review-radar-state&lt;/code&gt;) with one record per place: the set of review IDs already seen. Each run:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hashes each review to a stable ID (the native &lt;code&gt;data-review-id&lt;/code&gt; when present, otherwise a SHA-1 of author+date+text),&lt;/li&gt;
&lt;li&gt;flags &lt;code&gt;isNew: true&lt;/code&gt; only for IDs not in the store,&lt;/li&gt;
&lt;li&gt;persists the updated set at the end.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Proof from two consecutive real runs on the same restaurant. First run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cindy P. | 5★ | isNew=True
Raquel G. Urbano | 4★ | isNew=True
Chri Cou1967 | 5★ | isNew=True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second run, minutes later, identical reviews:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cindy P. | 5★ | isNew=False
Raquel G. Urbano | 4★ | isNew=False
Chri Cou1967 | 5★ | isNew=False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;False&lt;/code&gt; is the entire product. A scheduled run now only surfaces what changed.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Slack alerts without a token in sight&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same connector model as my previous build: the user authorizes Slack once in Apify Console → Settings → Integrations. The Actor declares the connector in its input schema and receives a connector ID at runtime — never a token:&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="nl"&gt;"slackConnector"&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;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Slack connector (optional)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcpConnector"&lt;/span&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="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;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tools"&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;"required"&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;"*message*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*chat*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*post*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*send*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"readOnly"&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;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;The &lt;code&gt;mcpServers&lt;/code&gt; declaration does double duty: it filters which connectors the picker offers, and the proxy refuses any tool call outside that list. At runtime the Actor lists the connector's actual tools and picks the first one matching &lt;code&gt;post/chat/message/send&lt;/code&gt;, then formats the alert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🚨 Nouvel avis 2★ sur *La Maison Lefèvre* (J. Dupont, il y a 2 jours)
Service décevant, attente de 40 minutes...
https://google.com/maps/place/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Making the connector input optional (&lt;code&gt;"required": []&lt;/code&gt;) was deliberate: without it, the Actor still produces the full dataset — which also makes the Actor testable without touching your Slack workspace.&lt;/p&gt;

&lt;p&gt;The two bugs that almost shipped&lt;/p&gt;

&lt;p&gt;Bug 1 — key-value store key charset. I keyed place records by the Maps feature ID (&lt;code&gt;0x45d3f1...:0x...&lt;/code&gt;). Apify record keys only allow &lt;code&gt;a-zA-Z0-9!-_.'()&lt;/code&gt; — the colon is illegal. The Actor scraped everything perfectly, then crashed on the very last line of the run. One regex fixed it, but it's exactly the class of bug that only appears after a full successful scrape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;place_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^a-zA-Z0-9!\-_.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;()]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;place_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bug 2 — duplicated review blocks. Querying both &lt;code&gt;div[data-review-id]&lt;/code&gt; and the legacy &lt;code&gt;div.jftiEf.fontBodyMedium&lt;/code&gt; selector returns overlapping containers — the same review twice. My first test dataset had Cindy P. duplicated. Fix: dedupe by review ID inside the scrape loop, before anything reaches the dataset.&lt;/p&gt;

&lt;p&gt;What I'd do differently at scale&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Relative dates ("il y a 2 semaines") don't sort. For precise alerting windows, resolve them against the run date.&lt;/li&gt;
&lt;li&gt;The Slack tool argument names vary by connector (&lt;code&gt;text&lt;/code&gt;, &lt;code&gt;message&lt;/code&gt;, &lt;code&gt;content&lt;/code&gt;). I inspect the tool's input schema and map fields — brittle but workable until connector schemas stabilize.&lt;/li&gt;
&lt;li&gt;Photos in reviews aren't extracted yet — for hospitality clients, a photo of a dirty room matters more than the text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try it&lt;/p&gt;

&lt;p&gt;The Actor is review-radar on my Apify account. Inputs: place URLs (or a search query), reviews per place, star threshold, optional Slack connector. First run reports the existing batch as new; every run after that reports only what changed. Point a daily schedule at it and you have a review monitoring product for the cost of a few compute units.&lt;/p&gt;

&lt;p&gt;A note on terms: Google Maps scraping sits uneasily with Google's ToS. Keep volumes polite (a handful of places, daily cadence), use a residential proxy, weigh the risk for production use, and prefer official sources where they cover your need — the Business Profile API works fine for businesses you own, it just can't watch anyone else's.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>javascript</category>
      <category>scraping</category>
    </item>
    <item>
      <title>From scraper to stack: pushing Google Maps leads straight into GitHub with MCP connectors</title>
      <dc:creator>Jeffrey Turov</dc:creator>
      <pubDate>Mon, 31 Aug 2026 23:39:38 +0000</pubDate>
      <link>https://dev.to/apify/from-scraper-to-stack-pushing-google-maps-leads-straight-into-github-with-mcp-connectors-43f3</link>
      <guid>https://dev.to/apify/from-scraper-to-stack-pushing-google-maps-leads-straight-into-github-with-mcp-connectors-43f3</guid>
      <description>&lt;p&gt;From scraper to stack: pushing Google Maps leads straight into GitHub with MCP connectors&lt;br&gt;
Every lead-generation pipeline I build ends the same way: a dataset full of freshly scraped businesses… that I then have to export, transform, and commit somewhere by hand. The scrape is automated; the last mile never is.&lt;br&gt;
When Apify launched MCP connectors - a new kind of Actor input that lets Actors securely call third-party services like GitHub, Notion, or Slack during a run - I rebuilt that last mile. This article walks through a working Actor that scrapes businesses from Google Maps and commits the results straight into a GitHub repository, without the Actor code ever seeing a credential.&lt;br&gt;
Everything below comes from a real build: the pitfalls are ones I actually hit, and the fixes are what actually solved them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full source code:&lt;/strong&gt; &lt;a href="https://codeberg.org/veysel-devia/apify-scraping-toolbox" rel="noopener noreferrer"&gt;Codeberg repository&lt;/a&gt; (canonical mirror, readable without an account). Also available as a &lt;a href="https://drive.google.com/file/d/1_j3DTZgmCRjM70LA9tSUTcHi2vlk5QGS/view" rel="noopener noreferrer"&gt;source ZIP&lt;/a&gt;. The GitHub original at github.com/jeffreyturov-dev/apify-scraping-toolbox is currently unreachable for logged-out viewers (account-level restriction, under review with GitHub Trust &amp;amp; Safety) - use the mirror.&lt;br&gt;
What we're building&lt;br&gt;
Maps to Stack: an Actor that&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;takes a Google Maps search query (e.g. restaurants Esch-sur-Alzette),&lt;/li&gt;
&lt;li&gt;scrapes each place page with Playwright (name, address, phone, website, rating, GPS),&lt;/li&gt;
&lt;li&gt;pushes the results to the dataset, and&lt;/li&gt;
&lt;li&gt;writes a JSON snapshot into your GitHub repo via an MCP connector - one commit per run, versioned by Git.
Why GitHub as the destination? Because for lead-gen pipelines, a repo is a free CRM: versioned, diffable, and already wired into everything else (CI, dashboards, git-based CMSs). The same pattern works identically for Notion or Slack - only the connector changes.
Prerequisites&lt;/li&gt;
&lt;li&gt;An Apify account (the free plan works for testing this)&lt;/li&gt;
&lt;li&gt;A GitHub account with a repository to write into, authorized as an MCP connector in Apify Console → Settings → Integrations (one OAuth flow, two clicks)&lt;/li&gt;
&lt;li&gt;A residential proxy for the Google Maps scraping half - Maps blocks datacenter IPs outright. A note on terms: routing around Google's consent wall and IP blocks sits uneasily with Google Maps' Terms of Service. For production use you should weigh that risk, keep request volumes polite, and consider official sources such as the Places API where they cover your need; the residential proxy here is what makes the unoffical path technically reliable, not legally bulletproof.&lt;/li&gt;
&lt;li&gt;Five minutes to read the Actor source top to bottom; it's intentionally small
The security model that makes this interesting
The classic way to do this is to pass a GitHub token as an Actor input. That means every run log, every shared input JSON, and every fork of your Actor is one leak away from a compromised token.
MCP connectors invert this:&lt;/li&gt;
&lt;li&gt;You authorize GitHub once in Apify Console → Settings → Integrations. The credential lives with the connector, not with your code.&lt;/li&gt;
&lt;li&gt;At run time, the Actor receives a connector ID (a string like ebw4ThD4cQbEKzC2l) - not a token.&lt;/li&gt;
&lt;li&gt;The Actor talks to the Apify MCP proxy at ${ACTOR_MCP_CONNECTOR_BASE_URL}/, authenticating with the run's own APIFY_TOKEN.&lt;/li&gt;
&lt;li&gt;The proxy enforces the tool permissions your Actor declared in its input schema. The Actor physically cannot call tools outside its declaration.
Declaring the connector input
In .actor/INPUT_SCHEMA.json, set resourceType: "mcpConnector". The mcpServers rule list both filters which connectors the picker offers and caps which tools the proxy will let the Actor call:
"githubConnector": {
"title": "GitHub connector",
"description": "MCP connector to your GitHub account. The Actor only sees a connector ID.",
"type": "string",
"resourceType": "mcpConnector",
"mcpServers": [
{
 "url": "&lt;em&gt;",
 "tools": {
   "required": ["create_&lt;/em&gt;", "push_&lt;em&gt;", "update_&lt;/em&gt;", "write_&lt;em&gt;", "commit_&lt;/em&gt;", "get_*"],
   "readOnly": false
 }
}
]
}&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At run time the input value is just the connector ID string.&lt;br&gt;
Verified in the run log: the proxy filtered the connector's tool list down to exactly what matched my declaration. My run saw 7 tools - create_branch, create_or_update_file, create_pull_request, create_repository, push_files, update_pull_request, update_pull_request_branch - and nothing else. The constraint layer works.&lt;br&gt;
Connecting from the Actor (Python)&lt;br&gt;
Two environment variables are injected into every run: ACTOR_MCP_CONNECTOR_BASE_URL (the proxy) and APIFY_TOKEN (the starter's token). You use the standard MCP Python SDK - nothing Apify-specific:&lt;br&gt;
import os&lt;br&gt;
import httpx&lt;br&gt;
from mcp import ClientSession&lt;br&gt;
from mcp.client.streamable_http import streamable_http_client&lt;/p&gt;

&lt;p&gt;base_url = os.environ["ACTOR_MCP_CONNECTOR_BASE_URL"]&lt;br&gt;
token = os.environ["APIFY_TOKEN"]&lt;br&gt;
proxy_url = f"{base_url}/{connector_id}"&lt;/p&gt;

&lt;p&gt;async with httpx.AsyncClient(&lt;br&gt;
   headers={"Authorization": f"Bearer {token}"}&lt;br&gt;
) as http:&lt;br&gt;
   async with streamable_http_client(proxy_url, http_client=http) as streams:&lt;br&gt;
       read, write = streams[0], streams[1]&lt;br&gt;
       async with ClientSession(read, write) as session:&lt;br&gt;
           await session.initialize()&lt;br&gt;
           tools = (await session.list_tools()).tools&lt;/p&gt;

&lt;p&gt;(Import verified against the installed SDK: mcp.client.streamable_http exposes both streamable_http_client and the alias streamablehttp_client, so the line above runs as pasted; the docs showcase the alias, which is worth knowing given the drift discussed below.)&lt;br&gt;
Pitfall #1 - SDK drift. The docs unpack streamable_http_client into three values (read, write, _), but the mcp version my image installed yields two. ValueError: not enough values to unpack (expected 3, got 2). Indexing the tuple (streams[0], streams[1]) works across versions. Same story for the result object: the error flag is result.is_error (snake_case), not isError - the docs' TypeScript casing leaks into expectations.&lt;br&gt;
Writing the file - and surviving reality&lt;br&gt;
With the session up, pick the file-writing tool from whatever the connector actually exposes (don't hard-code - the tool list is filtered by your schema &lt;em&gt;and&lt;/em&gt; by what the server advertised at authorization time):&lt;br&gt;
patterns = ["create_or_update_file", "push_files", "create_file", "update_file", "write_file"]&lt;br&gt;
tool = next((t for p in patterns for t in tools if p in t.name), None)&lt;/p&gt;

&lt;p&gt;owner, _, repo_name = actor_input["repo"].partition("/")&lt;br&gt;
args = {&lt;br&gt;
   "owner": owner,&lt;br&gt;
   "repo": repo_name,&lt;br&gt;
   "path": actor_input.get("path", "leads/output.json"),&lt;br&gt;
   "content": payload_json,&lt;br&gt;
   "message": f"leads: {query} ({date.today()})",&lt;br&gt;
   "branch": actor_input.get("branch", "main"),&lt;br&gt;
}&lt;br&gt;
result = await session.call_tool(tool.name, arguments=args)&lt;/p&gt;

&lt;p&gt;Two failures hit me immediately in real runs - both easy to fix once you see them:&lt;br&gt;
Pitfall #2 - Branch main not found. My repo's default branch is master, not main. The MCP tool doesn't fall back to the repo's default; it fails loudly. Cheap robust fix: detect the failure and retry once with the other common branch name.&lt;br&gt;
Pitfall #3 - File already exists… provide the current file's SHA. The second run collided with the first run's file. The GitHub API wants the blob SHA for updates - but the connector I authorized only exposes write tools (no get_file_contents to fetch the SHA). Instead of fighting it, I leaned into a better pattern for pipelines: immutable, timestamped snapshots. Every run writes a new dated file; Git itself becomes the history. If a read tool &lt;em&gt;is&lt;/em&gt; available, the code uses the SHA path instead:&lt;br&gt;
text = "".join(getattr(c, "text", "") for c in (result.content or []))&lt;br&gt;
if "already exists" in text.lower():&lt;br&gt;
   get_tool = next((t for t in tools if "get_file_contents" in t.name), None)&lt;br&gt;
   if get_tool:&lt;br&gt;
       ...  # fetch sha, retry with args["sha"] = sha&lt;br&gt;
   else:&lt;br&gt;
       stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")&lt;br&gt;
       args["path"] = re.sub(r".json$", f"-{stamp}.json", args["path"])&lt;br&gt;
   result = await session.call_tool(tool.name, arguments=args)&lt;/p&gt;

&lt;p&gt;Worth knowing: the connector's tool set is fixed at authorization time ("Layer 1" in the docs). If you authorize a connector and later wish it exposed more tools, re-authorize it - the discovered set doesn't refresh on its own.&lt;br&gt;
The scraping side, briefly&lt;br&gt;
The Maps half of the Actor uses Crawlee's PlaywrightCrawler with a residential proxy (Google blocks datacenter IPs outright). Three details that make it reliable, all learned the hard way on earlier Actors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kill the consent wall first. consent.google.com intercepts the first navigation; click "Accept" before waiting for any results selector.&lt;/li&gt;
&lt;li&gt;Block heavy resources in a pre-navigation hook. The load event never fires on Maps (continuous analytics), so route-abort images/fonts/media, or every navigation times out.&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Don't trust a green run. A SUCCEEDED run can still hold zero useful items - always assert on dataset item count and log per-item progress.&lt;br&gt;
The first two are a few lines each, and they are the difference between an Actor that works and one that silently dies on every third navigation:&lt;br&gt;
&lt;a class="mentioned-user" href="https://dev.to/crawler"&gt;@crawler&lt;/a&gt;.pre_navigation_hook&lt;br&gt;
async def optimize(context: PlaywrightCrawlingContext):&lt;br&gt;
page = context.page&lt;br&gt;
page.set_default_navigation_timeout(120_000)&lt;/p&gt;

&lt;p&gt;async def _abort(route):&lt;br&gt;
    await route.abort()&lt;/p&gt;
&lt;h1&gt;
  
  
  The load event never fires on Maps (continuous analytics).
&lt;/h1&gt;
&lt;h1&gt;
  
  
  Without this, every navigation hits the navigation timeout.
&lt;/h1&gt;

&lt;p&gt;await page.route(&lt;br&gt;
    "*&lt;em&gt;/&lt;/em&gt;.{png,jpg,jpeg,gif,webp,svg,ico,woff,woff2,ttf,mp4,webm,avi}",&lt;br&gt;
    _abort,&lt;br&gt;
)&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/crawler"&gt;@crawler&lt;/a&gt;.router.default_handler&lt;br&gt;
async def search_handler(context: PlaywrightCrawlingContext):&lt;br&gt;
    page = context.page&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# consent.google.com intercepts the first navigation.
# Click "Accept" before waiting for any results selector.
if "consent.google" in page.url:
    for sel in ('button[aria-label*="Accept"]',
                'button[aria-label*="Tout accepter"]',
                'button[aria-label*="Accepter"]',
                'form[action*="consent"] button'):
        btn = await page.query_selector(sel)
        if btn:
            await btn.click()
            await page.wait_for_timeout(2000)
            break

await page.wait_for_selector('div[role="feed"]', timeout=15000)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The full scraper is ~150 lines; the dataset holds one item per business with name, category, address, phone, website, rating, coordinates, and the Maps URL.&lt;br&gt;
What a real run looks like&lt;br&gt;
Input:&lt;br&gt;
{&lt;br&gt;
 "query": "restaurants Esch-sur-Alzette",&lt;br&gt;
 "maxResults": 3,&lt;br&gt;
 "githubConnector": "ebw4ThD4cQbEKzC2l",&lt;br&gt;
 "repo": "jeffreyturov-dev/apify-scraping-toolbox",&lt;br&gt;
 "path": "leads/esch-restaurants.json"&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Run log (abridged):&lt;br&gt;
Scraped 3 businesses&lt;br&gt;
Connector exposes 7 tools: ['create_branch', 'create_or_update_file', ...]&lt;br&gt;
Using tool: create_or_update_file&lt;br&gt;
Branch 'main' not found - retrying 'master'&lt;br&gt;
Timestamped snapshot: leads/esch-restaurants-20260903T220554Z.json&lt;br&gt;
Done - create_or_update_file wrote 3 leads&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D19mE12lo1DIsu6GEznRC6wU6d0M_ETxF2" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D19mE12lo1DIsu6GEznRC6wU6d0M_ETxF2" alt="Run log of a real Maps-to-Stack run in Apify Console" width="1440" height="900"&gt;&lt;/a&gt;&lt;br&gt;
And the commit lands in GitHub with the full JSON: 3 businesses, ratings, phone numbers, coordinates - queryable, diffable, and already where the rest of my tooling lives.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1i8DhkCjxyAeRgE3_2vWuNw51dQWjFp5p" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1i8DhkCjxyAeRgE3_2vWuNw51dQWjFp5p" alt="The timestamped snapshot commit landing in the GitHub repo" width="1440" height="1500"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1RALZ2iRAtRr0S7u0WWCmCwfgFYRS7D6g" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1RALZ2iRAtRr0S7u0WWCmCwfgFYRS7D6g" alt="Apify Console Integrations section with the MCP connectors card" width="1265" height="1382"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1HSLcZVg4_gv-kWizDvRgWc9Fff2Lr_MO" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1HSLcZVg4_gv-kWizDvRgWc9Fff2Lr_MO" alt="Actor source: INPUT_SCHEMA.json and the connector session code" width="1265" height="1202"&gt;&lt;/a&gt;&lt;br&gt;
Where this pattern earns its keep&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lead-gen pipelines: scrape on a schedule, each run commits a dated snapshot; a git log &lt;em&gt;is&lt;/em&gt; your change history of who opened/closed in a neighborhood.&lt;/li&gt;
&lt;li&gt;Any-to-any delivery: swap the connector for Notion (a database row per business) or Slack (a summary message) - the Actor code changes by a dozen lines; the security model stays identical.&lt;/li&gt;
&lt;li&gt;Untrusted Actor code: if you consume third-party Actors, connectors are the only sane way to give them access to &lt;em&gt;your&lt;/em&gt; services - the schema's tool constraints and the proxy's enforcement mean a malicious or sloppy Actor can't exceed its brief.
Why the connector model is the only sane way to share access
That last point deserves its own paragraph, because it generalizes beyond Apify. Every integration platform faces the same dilemma: users want automations that touch their GitHub, their CRM, their Slack - but handing a raw token to third-party code is an unacceptable blast radius. The connector pattern resolves it with three properties that are hard to get simultaneously any other way. First, mediation: every call passes through a proxy that authenticates the caller and authorizes the tool, so the credential is never exposed to the code that uses it. Second, declared least privilege: the input schema caps what the Actor may do, and the cap is enforced outside the Actor, where the Actor cannot rewrite it. Third, auditability: because calls flow through one chokepoint, every tool invocation is attributable to a specific run. Tokens in code give you none of these; connectors give you all three for the price of one OAuth flow. If you build or consume automations that touch shared services, this is the shape the access layer should have.
Try it
The Actor source (schema, scraper, connector logic) is intentionally small - read it top to bottom in five minutes. The moving parts that matter:&lt;/li&gt;
&lt;li&gt;resourceType: "mcpConnector" in the input schema, with mcpServers tool constraints.&lt;/li&gt;
&lt;li&gt;${ACTOR_MCP_CONNECTOR_BASE_URL}/ + APIFY_TOKEN bearer, via the stock MCP SDK.&lt;/li&gt;
&lt;li&gt;Defensive write logic: branch fallback, SHA-if-available, timestamped snapshot otherwise.
The connector model removes the part of pipeline building I liked least - sprinkling credentials through code - and replaces it with one authorization, one ID, and a proxy that keeps everyone honest.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Useful links: the &lt;a href="https://docs.apify.com/platform/actors/development/actor-definition/input-schema" rel="noopener noreferrer"&gt;Apify input schema reference&lt;/a&gt;, the &lt;a href="https://docs.apify.com/platform/actors/publishing/monetize" rel="noopener noreferrer"&gt;pay-per-event monetization docs&lt;/a&gt;, the &lt;a href="https://docs.apify.com/platform/integrations/mcp" rel="noopener noreferrer"&gt;Apify MCP integration docs&lt;/a&gt;, and the &lt;a href="https://apify.com/mcp" rel="noopener noreferrer"&gt;Apify MCP server page&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>javascript</category>
      <category>scraping</category>
    </item>
    <item>
      <title>I built 8 pay-per-use scraping APIs that AI agents can call directly (Google Maps, TikTok, Instagram, YouTube, LinkedIn) — here's what I learned</title>
      <dc:creator>Jeffrey Turov</dc:creator>
      <pubDate>Tue, 28 Jul 2026 09:28:47 +0000</pubDate>
      <link>https://dev.to/jeffreyturov/i-built-8-pay-per-use-scraping-apis-that-ai-agents-can-call-directly-google-maps-tiktok-o61</link>
      <guid>https://dev.to/jeffreyturov/i-built-8-pay-per-use-scraping-apis-that-ai-agents-can-call-directly-google-maps-tiktok-o61</guid>
      <description>&lt;p&gt;A few weeks ago I published a set of Actors on Apify Store. Today they're all &lt;strong&gt;AI-agent ready&lt;/strong&gt;: any LLM agent (Claude, GPT, Cursor, LangChain, n8n) can discover and call them through the Apify MCP server — no custom integration code needed.&lt;/p&gt;

&lt;p&gt;This post is the full playbook: what the tools do, how the pay-per-event monetization works, the bugs I hit, and how AI agents actually consume them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toolbox
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Actor&lt;/th&gt;
&lt;th&gt;What it extracts&lt;/th&gt;
&lt;th&gt;Price (pay-per-event)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://apify.com/travelmonitorlab/google-maps-scraper" rel="noopener noreferrer"&gt;Google Maps Business Scraper&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Names, phones, websites, ratings, reviews, GPS&lt;/td&gt;
&lt;td&gt;$0.005 / business&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://apify.com/travelmonitorlab/tiktok-scraper" rel="noopener noreferrer"&gt;TikTok Profile &amp;amp; Video Scraper&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Followers, likes, bio, per-video stats&lt;/td&gt;
&lt;td&gt;$0.01 / profile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://apify.com/travelmonitorlab/instagram-scraper" rel="noopener noreferrer"&gt;Instagram Profile Scraper&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Followers, bio, verified, engagement&lt;/td&gt;
&lt;td&gt;$0.01 / profile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://apify.com/travelmonitorlab/youtube-scraper" rel="noopener noreferrer"&gt;YouTube Video &amp;amp; Channel Scraper&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Views, likes, subscribers, search results&lt;/td&gt;
&lt;td&gt;$0.002 / video&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://apify.com/travelmonitorlab/linkedin-profile-scraper" rel="noopener noreferrer"&gt;LinkedIn Profile Scraper&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Headlines, companies, skills, experience&lt;/td&gt;
&lt;td&gt;$0.02 / profile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://apify.com/travelmonitorlab/rag-web-browser" rel="noopener noreferrer"&gt;RAG Web Browser&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Clean Markdown from any URL + Google search&lt;/td&gt;
&lt;td&gt;$0.003 / page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://apify.com/travelmonitorlab/hermes-revenu-api" rel="noopener noreferrer"&gt;Fuel Prices France API&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Real-time prices, 9,800 stations, GPS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FREE&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://apify.com/travelmonitorlab/travel-monitor-launch" rel="noopener noreferrer"&gt;Hotel Rate Monitoring&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Competitor rates, parity checks&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FREE&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two free ones are deliberate lead magnets — more on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "AI-agent ready" changes everything
&lt;/h2&gt;

&lt;p&gt;The old model: a human finds your scraper on the store, reads the docs, clicks buttons.&lt;/p&gt;

&lt;p&gt;The new model: an AI agent gets a task ("find me 50 plumbers in Austin with their phone numbers"), searches the Apify Store via MCP, reads the actor's README and input schema, and calls it — end to end, no human.&lt;/p&gt;

&lt;p&gt;For that to work, three things must be true:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your README is written for an LLM, not just humans.&lt;/strong&gt; Mine now all start with a "Use this tool when..." section — that's what the agent pattern-matches against the user's request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your input schema has a description on every field.&lt;/strong&gt; The agent constructs the JSON input from those descriptions. No description = hallucinated parameters = failed runs = no revenue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your output is documented field by field.&lt;/strong&gt; The agent needs to know what it gets back to reason over it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the actual flow with the Apify MCP server (&lt;code&gt;https://mcp.apify.com&lt;/code&gt; — add it to Claude Desktop or Cursor in 30 seconds):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User: "Get me the follower counts of these 5 TikTok creators"
Agent: → search-actors("tiktok profile")
       → fetch-actor-details (reads README + input schema)
       → call-actor(travelmonitorlab/tiktok-scraper,
                    {"profiles": [...], "maxVideosPerProfile": 0})
       → returns structured JSON
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or skip MCP entirely — every actor is a single synchronous HTTP call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/travelmonitorlab~google-maps-scraper/run-sync-get-dataset-items?token=&lt;/span&gt;&lt;span class="nv"&gt;$APIFY_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"queries": ["plumbers Austin TX"], "maxResults": 50}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Monetization: pay-per-event (and the trap that cost me hours)
&lt;/h2&gt;

&lt;p&gt;Apify offers several pricing models. For new actors, &lt;code&gt;PRICE_PER_DATASET_ITEM&lt;/code&gt; is &lt;strong&gt;rejected&lt;/strong&gt; — you must use &lt;code&gt;PAY_PER_EVENT&lt;/code&gt;. The model is better anyway: you define events (e.g. &lt;code&gt;business-scraped&lt;/code&gt; at $0.005) and charge explicitly in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business-scraped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The trap:&lt;/strong&gt; put the charge AFTER &lt;code&gt;crawler.run()&lt;/code&gt; and your event loop may already be closed — the charge silently vanishes, you deliver data for free. Charge inside the handler, right before pushing data. Always verify with &lt;code&gt;chargedEventCounts&lt;/code&gt; in the run object after a test run.&lt;/p&gt;

&lt;p&gt;Setting pricing is pure API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;PUT&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;v2&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;acts&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;actorId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pricingInfos&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pricingModel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PAY_PER_EVENT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasonForChange&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Launch pricing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pricingPerEvent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;actorChargeEvents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business-scraped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eventTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Business scraped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eventDescription&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;One Google Maps business record&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eventPriceUsd&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.005&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;Apify takes 20%. Compute is paid by the user; you pocket the event fees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Battle scars (so you don't get them)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Crawlee 1.8 breaking changes:&lt;/strong&gt; &lt;code&gt;purge_on_start&lt;/code&gt; and &lt;code&gt;navigation_timeout_secs&lt;/code&gt; are no longer valid kwargs — use &lt;code&gt;page.set_default_navigation_timeout()&lt;/code&gt; in a &lt;code&gt;pre_navigation_hook&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Maps never fires &lt;code&gt;load&lt;/code&gt;:&lt;/strong&gt; analytics keep streaming forever, so navigation always times out. Fix: abort images/fonts/media via &lt;code&gt;page.route()&lt;/code&gt; (the handler must be a coroutine, not a lambda).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Residential proxies are mandatory for Google Maps&lt;/strong&gt;, and you must pass &lt;code&gt;actor_proxy_input=&lt;/code&gt; as a &lt;em&gt;named&lt;/em&gt; argument to &lt;code&gt;Actor.create_proxy_configuration()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;French number formats will crash your floats:&lt;/strong&gt; &lt;code&gt;"4,8"&lt;/code&gt; → replace comma; &lt;code&gt;"1 234"&lt;/code&gt; reviews can use &lt;code&gt;\xa0&lt;/code&gt; &lt;em&gt;or&lt;/em&gt; &lt;code&gt;\u202f&lt;/code&gt; as thousand separator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A "SUCCEEDED" run can contain zero useful data.&lt;/strong&gt; Always check &lt;code&gt;itemCount&lt;/code&gt; + sample the dataset + read the end of the log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't run 7 queries × 25 results in one run.&lt;/strong&gt; Split into parallel runs of ≤5 queries × 15 results; retry failed ones sequentially (residential proxy tunnels occasionally die).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The distribution strategy
&lt;/h2&gt;

&lt;p&gt;Publishing on the store is step 0. What actually moves the needle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Free lead magnets&lt;/strong&gt; — the fuel-price and hotel-rate actors are 100% free. Free tools get users, ratings, and store ranking; ranked actors surface in MCP search results; MCP visibility drives paying users to the paid actors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;README written for LLMs&lt;/strong&gt; — agents choose tools whose docs they can parse. Clear "use when", typed inputs, example I/O.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Niche SEO titles&lt;/strong&gt; — "Google Maps Scraper" is saturated; "Fuel Prices France API" has zero competition on the store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dogfooding&lt;/strong&gt; — I use my own Google Maps actor to build lead lists I sell elsewhere. Every sale is also a demo.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try them
&lt;/h2&gt;

&lt;p&gt;All 8 actors are live on &lt;a href="https://apify.com/travelmonitorlab" rel="noopener noreferrer"&gt;Apify Store&lt;/a&gt;. If you build agents, add &lt;code&gt;https://mcp.apify.com&lt;/code&gt; to your MCP client and just ask for the data — the agent will find the tools.&lt;/p&gt;

&lt;p&gt;Feedback, bugs, feature requests: open an issue on any actor page, I answer fast.&lt;/p&gt;

</description>
      <category>apify</category>
      <category>webscraping</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
