DEV Community

Matvey
Matvey

Posted on

Facebook Let Me In on a Plain Datacenter IP 114 Times. A Burger Chain's Own Website Needed a Paid Unblocker

I run a visa agency in Bali, and the unglamorous engine behind it is lead lists. Here, a restaurant, a clinic, a surf school or a laundry very often has no website worth the name — it has a Facebook page, and the e-mail and the WhatsApp number sit in the page's intro card. So one of the Apify Actors I maintain takes a list of Facebook page URLs and returns one flat row per page: Facebook Page Contact Scraper. No login, no cookies, no browser — the logged-out page HTML, parsed. When the page shows no e-mail, the Actor opens the business website (homepage plus one contact page) and takes the e-mail from there.

Because the input prefill is three pages, and because health checks have been hammering that prefill for weeks, I now have something better than a test: 42 runs between 6 and 31 October 2026, 120 page visits, 116 delivered rows, zero failures. Each run also writes a route ledger — which kind of IP opened which page, and what the fallbacks cost. That ledger said something I did not expect.

The input is as dull as it gets:

{
  "pages": [
    "https://www.facebook.com/nike",
    "https://www.facebook.com/balizoopark",
    "https://www.facebook.com/shakeshack"
  ],
  "searchWebsite": true,
  "onlyWithContacts": true
}
Enter fullscreen mode Exit fullscreen mode

The Facebook half never needed help

The Actor has a ladder for Facebook, cheapest rung first, and the next rung only runs if the previous one got the login wall: a fresh Apify datacenter address, then a residential address, then a paid unblocker. Residential traffic costs about a dollar per gigabyte, the unblocker costs more, and both are included in the row price — which means every rung I climb comes out of my own margin, not the buyer's. So I watch those counters.

Summed over the 40 runs that carry the counters:

pages opened                 114
opened on datacenter IPs     114   (100%)
opened on residential IPs      0
opened through unblocker       0
datacenter attempts           121
login walls hit                 3
failed pages                    0
Enter fullscreen mode Exit fullscreen mode

Three login walls in 121 attempts, and all three pages opened on the next datacenter address. Facebook's public page HTML — og:title, og:description, and the timeline_context_item JSON blobs that hold the intro card — is served to anonymous cloud IPs with almost no resistance. I built two expensive rungs for Facebook and in 26 days of this panel neither was ever used.

That is not a promise. An early-October measurement on a wider list, dead pages included, was far rougher: one batch opened 15 of 20 pages on datacenter addresses, the next opened 4 of 74 attempts. The wall rate swings by page, by hour and by subnet, so the ladder stays — it just turned out to be insurance, not the main cost.

The website half needed a paid unblocker

The second half of the job is the part I thought of as the cheap bonus: if the page has no e-mail, go read the company's own website. Nobody blocks their own marketing site, right?

Look at what happened to the Shake Shack row on 21 October 2026, between two runs four hours apart:

run 05:22 UTC   websitePagesRead 0   socialLinks {}                                      unblocker requests 0
run 09:01 UTC   websitePagesRead 3   socialLinks {instagram,tiktok,youtube,x,linkedin}   unblocker requests 3
Enter fullscreen mode Exit fullscreen mode

Nothing about Facebook changed. What changed is that I had just wired the paid unblocker in as a last rung for outgoing website requests too — a wrapper that watches for block-shaped answers (403, 429, 503, an empty body, a Cloudflare/DataDome interstitial) and retries that one request through the unblocker. Before it, shakeshack.com answered my plain HTTPS client with nothing usable, and the Actor recorded websitePagesRead: 0 — zero pages read, silently, for 16 consecutive runs. After it, the same site handed over three pages.

Totals since that switch flipped: 63 unblocker requests, 63 successes, 5.3 MB, across the same three-page input. In other words, across this panel the brand websites needed the expensive route roughly 1.5 times per run, while Facebook needed it zero times. The platform everyone writes anti-bot articles about was the easy side of this scraper. The corporate CDN in front of a burger chain's homepage was the hard side.

And now the part that actually changed how I price this thing:

emailFromWebsite, summed over all 42 runs:  0
Enter fullscreen mode Exit fullscreen mode

The unblocker bought me not one e-mail address. Every e-mail in this panel came from the Facebook intro card (emailSource: "facebook"). What the website pass delivered was social links — five columns on the Shake Shack row that were empty before. Useful, but not what the rung was built for. If you are deciding whether to pay for unblocking in your own pipeline, measure the field you care about, not the number of pages you rescued. Mine looked like a win in the request log and a zero in the e-mail column.

Contact fields are display strings, not data

Here is a real row, trimmed, from the run at 20:27 UTC on 31 October:

