wael-sudo2/facebook-page-info-scraper

Free Facebook pages MetaData Scraping Library - Unlimited Calls

Python

68

105 commits

updated Oct 12, 2026

See the code

README

facebook-page-info-scraper

Public Facebook page info as a Python dict — fast, no login, no API key.

PyPI Python License Downloads Monthly Weekly

Requests Playwright Scrapy Selenium

OpenAI DeepSeek curl_cffi


ScrapingAnt

When contact enrichment meets a company site that refuses a direct request, ScrapingAnt returns it from a residential IP behind a single API key — in testing it cleared sites this library could not reach on its own.

enrich_records(records, use_proxy=True)

For scraping Facebook itself, use a plain proxy instead — see below.

Affiliate link — subscribing through it supports this project at no extra cost to you.
Entirely optional: any proxy works, and the package ships with none.


⚡ Quick start

pip install facebook-page-info-scraper
from facebook_page_info_scraper import scrape_page

scrape_page("https://www.facebook.com/examplepage")

That is everything. One install, no extras to pick.

📦 What's in the box

🌐 HTTP enginepublic page data~1.4 s per page
🎭 Browser enginepages whose data needs JavaScript~3 s per page
📧 Contact enrichmentrecovers missing emails from company websites🤖 AI-labelled
from facebook_page_info_scraper import scrape_pages, enrich_records

records = scrape_pages(urls, threads=16)   # scrape
records = enrich_records(records)          # fill in the missing emails

Ships with requests, scrapy-playwright, openai (serves OpenAI and DeepSeek) and curl_cffi. The AI step is opt-in and needs only an API key — everything else works without one.


🎯 What it solves

Getting the data. Give it a Facebook page URL, get back the page's public details — name, category, email, phone, website, address, likes, ratings, opening status. This used to mean driving a real browser: slow, heavy, and it broke every time Facebook changed its CSS. Both engines here read Facebook's own page payload instead, so neither depends on how the page looks.

The holes in it. Facebook pages very often list a website but no email — so a scraped list comes back empty in exactly the column you needed. Contact enrichment visits the website Facebook did list, finds the address there, and scores how much to trust it. In testing that turned about half of those otherwise-dead records into usable contacts.


🚀 Two engines, one result

Both take the same URLs, return the same fields, and run the same extraction. They differ only in how the page is requested.

🌐 HTTP🎭 Browser
requests withrequestsChromium via Playwright
speed~1.4 s per page~3 s per page
memorynegligible~60–80 MB per page
runs JavaScript—✅
reads network traffic—✅ captures GraphQL responses
suitslarge lists, minimal setuppages whose data never reaches the raw HTML
# 🌐 HTTP — one page or many
from facebook_page_info_scraper import scrape_page, scrape_pages

page  = scrape_page("https://www.facebook.com/examplepage")
pages = scrape_pages(urls, threads=16)

print(page["email"])        # hello@example.com
print(page["page_likes"])   # 12,480

# 🎭 Browser — always a batch, never one at a time
from facebook_page_info_scraper.spider import run

run(urls, threads=8)                    # -> pages_browser.jsonl
run(urls, threads=8, headless=False)    # watch it work

From the command line:

python scrape.py urls.txt --threads 24 --out output.json
python -m facebook_page_info_scraper.spider urls.txt --threads 8 --headed

All options:

scrape_page(url,   want_about=True, timeout=25)
scrape_pages(urls, threads=16, want_about=True, timeout=25)

run(urls, threads=8,                   # pages rendered at once, no cap
    out="pages_browser.jsonl",
    settle_ms=2000,                    # pause after load before reading
    headless=True)

want_about=False reads only the page header — faster, but no email or phone.

Scraping through a proxy. Facebook rate-limits by IP, so at high thread counts a rotating address helps:

FBPS_PROXY_URL=http://user:pass@host:port
scrape_pages(urls, threads=16, use_proxy=True)

Only a real HTTP proxy works here, and the library refuses anything else with an explanation. A scraping API builds the request itself, and Facebook then withholds the prefetched About payload — measured on live pages, the same page drops from 6 extracted fields to 3, losing email, phone, category and address. Rotating your own exit IP keeps our headers intact and costs you nothing in data.

The browser engine downloads Chromium (~150 MB) the first time it runs — pip cannot fetch browser binaries, so it happens on first use. One time only.


📧 Contact enrichment

Facebook pages very often list a website but no email. Enrichment visits that website and recovers the missing contact.

from facebook_page_info_scraper import scrape_pages, enrich_records

records = scrape_pages(urls, threads=16)
records = enrich_records(records, threads=8)

records[0]["enriched_email"]        # hello@example.com
records[0]["enriched_confidence"]   # high

