# Facebook Search Scraper With WhatsApp & Contact Leads (`api-empire/facebook-search-scraper`) Actor

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.

- **URL**: https://apify.com/api-empire/facebook-search-scraper.md
- **Developed by:** [API Empire](https://apify.com/api-empire) (community)
- **Categories:** Social media, Lead generation, Other
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.99 / 1,000 results

This Actor is paid per event and usage. You are charged both the fixed price for specific events and for Apify platform usage.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

### 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 published `whatsappLink`, a stable `whatsappChatUrl`, and `isWhatsappReachable`
- **Scrape linked social accounts** — `linkedAccounts` splits Instagram, TikTok, YouTube, X/Twitter and LinkedIn into named keys, with everything else in `other[]`
- **Scrape outbound links and geo coordinates** — `externalLinks[]` with Facebook's `l.php` redirector unwrapped, plus `pageLatitude` / `pageLongitude` from 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](https://developers.facebook.com/docs/pages-api/search-pages) 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](https://developers.facebook.com/docs/pages-api/search-pages) | 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)

1. **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.
2. **Enter your categories.** Fill `categories` with the business types you want — `Pub`, `Bar`, `Dental clinic`. This is the only field most runs need to change; it defaults to `["Pub"]`.
3. **Enter your locations.** Fill `locations` with cities or areas — `NYC`, `London`, `Dubai`. Every category is paired with every location, so 3 categories × 4 locations = 12 search queries.
4. **Set the size and the contact layer.** `resultsLimit` caps how many pages get fetched across all queries (1–500, default 10). Leave `extractWhatsApp` and `extractLinkedAccounts` on. Turn on `requireAnyContact` if you only want rows that are actually reachable, and `findWebsiteContactForm` if you want the off-Facebook enrichment.
5. **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 `checkpoint` and `blocked`, 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

```json
{
  "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_SERP` proxy group (`src/google_serp.py:27`). If that proxy cannot be created, the run logs `Search proxy unavailable.` and finishes cleanly with **zero rows** (`src/google_serp.py:220-224`, `src/main.py:258-260`). This is independent of your `proxyConfiguration`, which only governs the Facebook page fetches.
- **`cookies` must be a flat dict.** The value is handed straight to `aiohttp`'s `cookies=` argument. A cookie-editor JSON *array* export will raise on every request, and every URL will come back as an uncharged `fetch_error` row.

***

### ⬆️ 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.

```json
{
  "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**

- `messenger` is always `null` — a base-contract key kept for compatibility (`src/page_scraper.py:531`). Use `messengerLink`, which is **constructed** from the vanity slug or `pageId` and never observed on the page, which is why `messengerLinkStatus` reads `constructed_unverified`. Treat it as a best-effort deep link.
- `ratings` duplicates `rating`, and `business_services` duplicates `services`. Both duplicates are part of the frozen key contract.
- `website` is the bare domain with `www.` stripped; `websites[]` is the full URL form; `externalLinks[]` is every outbound destination with `facebook.com` and `fb.com` self-links removed and `l.php` redirects unwrapped.
- `priceTier` counts currency glyphs in `$£€¥₹₩₽₦` and returns 1–4, because Facebook renders the same band with a different glyph depending on locale. Compare `priceTier` across countries, not `priceRange`.
- `followers` and `followings` are integers — compact forms like `5.2K` are expanded before the push (`src/page_scraper.py:384-394`).
- `ratingOverall` carries 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-readable `rating` string, 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`)

```json
{
  "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:

```json
{
  "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:

```js
// JavaScript — keep only charged business rows
const leads = items.filter(item => item.errorReason === null);
```

```bash
## jq — same filter on an exported JSON dataset
jq '[.[] | 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.

#### WhatsApp

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`):