{
  "name": "Bali Zoo Park Ticket",
  "username": "balizoopark",
  "category": "Zoo",
  "email": "thebalipackage@gmail.com",
  "emailSource": "facebook",
  "phone": "+81 23-619-4398",
  "whatsapp": "https://api.whatsapp.com/send?phone=+6281236194398",
  "address": "Singapadu, Sukawati, Gianyar, Bali 80582, Mbali, Democratic Republic of the Congo",
  "latitude": -8.59404852,
  "longitude": 115.26838303,
  "rating": "92% recommend (259 reviews)",
  "recommendPercent": 92,
  "hours": "Always open",
  "hasContact": true,
  "charged": true
}
Enter fullscreen mode Exit fullscreen mode

Two fields in there are wrong, identically wrong in all 36 rows this page produced over the month, and both are wrong because the Actor returns the intro card's own text verbatim.

The phone. Line the digits up:

whatsapp link   +62 812 3619 4398     13 digits
phone field     +81 23-619-4398       11 digits
Enter fullscreen mode Exit fullscreen mode

Same number, minus the leading 62. Indonesia's country code was dropped and whatever formatted the remainder read the next two digits, 81, as a country code — Japan's. The digits that survive are correct; the plus sign in front of them is fiction. The WhatsApp link, which is just a URL nobody reformatted, kept all 13.

The address. The string ends in "Democratic Republic of the Congo". The coordinates on the same row are -8.594, 115.268, which is Singapadu in Gianyar, Bali — and no point in the DRC has a longitude anywhere near 115. The street, the regency and the postcode are right; the country token is garbage, probably from "Mbali", a real place name in the Congo.

So the rule for any Facebook-sourced contact column: the coordinates are data, the address is a label, and the phone is a rendering. In my pipeline the row now goes through this before it reaches the CRM:

import re

def normalise(row):
    wa = row.get("whatsapp") or ""
    m = re.search(r"phone=\+?(\d{8,15})", wa)
    digits = m.group(1) if m else re.sub(r"\D", "", row.get("phone") or "")
    lat, lon = row.get("latitude"), row.get("longitude")
    region = "ID" if lat and lon and -11 < lat < 6 and 95 < lon < 141 else None
    return {**row, "phoneDigits": digits or None, "phoneRegionGuess": region}
Enter fullscreen mode Exit fullscreen mode

The WhatsApp link wins when it exists, the country comes from the geography, and the pretty +81 never reaches anybody who might dial it. One gap worth knowing: emailSource says whether the e-mail came from Facebook or the website, but the social columns carry no such marker — a WhatsApp link may come from either side.

What a global brand gives you, and what it costs

The Nike row, 39 times in a row, is the counter-example to the whole use case:

email        null  (39 of 39 rows)
phone        "+972 54-292-7777"  (identical in all 39)
website      "http://nike.com/"
confirmedOwner  "NIKE, Inc."
followers    ~39.49M
Enter fullscreen mode Exit fullscreen mode

No e-mail, ever. A phone number with an Israeli country code that never moved in 26 days — I can tell you it is stable and that I have no idea who answers it. Big brands publish a website and a social wall; the contact data lives with local businesses, which is the opposite of how people pick their test inputs.

That matters because the Actor charges per row with a contact — $0.006 on the free tier, $0.0042 on the top plans; the summary row in my own Bronze-account run read $0.0054 each. In this panel, 114 of 120 visits were charged and 75 of 116 delivered rows carried an e-mail. The other 39 charged rows were phone-or-socials only — the Nike shape. If your outreach is e-mail, switch requireEmail on: a page with a phone and no e-mail then counts as no contact, is not delivered, and is not billed. Six visits here found neither and cost nothing. Deleted pages and login-walled pages are free too, which is the part I care about when a buyer feeds in a list scraped two years ago.

Running it straight from the API, one call, rows back in the response:

curl -s -X POST "https://api.apify.com/v2/acts/lergassy~facebook-page-contact-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"pages":["balizoopark","potatoheadbali"],"searchWebsite":true,"requireEmail":true}'
Enter fullscreen mode Exit fullscreen mode

One row in the output is {"type":"notice", ...} — a free summary line saying how many rows were delivered and charged. Filter on type != "notice" and you have your lead list.

When this Actor is the wrong tool. It takes page URLs or usernames; it does not find pages, so the discovery step (Maps, a directory, a competitor's follower list) happens before it. It reads the intro card, not the wall — no posts, no reviews, no ad history. It does not touch personal profiles. And it does not verify that thebalipackage@gmail.com accepts mail; it only reports that the business published it. Verification is a separate job, and worth doing before anyone presses send.

The thing I would take to the next scraper, though, is the route ledger itself. Counting which rung opened each page cost me three integers in a key-value store, and it told me my anti-bot budget was aimed at the wrong half of the pipeline.

Top comments (0)