It only touches records that need it — a page that already has an email is skipped, and sites shared by several pages are fetched once.

How it works

Enrichment flow: fetch the homepage; retry bot-challenged sites with a browser TLS
          fingerprint; fall back to /contact-style pages; decide company inbox versus person;
          score every candidate into enriched_email with confidence and reasons.

Both marked steps ship with the package. The fingerprint retry happens on its own; the role/person check is the only thing you switch on, and it needs just an API key. Leave it off and every address is still found and scored — only the role / personal label is left as unknown.

Nothing is thrown away

Every address found comes back with its score and the reasons behind it, so you pick your own threshold rather than trusting ours:

{
  "email": "hello@example.se",
  "type": "role",              # role | personal | unknown
  "type_source": "deepseek",   # who decided that: provider | shape | none
  "confidence": "high",
  "score": 9,
  "source": "mailto",          # mailto | text
  "found_on": "https://example.se/kontakt",
  "reasons": [
    "mailto link",
    "same brand, different TLD",
    "role address (via deepseek)"
  ]
}

That matters because the obvious filters are wrong. Rejecting a different TLD would discard hello@example.se found on example.com — which is the same company.

signalscore
email domain matches the site+4
same brand, different TLD+3
found in a mailto: link+3
a company inbox rather than a person+2
a freemail address (gmail, outlook …)+1
domain unrelated to the site−3
domain does not resolve in DNS−4

high ≥ 6 · medium ≥ 3 · low below that.

Company inbox, or a person?

info@example.com is a company inbox. anna@example.com is a person. Mailing the first is ordinary business; the second is personal data under GDPR — so enrichment labels which is which, and never guesses when it cannot tell.

There is no word list, in any language. A list can never cover them all — an early version of this package missed customerservice, and would never have contained the Finnish asiakaspalvelu or the Dutch klantenservice. A language model answers the question directly instead.

Nothing extra to install — set an API key and switch it on:

enrich_records(records, use_llm=True)                       # picks up your key
enrich_records(records, use_llm=True, provider="deepseek", model="deepseek-v4-flash")  # or choose
providerenvironment variabledefault model
deepseekDEEPSEEK_API_KEYdeepseek-chat
openaiOPENAI_API_KEYgpt-4o-mini

Whichever key is present is used. One request covers a whole batch, nothing is written to disk, and if the call fails the run continues with the type reported as unknown. With no key at all, enrichment still finds and scores every address — only the role / personal label is left as unknown.

What you get back

Sites that block you

A small share of sites answer with a bot challenge instead of their page. Those get retried automatically with a real browser's TLS fingerprint — no setup, it ships with the package. Anything still refusing after that is reported as challenged: alive and blocking you, which is a very different thing from a dead domain.

How often this is needed varies a lot. A probe of 100 sites measured 7% challenged; re-testing the same sites later found only one still challenging. Treat any single measurement as a snapshot.

A handful stay blocked even then, and some fail for reasons a fingerprint cannot fix — a payment-required host, an expired certificate, an IP-based geo-block. Those are the cases a proxy answers, by coming from a different address entirely.

ScrapingAnt - residential proxy and scraping API

This is the one I reach for on this job, for three reasons that matter here specifically:

  • Nothing to plumb. One key and a plain HTTP endpoint — no proxy credentials, no rotation logic, no session handling to get wrong.
  • Residential IPs by default, which is what the awkward failures need: the geo-blocks and reputation blocks that a browser fingerprint alone cannot talk its way past.
  • It cleared sites this library could not reach on its own. Testing it against real failures from a live run, it returned pages from a payment-required host and from one with an expired certificate — both of which the built-in tiers gave up on.

Their docs cover the API, and pricing has current plans and the free allowance.

Affiliate link — subscribing through it supports this project at no extra cost to you.

Already have a proxy? Use it. Point the library at any standard http://user:pass@host:port — Bright Data, Oxylabs, Smartproxy, a box you rent. Nothing here is tied to one vendor, and the package ships with no proxy dependency at all.

Checking a run — summarise_enrichment()
from facebook_page_info_scraper import summarise_enrichment

summarise_enrichment(records)
{
  "records": 40,
  "attempted": 40,
  "found_email": 22,
  "by_confidence": {"high": 13, "medium": 5, "low": 4},
  "by_tier": {"requests": 39, "curl_cffi": 1},
  "unreachable": 11,        # dead domain, expired cert, 404 …
  "no_contact": 7,          # site loaded, no email anywhere
  "challenged": 0,
  "hint": None              # set when something in the environment is missing
}

Fields added to each record

Your original fields are never overwritten — enrichment only adds.