1. Parse `wa_link` with `urlparse` + `parse_qs` and take the `phone` query parameter.
2. If that yields nothing, fall back to `wa_number`.
3. Either candidate passes through `phone_digits()`, which concatenates **every** digit run (`re.compile(r'\d+')`) and accepts the result only if `7 <= len(digits) <= 15`.
4. `whatsappChatUrl` is then `https://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 900431` becomes `wa.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. 12` strips to a 13-digit run that passes the length gate and produces a wrong `wa.me` link. 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.
- **`isWhatsappReachable` means "published", not "answers".** It is `true` whenever a valid digit run was extracted. Nothing contacts WhatsApp to verify the number exists.
- **`whatsappCardPresent` ignores the toggle.** It is computed from the raw card regardless of `extractWhatsApp` (`src/page_scraper.py:578`), so with `extractWhatsApp: false` you can see `whatsappCardPresent: true` alongside `whatsappNumber: 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 from `phone`.

#### Phone (from Facebook)

Resolved by precedence (`src/page_scraper.py:506`, with the fallback at `:275`):

1. `INTRO_CARD_PROFILE_PHONE` → `context_item.title.text`, else `plaintext_title.text` — the structured card, and the correct source.
2. `delegate_page.phone_number.universal_number`, else `delegate_page.phone_number.phone_number`.
3. **Whole-document regex**, first match wins, in this order: `"phone_number"[:\s]*"([^"]+)"`, then `"universal_number"[:\s]*"([^"]+)"`, then `tel:([+\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 a `tel:` 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 with `errorReason === 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_result` when 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.

```python
from apify_client import ApifyClient

client = 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 it
        continue
    print(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.

# Actor input Schema

## `categories` (type: `array`):

Business types to search (Pub, Bar, Restaurant...). Combined with locations → e.g. "Pub NYC", "Bar London".

## `locations` (type: `array`):

Where to search (NYC, London...) — paired with each category.

## `resultsLimit` (type: `integer`):

Max pages to scrape across all queries. Higher = more data, longer run.

## `proxyConfiguration` (type: `object`):

Optional. No proxy by default. Auto fallback to datacenter → residential if blocked.

## `extractWhatsApp` (type: `boolean`):

Read the page's WhatsApp business card and emit whatsappNumber, the published whatsappLink, a stable wa.me chat URL and isWhatsappReachable. Measured on 67 live pages: 21% publish one (24% outside the US, 0% on US pages).

## `extractLinkedAccounts` (type: `boolean`):

Emit linkedAccounts (Instagram / TikTok / YouTube / X / LinkedIn / other) and externalLinks\[] from the page's linked-account and website cards. Measured 19% of live pages publish at least one linked account.

## `requireAnyContact` (type: `boolean`):

Skip any page with no email, no phone and no WhatsApp. Off by default so nothing is dropped silently.

## `findWebsiteContactForm` (type: `boolean`):

Slow, off by default. Fetches the business's own website (and its contact page) and adds websiteContact with emails, phones, socials and a contact-form flag — every value stamped source: website\_crawl. Booking and ordering platforms are skipped so their contacts cannot poison your list.

## `cookies` (type: `object`):

Optional. Leave empty — every field this actor returns was verified logged-out. Only supply a cookie jar (for example {"c\_user":"...","xs":"..."}) if you specifically want your own session used.

## Actor input object example

```json
{
  "categories": [
    "Pub"
  ],
  "locations": [
    "NYC"
  ],
  "resultsLimit": 10,
  "proxyConfiguration": {
    "useApifyProxy": false
  },
  "extractWhatsApp": true,
  "extractLinkedAccounts": true,
  "requireAnyContact": false,
  "findWebsiteContactForm": false
}
```

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "categories": [
        "Pub"
    ],
    "locations": [
        "NYC"
    ],
    "proxyConfiguration": {
        "useApifyProxy": false
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("api-empire/facebook-search-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "categories": ["Pub"],
    "locations": ["NYC"],
    "proxyConfiguration": { "useApifyProxy": False },
}

# Run the Actor and wait for it to finish
run = client.actor("api-empire/facebook-search-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "categories": [
    "Pub"
  ],
  "locations": [
    "NYC"
  ],
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}' |
apify call api-empire/facebook-search-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=api-empire/facebook-search-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/acts/Xj9WcLfQZ6wwHFxmv/builds/beANezs8NRoDECFd8/openapi.json
