Free Facebook pages MetaData Scraping Library - Unlimited Calls
Python
68
105 commits
updated Oct 12, 2026
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.
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.
| 🌐 HTTP engine | public page data | ~1.4 s per page |
| 🎭 Browser engine | pages whose data needs JavaScript | ~3 s per page |
| 📧 Contact enrichment | recovers 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.
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.
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 with | requests | Chromium via Playwright |
| speed | ~1.4 s per page | ~3 s per page |
| memory | negligible | ~60–80 MB per page |
| runs JavaScript | — | ✅ |
| reads network traffic | — | ✅ captures GraphQL responses |
| suits | large lists, minimal setup | pages 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.
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.
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.
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.
| signal | score |
|---|---|
| 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.
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
| provider | environment variable | default model |
|---|---|---|
deepseek | DEEPSEEK_API_KEY | deepseek-chat |
openai | OPENAI_API_KEY | gpt-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.
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.
This is the one I reach for on this job, for three reasons that matter here specifically:
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.
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
}
Your original fields are never overwritten — enrichment only adds.
| field | type | description |
|---|---|---|
enriched_email | str | Best candidate found. Absent when none was. |
enriched_email_type | str | role, personal or unknown |
enriched_confidence | str | high, medium or low |
enrichment | dict | The full result — see below |
_enrich_status | str | ok, no_contact_found, challenged, unreachable |
_enrich_tier | str | requests or curl_cffi |
_enrich_email_source | str | mailto or text — how the winner was found |
The enrichment dict holds everything, not just the winner:
| key | type | description |
|---|---|---|
website | str | The URL that was visited |
emails | list | Every candidate, best first, each with its score and reasons |
phones | list | Up to 3 numbers from tel: links |
status | str | Same as _enrich_status |
tier | str | Which fetch tier succeeded |
fetch_outcome | str | ok, challenged, forbidden, not_found, timeout, dns, error, http_NNN |
error | str | Exception name, when one occurred |
pages_fetched | int | Requests spent on this site |
found_on_path | str | Which contact path paid off, if one did |
seconds | float | Wall 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).
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)
| option | default | what it does |
|---|---|---|
threads | 8 | Sites 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. |
timeout | 12 | Seconds per request. Lower it to move past slow or half-dead hosts faster. |
only_missing_email | True | Skip records that already have an email. Set False to re-check every record, e.g. to cross-check what Facebook listed. |
follow_contact_pages | True | When 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_fingerprint | True | Retry bot-challenged sites with a real browser's TLS fingerprint. Set False to skip the retry and report them as challenged. |
use_proxy | False | Retry through a proxy when a site refuses you directly. Off by default because it costs money. |
proxy_provider | None | "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_llm | False | Label each address role or personal. Needs an API key; off by default because it costs money. Everything else works either way. |
provider | None | "deepseek" or "openai". None picks whichever key is set. |
model | None | Override 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.
Measured on the same 183-page list, same machine.
| 🐌 old (Selenium) | 🌐 HTTP | 🎭 Browser | |
|---|---|---|---|
| per page | 15–20 s | ~1.4 s | ~3 s |
| pages in parallel | ❌ | ✅ 16+ | ✅ you choose |
| 200 pages | ~50 min | under 1 min | ~2 min |
| memory | ~60 MB/page | negligible | ~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.
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)
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
}
| field | type | description |
|---|---|---|
page_name | str | Page name |
page_id | str | Numeric Facebook page ID |
page_category | str | None | e.g. Bakery, Clothing Store |
email | str | list | None | Contact email(s) |
phone_number | str | list | None | Contact phone(s) |
page_website | str | list | None | External website(s) |
address | str | None | Full street address |
page_likes | str | None | Like count, e.g. 12,480 |
page_followers | str | None | Follower count |
talking_about | str | None | "talking about this" count |
were_here | str | None | Check-in count |
recommend_percent | str | None | e.g. 74 from "74% recommend" |
page_rate | str | None | Star rating, where shown |
page_review_number | str | None | Review count |
open_status | str | None | Closed now, Always open, … |
price_range | str | None | e.g. Price Range · $ |
bio | str | None | Page description |
image | str | None | Profile picture URL |
canonical | str | Canonical page URL |
locale | str | Language Facebook served |
ok | bool | Whether the scrape succeeded |
error | str | Present 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.
phone_numberandpage_websiteare 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])
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 moved | what it means |
|---|---|
ok dropped | fetching — blocks, rate limits, bad URLs |
ok steady, with_email dropped | extraction — Facebook changed something |
wrong_locale above 0 | those 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")
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.
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.Contributions are welcome. If you find an issue or have a suggestion, please open an issue or submit a pull request on GitHub.
threads — this hits Facebook from your IP. Set
FBPS_PROXY_URL and use_proxy=True to spread the load across a proxy.MIT — see LICENSE.txt.
Free Facebook pages MetaData Scraping Library - Unlimited Calls
Python
68
105 commits
updated Oct 12, 2026
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.
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.
| 🌐 HTTP engine | public page data | ~1.4 s per page |
| 🎭 Browser engine | pages whose data needs JavaScript | ~3 s per page |
| 📧 Contact enrichment | recovers 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.
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.
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 with | requests | Chromium via Playwright |
| speed | ~1.4 s per page | ~3 s per page |
| memory | negligible | ~60–80 MB per page |
| runs JavaScript | — | ✅ |
| reads network traffic | — | ✅ captures GraphQL responses |
| suits | large lists, minimal setup | pages 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.
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.
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.
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.
| signal | score |
|---|---|
| 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.
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
| provider | environment variable | default model |
|---|---|---|
deepseek | DEEPSEEK_API_KEY | deepseek-chat |
openai | OPENAI_API_KEY | gpt-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.
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.
This is the one I reach for on this job, for three reasons that matter here specifically:
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.
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
}
Your original fields are never overwritten — enrichment only adds.
| field | type | description |
|---|---|---|
enriched_email | str | Best candidate found. Absent when none was. |
enriched_email_type | str | role, personal or unknown |
enriched_confidence | str | high, medium or low |
enrichment | dict | The full result — see below |
_enrich_status | str | ok, no_contact_found, challenged, unreachable |
_enrich_tier | str | requests or curl_cffi |
_enrich_email_source | str | mailto or text — how the winner was found |
The enrichment dict holds everything, not just the winner:
| key | type | description |
|---|---|---|
website | str | The URL that was visited |
emails | list | Every candidate, best first, each with its score and reasons |
phones | list | Up to 3 numbers from tel: links |
status | str | Same as _enrich_status |
tier | str | Which fetch tier succeeded |
fetch_outcome | str | ok, challenged, forbidden, not_found, timeout, dns, error, http_NNN |
error | str | Exception name, when one occurred |
pages_fetched | int | Requests spent on this site |
found_on_path | str | Which contact path paid off, if one did |
seconds | float | Wall 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).
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)
| option | default | what it does |
|---|---|---|
threads | 8 | Sites 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. |
timeout | 12 | Seconds per request. Lower it to move past slow or half-dead hosts faster. |
only_missing_email | True | Skip records that already have an email. Set False to re-check every record, e.g. to cross-check what Facebook listed. |
follow_contact_pages | True | When 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_fingerprint | True | Retry bot-challenged sites with a real browser's TLS fingerprint. Set False to skip the retry and report them as challenged. |
use_proxy | False | Retry through a proxy when a site refuses you directly. Off by default because it costs money. |
proxy_provider | None | "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_llm | False | Label each address role or personal. Needs an API key; off by default because it costs money. Everything else works either way. |
provider | None | "deepseek" or "openai". None picks whichever key is set. |
model | None | Override 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.
Measured on the same 183-page list, same machine.
| 🐌 old (Selenium) | 🌐 HTTP | 🎭 Browser | |
|---|---|---|---|
| per page | 15–20 s | ~1.4 s | ~3 s |
| pages in parallel | ❌ | ✅ 16+ | ✅ you choose |
| 200 pages | ~50 min | under 1 min | ~2 min |
| memory | ~60 MB/page | negligible | ~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.
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)
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
}
| field | type | description |
|---|---|---|
page_name | str | Page name |
page_id | str | Numeric Facebook page ID |
page_category | str | None | e.g. Bakery, Clothing Store |
email | str | list | None | Contact email(s) |
phone_number | str | list | None | Contact phone(s) |
page_website | str | list | None | External website(s) |
address | str | None | Full street address |
page_likes | str | None | Like count, e.g. 12,480 |
page_followers | str | None | Follower count |
talking_about | str | None | "talking about this" count |
were_here | str | None | Check-in count |
recommend_percent | str | None | e.g. 74 from "74% recommend" |
page_rate | str | None | Star rating, where shown |
page_review_number | str | None | Review count |
open_status | str | None | Closed now, Always open, … |
price_range | str | None | e.g. Price Range · $ |
bio | str | None | Page description |
image | str | None | Profile picture URL |
canonical | str | Canonical page URL |
locale | str | Language Facebook served |
ok | bool | Whether the scrape succeeded |
error | str | Present 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.
phone_numberandpage_websiteare 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])
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 moved | what it means |
|---|---|
ok dropped | fetching — blocks, rate limits, bad URLs |
ok steady, with_email dropped | extraction — Facebook changed something |
wrong_locale above 0 | those 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")
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.
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.Contributions are welcome. If you find an issue or have a suggestion, please open an issue or submit a pull request on GitHub.
threads — this hits Facebook from your IP. Set
FBPS_PROXY_URL and use_proxy=True to spread the load across a proxy.MIT — see LICENSE.txt.