fieldtypedescription
enriched_emailstrBest candidate found. Absent when none was.
enriched_email_typestrrole, personal or unknown
enriched_confidencestrhigh, medium or low
enrichmentdictThe full result — see below
_enrich_statusstrok, no_contact_found, challenged, unreachable
_enrich_tierstrrequests or curl_cffi
_enrich_email_sourcestrmailto or text — how the winner was found

The enrichment dict holds everything, not just the winner:

keytypedescription
websitestrThe URL that was visited
emailslistEvery candidate, best first, each with its score and reasons
phoneslistUp to 3 numbers from tel: links
statusstrSame as _enrich_status
tierstrWhich fetch tier succeeded
fetch_outcomestrok, challenged, forbidden, not_found, timeout, dns, error, http_NNN
errorstrException name, when one occurred
pages_fetchedintRequests spent on this site
found_on_pathstrWhich contact path paid off, if one did
secondsfloatWall time for this site

fetch_outcome is the one to read when a site fails — it separates a dead domain (dns) from an expired certificate (error), a missing page (not_found) and a site that is up and refusing you (challenged).

Options

enrich_records(records,
               threads=8,
               timeout=12,
               only_missing_email=True,
               follow_contact_pages=True,
               use_fingerprint=True,
               use_proxy=False,
               proxy_provider=None,
               proxy_mode="fallback",
               use_llm=False,
               provider=None,
               model=None)
optiondefaultwhat it does
threads8Sites fetched in parallel. These are other people's servers, and each site is hit at most a few times — raise it for large lists, but stay civil.
timeout12Seconds per request. Lower it to move past slow or half-dead hosts faster.
only_missing_emailTrueSkip records that already have an email. Set False to re-check every record, e.g. to cross-check what Facebook listed.
follow_contact_pagesTrueWhen the homepage has no email, try /contact, /kontakt, /contacto, /impressum and friends. This is what lifts the hit rate from ~35% to ~50% — set False for homepage-only, which is much faster and much less thorough.
use_fingerprintTrueRetry bot-challenged sites with a real browser's TLS fingerprint. Set False to skip the retry and report them as challenged.
use_proxyFalseRetry through a proxy when a site refuses you directly. Off by default because it costs money.
proxy_providerNone"custom" (your own proxy) or "scrapingant". None picks whichever is configured.
proxy_mode"fallback""fallback" proxies only the sites that failed. "always" routes every request through it — for unmetered plans.
use_llmFalseLabel each address role or personal. Needs an API key; off by default because it costs money. Everything else works either way.
providerNone"deepseek" or "openai". None picks whichever key is set.
modelNoneOverride the provider's default model — deepseek-chat or gpt-4o-mini.

Configuration. Put either of these in a .env beside your script, or export them — real environment variables win over the file:

FBPS_PROXY_URL=http://user:pass@host:port   # any proxy you already have
ANT_API_KEY=your_scrapingant_key            # or SCRAPINGANT_API_KEY
from facebook_page_info_scraper import proxy_status, proxy_available

proxy_status()       # what is configured, without printing any secret
proxy_available()    # True if a proxy is ready to use

Already using HTTPS_PROXY? requests honours it on every call, so it is in effect from the first tier and needs none of this.

One site at a time, if you prefer:

from facebook_page_info_scraper import enrich_site

enrich_site("https://example.com")
enrich_site("https://example.com", use_llm=True, provider="openai")

enrich_site takes the same options as enrich_records, minus threads and only_missing_email — plus session= if you want to supply your own requests.Session. It returns the result dict directly instead of attaching it to a record.


📊 Speed

Measured on the same 183-page list, same machine.

🐌 old (Selenium)🌐 HTTP🎭 Browser
per page15–20 s~1.4 s~3 s
pages in parallel❌✅ 16+✅ you choose
200 pages~50 minunder 1 min~2 min
memory~60 MB/pagenegligible~60–80 MB/page
needs Chrome installed✅❌downloads its own

Both engines beat the old Selenium version by a wide margin — HTTP by roughly 50×, the browser engine by about 6× while still running JavaScript.


📥 Input

Any Facebook page URL. These all work with either engine:

https://www.facebook.com/examplepage
https://facebook.com/examplepage                        (no www)
https://www.facebook.com/examplepage/                   (trailing slash)
https://www.facebook.com/pages/Example-Page/100000000000000
https://www.facebook.com/profile.php?id=100000000000000
http://sv-se.facebook.com/pages/Example-Page/100000000000000   (locale subdomain)

📤 Output

Identical from either engine.

