Facebook Search Scraper With WhatsApp & Contact Leads
Pricing
from $4.99 / 1,000 results
Facebook Search Scraper With WhatsApp & Contact Leads
Extract public Facebook search results instantly with Facebook Search Scraper. Gather pages, profiles, groups, posts, events, and valuable metadata using keywords for lead generation, competitor tracking, audience insights, and market research.
Pricing
from $4.99 / 1,000 results
Rating
0.0
(0)
Developer
API Empire
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
4 days ago
Last modified
Categories
Share
Facebook Search Scraper — Pages, WhatsApp Leads & Linked Accounts
Facebook Search Scraper With WhatsApp & Contact Leads turns a category and a location — Pub + NYC — into contact-ready rows for Facebook business pages. Every run returns a 57-key JSON record per page: the full 38-field business profile (title, address, phone, email, website, rating, hours, price band, photos) plus a contact-lead layer — WhatsApp click-to-chat link, linked Instagram / TikTok / YouTube / X / LinkedIn accounts, every outbound link, and real page coordinates. Typed, normalized JSON — no HTML, no selectors, no parsing. By the end of this page you'll know which field carries which value and what you're charged for.
What is Facebook Search Scraper With WhatsApp & Contact Leads?
It is an Apify Actor that discovers Facebook business pages from search results and scrapes each one logged out, then adds a contact layer on top of the standard page record. You give it business categories and locations; it builds one search query per category × location pair, collects the Facebook page URLs those queries surface, fetches every page, and parses the page's own Relay payload into a flat JSON row.
No Facebook account and no cookie are required. src/main.py:31 sets DEFAULT_COOKIES = {} and every field the Actor emits is read from the logged-out HTML. The cookies input exists only for users who explicitly want their own session used.
It runs from the Apify Console (fill the input form, press Start) or from the Apify API — those are the only two run surfaces.
- Scrape Facebook business pages — 38-field record per page: title, categories, address, phone, email, website, rating, review count, followers, likes, hours, price range, creation date, ad status, profile and cover photos
- Scrape WhatsApp contact leads — the page's WhatsApp business card gives you
whatsappNumber, the publishedwhatsappLink, a stablewhatsappChatUrl, andisWhatsappReachable - Scrape linked social accounts —
linkedAccountssplits Instagram, TikTok, YouTube, X/Twitter and LinkedIn into named keys, with everything else inother[] - Scrape outbound links and geo coordinates —
externalLinks[]with Facebook'sl.phpredirector unwrapped, pluspageLatitude/pageLongitudefrom the address card's Maps link - Optional off-Facebook enrichment — fetch the business's own website and contact page for extra emails, phones, social links and a contact-form flag
- Export as JSON, JSONL, CSV, Excel, XML or HTML from the Apify dataset — no proxy management, no parsing on your side
🔑 What data does this Facebook page scraper collect?
One run returns five structurally distinct blocks inside every row: the business page record, the WhatsApp contact-lead block, the linked-accounts block, the location block, and — when you switch it on — an off-Facebook website-contact block. Every JSON key below is copied from src/page_scraper.py (build_row_from_html) and src/contact_leads.py.
| Data type | Key fields | JSON field names |
|---|---|---|
| Business page record | Title, categories, address, phone, email, website, intro, price band, hours, services, ad status, page age | title, categories, category, address, addressUrl, phone, email, website, websites, intro, about_me, priceRange, business_price, priceTier, business_hours, services, business_services, ad_status, creation_date, pageId, facebookId, pageName, pageUrl, facebookUrl, pageAdLibrary |
| Audience & reputation | Followers, follows, likes, star/recommend rating, review count, description snippets | followers, followings, likes, rating, ratings, ratingOverall, ratingCount, info |
| WhatsApp contact lead | Published number, Facebook's signed chat link, a stable wa.me link, reachability, phone match | whatsappNumber, whatsappLink, whatsappChatUrl, isWhatsappReachable, whatsappMatchesPhone, whatsappCardPresent |
| Linked social accounts & links | Instagram / TikTok / YouTube / X / LinkedIn handles, all outbound destinations, Instagram sub-object | linkedAccounts, linkedAccountCount, externalLinks, websiteCardCount, instagram, alternativeSocialMedia |
| Location & messaging | Latitude, longitude, Bing Maps address link, constructed Messenger deep link | pageLatitude, pageLongitude, addressUrl, messengerLink, messengerLinkStatus, messenger |
| Media | Profile picture, cover photo, canonical profile-photo permalink | profilePictureUrl, coverPhotoUrl, profilePhoto |
| Reachability summary | Which channels exist, whether the lead is actionable at all | contactChannels, hasContact, errorReason |
| Website contact block (opt-in) | Emails, phones, socials, contact page URL, contact-form flag, fetch status | websiteContact → source, websiteUrl, contactPageUrl, hasContactForm, emails[], phones[], socialLinks[], fetchStatus |
The dataset's default table view shows 21 of these columns. That view is a display setting, not the schema — every charged row carries all 57 keys, and JSON/CSV exports include them all.
Need more Facebook data?
If you already have specific page URLs rather than a category + location, Facebook Pages Scraper — page metadata & contact info takes URLs directly. For community-side data, Facebook Groups Search Scraper With Group Details & Rules returns group metadata and posting rules, and Facebook Videos Scraper: Commenters, Reactors & Sharers returns the people who engaged with a video rather than the page that posted it. If your lead list is location-first rather than Facebook-first, Google Maps Scraper With Lead & Contact Enrichment covers the same "business → reachable contact" job from the Maps side and pairs well with this Actor for coverage overlap.
How does this scraper differ from the official Facebook Pages API?
Meta's Pages API search endpoint is built for apps that already have a relationship with a Page: you need a Facebook app, an access token, and — for public Page metadata beyond your own Pages — a feature that goes through App Review. This Actor reads the same public page surface a logged-out browser sees and returns it as a flat JSON row, with no app, no token and no review.
| Feature | Official Facebook Pages API | This Actor |
|---|---|---|
| Access setup | Facebook app + access token; public-Page metadata requires App Review approval, per Meta's Pages API docs | Apify account only — no Facebook app, no token, no login (src/main.py:31) |
| Discovery model | Search endpoint scoped by the permissions your token was granted | Category × location query expansion; every page URL a public search surfaces |
| WhatsApp business number | Not exposed by the Pages search endpoint | whatsappNumber, whatsappLink, whatsappChatUrl, isWhatsappReachable |
| Linked Instagram / TikTok / YouTube accounts | Not part of the Pages search response | linkedAccounts object with five named platforms plus other[] |
| Output shape | Graph API field-selection response; shape varies with the fields parameter you send | Fixed 57-key row on every charged result — keys present even when the value is null |
| Geo coordinates | Depends on the Page's structured location and your token's field access | pageLatitude / pageLongitude parsed from the page's own Maps link |
| Failure handling | HTTP error codes you map yourself | Blocked, login-walled and non-business pages become uncharged rows with a typed errorReason |
Use the official API when you administer the Pages, need write access, or need data Meta only exposes to authorised apps. Use this Actor when you need a wide, contact-ready lead list across pages you have no relationship with, in one consistent shape.
Why do teams scrape Facebook business pages?
Facebook remains one of the largest public directories of small and mid-sized local businesses that publish a phone number, an email or a WhatsApp number openly. Four groups get concrete value from that.
For sales and lead-generation teams
Facebook business pages are one of the few public sources where a small business publishes a WhatsApp number rather than a switchboard. Set categories to your ICP verticals, locations to your territory, turn on requireAnyContact, and every row you get back has at least one of email, phone or WhatsApp in contactChannels. Route on whatsappChatUrl in markets where WhatsApp is the default business channel and on email elsewhere; hasContact and linkedAccountCount make good prioritisation columns before anything reaches a CRM.
For marketers and brand teams
Run the same category across several cities and compare rating, ratingCount, followers, priceRange and priceTier to see where a segment is crowded and where it is thin. linkedAccounts tells you which competitors run Instagram and TikTok alongside Facebook and which have only a Facebook presence — a direct read on channel maturity in a local market. ad_status flags pages currently running ads, a useful proxy for who is actively spending against your keywords.
For researchers and analysts
Everything the Actor returns is publicly visible without a login, which keeps the collection scope clean for a methods section. pageLatitude / pageLongitude support spatial analysis of business density; creation_date supports cohort work on how long a category's businesses have existed in a city; followers and ratingCount give a public-engagement proxy. The uncharged accounting rows record exactly which URLs were unreachable, so coverage gaps stay auditable rather than invisible.
For developers building data products
The row shape is fixed, so a downstream table never gains or loses a column between runs. Schedule the Actor per city, read the dataset over the Apify API, and diff on pageId to detect new businesses, changed phone numbers or a newly published WhatsApp card. Because every value is already typed JSON, an AI agent can call the Actor as a tool and pass rows into an LLM context window or a vector store with no HTML-parsing step in the loop.
How to scrape Facebook pages with WhatsApp contacts (step by step)
- Open the Actor in the Apify Console. Sign in to your Apify account and open this Actor's page. No Facebook account, cookie or API key is needed.
- Enter your categories. Fill
categorieswith the business types you want —Pub,Bar,Dental clinic. This is the only field most runs need to change; it defaults to["Pub"]. - Enter your locations. Fill
locationswith cities or areas —NYC,London,Dubai. Every category is paired with every location, so 3 categories × 4 locations = 12 search queries. - Set the size and the contact layer.
resultsLimitcaps how many pages get fetched across all queries (1–500, default 10). LeaveextractWhatsAppandextractLinkedAccountson. Turn onrequireAnyContactif you only want rows that are actually reachable, andfindWebsiteContactFormif you want the off-Facebook enrichment. - Start the run and export. Press Start, watch rows stream into the dataset live, then download as JSON, JSONL, CSV, Excel, XML or HTML — or fetch the dataset over the Apify API.
What to do when Facebook changes its structure
Nothing on your side. The Actor reads named card types out of the page's Relay payload rather than CSS selectors, so it survives layout changes, and the output key list is a frozen contract — BASE_OUTPUT_KEYS in src/page_scraper.py:66 exists precisely so no field is ever removed from a row. When Facebook changes a card, the parser is updated and the key stays, so your integration keeps working.
What changed in Facebook page scraping recently?
The most consequential change is that Facebook's login wall now answers HTTP 200. A walled request 302s to /login/?next=… and returns a normal-looking 200 response with no page payload — it never raises an error. This Actor's own instrumentation recorded that behaviour on 2026-07-23, documented in src/proxy_manager.py:71.
Three consequences follow, all measured in the same session and recorded in the source:
- A login wall is exit-IP driven, not page-specific. Six pages that walled from a bare Apify container returned clean payloads through a US datacenter exit and a US residential exit (
src/main.py:114-118). Rotating the exit IP is the fix, not changing the URL. - Naive "blocked?" checks now false-positive. Every Facebook JS bundle contains the strings
checkpointandblocked, so substring-based detection flags healthy pages. Detection here keys on the HTTP status, the final resolved URL and the absence of the expected Relay container (src/proxy_manager.py:84-100). - A DIY scraper silently returns nothing. Because the wall is a 200, a scraper without this classification pushes rows full of nulls and reports a green run.
None of this needs action from you: walled pages are classified, retried up to four times through an escalating proxy ladder, and — if they still fail — emitted as uncharged rows carrying errorReason: "login_wall". Classification and the ladder are maintained as Facebook's posture shifts.
⬇️ Input
All nine parameters below come from .actor/actor.json. None of them is required — the schema's required array is empty, so pressing Start with an untouched form runs "Pub" × "NYC" for 10 pages.
| Parameter | Required | Type | Description | Example value |
|---|---|---|---|---|
categories | No | array of strings | Business types to search. Editor: stringList. Default ["Pub"], prefill ["Pub"]. Each entry is paired with every entry in locations. An empty array is silently replaced with ["Pub"] (src/main.py:202). | ["Pub", "Bar", "Restaurant"] |
locations | No | array of strings | Where to search — paired with each category. Editor: stringList. Default ["NYC"], prefill ["NYC"]. An empty array is silently replaced with ["NYC"] (src/main.py:203). To search with no location, put the qualifier inside the category string instead. | ["NYC", "London", "Dubai"] |
resultsLimit | No | integer | Maximum number of Facebook pages fetched across all queries. Editor: number. Default 10, minimum: 1, maximum: 500. Clamped in code to max(1, min(500, int(value))); a non-numeric value falls back to 10 (src/main.py:209-211). | 120 |
proxyConfiguration | No | object | Apify Proxy configuration for the Facebook page fetches only. Editor: proxy. Prefill {"useApifyProxy": false} — no proxy by default. When a page is blocked or login-walled the Actor escalates on its own: none → datacenter (BUYPROXIES94952) → residential (src/proxy_manager.py:21-22). | {"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]} |
extractWhatsApp | No | boolean | Default true. Reads the page's INTRO_CARD_PROFILE_WHATSAPP_NUMBER card and emits whatsappNumber, whatsappLink, whatsappChatUrl, isWhatsappReachable and whatsappMatchesPhone. Set it to false and those five fields stay at their empty values; no extra request is saved either way, because the card comes from the same page response. | true |
extractLinkedAccounts | No | boolean | Default true. Emits linkedAccounts (Instagram / TikTok / YouTube / X / LinkedIn / other[]) and feeds the linked-account URLs into externalLinks[]. Set to false and linkedAccounts returns with all five platform keys null and other: []. | true |
requireAnyContact | No | boolean | Default false. When true, any page with no email, no phone and no whatsappNumber is dropped before the push — neither stored nor charged (src/main.py:169-172). Off by default so nothing disappears silently. | false |
findWebsiteContactForm | No | boolean | Default false. Slow. Fetches the business's own website plus one linked contact page and fills websiteContact with emails, phones, social links and a contact-form flag, every value stamped source: "website_crawl". Booking, ordering, delivery and directory platforms are skipped so their contacts cannot poison your list (src/website_contact.py:57-67). | false |
cookies | No | object | Optional, and masked — the schema sets isSecret: true. Editor: json. Leave it empty. Every field this Actor returns was verified logged-out. If you do supply one, it must be a flat name → value map such as {"c_user": "...", "xs": "..."}, not a browser-extension cookie array. | {} |
Example input
{"categories": ["Pub", "Cocktail bar", "Gastropub"],"locations": ["London", "Manchester", "Dubai"],"resultsLimit": 120,"proxyConfiguration": {"useApifyProxy": true,"apifyProxyGroups": ["RESIDENTIAL"]},"extractWhatsApp": true,"extractLinkedAccounts": true,"requireAnyContact": true,"findWebsiteContactForm": false,"cookies": {}}
Common pitfall
resultsLimit is divided across your queries with floor division, so a small limit spread over many queries returns fewer pages than you asked for. The Actor computes urls_per_query = max(1, resultsLimit // len(search_queries)) (src/main.py:250). With resultsLimit: 10 and 3 categories × 1 location = 3 queries, each query is allowed 3 URLs — a maximum of 9 pages, not 10. Keep resultsLimit comfortably larger than your query count (a good rule: at least 10× the number of category × location pairs), or reduce the number of pairs.
Two smaller traps worth knowing:
- URL discovery always uses Apify Proxy. Search runs through the
GOOGLE_SERPproxy group (src/google_serp.py:27). If that proxy cannot be created, the run logsSearch proxy unavailable.and finishes cleanly with zero rows (src/google_serp.py:220-224,src/main.py:258-260). This is independent of yourproxyConfiguration, which only governs the Facebook page fetches. cookiesmust be a flat dict. The value is handed straight toaiohttp'scookies=argument. A cookie-editor JSON array export will raise on every request, and every URL will come back as an unchargedfetch_errorrow.
⬆️ Output
Every result is pushed to the Apify dataset as flat, typed JSON with stable key names — the same 57 keys on every charged row, present even when a value is null. Export as JSON, JSONL, CSV, Excel, XML or HTML from the Console, or read the dataset over the Apify API. Rows stream in as they are scraped, so you can start consuming before the run finishes. The internal _meta object used by the retry ladder is stripped before the push (src/main.py:143) and never appears in your data.
Scraped Facebook business page
The full charged row. Values below are illustrative in content, but every key, type and construction rule is real.
{"facebookUrl": "https://www.facebook.com/theanchortapbar","categories": ["Pub"],"info": ["The Anchor Tap, London, United Kingdom. 4,812 likes","212 talking about this. Riverside pub in Bermondsey since 1892."],"likes": 4812,"messenger": null,"priceRange": "££","title": "The Anchor Tap","address": "28 Horselydown Ln, London, SE1 2LN, United Kingdom","pageId": "100063512477981","pageName": "theanchortapbar","pageUrl": "https://www.facebook.com/theanchortapbar","intro": "Riverside pub in Bermondsey since 1892. Cask ale, Sunday roasts, dog friendly.","websites": ["https://theanchortap.co.uk"],"phone": "+44 20 7403 4864","email": "bookings@theanchortap.co.uk","alternativeSocialMedia": "https://www.instagram.com/theanchortap","website": "theanchortap.co.uk","services": "Dine-in · Takeaway · Outdoor seating","rating": "94% recommend (1,669 reviews)","followers": 5200,"followings": 118,"profilePictureUrl": "https://scontent.xx.fbcdn.net/v/t39.30808-1/301151412_570690141417755_n.jpg","coverPhotoUrl": "https://scontent.xx.fbcdn.net/v/t39.30808-6/318842190_115509857900372_n.jpg","profilePhoto": "https://www.facebook.com/photo/?fbid=570690141417755","ratingOverall": 94,"ratingCount": 1669,"category": "Pub","addressUrl": "https://www.bing.com/maps/default.aspx?v=2&pc=FACEBK&mid=8100&where1=28+Horselydown+Ln%2C+London&FORM=FBKPL1&mkt=en-US","instagram": [{ "username": "theanchortap", "url": "https://www.instagram.com/theanchortap" }],"ratings": "94% recommend (1,669 reviews)","business_hours": "Open now","business_price": "Price Range · ££","business_services": "Dine-in · Takeaway · Outdoor seating","creation_date": "March 14, 2011","ad_status": "This Page is currently running ads.","about_me": {"text": "Riverside pub in Bermondsey since 1892. Cask ale, Sunday roasts, dog friendly."},"facebookId": "100063512477981","pageAdLibrary": {"is_business_page_active": true,"id": "1449158455201923"},"whatsappNumber": "+44 7700 900431","whatsappLink": "https://api.whatsapp.com/send?phone=447700900431&token=eyJhbGciOiJIUzI1NiJ9...","whatsappChatUrl": "https://wa.me/447700900431","isWhatsappReachable": true,"whatsappMatchesPhone": false,"whatsappCardPresent": true,"linkedAccounts": {"instagram": "https://www.instagram.com/theanchortap","tiktok": "https://www.tiktok.com/@theanchortap","youtube": null,"twitter": null,"linkedin": null,"other": ["https://linktr.ee/theanchortap"]},"linkedAccountCount": 3,"externalLinks": ["https://theanchortap.co.uk","https://www.instagram.com/theanchortap","https://www.tiktok.com/@theanchortap","https://linktr.ee/theanchortap","https://maps.google.com/maps?q=51.5029,-0.0729&hl=en"],"websiteCardCount": 1,"pageLatitude": 51.5029,"pageLongitude": -0.0729,"messengerLink": "https://m.me/theanchortapbar","messengerLinkStatus": "constructed_unverified","contactChannels": ["email", "phone", "whatsapp"],"hasContact": true,"websiteContact": null,"errorReason": null,"priceTier": 2}
Field notes that matter in practice
messengeris alwaysnull— a base-contract key kept for compatibility (src/page_scraper.py:531). UsemessengerLink, which is constructed from the vanity slug orpageIdand never observed on the page, which is whymessengerLinkStatusreadsconstructed_unverified. Treat it as a best-effort deep link.ratingsduplicatesrating, andbusiness_servicesduplicatesservices. Both duplicates are part of the frozen key contract.websiteis the bare domain withwww.stripped;websites[]is the full URL form;externalLinks[]is every outbound destination withfacebook.comandfb.comself-links removed andl.phpredirects unwrapped.priceTiercounts currency glyphs in$£€¥₹₩₽₦and returns 1–4, because Facebook renders the same band with a different glyph depending on locale. ComparepriceTieracross countries, notpriceRange.followersandfollowingsare integers — compact forms like5.2Kare expanded before the push (src/page_scraper.py:384-394).ratingOverallcarries whatever numeric rating the page exposes: a 0–100 percentage on pages using the "N% recommend" format, or the star value cast to an integer on pages exposing a star rating. Read it alongside the human-readableratingstring, not on its own.
WhatsApp contact-lead block
The six WhatsApp keys shown inside the row above behave as a unit. whatsappLink is Facebook's own URL and carries a signed, expiring token — store whatsappChatUrl instead, which is constructed from the digits alone and does not expire. When the page publishes no WhatsApp card, the whole block comes back as whatsappNumber: null, whatsappLink: null, whatsappChatUrl: null, isWhatsappReachable: false, whatsappMatchesPhone: null, whatsappCardPresent: false.
Website contact block (findWebsiteContactForm: true)
{"websiteContact": {"source": "website_crawl","websiteUrl": "https://theanchortap.co.uk","contactPageUrl": "https://theanchortap.co.uk/contact","hasContactForm": true,"emails": ["bookings@theanchortap.co.uk", "events@theanchortap.co.uk"],"phones": ["+44 20 7403 4864", "020 7403 4865"],"socialLinks": ["https://www.instagram.com/theanchortap","https://www.facebook.com/theanchortapbar"],"fetchStatus": "ok"}}
fetchStatus is always populated, so the field is never silently empty. Possible values: ok, not_attempted, skipped_platform_host (the "website" was a booking, delivery, directory or link-in-bio platform), timeout, http_<status>, or error:<ExceptionName>. emails, phones and socialLinks are each capped at 10 entries.
Uncharged accounting row
When a page cannot be turned into a business record, the Actor pushes a three-key row and does not charge for it:
{"facebookUrl": "https://www.facebook.com/somewalledpage","error": "login_wall (HTTP 200)","errorReason": "login_wall"}
errorReason values you can encounter: login_wall, blocked, unsupported_page (the URL resolved to something with no business Page payload), http_<status>, timeout, fetch_error, error. On every charged row errorReason is null, which gives you a one-expression filter:
// JavaScript — keep only charged business rowsconst leads = items.filter(item => item.errorReason === null);
# jq — same filter on an exported JSON datasetjq '[.[] | select(.errorReason == null)]' dataset.json
💬 How WhatsApp, phone and email extraction actually works
This is the part that decides whether a lead list is usable, so here is the exact rule for each field and the ways each one can be wrong.
The number comes from one named card: timeline_context_list_item_type == "INTRO_CARD_PROFILE_WHATSAPP_NUMBER", read as renderer.wa_number and renderer.wa_link (src/page_scraper.py:201-206). That renderer carries no context_item, which is why intro-card walkers that only read renderer.context_item never see it at all.
The digits are then resolved in this order (src/contact_leads.py:130-164):
- Parse
wa_linkwithurlparse+parse_qsand take thephonequery parameter. - If that yields nothing, fall back to
wa_number. - Either candidate passes through
phone_digits(), which concatenates every digit run (re.compile(r'\d+')) and accepts the result only if7 <= len(digits) <= 15. whatsappChatUrlis thenhttps://wa.me/{digits}.
Known false-positive and false-negative modes:
- No country normalisation. The digits are used exactly as found. A number published as
00 44 7700 900431becomeswa.me/00447700900431, which WhatsApp will not resolve. Check that the digits start with a plausible country code before dialling in bulk. - Extensions get concatenated.
+1 555 010 0100 ext. 12strips to a 13-digit run that passes the length gate and produces a wrongwa.melink. Rare on WhatsApp cards, but possible. - The length gate drops edge cases. Anything under 7 or over 15 digits is discarded entirely and the whole block returns null — no partial value is ever emitted.
isWhatsappReachablemeans "published", not "answers". It istruewhenever a valid digit run was extracted. Nothing contacts WhatsApp to verify the number exists.whatsappCardPresentignores the toggle. It is computed from the raw card regardless ofextractWhatsApp(src/page_scraper.py:578), so withextractWhatsApp: falseyou can seewhatsappCardPresent: truealongsidewhatsappNumber: null. Useful for measuring coverage without extracting, surprising if you assume the two always agree.- Coverage is geography-dependent. WhatsApp business cards are common in markets where WhatsApp is the default business channel and rare on US pages. When a page publishes none, the field is
null— never guessed, never back-filled fromphone.
Phone (from Facebook)
Resolved by precedence (src/page_scraper.py:506, with the fallback at :275):
INTRO_CARD_PROFILE_PHONE→context_item.title.text, elseplaintext_title.text— the structured card, and the correct source.delegate_page.phone_number.universal_number, elsedelegate_page.phone_number.phone_number.- Whole-document regex, first match wins, in this order:
"phone_number"[:\s]*"([^"]+)", then"universal_number"[:\s]*"([^"]+)", thentel:([+\d\s\-\(\)]+)(src/page_scraper.py:333).
Known false-positive mode: step 3 scans the entire HTML document, including payload for related pages, embedded widgets and inline ad markup. On a page that publishes no phone card, step 3 can return a number belonging to a different entity. There is no provenance flag on phone, so you cannot tell from the row which of the three paths produced it. If wrong-entity numbers are unacceptable for your use case, cross-check phone against websiteContact.phones (turn on findWebsiteContactForm) or against the WhatsApp number via whatsappMatchesPhone.
The same whole-document-regex-first pattern applies to address: the document-level "address_text" / "street" regex is consulted before the structured INTRO_CARD_ADDRESS card text (src/page_scraper.py:463). In practice the two agree on a normal business page, but on pages with heavy embedded markup the document regex can win with a neighbouring entity's street line. pageLatitude / pageLongitude are unaffected — they come from the address card's own Maps link.
Email (from Facebook)
email comes from exactly one place: the INTRO_CARD_PROFILE_EMAIL card's title.text (src/page_scraper.py:213-214). There is no document-wide email regex on the Facebook side, so this field has no false-positive mode — but it is null on every page that does not publish an email card, which is most of them.
Emails and phones from the business website (findWebsiteContactForm: true)
This is the only code path that leaves facebook.com, and everything it produces is stamped source: "website_crawl" so it can never be confused with a value Facebook itself published.
Emails — ([A-Za-z0-9._%+\-]+)@([A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+) run over NFKC-normalised html + " " + visible_text with zero-width characters stripped (src/website_contact.py:32, :88-105), then four guards: reject anything ending in .png .jpg .jpeg .gif .webp .svg .css .js .woff .ttf (kills logo@2x.png); the TLD must appear in a fixed allowlist of roughly 90 real TLDs (kills @handle.something); reject known noise substrings (sentry.io, wixpress.com, example.com, domain.com, yourdomain, email.com, sentry-next, godaddy.com, .local); reject addresses over 100 characters or containing ...
Known false negative: the TLD allowlist is closed. A legitimate address on a TLD outside it — .xyz, .tech, .digital, .tv, .ro, .ua, .si and similar — is silently dropped. Country-code pairs such as .co.uk are fine, because the check reads the final label (uk). Known false positive: raw HTML is scanned as well as visible text, so an address embedded in an inline analytics or CMS config blob is emitted if its TLD passes.
Phones — two patterns (src/website_contact.py:49-50):
tel:([+0-9().\-\s]{7,25})over the raw HTML — authoritative, since atel:href is an explicit dial intent(?<![\w])(\+\d[\d().\-\s]{6,20}\d|\(\d{2,4}\)[\d().\-\s]{5,16}\d|\d{2,4}[\s.\-]\d{2,4}[\s.\-]\d{2,6})(?![\w])over visible text
Every candidate then passes three guards (src/website_contact.py:112-122): 7–15 digits after stripping non-digits; reject a bare unbroken digit run with no + and no separators, so an ID, VAT or licence number cannot become a phone; reject numbers with two or fewer distinct digits (0000000). Output is capped at 10 entries.
Known false positive: the third alternation branch, \d{2,4}[\s.\-]\d{2,4}[\s.\-]\d{2,6}, matches any separator-grouped numeric triple in body copy — dates like 12-05-2024, invoice and SKU codes, measurement ranges. It is the largest source of junk in websiteContact.phones; prefer entries that also appear as a tel: link or start with +. Known false negative: an unbroken local number such as 02012345678, written with no + and no separators, is rejected by the ID guard.
hasContactForm is bool(soup.find("form")) on the home page or the contact page (src/website_contact.py:203, :223). Any <form> counts — a newsletter signup, a search box or a cookie banner sets it to true. Read it as "this site has at least one form", not "this site has a contact form".
Coordinates
pageLatitude / pageLongitude are parsed from URLs that contain both google. and map, using three patterns in order: [?&]q=(lat),(lng), /@(lat),(lng), [?&]ll=(lat),(lng) (src/contact_leads.py:25-27). Values are range-validated to ±90 / ±180 and an exact 0,0 is rejected. Address-card URLs are searched before externalLinks, so the page's own location wins over any other Maps link on the page.
How does this Actor compare to other Facebook scrapers?
The Apify Store has several Facebook page and search scrapers. The rows below are observable differences, each checked against the competitor's live Apify Store listing on 2026-07-25. Figures and claims in the competitor columns are what their listings say — none of it was measured here.
| Feature | This Actor | apify/facebook-search-scraper | memo23/facebook-pages-and-contacts-scraper |
|---|---|---|---|
| Facebook login required | No — every field verified logged-out (src/main.py:31); cookies is optional and masked | Not stated as required on its listing | Yes — its listing states "Requires login via cookies for comprehensive access to search results" and walks users through exporting cookies with a browser extension |
| WhatsApp business number | whatsappNumber, whatsappLink, whatsappChatUrl, isWhatsappReachable, whatsappMatchesPhone | Not listed among its extracted fields | Not listed among its extracted fields |
| Linked social accounts | linkedAccounts object with Instagram, TikTok, YouTube, X and LinkedIn keys plus other[], and a deduplicated externalLinks[] with l.php redirects unwrapped | Its listing names "Social media links" as an extracted field | Its sample output nests links under pageDetails["Websites and social links"], with the raw l.facebook.com/l.php?u=… wrapper left intact |
| Output shape | Flat 57-key row, identical on every charged result | Flat row; its sample output omits keys when a value is absent | Nested — its sample repeats the same contact object inside pageDetails, Contact info and Websites and social links |
| Geo coordinates | pageLatitude / pageLongitude as validated floats | Its sample appends the Maps link to the address string rather than parsing coordinates | Its sample returns location: null with the address as free text |
| Failed pages | Pushed as three-key uncharged rows with a typed errorReason — you can audit coverage and you are not billed | Not described on its listing | Not described on its listing |
Every claim in the two competitor columns is what that Actor's own Apify Store listing said when it was checked on 2026-07-25. None of it was measured here.
If you're building an AI agent or a RAG pipeline, the output-shape row is the decision-maker — parsing HTML, or reconciling three copies of the same nested contact object, inside an agent loop is a reliability failure mode, not a feature. The WhatsApp row matters if your outreach happens in markets where WhatsApp, not email, is how businesses actually reply.
How many results can you scrape?
The hard cap is resultsLimit, which the input schema constrains to 1–500 and the code clamps to max(1, min(500, int(value))) (src/main.py:209). That number is the maximum count of Facebook pages fetched across every query in the run — not the number of charged rows you end up with.
Pagination works per query. Each query is allocated resultsLimit // len(search_queries) URLs, floored, with a minimum of 1 (src/main.py:250). Search results are then paged with a stride of 10 organic results per request, up to min(30, (urls_per_query // 10) + 3) requests per query (src/google_serp.py:174). Collected URLs are deduplicated by canonical form, page sub-tabs such as /about and /reviews are folded back to the parent page URL, and anything matching Facebook's personal-profile vanity shape (firstname.lastname.123) or a non-page path (/photos/, /posts/, /groups/, /marketplace/, media files) is discarded before anything is scraped.
Three things shrink the final count below resultsLimit: floor division across queries, cross-query duplicates, and pages that come back blocked, login-walled or non-business — those become uncharged rows. Turning on requireAnyContact shrinks it further by design. Widen coverage by adding more categories × locations pairs rather than by raising resultsLimit alone; the run log tells you when it found fewer URLs than requested.
One undocumented behaviour worth knowing: if your run produces exactly one query, resultsLimit is above 100, and that query contains NYC, the Actor appends a second query with NYC rewritten to New York to widen the result pool (src/main.py:45-50).
💳 What gets charged — and what does not
This Actor uses Apify's pay-per-event model with a single event: row_result, charged once per successfully scraped business page at the moment the row is pushed (src/main.py:182). Current per-event pricing is shown on the Actor's Apify Store listing and in the Console before you start a run.
You are not charged for:
- Login-walled, blocked, timed-out, errored or non-business pages. These are pushed as three-key accounting rows without the charged-event flag (
src/main.py:147-157), so they cost nothing and still give you a complete audit trail of what the run attempted. Filter them out witherrorReason === null. - Pages dropped by
requireAnyContact. A page with no email, no phone and no WhatsApp is discarded before the push (src/main.py:169-172) — never stored, never billed. The trade-off is that you also lose the record that the URL was visited, so leave the flag off when you need full coverage accounting. - Search and discovery requests. Only result rows are billed as events.
- Retries. A page that needs all four attempts and every proxy escalation still bills exactly one
row_resultwhen it finally succeeds.
The run log makes the split explicit — it ends with a line reporting charged rows, uncharged accounting rows and rows filtered out, plus a coverage line showing how many charged rows carried WhatsApp, linked accounts and coordinates (src/main.py:273-282). Apify platform compute and proxy usage apply on top of event charges, as they do for any Actor.
🔌 Integrate this Facebook scraper into your workflow
This Actor works with any language or tool that can send an HTTP request to the Apify API.
REST API integration
Run the Actor and read the dataset with the official apify-client package. Replace <YOUR_USERNAME> with the account the Actor is published under and <YOUR_APIFY_TOKEN> with a token from Settings → Integrations in the Apify Console.
from apify_client import ApifyClientclient = ApifyClient("<YOUR_APIFY_TOKEN>")run = client.actor("<YOUR_USERNAME>/facebook-search-scraper-whatsapp-contact-leads").call(run_input={"categories": ["Pub", "Cocktail bar"],"locations": ["London", "Dubai"],"resultsLimit": 100,"extractWhatsApp": True,"extractLinkedAccounts": True,"requireAnyContact": True,})for item in client.dataset(run["defaultDatasetId"]).iterate_items():if item.get("errorReason"): # uncharged accounting row — skip itcontinueprint(item["title"], item["whatsappChatUrl"], item["contactChannels"])
Works in Python, Node.js, Go, Ruby and cURL — the Apify API is plain REST, and official clients exist for Python and JavaScript.
Automation platforms (n8n, Make, Zapier, LangChain)
n8n — use the Apify node's Run an Actor operation, then chain Get dataset items to pull rows into your workflow. A common shape: run nightly per city, filter on hasContact, push each row into a CRM node.
Make — the Apify app provides Run an Actor and Watch Actor Runs modules. Trigger on run completion, iterate the dataset, and map title, whatsappChatUrl, email and phone into a Google Sheets or HubSpot module.
Zapier — the Apify integration exposes Run Actor as an action and Actor Run Finished as a trigger, enough to append every new lead row to a spreadsheet or a Slack channel without writing code.
LangChain — load a finished run's dataset with ApifyDatasetLoader and map each row to a Document. Index intro, about_me.text, services and categories as text while keeping whatsappChatUrl, pageLatitude and pageLongitude as metadata for filtered retrieval — no HTML-cleaning step required.
For AI agents, the Actor is callable as a plain HTTP endpoint through the Apify API, so any framework that can make a POST request can use it as a tool.
Is it legal to scrape Facebook pages?
Scraping publicly available data is generally lawful in the US and the EU, and this Actor only ever reads what an anonymous visitor sees — it runs logged out by default and never accesses login-gated content.
Most of what it returns is business data: a company's trading name, category, address, opening hours, price band, rating and published contact channels. That sits under Facebook's terms of service and, in the EU, database-rights considerations rather than data-protection law.
A business contact detail can also be personal data, though — a sole trader's mobile number in phone or whatsappNumber, or a named individual's address in email. Where that applies, GDPR and CCPA obligations attach: you need a lawful basis to store and use it, and marketing outreach is separately regulated (GDPR and PECR in the EU and UK; TCPA and CAN-SPAM in the US). WhatsApp outreach in particular carries its own platform rules on top of the law.
Consult legal counsel for commercial use cases involving bulk personal data.
Frequently asked questions
Does this Facebook scraper work without a Facebook account?
Yes. It runs logged out by default — DEFAULT_COOKIES is an empty dict (src/main.py:31) and every output field was verified against logged-out page HTML. You need only an Apify account. cookies is optional, masked in the Console (isSecret: true), and exists solely for users who want their own session used.
Can I use it without managing proxies or infrastructure?
Yes. URL discovery always routes through Apify Proxy's GOOGLE_SERP group automatically (src/google_serp.py:27) — you do not configure it. Facebook page fetches use no proxy by default; when a page returns blocked or login-walled, the Actor escalates on its own through none → datacenter (BUYPROXIES94952) → residential, up to four attempts per URL, and sticks with the escalated mode (src/main.py:105-135). Setting proxyConfiguration yourself just pins the starting exit. Request pacing, retries, HTML parsing and dataset writes are all handled for you; nothing runs on your machine.
How often is the data updated?
Every run is a live fetch. There is no cache and no stored snapshot: each page is requested at run time and parsed from that response, so rating, followers, business_hours and ad_status reflect the page as it stood during your run. Schedule the Actor if you need a refresh cadence.
What happens when a page is deleted, walled, or is not a business page?
You get an uncharged three-key row: facebookUrl, error and errorReason. errorReason tells you which case it was — login_wall, blocked, unsupported_page (no business Page payload), http_<status>, timeout or fetch_error. Charged rows always carry errorReason: null, so items.filter(i => i.errorReason === null) separates leads from accounting rows. Nothing fails the whole run — one bad URL never stops the others.
Can I scrape private groups, personal profiles or login-gated content?
No. Only publicly accessible business pages are returned. Personal profiles are excluded twice — once when parsing search results and once after deduplication — using Facebook's own personal-vanity shape firstname.lastname.123 plus /profile.php and /people/ paths (src/google_serp.py:79-90). Group, Marketplace, Reels, Watch, event and post-permalink URLs are filtered out by path before anything is fetched. Contact details a page keeps behind a login come back null.
Why is whatsappNumber null on so many rows?
Because the page did not publish a WhatsApp business card. The field is read from one named card and is never guessed, inferred, or copied from phone. Coverage varies strongly by geography — cards are common in markets where WhatsApp is the default business channel and rare on US pages. Use whatsappCardPresent to distinguish "no card on this page" from "extraction turned off", and set requireAnyContact: true if you only want rows reachable by some channel.
Does this scraper work for AI agent workflows and LLM pipelines?
Yes. It is callable as a plain HTTP endpoint on the Apify API, so any agent framework — LangChain, LlamaIndex, or a custom tool-calling loop — can invoke it as a tool and read the dataset back. Every response is typed JSON with stable keys, so there is no parsing step before the data reaches an LLM context window or a vector store.
Does it return data in a format LLMs can use directly?
Yes. Flat, normalized JSON with stable field names and consistent types — no HTML, no CSS selectors, no scraping artefacts. The same 57 keys appear on every charged row whether or not each value exists, so a downstream schema never shifts between runs.
How does this Actor handle Facebook's anti-bot defences?
With four countermeasures, all in the source. Classification: a login wall is detected from the final resolved URL and the absence of the expected Relay container, not from loose token matching — Facebook's own JS bundles contain the words "checkpoint" and "blocked", which is why substring checks false-positive (src/proxy_manager.py:84-100). Escalation: blocked or walled pages retry through none → datacenter → residential exits, up to four attempts. Pacing: page fetches run behind a semaphore of 5 with a 0.4-second stagger and a connection pool limited to 10 total and 3 per host (src/main.py:93, :100, :269); search requests sleep a randomised 0.6–1.4 seconds between pages. Realistic headers: a full modern Chrome header set including client hints is sent on every page request (src/page_scraper.py:43-63).
How does it compare to other Facebook scrapers on the Store?
The verifiable differences are output shape, login requirement and contact coverage. memo23/facebook-pages-and-contacts-scraper states on its listing that it requires Facebook login cookies, and its sample output is nested, repeating the same contact object in three places. apify/facebook-search-scraper returns a flat page record but does not list WhatsApp numbers, parsed coordinates or a linked-accounts object among its extracted fields. Both listings checked on the Apify Store, 2026-07-25. This Actor runs logged out, returns one flat 57-key row per page, and adds the WhatsApp, linked-accounts, external-links and coordinate blocks on top of the standard business record.
What happens when Facebook changes its structure or blocks the scraper?
The Actor is maintained and the output schema stays stable. BASE_OUTPUT_KEYS (src/page_scraper.py:66) is a frozen contract, and the contact-lead keys are appended after it — field names and types do not change on your end, so integrations, spreadsheets and database schemas keep working. When Facebook changes a card type or its anti-bot posture, the parser and the classification logic are updated behind that stable schema.
Your feedback
Found a bug, hit a page shape the parser mishandles, or need a field this Actor does not return yet? We want to know — concrete cases are the fastest way to get something fixed. Open an issue on the Issues tab of this Actor in the Apify Console, and include the run ID, your input JSON, and the row that looked wrong. Field requests are welcome too: the key contract only ever grows, so a new field never breaks an existing integration.
