SearchPeopleFree.com is one of the more generous US people search sites: type a name and a free profile shows the exact age, current and past addresses, phone numbers, emails, the spouse and relatives, with no signup. That also makes it one of the harder ones to scrape. Here is a Python scraper that turns a name search into a CSV of profiles, and the three things in its way.

The block: Cloudflare, DataDome and a US-only geoblock

import requests r = requests.get("https://www.searchpeoplefree.com/") print(r.status_code) # 403, "Attention Required! | Cloudflare" 

A plain request gets HTTP 403 and Cloudflare's "Attention Required!" page. Cloudflare is only the front door: behind it the site runs DataDome, a second bot detection layer that scores fingerprints and behaviour, so passing one is not enough. And the whole site only answers visitors from the United States; datacenter IPs and most VPNs are dropped too.

The fix: one request from a US residential IP

Send the same URL through Scrape.do with super=true (residential IP plus anti-bot handling) and geoCode=us:

import json import re import requests from bs4 import BeautifulSoup TOKEN = "<your_token>" def fetch(url): response = requests.get("https://api.scrape.do/", params={ "token": TOKEN, "url": url, "super": "true", "geoCode": "us", }, timeout=90) response.raise_for_status() return BeautifulSoup(response.text, "html.parser") 

In my tests every profile and search page came back with HTTP 200 in about 3 seconds, for 10 credits each.

Trap: the emails are not in the HTML

Open a profile in the browser and the emails are on screen. In the page source they look like this:

<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="5a303b343f3e353f1a3f223b372a363f74393537">[email&#160;protected]</a> 

That is Cloudflare's email obfuscation: the first byte is a key and every following byte is XORed with it; JavaScript decodes it after the page loads. You could decode it yourself:

def decode_cfemail(hex_string): data = bytes.fromhex(hex_string) return bytes(b ^ data[0] for b in data[1:]).decode() decode_cfemail("5a303b343f3e353f1a3f223b372a363f74393537") # 'janedoe@example.com' 

But there is a better source.

The data: one JSON-LD block of type Person

Every profile embeds a <script type="application/ld+json"> block of type Person (schema.org), the structured data the site publishes for search engines. It has the name, every phone and email (in plain text, no cipher), the current address under contentLocation.address, past addresses under homeLocation, aliases, the spouse and the relatives. One test profile listed 37 phone numbers, 30 emails, 54 relatives and 27 past addresses, far more than the visible page shows.

The one field missing from it is the age. It sits in the profile summary, <article class="current-bg">, as "Age 41", so a regex picks it up:

def person_jsonld(soup): for script in soup.find_all("script", type="application/ld+json"): try: data = json.loads(script.string) except (json.JSONDecodeError, TypeError): continue if isinstance(data, dict) and data.get("@type") == "Person": return data return None def names(values): return [v["name"] if isinstance(v, dict) else v for v in values or []] def parse_profile(soup, url): person = person_jsonld(soup) if person is None: raise ValueError("No Person JSON-LD: blocked page or not a profile") address = person.get("contentLocation", {}).get("address", {}) summary = soup.find("article", class_="current-bg") age = re.search(r"Age\s+(\d+)", summary.get_text(" ")) if summary else None return { "name": person.get("name"), "age": int(age.group(1)) if age else None, "address": address.get("streetAddress"), "city": address.get("addressLocality"), "state": address.get("addressRegion"), "zip": address.get("postalCode"), "phones": person.get("telephone", []), "emails": person.get("email", []), "spouse": names(person.get("spouse")), "relatives": names(person.get("relatedTo")), "aliases": person.get("additionalName", []), "address_history": [ ", ".join(filter(None, [a.get("streetAddress"), a.get("addressLocality"), a.get("addressRegion")])) for a in (place.get("address", {}) for place in person.get("homeLocation", [])) ], "url": url, } 

On a fictional test page (no real person) the output looks like this:

{ "name": "Jane Doe", "age": 41, "address": "2912 Northern Ave", "city": "Washington", "state": "DC", "zip": "20001", "phones": ["(202) 555-0147", "(202) 555-0199"], "emails": ["janedoe@example.com", "jane.m.doe@example.org"], "spouse": ["John Doe"], "relatives": ["Mary Doe", "Robert Smith", "Emily Doe", "Thomas Doe", "Anna Roe", "Peter Roe"], "aliases": ["Jane M Doe", "Jane Marie Doe"], "address_history": ["2912 Northern Ave, Washington, DC", "44 Elm St, Arlington, VA"] } 

From a name to a CSV

A name search lives at /find/<first-last>, with an optional two-letter state (/find/john-smith/tx). Each results page lists about 10 people, the next page adds /p-2, and the page carries an ItemList JSON-LD block with every profile link in order:

import csv def search_url(name, state="", page=1): url = "https://www.searchpeoplefree.com/find/" + "-".join(name.lower().split()) if state: url += "/" + state.lower() if page > 1: url += f"/p-{page}" return url def profile_links(soup): for script in soup.find_all("script", type="application/ld+json"): try: data = json.loads(script.string) except (json.JSONDecodeError, TypeError): continue if isinstance(data, dict) and data.get("@type") == "ItemList": return [item["url"] for item in data.get("itemListElement", [])] return [] rows = [] for url in profile_links(fetch(search_url("John Smith", "TX")))[:5]: rows.append(parse_profile(fetch(url), url)) with open("people.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) writer.writeheader() for row in rows: writer.writerow({k: " | ".join(v) if isinstance(v, list) else v for k, v in row.items()}) print(f"Saved {len(rows)} profiles") 

One search page plus three profiles was 4 requests and 40 credits.

Skip the boilerplate

The same scraper is open source, with retries, the email decoder as a fallback, aliases and past addresses, and a CLI:

pip install searchpeoplefree-scraper export SCRAPEDO_TOKEN=your_token searchpeoplefree-scraper --name "John Smith" --state TX --limit 5 -f csv -o john-smith.csv searchpeoplefree-scraper "https://www.searchpeoplefree.com/find/<name>/<id>" 

Source: github.com/bugrahansss/searchpeoplefree-scraper. There is also a no-code version on Apify (SearchPeopleFree Scraper) and a browser demo on Hugging Face.

Use it responsibly

This is personal data about real people. SearchPeopleFree is not a consumer reporting agency, so don't use it for decisions about employment, housing, credit or insurance (the FCRA regulates those in the US). Honor opt-out requests, follow the privacy laws that apply to you, and keep request rates modest.

Run it at scale with Scrape.do

Every page here is one Scrape.do call through a US residential IP: no proxy pool to manage, no CAPTCHA solving, no headless browser to keep alive against Cloudflare and DataDome. Get 1000 free credits and start scraping with Scrape.do.

Full walkthrough on the Scrape.do blog: SearchPeopleFree.com Scraping: Extract Emails, Phones, and Family Data