{
  "page_name": "Example Store",
  "page_id": "100000000000000",
  "page_category": "Bakery",
  "email": "hello@example.com",
  "phone_number": "+33 1 23 45 67 89",
  "page_website": "https://example.com",
  "address": "1 Example Street\n12345 Sampletown",
  "page_likes": "12,480",
  "talking_about": "215",
  "were_here": "33",
  "recommend_percent": "94",
  "page_review_number": "18",
  "open_status": "Closed now",
  "price_range": "Price Range - $",
  "bio": "Example Store. 12,480 likes ...",
  "image": "https://scontent.xx.fbcdn.net/...",
  "canonical": "https://www.facebook.com/examplepage",
  "locale": "en_US",
  "ok": True
}
Every field, with types
fieldtypedescription
page_namestrPage name
page_idstrNumeric Facebook page ID
page_categorystr | Nonee.g. Bakery, Clothing Store
emailstr | list | NoneContact email(s)
phone_numberstr | list | NoneContact phone(s)
page_websitestr | list | NoneExternal website(s)
addressstr | NoneFull street address
page_likesstr | NoneLike count, e.g. 12,480
page_followersstr | NoneFollower count
talking_aboutstr | None"talking about this" count
were_herestr | NoneCheck-in count
recommend_percentstr | Nonee.g. 74 from "74% recommend"
page_ratestr | NoneStar rating, where shown
page_review_numberstr | NoneReview count
open_statusstr | NoneClosed now, Always open, …
price_rangestr | Nonee.g. Price Range · $
biostr | NonePage description
imagestr | NoneProfile picture URL
canonicalstrCanonical page URL
localestrLanguage Facebook served
okboolWhether the scrape succeeded
errorstrPresent only when ok is False

Always check ok:

page = scrape_page(url)
print(page.get("page_name")if page.get("ok") else page.get("error"))

One thing to know. email, phone_number and page_website are a string when the page lists one, and a list when it lists several.

"email": "hello@example.com"                          # one
"email": ["sales@example.com", "press@example.com"]   # several
"email": None                                         # none

def as_list(v):
    return [] if v is None else (v if isinstance(v, list) else [v])

🩺 Check your run

One call tells you how a batch went. Works on records from either engine.

from facebook_page_info_scraper import scrape_pages, summarise

records = scrape_pages(urls, threads=16)
summarise(records)
{
  "total": 213,          # records in
  "ok": 211,             # scraped successfully
  "failed": 2,
  "with_about": 164,     # found the About panel
  "with_email": 150,
  "with_phone": 115,
  "with_website": 161,
  "wrong_locale": 0,     # pages Facebook served in another language
  "avg_seconds": 1.44,
  "avg_kb": 533          # bytes actually read, not page size
}

Field counts are measured against ok, not total — dead URLs never drag your extraction numbers down. That makes the two problems easy to tell apart:

what movedwhat it means
ok droppedfetching — blocks, rate limits, bad URLs
ok steady, with_email droppedextraction — Facebook changed something
wrong_locale above 0those pages came back in another language

Handy as a guard in scheduled jobs:

stats = summarise(records)
if stats["with_email"] / stats["ok"] < 0.60:
    raise SystemExit("email extraction dropped — check for changes")

🔮 Roadmap

The first piece of the AI layer has landed: enrichment can ask a model whether an address is a company inbox or a person, in any language. It sits behind the deterministic work, not in front of it — every address is found and scored by rules, and the model answers only the one question rules cannot: what a word means in a language nobody enumerated.

Next along the same line is free-text extraction for unlabelled About rows in languages whose formatting conventions vary. Rules first, model only where rules come up empty.


⬆️ Upgrading from 1.x

The old Selenium class still works:

pip install facebook-page-info-scraper[selenium]
from facebook_page_info_scraper import FacebookPageInfoScraper
FacebookPageInfoScraper(url).get_page_info()

Either new engine is a recommended replacement — same idea, much faster.

Two output differences:

  • page_rate and page_review_number used to be swapped. They are correct now.
  • location was a country name; the new address is the full street address.

🤝 Contributing

Contributions are welcome. If you find an issue or have a suggestion, please open an issue or submit a pull request on GitHub.

📝 Notes

  • Only public pages. Nothing behind a login.
  • Be reasonable with threads — this hits Facebook from your IP. Set FBPS_PROXY_URL and use_proxy=True to spread the load across a proxy.
  • Enrichment hits third-party company websites, not Facebook. The same courtesy applies, and each site is fetched once per run.
  • Scraping Facebook is against their Terms of Service. Use accordingly.

License

MIT — see LICENSE.txt.

anitbots
crawler
crawling-python
curl-cffi
deepseek
enrichment
facebook
facebook-apis
facebook-page-information
facebook-page-scraper
facebook-scraper
facebook-scraping
leadgeneration
llm
openai
python
scraping
scraping-python
scrapy
selenium

wael-sudo2/facebook-page-info-scraper

Free Facebook pages MetaData Scraping Library - Unlimited Calls

Python

68

105 commits

updated Oct 12, 2026

See the code

README

facebook-page-info-scraper

Public Facebook page info as a Python dict — fast, no login, no API key.

PyPI Python License Downloads Monthly Weekly

Requests Playwright Scrapy Selenium

OpenAI DeepSeek curl_cffi


ScrapingAnt

When contact enrichment meets a company site that refuses a direct request, ScrapingAnt returns it from a residential IP behind a single API key — in testing it cleared sites this library could not reach on its own.

enrich_records(records, use_proxy=True)

For scraping Facebook itself, use a plain proxy instead — see below.

Affiliate link — subscribing through it supports this project at no extra cost to you.
Entirely optional: any proxy works, and the package ships with none.


⚡ Quick start

pip install facebook-page-info-scraper
from facebook_page_info_scraper import scrape_page

scrape_page("https://www.facebook.com/examplepage")

That is everything. One install, no extras to pick.

📦 What's in the box

🌐 HTTP enginepublic page data~1.4 s per page
🎭 Browser enginepages whose data needs JavaScript~3 s per page
📧 Contact enrichmentrecovers missing emails from company websites🤖 AI-labelled
from facebook_page_info_scraper import scrape_pages, enrich_records

records = scrape_pages(urls, threads=16)   # scrape
records = enrich_records(records)          # fill in the missing emails

Ships with requests, scrapy-playwright, openai (serves OpenAI and DeepSeek) and curl_cffi. The AI step is opt-in and needs only an API key — everything else works without one.


🎯 What it solves

Getting the data. Give it a Facebook page URL, get back the page's public details — name, category, email, phone, website, address, likes, ratings, opening status. This used to mean driving a real browser: slow, heavy, and it broke every time Facebook changed its CSS. Both engines here read Facebook's own page payload instead, so neither depends on how the page looks.

The holes in it. Facebook pages very often list a website but no email — so a scraped list comes back empty in exactly the column you needed. Contact enrichment visits the website Facebook did list, finds the address there, and scores how much to trust it. In testing that turned about half of those otherwise-dead records into usable contacts.


🚀 Two engines, one result

Both take the same URLs, return the same fields, and run the same extraction. They differ only in how the page is requested.

🌐 HTTP🎭 Browser
requests withrequestsChromium via Playwright
speed~1.4 s per page~3 s per page
memorynegligible~60–80 MB per page
runs JavaScript—✅
reads network traffic—✅ captures GraphQL responses
suitslarge lists, minimal setuppages whose data never reaches the raw HTML
# 🌐 HTTP — one page or many
from facebook_page_info_scraper import scrape_page, scrape_pages

page  = scrape_page("https://www.facebook.com/examplepage")
pages = scrape_pages(urls, threads=16)

print(page["email"])        # hello@example.com
print(page["page_likes"])   # 12,480

# 🎭 Browser — always a batch, never one at a time
from facebook_page_info_scraper.spider import run

run(urls, threads=8)                    # -> pages_browser.jsonl
run(urls, threads=8, headless=False)    # watch it work

From the command line:

python scrape.py urls.txt --threads 24 --out output.json
python -m facebook_page_info_scraper.spider urls.txt --threads 8 --headed

All options:

scrape_page(url,   want_about=True, timeout=25)
scrape_pages(urls, threads=16, want_about=True, timeout=25)

run(urls, threads=8,                   # pages rendered at once, no cap
    out="pages_browser.jsonl",
    settle_ms=2000,                    # pause after load before reading
    headless=True)

want_about=False reads only the page header — faster, but no email or phone.

Scraping through a proxy. Facebook rate-limits by IP, so at high thread counts a rotating address helps:

FBPS_PROXY_URL=http://user:pass@host:port
scrape_pages(urls, threads=16, use_proxy=True)

Only a real HTTP proxy works here, and the library refuses anything else with an explanation. A scraping API builds the request itself, and Facebook then withholds the prefetched About payload — measured on live pages, the same page drops from 6 extracted fields to 3, losing email, phone, category and address. Rotating your own exit IP keeps our headers intact and costs you nothing in data.

The browser engine downloads Chromium (~150 MB) the first time it runs — pip cannot fetch browser binaries, so it happens on first use. One time only.


📧 Contact enrichment

Facebook pages very often list a website but no email. Enrichment visits that website and recovers the missing contact.

from facebook_page_info_scraper import scrape_pages, enrich_records

records = scrape_pages(urls, threads=16)
records = enrich_records(records, threads=8)

records[0]["enriched_email"]        # hello@example.com
records[0]["enriched_confidence"]   # high

It only touches records that need it — a page that already has an email is skipped, and sites shared by several pages are fetched once.

How it works

Enrichment flow: fetch the homepage; retry bot-challenged sites with a browser TLS
          fingerprint; fall back to /contact-style pages; decide company inbox versus person;
          score every candidate into enriched_email with confidence and reasons.

Both marked steps ship with the package. The fingerprint retry happens on its own; the role/person check is the only thing you switch on, and it needs just an API key. Leave it off and every address is still found and scored — only the role / personal label is left as unknown.

Nothing is thrown away

Every address found comes back with its score and the reasons behind it, so you pick your own threshold rather than trusting ours:

{
  "email": "hello@example.se",
  "type": "role",              # role | personal | unknown
  "type_source": "deepseek",   # who decided that: provider | shape | none
  "confidence": "high",
  "score": 9,
  "source": "mailto",          # mailto | text
  "found_on": "https://example.se/kontakt",
  "reasons": [
    "mailto link",
    "same brand, different TLD",
    "role address (via deepseek)"
  ]
}

That matters because the obvious filters are wrong. Rejecting a different TLD would discard hello@example.se found on example.com — which is the same company.

signalscore
email domain matches the site+4
same brand, different TLD+3
found in a mailto: link+3
a company inbox rather than a person+2
a freemail address (gmail, outlook …)+1
domain unrelated to the site−3
domain does not resolve in DNS−4

high ≥ 6 · medium ≥ 3 · low below that.

Company inbox, or a person?

info@example.com is a company inbox. anna@example.com is a person. Mailing the first is ordinary business; the second is personal data under GDPR — so enrichment labels which is which, and never guesses when it cannot tell.

There is no word list, in any language. A list can never cover them all — an early version of this package missed customerservice, and would never have contained the Finnish asiakaspalvelu or the Dutch klantenservice. A language model answers the question directly instead.

Nothing extra to install — set an API key and switch it on:

enrich_records(records, use_llm=True)                       # picks up your key
enrich_records(records, use_llm=True, provider="deepseek", model="deepseek-v4-flash")  # or choose
providerenvironment variabledefault model
deepseekDEEPSEEK_API_KEYdeepseek-chat
openaiOPENAI_API_KEYgpt-4o-mini

Whichever key is present is used. One request covers a whole batch, nothing is written to disk, and if the call fails the run continues with the type reported as unknown. With no key at all, enrichment still finds and scores every address — only the role / personal label is left as unknown.

What you get back

Sites that block you

A small share of sites answer with a bot challenge instead of their page. Those get retried automatically with a real browser's TLS fingerprint — no setup, it ships with the package. Anything still refusing after that is reported as challenged: alive and blocking you, which is a very different thing from a dead domain.

How often this is needed varies a lot. A probe of 100 sites measured 7% challenged; re-testing the same sites later found only one still challenging. Treat any single measurement as a snapshot.

A handful stay blocked even then, and some fail for reasons a fingerprint cannot fix — a payment-required host, an expired certificate, an IP-based geo-block. Those are the cases a proxy answers, by coming from a different address entirely.

ScrapingAnt - residential proxy and scraping API

This is the one I reach for on this job, for three reasons that matter here specifically:

  • Nothing to plumb. One key and a plain HTTP endpoint — no proxy credentials, no rotation logic, no session handling to get wrong.
  • Residential IPs by default, which is what the awkward failures need: the geo-blocks and reputation blocks that a browser fingerprint alone cannot talk its way past.
  • It cleared sites this library could not reach on its own. Testing it against real failures from a live run, it returned pages from a payment-required host and from one with an expired certificate — both of which the built-in tiers gave up on.

Their docs cover the API, and pricing has current plans and the free allowance.

Affiliate link — subscribing through it supports this project at no extra cost to you.

Already have a proxy? Use it. Point the library at any standard http://user:pass@host:port — Bright Data, Oxylabs, Smartproxy, a box you rent. Nothing here is tied to one vendor, and the package ships with no proxy dependency at all.

Checking a run — summarise_enrichment()
from facebook_page_info_scraper import summarise_enrichment

summarise_enrichment(records)
{
  "records": 40,
  "attempted": 40,
  "found_email": 22,
  "by_confidence": {"high": 13, "medium": 5, "low": 4},
  "by_tier": {"requests": 39, "curl_cffi": 1},
  "unreachable": 11,        # dead domain, expired cert, 404 …
  "no_contact": 7,          # site loaded, no email anywhere
  "challenged": 0,
  "hint": None              # set when something in the environment is missing
}

Fields added to each record

Your original fields are never overwritten — enrichment only adds.

fieldtypedescription
enriched_emailstrBest candidate found. Absent when none was.
enriched_email_typestrrole, personal or unknown
enriched_confidencestrhigh, medium or low
enrichmentdictThe full result — see below
_enrich_statusstrok, no_contact_found, challenged, unreachable
_enrich_tierstrrequests or curl_cffi
_enrich_email_sourcestrmailto or text — how the winner was found

The enrichment dict holds everything, not just the winner:

keytypedescription
websitestrThe URL that was visited
emailslistEvery candidate, best first, each with its score and reasons
phoneslistUp to 3 numbers from tel: links
statusstrSame as _enrich_status
tierstrWhich fetch tier succeeded
fetch_outcomestrok, challenged, forbidden, not_found, timeout, dns, error, http_NNN
errorstrException name, when one occurred
pages_fetchedintRequests spent on this site
found_on_pathstrWhich contact path paid off, if one did
secondsfloatWall time for this site

fetch_outcome is the one to read when a site fails — it separates a dead domain (dns) from an expired certificate (error), a missing page (not_found) and a site that is up and refusing you (challenged).

Options

enrich_records(records,
               threads=8,
               timeout=12,
               only_missing_email=True,
               follow_contact_pages=True,
               use_fingerprint=True,
               use_proxy=False,
               proxy_provider=None,
               proxy_mode="fallback",
               use_llm=False,
               provider=None,
               model=None)
optiondefaultwhat it does
threads8Sites fetched in parallel. These are other people's servers, and each site is hit at most a few times — raise it for large lists, but stay civil.
timeout12Seconds per request. Lower it to move past slow or half-dead hosts faster.
only_missing_emailTrueSkip records that already have an email. Set False to re-check every record, e.g. to cross-check what Facebook listed.
follow_contact_pagesTrueWhen the homepage has no email, try /contact, /kontakt, /contacto, /impressum and friends. This is what lifts the hit rate from ~35% to ~50% — set False for homepage-only, which is much faster and much less thorough.
use_fingerprintTrueRetry bot-challenged sites with a real browser's TLS fingerprint. Set False to skip the retry and report them as challenged.
use_proxyFalseRetry through a proxy when a site refuses you directly. Off by default because it costs money.
proxy_providerNone"custom" (your own proxy) or "scrapingant". None picks whichever is configured.
proxy_mode"fallback""fallback" proxies only the sites that failed. "always" routes every request through it — for unmetered plans.
use_llmFalseLabel each address role or personal. Needs an API key; off by default because it costs money. Everything else works either way.
providerNone"deepseek" or "openai". None picks whichever key is set.
modelNoneOverride the provider's default model — deepseek-chat or gpt-4o-mini.

Configuration. Put either of these in a .env beside your script, or export them — real environment variables win over the file:

FBPS_PROXY_URL=http://user:pass@host:port   # any proxy you already have
ANT_API_KEY=your_scrapingant_key            # or SCRAPINGANT_API_KEY
from facebook_page_info_scraper import proxy_status, proxy_available

proxy_status()       # what is configured, without printing any secret
proxy_available()    # True if a proxy is ready to use

Already using HTTPS_PROXY? requests honours it on every call, so it is in effect from the first tier and needs none of this.

One site at a time, if you prefer:

from facebook_page_info_scraper import enrich_site

enrich_site("https://example.com")
enrich_site("https://example.com", use_llm=True, provider="openai")

enrich_site takes the same options as enrich_records, minus threads and only_missing_email — plus session= if you want to supply your own requests.Session. It returns the result dict directly instead of attaching it to a record.


📊 Speed

Measured on the same 183-page list, same machine.

🐌 old (Selenium)🌐 HTTP🎭 Browser
per page15–20 s~1.4 s~3 s
pages in parallel❌✅ 16+✅ you choose
200 pages~50 minunder 1 min~2 min
memory~60 MB/pagenegligible~60–80 MB/page
needs Chrome installed✅❌downloads its own

Both engines beat the old Selenium version by a wide margin — HTTP by roughly 50×, the browser engine by about 6× while still running JavaScript.


📥 Input

Any Facebook page URL. These all work with either engine:

https://www.facebook.com/examplepage
https://facebook.com/examplepage                        (no www)
https://www.facebook.com/examplepage/                   (trailing slash)
https://www.facebook.com/pages/Example-Page/100000000000000
https://www.facebook.com/profile.php?id=100000000000000
http://sv-se.facebook.com/pages/Example-Page/100000000000000   (locale subdomain)

📤 Output

Identical from either engine.

{
  "page_name": "Example Store",
  "page_id": "100000000000000",
  "page_category": "Bakery",
  "email": "hello@example.com",
  "phone_number": "+33 1 23 45 67 89",
  "page_website": "https://example.com",
  "address": "1 Example Street\n12345 Sampletown",
  "page_likes": "12,480",
  "talking_about": "215",
  "were_here": "33",
  "recommend_percent": "94",
  "page_review_number": "18",
  "open_status": "Closed now",
  "price_range": "Price Range - $",
  "bio": "Example Store. 12,480 likes ...",
  "image": "https://scontent.xx.fbcdn.net/...",
  "canonical": "https://www.facebook.com/examplepage",
  "locale": "en_US",
  "ok": True
}
Every field, with types
fieldtypedescription
page_namestrPage name
page_idstrNumeric Facebook page ID
page_categorystr | Nonee.g. Bakery, Clothing Store
emailstr | list | NoneContact email(s)
phone_numberstr | list | NoneContact phone(s)
page_websitestr | list | NoneExternal website(s)
addressstr | NoneFull street address
page_likesstr | NoneLike count, e.g. 12,480
page_followersstr | NoneFollower count
talking_aboutstr | None"talking about this" count
were_herestr | NoneCheck-in count
recommend_percentstr | Nonee.g. 74 from "74% recommend"
page_ratestr | NoneStar rating, where shown
page_review_numberstr | NoneReview count
open_statusstr | NoneClosed now, Always open, …
price_rangestr | Nonee.g. Price Range · $
biostr | NonePage description
imagestr | NoneProfile picture URL
canonicalstrCanonical page URL
localestrLanguage Facebook served
okboolWhether the scrape succeeded
errorstrPresent only when ok is False

Always check ok:

page = scrape_page(url)
print(page.get("page_name")if page.get("ok") else page.get("error"))

One thing to know. email, phone_number and page_website are a string when the page lists one, and a list when it lists several.

"email": "hello@example.com"                          # one
"email": ["sales@example.com", "press@example.com"]   # several
"email": None                                         # none

def as_list(v):
    return [] if v is None else (v if isinstance(v, list) else [v])

🩺 Check your run

One call tells you how a batch went. Works on records from either engine.

from facebook_page_info_scraper import scrape_pages, summarise

records = scrape_pages(urls, threads=16)
summarise(records)
{
  "total": 213,          # records in
  "ok": 211,             # scraped successfully
  "failed": 2,
  "with_about": 164,     # found the About panel
  "with_email": 150,
  "with_phone": 115,
  "with_website": 161,
  "wrong_locale": 0,     # pages Facebook served in another language
  "avg_seconds": 1.44,
  "avg_kb": 533          # bytes actually read, not page size
}

Field counts are measured against ok, not total — dead URLs never drag your extraction numbers down. That makes the two problems easy to tell apart:

what movedwhat it means
ok droppedfetching — blocks, rate limits, bad URLs
ok steady, with_email droppedextraction — Facebook changed something
wrong_locale above 0those pages came back in another language

Handy as a guard in scheduled jobs:

stats = summarise(records)
if stats["with_email"] / stats["ok"] < 0.60:
    raise SystemExit("email extraction dropped — check for changes")

🔮 Roadmap

The first piece of the AI layer has landed: enrichment can ask a model whether an address is a company inbox or a person, in any language. It sits behind the deterministic work, not in front of it — every address is found and scored by rules, and the model answers only the one question rules cannot: what a word means in a language nobody enumerated.

Next along the same line is free-text extraction for unlabelled About rows in languages whose formatting conventions vary. Rules first, model only where rules come up empty.


⬆️ Upgrading from 1.x

The old Selenium class still works:

pip install facebook-page-info-scraper[selenium]
from facebook_page_info_scraper import FacebookPageInfoScraper
FacebookPageInfoScraper(url).get_page_info()

Either new engine is a recommended replacement — same idea, much faster.

Two output differences:

  • page_rate and page_review_number used to be swapped. They are correct now.
  • location was a country name; the new address is the full street address.

🤝 Contributing

Contributions are welcome. If you find an issue or have a suggestion, please open an issue or submit a pull request on GitHub.

📝 Notes

  • Only public pages. Nothing behind a login.
  • Be reasonable with threads — this hits Facebook from your IP. Set FBPS_PROXY_URL and use_proxy=True to spread the load across a proxy.
  • Enrichment hits third-party company websites, not Facebook. The same courtesy applies, and each site is fetched once per run.
  • Scraping Facebook is against their Terms of Service. Use accordingly.

License

MIT — see LICENSE.txt.

anitbots
crawler
crawling-python
curl-cffi
deepseek
enrichment
facebook
facebook-apis
facebook-page-information
facebook-page-scraper
facebook-scraper
facebook-scraping
leadgeneration
llm
openai
python
scraping
scraping-python
scrapy
selenium

Project health

Latest release

2.2.0 · Oct 12, 2026

Releases this year

6