whitepages-scraper 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Buğrahan Saka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include README.md LICENSE
2
+ recursive-include tests *.py *.html
3
+ recursive-include examples *.py
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: whitepages-scraper
3
+ Version: 0.1.0
4
+ Summary: Scrape Whitepages.com name searches and person pages (name, age range, address, phones, relatives, associates) with Python.
5
+ Author: Buğrahan Saka
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bugrahansss/whitepages-scraper
8
+ Project-URL: Source, https://github.com/bugrahansss/whitepages-scraper
9
+ Project-URL: Issues, https://github.com/bugrahansss/whitepages-scraper/issues
10
+ Project-URL: Guide, https://scrape.do/blog/white-pages-scraping/
11
+ Keywords: whitepages,web scraping,people search,scraper,json-ld,cloudflare,scrapedo
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: requests>=2.25
21
+ Requires-Dist: beautifulsoup4>=4.9
22
+ Dynamic: license-file
23
+
24
+ # Whitepages Scraper
25
+
26
+ [![PyPI](https://img.shields.io/pypi/v/whitepages-scraper)](https://pypi.org/project/whitepages-scraper/)
27
+ [![Python](https://img.shields.io/pypi/pyversions/whitepages-scraper)](https://pypi.org/project/whitepages-scraper/)
28
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
29
+
30
+ Scrape [Whitepages.com](https://www.whitepages.com) people search into structured data. Search a name (optionally in a state or city) and get every person on the result pages with their **current address, phone numbers, age range, neighborhood, previous locations and relatives**, then open person pages for **associates** and the rest. JSON or CSV.
31
+
32
+ ```bash
33
+ pip install whitepages-scraper
34
+ export SCRAPEDO_TOKEN=your_token
35
+ whitepages-scraper --name "John Smith" --city Seattle --state WA --pages 2 -f csv -o people.csv
36
+ ```
37
+
38
+ ## What is in the way
39
+
40
+ ```python
41
+ import requests
42
+
43
+ requests.get("https://www.whitepages.com/name/John-Smith/Seattle-WA")
44
+ # outside the US: the connection is dropped; from a datacenter IP: Cloudflare challenge
45
+ ```
46
+
47
+ 1. **US only.** Visitors outside the United States, datacenter IPs and most VPNs are dropped without an error page.
48
+ 2. **Cloudflare.** Every page sits behind Cloudflare's bot checks, so a plain HTTP client gets a challenge instead of HTML.
49
+
50
+ This package fetches every page through [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=whitepages-scraper) with `super=true&geoCode=us` (a US residential IP): about 3 seconds and 10 credits per page.
51
+
52
+ ## Where the data is
53
+
54
+ Whitepages renders on the server and publishes the people on each page as schema.org JSON-LD:
55
+
56
+ * **Search pages** carry an `ItemList` with up to **25 `Person` items**: full name, the current street address, phone numbers, `Age Range`, `Neighborhood` and `Previous Location` properties, relatives (`relatedTo`) and the person page URL. The visible cards mask phones ("(206) \*\*\* \*\*\*\*"); the JSON-LD has the full number. In a live test, one Seattle search page had 25 people, 25 addresses and 15 phone numbers in one request.
57
+ * **Person pages** carry one `Person` with phones under `contactPoint`, relatives and associates (`knows`). The address is often only in the HTML (`.address-line1`, `.address-line2`) and the age range in the text ("is in their 60s"); the parser reads both.
58
+ * **Pagination** is `?page=2`, `?page=3`, 25 people each.
59
+
60
+ Ages are ranges ("40s", "80+"), never exact. Free pages show the current phone at most; phone history, emails and background reports are behind Whitepages' paid tier and are not scraped.
61
+
62
+ ## Python
63
+
64
+ ```python
65
+ from whitepages_scraper import WhitepagesScraper
66
+
67
+ wp = WhitepagesScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
68
+
69
+ people = wp.search("John Smith", city="Seattle", state="WA", pages=2)
70
+ for p in people[:3]:
71
+ print(p.name, p.age_range, p.address, p.city, p.phone, p.relatives)
72
+
73
+ person = wp.get_profile(people[0].url)
74
+ print(person.associates, person.phones)
75
+ print(wp.credits_used)
76
+ ```
77
+
78
+ ## Command line
79
+
80
+ ```bash
81
+ # Name search: 25 people per page, no extra requests
82
+ whitepages-scraper --name "Mary Johnson" --state TX --pages 2 -f csv -o mary.csv
83
+
84
+ # Open every person page found (one more request each)
85
+ whitepages-scraper --name "John Smith" --city Seattle --state WA --profiles --limit 10
86
+
87
+ # Person page URLs
88
+ whitepages-scraper "https://www.whitepages.com/name/<Name>/<City-ST>/<id>"
89
+ ```
90
+
91
+ ## Fields
92
+
93
+ `name`, `first_name`, `middle_name`, `last_name`, `age_range`, `address`, `city`, `state`, `zip_code`, `neighborhood`, `phones`, `aliases`, `relatives`, `associates`, `previous_locations`, `url`.
94
+
95
+ ## Use it responsibly
96
+
97
+ This is personal data about real people. Whitepages is not a consumer reporting agency: don't use this data 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 (CCPA, GDPR) and Whitepages' terms, and keep request rates modest.
98
+
99
+ ## Also available
100
+
101
+ - **No-code:** [Whitepages Scraper on Apify](https://apify.com/scrapedo/whitepages-scraper)
102
+ - **Browser demo:** [Hugging Face Space](https://huggingface.co/spaces/bugrahans/whitepages-scraper)
103
+ - **Full guide:** [Whitepages.com Scraping: Bypass Cloudflare and Extract People Data](https://scrape.do/blog/white-pages-scraping/?utm_source=github&utm_medium=readme&utm_campaign=whitepages-scraper)
104
+
105
+ ## Tests
106
+
107
+ ```bash
108
+ python -m unittest discover -s tests
109
+ ```
110
+
111
+ The fixtures are fictional people (Jane Doe, 555 numbers); the tests never hit the network.
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,92 @@
1
+ # Whitepages Scraper
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/whitepages-scraper)](https://pypi.org/project/whitepages-scraper/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/whitepages-scraper)](https://pypi.org/project/whitepages-scraper/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
6
+
7
+ Scrape [Whitepages.com](https://www.whitepages.com) people search into structured data. Search a name (optionally in a state or city) and get every person on the result pages with their **current address, phone numbers, age range, neighborhood, previous locations and relatives**, then open person pages for **associates** and the rest. JSON or CSV.
8
+
9
+ ```bash
10
+ pip install whitepages-scraper
11
+ export SCRAPEDO_TOKEN=your_token
12
+ whitepages-scraper --name "John Smith" --city Seattle --state WA --pages 2 -f csv -o people.csv
13
+ ```
14
+
15
+ ## What is in the way
16
+
17
+ ```python
18
+ import requests
19
+
20
+ requests.get("https://www.whitepages.com/name/John-Smith/Seattle-WA")
21
+ # outside the US: the connection is dropped; from a datacenter IP: Cloudflare challenge
22
+ ```
23
+
24
+ 1. **US only.** Visitors outside the United States, datacenter IPs and most VPNs are dropped without an error page.
25
+ 2. **Cloudflare.** Every page sits behind Cloudflare's bot checks, so a plain HTTP client gets a challenge instead of HTML.
26
+
27
+ This package fetches every page through [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=whitepages-scraper) with `super=true&geoCode=us` (a US residential IP): about 3 seconds and 10 credits per page.
28
+
29
+ ## Where the data is
30
+
31
+ Whitepages renders on the server and publishes the people on each page as schema.org JSON-LD:
32
+
33
+ * **Search pages** carry an `ItemList` with up to **25 `Person` items**: full name, the current street address, phone numbers, `Age Range`, `Neighborhood` and `Previous Location` properties, relatives (`relatedTo`) and the person page URL. The visible cards mask phones ("(206) \*\*\* \*\*\*\*"); the JSON-LD has the full number. In a live test, one Seattle search page had 25 people, 25 addresses and 15 phone numbers in one request.
34
+ * **Person pages** carry one `Person` with phones under `contactPoint`, relatives and associates (`knows`). The address is often only in the HTML (`.address-line1`, `.address-line2`) and the age range in the text ("is in their 60s"); the parser reads both.
35
+ * **Pagination** is `?page=2`, `?page=3`, 25 people each.
36
+
37
+ Ages are ranges ("40s", "80+"), never exact. Free pages show the current phone at most; phone history, emails and background reports are behind Whitepages' paid tier and are not scraped.
38
+
39
+ ## Python
40
+
41
+ ```python
42
+ from whitepages_scraper import WhitepagesScraper
43
+
44
+ wp = WhitepagesScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
45
+
46
+ people = wp.search("John Smith", city="Seattle", state="WA", pages=2)
47
+ for p in people[:3]:
48
+ print(p.name, p.age_range, p.address, p.city, p.phone, p.relatives)
49
+
50
+ person = wp.get_profile(people[0].url)
51
+ print(person.associates, person.phones)
52
+ print(wp.credits_used)
53
+ ```
54
+
55
+ ## Command line
56
+
57
+ ```bash
58
+ # Name search: 25 people per page, no extra requests
59
+ whitepages-scraper --name "Mary Johnson" --state TX --pages 2 -f csv -o mary.csv
60
+
61
+ # Open every person page found (one more request each)
62
+ whitepages-scraper --name "John Smith" --city Seattle --state WA --profiles --limit 10
63
+
64
+ # Person page URLs
65
+ whitepages-scraper "https://www.whitepages.com/name/<Name>/<City-ST>/<id>"
66
+ ```
67
+
68
+ ## Fields
69
+
70
+ `name`, `first_name`, `middle_name`, `last_name`, `age_range`, `address`, `city`, `state`, `zip_code`, `neighborhood`, `phones`, `aliases`, `relatives`, `associates`, `previous_locations`, `url`.
71
+
72
+ ## Use it responsibly
73
+
74
+ This is personal data about real people. Whitepages is not a consumer reporting agency: don't use this data 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 (CCPA, GDPR) and Whitepages' terms, and keep request rates modest.
75
+
76
+ ## Also available
77
+
78
+ - **No-code:** [Whitepages Scraper on Apify](https://apify.com/scrapedo/whitepages-scraper)
79
+ - **Browser demo:** [Hugging Face Space](https://huggingface.co/spaces/bugrahans/whitepages-scraper)
80
+ - **Full guide:** [Whitepages.com Scraping: Bypass Cloudflare and Extract People Data](https://scrape.do/blog/white-pages-scraping/?utm_source=github&utm_medium=readme&utm_campaign=whitepages-scraper)
81
+
82
+ ## Tests
83
+
84
+ ```bash
85
+ python -m unittest discover -s tests
86
+ ```
87
+
88
+ The fixtures are fictional people (Jane Doe, 555 numbers); the tests never hit the network.
89
+
90
+ ## License
91
+
92
+ MIT
@@ -0,0 +1,25 @@
1
+ """Search one name in one city, then open every person page and save a CSV.
2
+
3
+ export SCRAPEDO_TOKEN=...
4
+ python examples/search_to_csv.py
5
+ """
6
+
7
+ import csv
8
+
9
+ from whitepages_scraper import CSV_FIELDS, WhitepagesScraper
10
+
11
+ wp = WhitepagesScraper() # reads SCRAPEDO_TOKEN
12
+
13
+ people = wp.search("John Smith", city="Seattle", state="WA", pages=2)
14
+ print(f"{len(people)} people on the search pages")
15
+
16
+ # The search pages already carry address, phone and age range.
17
+ # Open the person pages only when you need associates or the HTML-only address.
18
+ detailed = [wp.get_profile(p.url) for p in people[:5] if p.url]
19
+
20
+ with open("people.csv", "w", newline="", encoding="utf-8") as handle:
21
+ writer = csv.DictWriter(handle, fieldnames=CSV_FIELDS)
22
+ writer.writeheader()
23
+ writer.writerows(p.to_row() for p in people)
24
+
25
+ print(f"saved people.csv, {wp.credits_used} credits used")
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "whitepages-scraper"
7
+ version = "0.1.0"
8
+ description = "Scrape Whitepages.com name searches and person pages (name, age range, address, phones, relatives, associates) with Python."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Buğrahan Saka" }]
14
+ keywords = ["whitepages", "web scraping", "people search", "scraper", "json-ld", "cloudflare", "scrapedo"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
21
+ ]
22
+ dependencies = ["requests>=2.25", "beautifulsoup4>=4.9"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/bugrahansss/whitepages-scraper"
26
+ Source = "https://github.com/bugrahansss/whitepages-scraper"
27
+ Issues = "https://github.com/bugrahansss/whitepages-scraper/issues"
28
+ Guide = "https://scrape.do/blog/white-pages-scraping/"
29
+
30
+ [project.scripts]
31
+ whitepages-scraper = "whitepages_scraper.cli:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ """Scrape Whitepages.com name searches and person pages with Python."""
2
+
3
+ from .client import ScrapeError, WhitepagesScraper
4
+ from .parser import CSV_FIELDS, Person, format_phone, is_profile_url, name_search_url, parse_profile, parse_search_results
5
+
6
+ __all__ = [
7
+ "CSV_FIELDS",
8
+ "Person",
9
+ "ScrapeError",
10
+ "WhitepagesScraper",
11
+ "format_phone",
12
+ "is_profile_url",
13
+ "name_search_url",
14
+ "parse_profile",
15
+ "parse_search_results",
16
+ ]
17
+
18
+ __version__ = "0.1.0"
@@ -0,0 +1,100 @@
1
+ """Command line entry point.
2
+
3
+ whitepages-scraper --name "John Smith" --city Seattle --state WA --pages 2 -f csv -o people.csv
4
+ whitepages-scraper https://www.whitepages.com/name/<Name>/<City-ST>/<id>
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import csv
11
+ import json
12
+ import sys
13
+ from typing import List, Optional
14
+
15
+ from . import __version__
16
+ from .client import TOKEN_ENV, ScrapeError, WhitepagesScraper
17
+ from .parser import CSV_FIELDS, Person
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ p = argparse.ArgumentParser(prog="whitepages-scraper",
22
+ description="Scrape Whitepages.com name searches and person pages into JSON or CSV.")
23
+ p.add_argument("urls", nargs="*", help="Person page URLs to scrape")
24
+ p.add_argument("-i", "--input", help="Text file with one person page URL per line")
25
+ p.add_argument("-n", "--name", action="append", default=[], help='Search a name, e.g. "John Smith" (repeatable)')
26
+ p.add_argument("--city", default="", help="City for --name searches (needs --state)")
27
+ p.add_argument("-s", "--state", default="", help="Two-letter state code for --name searches, e.g. WA")
28
+ p.add_argument("--pages", type=int, default=1, help="Result pages per name, 25 people each (default: 1)")
29
+ p.add_argument("--profiles", action="store_true", help="Also open every person page found by --name (1 request each)")
30
+ p.add_argument("--limit", type=int, default=0, help="Stop after this many people (default: no limit)")
31
+ p.add_argument("-t", "--token", help=f"Scrape.do API token (default: ${TOKEN_ENV})")
32
+ p.add_argument("-f", "--format", choices=("json", "csv"), default="json", help="Output format (default: json)")
33
+ p.add_argument("-o", "--output", help="Write to this file instead of stdout")
34
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
35
+ return p
36
+
37
+
38
+ def _write(people: List[Person], fmt: str, stream) -> None:
39
+ if fmt == "csv":
40
+ writer = csv.DictWriter(stream, fieldnames=CSV_FIELDS)
41
+ writer.writeheader()
42
+ for person in people:
43
+ writer.writerow(person.to_row())
44
+ else:
45
+ json.dump([p.to_dict() for p in people], stream, indent=2, ensure_ascii=False)
46
+ stream.write("\n")
47
+
48
+
49
+ def main(argv: Optional[List[str]] = None) -> int:
50
+ args = build_parser().parse_args(argv)
51
+ urls = list(args.urls)
52
+ if args.input:
53
+ with open(args.input, encoding="utf-8") as handle:
54
+ urls.extend(l.strip() for l in handle if l.strip() and not l.startswith("#"))
55
+ if not urls and not args.name:
56
+ print("Nothing to do. Pass person page URLs, --input FILE or --name NAME.", file=sys.stderr)
57
+ return 2
58
+ try:
59
+ wp = WhitepagesScraper(token=args.token)
60
+ except ValueError as exc:
61
+ print(exc, file=sys.stderr)
62
+ return 2
63
+
64
+ people: List[Person] = []
65
+ failures = 0
66
+ for name in args.name:
67
+ try:
68
+ found = wp.search(name, args.city, args.state, args.pages)
69
+ except (ScrapeError, ValueError) as exc:
70
+ failures += 1
71
+ print(f"fail search {name!r}: {exc}", file=sys.stderr)
72
+ continue
73
+ print(f"found {len(found)} people for {name!r}", file=sys.stderr)
74
+ if args.profiles:
75
+ urls.extend(p.url for p in found if p.url and p.url not in urls)
76
+ else:
77
+ people.extend(found)
78
+ if args.limit > 0:
79
+ urls = urls[: max(0, args.limit - len(people))]
80
+ for url in urls:
81
+ try:
82
+ people.append(wp.get_profile(url))
83
+ print(f"ok {url}", file=sys.stderr)
84
+ except (ScrapeError, ValueError) as exc:
85
+ failures += 1
86
+ print(f"fail {url}: {exc}", file=sys.stderr)
87
+ if args.limit > 0:
88
+ people = people[: args.limit]
89
+
90
+ if args.output:
91
+ with open(args.output, "w", encoding="utf-8", newline="" if args.format == "csv" else None) as handle:
92
+ _write(people, args.format, handle)
93
+ print(f"Saved {len(people)} people to {args.output} ({wp.credits_used} credits)", file=sys.stderr)
94
+ else:
95
+ _write(people, args.format, sys.stdout)
96
+ return 1 if failures and not people else 0
97
+
98
+
99
+ if __name__ == "__main__":
100
+ raise SystemExit(main())
@@ -0,0 +1,110 @@
1
+ """Fetch Whitepages pages through the Scrape.do API.
2
+
3
+ Whitepages only answers visitors from the United States (other IPs, datacenter
4
+ ranges and most VPNs are dropped) and puts Cloudflare in front of every page,
5
+ so a plain HTTP client never gets the HTML. Sending the request through
6
+ Scrape.do with ``super=true`` and ``geoCode=us`` routes it through a US
7
+ residential IP, which clears both. Each page is 10 credits.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import time
15
+ from typing import Iterator, List, Optional
16
+
17
+ import requests
18
+
19
+ from .parser import Person, is_profile_url, name_search_url, parse_profile, parse_search_results
20
+
21
+ API_URL = "https://api.scrape.do/"
22
+ TOKEN_ENV = "SCRAPEDO_TOKEN"
23
+ BLOCK_RE = re.compile(r"just a moment\.\.\.|cf-chl-|attention required|cf-turnstile", re.I)
24
+
25
+
26
+ class ScrapeError(RuntimeError):
27
+ """Raised when a page cannot be fetched."""
28
+
29
+ def __init__(self, message: str, status_code: Optional[int] = None):
30
+ super().__init__(message)
31
+ self.status_code = status_code
32
+
33
+
34
+ class WhitepagesScraper:
35
+ """Search Whitepages by name and read person pages.
36
+
37
+ >>> wp = WhitepagesScraper(token="YOUR_SCRAPEDO_TOKEN")
38
+ >>> people = wp.search("John Smith", city="Seattle", state="WA")
39
+ >>> people[0].name, people[0].age_range, people[0].phone
40
+ >>> person = wp.get_profile(people[0].url)
41
+ """
42
+
43
+ def __init__(self, token: Optional[str] = None, timeout: float = 90.0, retries: int = 2,
44
+ session: Optional[requests.Session] = None):
45
+ self.token = token or os.environ.get(TOKEN_ENV, "")
46
+ if not self.token:
47
+ raise ValueError(f"A Scrape.do API token is required. Pass token=... or set {TOKEN_ENV}.")
48
+ self.timeout = timeout
49
+ self.retries = max(0, retries)
50
+ self.session = session or requests.Session()
51
+ self.requests_made = 0
52
+
53
+ def fetch(self, url: str) -> str:
54
+ """Raw HTML of ``url`` from a US residential IP. Cloudflare pages are retried."""
55
+ params = {"token": self.token, "url": url, "super": "true", "geoCode": "us"}
56
+ last_error: Optional[ScrapeError] = None
57
+ for attempt in range(self.retries + 1):
58
+ try:
59
+ response = self.session.get(API_URL, params=params, timeout=self.timeout)
60
+ except requests.RequestException as exc:
61
+ last_error = ScrapeError(f"Request failed: {exc}")
62
+ else:
63
+ if response.status_code == 200:
64
+ self.requests_made += 1
65
+ if not BLOCK_RE.search(response.text[:5000]):
66
+ return response.text
67
+ last_error = ScrapeError(f"Cloudflare challenge page for {url}", 200)
68
+ else:
69
+ last_error = ScrapeError(f"Request for {url} failed with status {response.status_code}",
70
+ status_code=response.status_code)
71
+ if 400 <= response.status_code < 500 and response.status_code != 429:
72
+ break
73
+ if attempt < self.retries:
74
+ time.sleep(2 * (attempt + 1))
75
+ assert last_error is not None
76
+ raise last_error
77
+
78
+ def search(self, name: str, city: str = "", state: str = "", pages: int = 1) -> List[Person]:
79
+ """People for a name search, 25 per page. Stops when a page brings nobody new."""
80
+ people: List[Person] = []
81
+ seen = set()
82
+ for page in range(1, max(1, pages) + 1):
83
+ found = parse_search_results(self.fetch(name_search_url(name, city, state, page)))
84
+ fresh = [p for p in found if (p.url or p.name + p.address) not in seen]
85
+ if not fresh:
86
+ break
87
+ for person in fresh:
88
+ seen.add(person.url or person.name + person.address)
89
+ people.append(person)
90
+ if len(found) < 25:
91
+ break
92
+ return people
93
+
94
+ def get_profile(self, url: str) -> Person:
95
+ """One person page (``/name/<Name>/<City-ST>/<id>``)."""
96
+ if not is_profile_url(url):
97
+ raise ValueError(f"Not a Whitepages person page: {url}")
98
+ if url.startswith("/"):
99
+ url = "https://www.whitepages.com" + url
100
+ return parse_profile(self.fetch(url), url=url)
101
+
102
+ def search_profiles(self, name: str, city: str = "", state: str = "", pages: int = 1, limit: int = 0) -> Iterator[Person]:
103
+ """Search, then open each person page (one more request per person)."""
104
+ for person in self.search(name, city, state, pages)[: limit or None]:
105
+ if person.url:
106
+ yield self.get_profile(person.url)
107
+
108
+ @property
109
+ def credits_used(self) -> int:
110
+ return self.requests_made * 10
@@ -0,0 +1,274 @@
1
+ """Parse Whitepages.com name search pages and person pages.
2
+
3
+ Whitepages renders its pages on the server and publishes the people on them as
4
+ schema.org JSON-LD for search engines:
5
+
6
+ * A **name search page** (``/name/John-Smith/Seattle-WA``) carries one ``ItemList``
7
+ block with up to 25 ``Person`` items: full name, current street address, phone
8
+ numbers, an age range, the neighborhood, previous locations, relatives and the
9
+ link to the person page. The visible cards mask the phone ("(206) *** ****");
10
+ the JSON-LD block does not.
11
+ * A **person page** (``/name/John-Smith/Seattle-WA/PAbc123XyZ9``) carries one
12
+ ``Person`` block with phones under ``contactPoint``, relatives (``relatedTo``) and
13
+ associates (``knows``). Its current address is often only in the HTML
14
+ (``.address-line1`` and ``.address-line2``), and the age range is in the text
15
+ ("John Smith is in their 60s").
16
+
17
+ Ages are ranges ("60s", "80+"), never exact, and free pages show at most the
18
+ current phone; phone history and emails are behind Whitepages' paid tier.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import re
25
+ from dataclasses import asdict, dataclass, field
26
+ from typing import Any, Dict, Iterable, List, Optional
27
+ from urllib.parse import quote
28
+
29
+ from bs4 import BeautifulSoup
30
+
31
+ BASE_URL = "https://www.whitepages.com"
32
+ PROFILE_RE = re.compile(r"^(?:https?://(?:www\.)?whitepages\.com)?/name/[^/?#]+/[^/?#]+/[A-Za-z0-9]{8,14}/?(?:[?#].*)?$")
33
+ AGE_TEXT_RE = re.compile(r"\bin (?:their|his|her) (\d{2}s|\d{2}\+)", re.I)
34
+ AGE_RANGE_RE = re.compile(r"^\d{2}(?:s|\+)$")
35
+ CITY_LINE_RE = re.compile(r"^\s*(.+?),\s*([A-Z]{2})\s*(\d{5}(?:-\d{4})?)?\s*$")
36
+
37
+
38
+ @dataclass
39
+ class Person:
40
+ """One person from a Whitepages search result or person page."""
41
+
42
+ name: str = ""
43
+ first_name: str = ""
44
+ middle_name: str = ""
45
+ last_name: str = ""
46
+ age_range: str = ""
47
+ address: str = ""
48
+ city: str = ""
49
+ state: str = ""
50
+ zip_code: str = ""
51
+ neighborhood: str = ""
52
+ phones: List[str] = field(default_factory=list)
53
+ aliases: List[str] = field(default_factory=list)
54
+ relatives: List[str] = field(default_factory=list)
55
+ associates: List[str] = field(default_factory=list)
56
+ previous_locations: List[str] = field(default_factory=list)
57
+ url: str = ""
58
+
59
+ @property
60
+ def phone(self) -> str:
61
+ return self.phones[0] if self.phones else ""
62
+
63
+ @property
64
+ def profile_id(self) -> str:
65
+ match = re.search(r"/([A-Za-z0-9]{8,14})/?$", self.url.split("?")[0])
66
+ return match.group(1) if match else ""
67
+
68
+ def to_dict(self) -> Dict[str, Any]:
69
+ return asdict(self)
70
+
71
+ def to_row(self, separator: str = " | ") -> Dict[str, Any]:
72
+ row = self.to_dict()
73
+ for key in ("phones", "aliases", "relatives", "associates", "previous_locations"):
74
+ row[key] = separator.join(row[key])
75
+ return row
76
+
77
+
78
+ CSV_FIELDS = [
79
+ "name", "first_name", "middle_name", "last_name", "age_range", "address", "city", "state", "zip_code",
80
+ "neighborhood", "phones", "aliases", "relatives", "associates", "previous_locations", "url",
81
+ ]
82
+
83
+
84
+ # --------------------------------------------------------------------------- helpers
85
+
86
+
87
+ def _squash(value: Any) -> str:
88
+ return " ".join(str(value).split()) if value is not None else ""
89
+
90
+
91
+ def _as_list(value: Any) -> List[Any]:
92
+ if value is None:
93
+ return []
94
+ return value if isinstance(value, list) else [value]
95
+
96
+
97
+ def _unique(values: Iterable[str]) -> List[str]:
98
+ seen, out = set(), []
99
+ for value in values:
100
+ value = _squash(value)
101
+ if value and value not in seen:
102
+ seen.add(value)
103
+ out.append(value)
104
+ return out
105
+
106
+
107
+ def _names(values: Any) -> List[str]:
108
+ return _unique(v.get("name", "") if isinstance(v, dict) else str(v) for v in _as_list(values))
109
+
110
+
111
+ def format_phone(value: str) -> str:
112
+ """Normalize ``+1-206-555-0142`` and similar to ``(206) 555-0142``; masked numbers return ""."""
113
+ value = _squash(value)
114
+ if not value or "*" in value:
115
+ return ""
116
+ digits = re.sub(r"\D", "", value)
117
+ if len(digits) == 11 and digits.startswith("1"):
118
+ digits = digits[1:]
119
+ if len(digits) != 10:
120
+ return value
121
+ return f"({digits[:3]}) {digits[3:6]}-{digits[6:]}"
122
+
123
+
124
+ def absolute_url(url: str) -> str:
125
+ url = _squash(url)
126
+ if url.startswith("/"):
127
+ url = BASE_URL + url
128
+ return url.split("#")[0]
129
+
130
+
131
+ def _slug(text: str) -> str:
132
+ words = re.findall(r"[A-Za-z0-9'.]+", text or "")
133
+ return "-".join(w.replace("'", "").replace(".", "") for w in words if w.strip("'."))
134
+
135
+
136
+ def name_search_url(name: str, city: str = "", state: str = "", page: int = 1) -> str:
137
+ """Build a name search URL: ``/name/John-Smith``, ``/name/John-Smith/WA`` or ``/name/John-Smith/Seattle-WA``.
138
+
139
+ ``page`` adds ``?page=N`` (25 people per page).
140
+ """
141
+ slug = _slug(name)
142
+ if not slug or not re.search(r"[A-Za-z]", slug):
143
+ raise ValueError(f"Not a name: {name!r}")
144
+ state = (state or "").strip().upper()
145
+ if state and not re.fullmatch(r"[A-Z]{2}", state):
146
+ raise ValueError(f"State must be a two-letter code like WA, got {state!r}")
147
+ if city and not state:
148
+ raise ValueError("A city needs a state, e.g. city='Seattle', state='WA'")
149
+ url = f"{BASE_URL}/name/{quote(slug)}"
150
+ if city:
151
+ url += f"/{quote(_slug(city))}-{state}"
152
+ elif state:
153
+ url += f"/{state}"
154
+ if page and int(page) > 1:
155
+ url += f"?page={int(page)}"
156
+ return url
157
+
158
+
159
+ def iter_jsonld(soup: BeautifulSoup) -> Iterable[Dict[str, Any]]:
160
+ for script in soup.find_all("script", type="application/ld+json"):
161
+ try:
162
+ data = json.loads(script.string or "")
163
+ except (TypeError, ValueError):
164
+ continue
165
+ for item in _as_list(data):
166
+ if isinstance(item, dict):
167
+ yield item
168
+
169
+
170
+ def _apply_address(person: Person, address: Any) -> None:
171
+ for addr in _as_list(address):
172
+ if not isinstance(addr, dict):
173
+ continue
174
+ person.address = _squash(addr.get("streetAddress"))
175
+ person.city = _squash(addr.get("addressLocality"))
176
+ person.state = _squash(addr.get("addressRegion"))
177
+ person.zip_code = _squash(addr.get("postalCode"))
178
+ if person.address or person.city:
179
+ return
180
+
181
+
182
+ def _person_from_jsonld(item: Dict[str, Any]) -> Person:
183
+ person = Person(
184
+ name=_squash(item.get("name")),
185
+ first_name=_squash(item.get("givenName")),
186
+ middle_name=" ".join(_unique(_as_list(item.get("additionalName")))),
187
+ last_name=_squash(item.get("familyName")),
188
+ url=absolute_url(item.get("url") or item.get("@id") or ""),
189
+ )
190
+ _apply_address(person, item.get("address"))
191
+ phones = [format_phone(p) for p in _as_list(item.get("telephone"))]
192
+ for cp in _as_list(item.get("contactPoint")):
193
+ if isinstance(cp, dict):
194
+ phones.append(format_phone(cp.get("telephone", "")))
195
+ person.phones = _unique(p for p in phones if p)
196
+ person.aliases = [a for a in _unique(_as_list(item.get("alternateName"))) if a != person.name]
197
+ person.relatives = _names(item.get("relatedTo"))
198
+ person.associates = _names(item.get("knows"))
199
+ for prop in _as_list(item.get("additionalProperty")):
200
+ if not isinstance(prop, dict):
201
+ continue
202
+ key, value = _squash(prop.get("name")).lower(), _squash(prop.get("value"))
203
+ if key == "age range" and AGE_RANGE_RE.match(value):
204
+ person.age_range = value
205
+ elif key == "neighborhood":
206
+ person.neighborhood = value
207
+ elif key == "previous location" and value:
208
+ person.previous_locations.append(value)
209
+ person.previous_locations = _unique(person.previous_locations)
210
+ if not person.age_range:
211
+ match = AGE_TEXT_RE.search(_squash(item.get("description")))
212
+ if match:
213
+ person.age_range = match.group(1)
214
+ return person
215
+
216
+
217
+ # --------------------------------------------------------------------------- pages
218
+
219
+
220
+ def parse_search_results(html: str) -> List[Person]:
221
+ """People on a name search page, from its ``ItemList`` JSON-LD (up to 25)."""
222
+ soup = BeautifulSoup(html, "html.parser")
223
+ people: List[Person] = []
224
+ for block in iter_jsonld(soup):
225
+ if block.get("@type") != "ItemList":
226
+ continue
227
+ for element in _as_list(block.get("itemListElement")):
228
+ item = element.get("item") if isinstance(element, dict) else None
229
+ if isinstance(item, dict) and item.get("@type") == "Person":
230
+ people.append(_person_from_jsonld(item))
231
+ # Ages missing from the JSON-LD are often on the visible card
232
+ cards = {}
233
+ for card in soup.select(".serp-card"):
234
+ link = card.select_one("a.name-link-text, [data-qa-selector=organic-card-person-name]")
235
+ age = card.select_one(".person-age")
236
+ if link and link.get("href") and age:
237
+ cards[absolute_url(link["href"])] = _squash(age.get_text())
238
+ for person in people:
239
+ if not person.age_range and AGE_RANGE_RE.match(cards.get(person.url, "")):
240
+ person.age_range = cards[person.url]
241
+ return people
242
+
243
+
244
+ def parse_profile(html: str, url: str = "") -> Person:
245
+ """A person page: JSON-LD ``Person`` plus the address and age from the HTML."""
246
+ soup = BeautifulSoup(html, "html.parser")
247
+ item = next((b for b in iter_jsonld(soup) if b.get("@type") == "Person"), None)
248
+ if item is None:
249
+ raise ValueError("No Person JSON-LD on this page; it is not a Whitepages person page")
250
+ person = _person_from_jsonld(item)
251
+ if url:
252
+ person.url = absolute_url(url)
253
+ if not person.address:
254
+ line1 = soup.select_one(".address-line1")
255
+ line2 = soup.select_one(".address-line2")
256
+ if line1:
257
+ person.address = _squash(line1.get_text(" "))
258
+ if line2:
259
+ match = CITY_LINE_RE.match(_squash(line2.get_text(" ")))
260
+ if match:
261
+ person.city, person.state, person.zip_code = match.group(1), match.group(2), match.group(3) or ""
262
+ if not person.age_range:
263
+ for el in soup.select("[data-qa-selector=person-age-mobile], .person-age"):
264
+ match = re.search(r"\d{2}(?:s|\+)", el.get_text(" "))
265
+ if match:
266
+ person.age_range = match.group(0)
267
+ break
268
+ html_phones = [format_phone(a.get_text()) for a in soup.select("[data-qa-selector=phone-number-link]")]
269
+ person.phones = _unique(person.phones + [p for p in html_phones if p])
270
+ return person
271
+
272
+
273
+ def is_profile_url(url: str) -> bool:
274
+ return bool(PROFILE_RE.match(_squash(url)))
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: whitepages-scraper
3
+ Version: 0.1.0
4
+ Summary: Scrape Whitepages.com name searches and person pages (name, age range, address, phones, relatives, associates) with Python.
5
+ Author: Buğrahan Saka
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bugrahansss/whitepages-scraper
8
+ Project-URL: Source, https://github.com/bugrahansss/whitepages-scraper
9
+ Project-URL: Issues, https://github.com/bugrahansss/whitepages-scraper/issues
10
+ Project-URL: Guide, https://scrape.do/blog/white-pages-scraping/
11
+ Keywords: whitepages,web scraping,people search,scraper,json-ld,cloudflare,scrapedo
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: requests>=2.25
21
+ Requires-Dist: beautifulsoup4>=4.9
22
+ Dynamic: license-file
23
+
24
+ # Whitepages Scraper
25
+
26
+ [![PyPI](https://img.shields.io/pypi/v/whitepages-scraper)](https://pypi.org/project/whitepages-scraper/)
27
+ [![Python](https://img.shields.io/pypi/pyversions/whitepages-scraper)](https://pypi.org/project/whitepages-scraper/)
28
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
29
+
30
+ Scrape [Whitepages.com](https://www.whitepages.com) people search into structured data. Search a name (optionally in a state or city) and get every person on the result pages with their **current address, phone numbers, age range, neighborhood, previous locations and relatives**, then open person pages for **associates** and the rest. JSON or CSV.
31
+
32
+ ```bash
33
+ pip install whitepages-scraper
34
+ export SCRAPEDO_TOKEN=your_token
35
+ whitepages-scraper --name "John Smith" --city Seattle --state WA --pages 2 -f csv -o people.csv
36
+ ```
37
+
38
+ ## What is in the way
39
+
40
+ ```python
41
+ import requests
42
+
43
+ requests.get("https://www.whitepages.com/name/John-Smith/Seattle-WA")
44
+ # outside the US: the connection is dropped; from a datacenter IP: Cloudflare challenge
45
+ ```
46
+
47
+ 1. **US only.** Visitors outside the United States, datacenter IPs and most VPNs are dropped without an error page.
48
+ 2. **Cloudflare.** Every page sits behind Cloudflare's bot checks, so a plain HTTP client gets a challenge instead of HTML.
49
+
50
+ This package fetches every page through [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=whitepages-scraper) with `super=true&geoCode=us` (a US residential IP): about 3 seconds and 10 credits per page.
51
+
52
+ ## Where the data is
53
+
54
+ Whitepages renders on the server and publishes the people on each page as schema.org JSON-LD:
55
+
56
+ * **Search pages** carry an `ItemList` with up to **25 `Person` items**: full name, the current street address, phone numbers, `Age Range`, `Neighborhood` and `Previous Location` properties, relatives (`relatedTo`) and the person page URL. The visible cards mask phones ("(206) \*\*\* \*\*\*\*"); the JSON-LD has the full number. In a live test, one Seattle search page had 25 people, 25 addresses and 15 phone numbers in one request.
57
+ * **Person pages** carry one `Person` with phones under `contactPoint`, relatives and associates (`knows`). The address is often only in the HTML (`.address-line1`, `.address-line2`) and the age range in the text ("is in their 60s"); the parser reads both.
58
+ * **Pagination** is `?page=2`, `?page=3`, 25 people each.
59
+
60
+ Ages are ranges ("40s", "80+"), never exact. Free pages show the current phone at most; phone history, emails and background reports are behind Whitepages' paid tier and are not scraped.
61
+
62
+ ## Python
63
+
64
+ ```python
65
+ from whitepages_scraper import WhitepagesScraper
66
+
67
+ wp = WhitepagesScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
68
+
69
+ people = wp.search("John Smith", city="Seattle", state="WA", pages=2)
70
+ for p in people[:3]:
71
+ print(p.name, p.age_range, p.address, p.city, p.phone, p.relatives)
72
+
73
+ person = wp.get_profile(people[0].url)
74
+ print(person.associates, person.phones)
75
+ print(wp.credits_used)
76
+ ```
77
+
78
+ ## Command line
79
+
80
+ ```bash
81
+ # Name search: 25 people per page, no extra requests
82
+ whitepages-scraper --name "Mary Johnson" --state TX --pages 2 -f csv -o mary.csv
83
+
84
+ # Open every person page found (one more request each)
85
+ whitepages-scraper --name "John Smith" --city Seattle --state WA --profiles --limit 10
86
+
87
+ # Person page URLs
88
+ whitepages-scraper "https://www.whitepages.com/name/<Name>/<City-ST>/<id>"
89
+ ```
90
+
91
+ ## Fields
92
+
93
+ `name`, `first_name`, `middle_name`, `last_name`, `age_range`, `address`, `city`, `state`, `zip_code`, `neighborhood`, `phones`, `aliases`, `relatives`, `associates`, `previous_locations`, `url`.
94
+
95
+ ## Use it responsibly
96
+
97
+ This is personal data about real people. Whitepages is not a consumer reporting agency: don't use this data 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 (CCPA, GDPR) and Whitepages' terms, and keep request rates modest.
98
+
99
+ ## Also available
100
+
101
+ - **No-code:** [Whitepages Scraper on Apify](https://apify.com/scrapedo/whitepages-scraper)
102
+ - **Browser demo:** [Hugging Face Space](https://huggingface.co/spaces/bugrahans/whitepages-scraper)
103
+ - **Full guide:** [Whitepages.com Scraping: Bypass Cloudflare and Extract People Data](https://scrape.do/blog/white-pages-scraping/?utm_source=github&utm_medium=readme&utm_campaign=whitepages-scraper)
104
+
105
+ ## Tests
106
+
107
+ ```bash
108
+ python -m unittest discover -s tests
109
+ ```
110
+
111
+ The fixtures are fictional people (Jane Doe, 555 numbers); the tests never hit the network.
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ examples/search_to_csv.py
6
+ src/whitepages_scraper/__init__.py
7
+ src/whitepages_scraper/cli.py
8
+ src/whitepages_scraper/client.py
9
+ src/whitepages_scraper/parser.py
10
+ src/whitepages_scraper.egg-info/PKG-INFO
11
+ src/whitepages_scraper.egg-info/SOURCES.txt
12
+ src/whitepages_scraper.egg-info/dependency_links.txt
13
+ src/whitepages_scraper.egg-info/entry_points.txt
14
+ src/whitepages_scraper.egg-info/requires.txt
15
+ src/whitepages_scraper.egg-info/top_level.txt
16
+ tests/test_parser.py
17
+ tests/fixtures/challenge.html
18
+ tests/fixtures/profile_jane_doe.html
19
+ tests/fixtures/search_jane_doe.html
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ whitepages-scraper = whitepages_scraper.cli:main
@@ -0,0 +1,2 @@
1
+ requests>=2.25
2
+ beautifulsoup4>=4.9
@@ -0,0 +1 @@
1
+ whitepages_scraper
@@ -0,0 +1 @@
1
+ <html><head><title>Just a moment...</title></head><body><div class="cf-turnstile"></div></body></html>
@@ -0,0 +1,3 @@
1
+ <!doctype html><html><head><title>Jane Doe | 40s | 100 Example St Apt 1, Seattle, WA | Whitepages</title><script type="application/ld+json">{"@context": "https://schema.org", "@type": "Person", "description": "Jane Doe is in their 40s, currently living in Seattle, WA", "name": "Jane Doe", "givenName": "Jane", "familyName": "Doe", "@id": "https://www.whitepages.com/name/Jane-Doe/Seattle-WA/PExample001#main", "url": "https://www.whitepages.com/name/Jane-Doe/Seattle-WA/PExample001", "alternateName": ["Jane Q Doe"], "contactPoint": [{"@type": "ContactPoint", "telephone": "+1-206-555-0101", "contactType": "landline"}], "address": [], "relatedTo": [{"@type": "Person", "name": "John Doe", "url": "/name/John-Doe/Seattle-WA/PRel0000001"}], "knows": [{"@type": "Person", "name": "Alex Roe"}]}</script><script type="application/ld+json">{"@type":"WebPage"}</script></head>
2
+ <body><div class="big-name">Jane Doe</div><div data-qa-selector="person-age-mobile">Age 40s</div><div class="address-line1">100 Example St Apt 1</div><div class="address-line2">Seattle, WA 98101</div>
3
+ <a data-qa-selector="phone-number-link" href="tel:2065550101">(206) 555-0101</a><a data-qa-selector="phone-number-link">(206) 555-0199</a><span data-qa-selector="masked-phone">(206) ***-****</span></body></html>
@@ -0,0 +1 @@
1
+ <!doctype html><html><head><title>Jane Doe in Seattle, Washington (WA) | Whitepages</title><script type="application/ld+json">[{"@context": "https://schema.org", "@type": "ItemList", "name": "Jane Doe", "numberOfItems": 3, "itemListElement": [{"@type": "ListItem", "position": 1, "item": {"@type": "Person", "name": "Jane Doe", "givenName": "Jane", "familyName": "Doe", "description": "Jane Doe lives at 100 Example St Apt 1 Seattle WA 98101 in the Capitol Hill neighborhood.", "@id": "/name/Jane-Doe/Seattle-WA/PExample001#main", "url": "/name/Jane-Doe/Seattle-WA/PExample001", "address": [{"@type": "PostalAddress", "streetAddress": "100 Example St Apt 1", "addressLocality": "Seattle", "addressRegion": "WA", "addressCountry": {"@type": "Country", "name": "US"}, "postalCode": "98101"}], "additionalProperty": [{"@type": "PropertyValue", "name": "Neighborhood", "value": "Capitol Hill"}, {"@type": "PropertyValue", "name": "Age Range", "value": "40s"}, {"@type": "PropertyValue", "name": "Previous Location", "value": "Tacoma, WA"}], "relatedTo": [{"@type": "Person", "name": "John Doe", "url": "/name/x/y/PRel0000001"}, {"@type": "Person", "name": "Mary Doe", "url": "/name/x/y/PRel0000001"}], "knows": [], "telephone": ["(206) 555-0101"], "additionalName": "Q", "alternateName": ["Jane Q Doe", "Jane Doe"]}}, {"@type": "ListItem", "position": 2, "item": {"@type": "Person", "name": "Jane Doe", "givenName": "Jane", "familyName": "Doe", "description": "Jane Doe lives at 200 Sample Ave Seattle WA 98101 in the Fremont neighborhood.", "@id": "/name/Jane-Doe/Seattle-WA/PExample002#main", "url": "/name/Jane-Doe/Seattle-WA/PExample002", "address": [{"@type": "PostalAddress", "streetAddress": "200 Sample Ave", "addressLocality": "Seattle", "addressRegion": "WA", "addressCountry": {"@type": "Country", "name": "US"}, "postalCode": "98101"}], "additionalProperty": [{"@type": "PropertyValue", "name": "Neighborhood", "value": "Fremont"}], "relatedTo": [], "knows": []}}, {"@type": "ListItem", "position": 3, "item": {"@type": "Person", "name": "Jane Doe", "givenName": "Jane", "familyName": "Doe", "description": "Jane Doe lives at 300 Test Rd Seattle WA 98101 in the Capitol Hill neighborhood.", "@id": "/name/Jane-Doe/Seattle-WA/PExample003#main", "url": "/name/Jane-Doe/Seattle-WA/PExample003", "address": [{"@type": "PostalAddress", "streetAddress": "300 Test Rd", "addressLocality": "Seattle", "addressRegion": "WA", "addressCountry": {"@type": "Country", "name": "US"}, "postalCode": "98101"}], "additionalProperty": [{"@type": "PropertyValue", "name": "Neighborhood", "value": "Capitol Hill"}, {"@type": "PropertyValue", "name": "Age Range", "value": "80+"}], "relatedTo": [], "knows": [], "telephone": ["+1-206-555-0103"]}}]}]</script><script type="application/ld+json">{"@type":"FAQPage"}</script></head><body><div class="serp-card"><dl class="age-container"><dt class="age-title">AGE</dt><dd class="person-age">40s</dd></dl><a class="amp-event name-link-text" data-qa-selector="organic-card-person-name" href="/name/Jane-Doe/Seattle-WA/PExample001">Jane Doe</a><dd class="person-location-city">Seattle, WA</dd><div>Phone (206) *** ****</div></div><div class="serp-card"><dl class="age-container"><dt class="age-title">AGE</dt><dd class="person-age">50s</dd></dl><a class="amp-event name-link-text" data-qa-selector="organic-card-person-name" href="/name/Jane-Doe/Seattle-WA/PExample002">Jane Doe</a><dd class="person-location-city">Seattle, WA</dd><div>Phone (206) *** ****</div></div><div class="serp-card"><dl class="age-container"><dt class="age-title">AGE</dt><dd class="person-age">80+</dd></dl><a class="amp-event name-link-text" data-qa-selector="organic-card-person-name" href="/name/Jane-Doe/Seattle-WA/PExample003">Jane Doe</a><dd class="person-location-city">Seattle, WA</dd><div>Phone (206) *** ****</div></div></body></html>
@@ -0,0 +1,164 @@
1
+ import csv
2
+ import io
3
+ import os
4
+ import sys
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest import mock
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ from whitepages_scraper import ( # noqa: E402
12
+ CSV_FIELDS,
13
+ ScrapeError,
14
+ WhitepagesScraper,
15
+ format_phone,
16
+ is_profile_url,
17
+ name_search_url,
18
+ parse_profile,
19
+ parse_search_results,
20
+ )
21
+ from whitepages_scraper import cli # noqa: E402
22
+
23
+ FIX = Path(__file__).parent / "fixtures"
24
+ SEARCH = (FIX / "search_jane_doe.html").read_text(encoding="utf-8")
25
+ PROFILE = (FIX / "profile_jane_doe.html").read_text(encoding="utf-8")
26
+ CHALLENGE = (FIX / "challenge.html").read_text(encoding="utf-8")
27
+ PURL = "https://www.whitepages.com/name/Jane-Doe/Seattle-WA/PExample001"
28
+
29
+
30
+ class R:
31
+ def __init__(self, status, text=""):
32
+ self.status_code, self.text = status, text
33
+
34
+
35
+ class Session:
36
+ def __init__(self, responses):
37
+ self.responses, self.calls = list(responses), []
38
+
39
+ def get(self, url, params=None, timeout=None):
40
+ self.calls.append(dict(params))
41
+ return self.responses.pop(0)
42
+
43
+
44
+ class ParserTests(unittest.TestCase):
45
+ def test_search_url(self):
46
+ self.assertEqual(name_search_url("John Smith"), "https://www.whitepages.com/name/John-Smith")
47
+ self.assertEqual(name_search_url("John Smith", state="wa"), "https://www.whitepages.com/name/John-Smith/WA")
48
+ self.assertEqual(name_search_url("John Smith", "Seattle", "WA", 2), "https://www.whitepages.com/name/John-Smith/Seattle-WA?page=2")
49
+ self.assertEqual(name_search_url("Mary O'Neil", "San Jose", "CA"), "https://www.whitepages.com/name/Mary-ONeil/San-Jose-CA")
50
+
51
+ def test_search_url_errors(self):
52
+ for args in (("",), ("123",), ("John", "", "Texas"), ("John", "Seattle", "")):
53
+ with self.assertRaises(ValueError):
54
+ name_search_url(*args)
55
+
56
+ def test_format_phone(self):
57
+ self.assertEqual(format_phone("+1-206-555-0103"), "(206) 555-0103")
58
+ self.assertEqual(format_phone("(206) 555-0101"), "(206) 555-0101")
59
+ self.assertEqual(format_phone("(206) *** ****"), "")
60
+ self.assertEqual(format_phone(""), "")
61
+
62
+ def test_parse_search(self):
63
+ people = parse_search_results(SEARCH)
64
+ self.assertEqual(len(people), 3)
65
+ jane = people[0]
66
+ self.assertEqual((jane.name, jane.first_name, jane.middle_name, jane.last_name), ("Jane Doe", "Jane", "Q", "Doe"))
67
+ self.assertEqual((jane.address, jane.city, jane.state, jane.zip_code), ("100 Example St Apt 1", "Seattle", "WA", "98101"))
68
+ self.assertEqual(jane.phones, ["(206) 555-0101"])
69
+ self.assertEqual(jane.age_range, "40s")
70
+ self.assertEqual(jane.neighborhood, "Capitol Hill")
71
+ self.assertEqual(jane.previous_locations, ["Tacoma, WA"])
72
+ self.assertEqual(jane.relatives, ["John Doe", "Mary Doe"])
73
+ self.assertEqual(jane.aliases, ["Jane Q Doe"])
74
+ self.assertEqual(jane.url, PURL)
75
+ self.assertEqual(jane.profile_id, "PExample001")
76
+
77
+ def test_search_age_falls_back_to_card(self):
78
+ people = parse_search_results(SEARCH)
79
+ self.assertEqual(people[1].age_range, "50s") # not in JSON-LD, read from the card
80
+ self.assertEqual(people[1].phones, [])
81
+ self.assertEqual(people[2].phones, ["(206) 555-0103"])
82
+ self.assertEqual(people[2].age_range, "80+")
83
+
84
+ def test_parse_search_empty(self):
85
+ self.assertEqual(parse_search_results("<html></html>"), [])
86
+
87
+ def test_parse_profile(self):
88
+ p = parse_profile(PROFILE)
89
+ self.assertEqual(p.name, "Jane Doe")
90
+ self.assertEqual(p.age_range, "40s")
91
+ self.assertEqual((p.address, p.city, p.state, p.zip_code), ("100 Example St Apt 1", "Seattle", "WA", "98101"))
92
+ self.assertEqual(p.phones, ["(206) 555-0101", "(206) 555-0199"])
93
+ self.assertEqual(p.relatives, ["John Doe"])
94
+ self.assertEqual(p.associates, ["Alex Roe"])
95
+ self.assertEqual(p.aliases, ["Jane Q Doe"])
96
+
97
+ def test_parse_profile_rejects_other_pages(self):
98
+ with self.assertRaises(ValueError):
99
+ parse_profile(SEARCH)
100
+
101
+ def test_profile_url(self):
102
+ self.assertTrue(is_profile_url(PURL))
103
+ self.assertTrue(is_profile_url("/name/Jane-Doe/Seattle-WA/PExample001"))
104
+ self.assertFalse(is_profile_url("https://www.whitepages.com/name/Jane-Doe/Seattle-WA"))
105
+ self.assertFalse(is_profile_url("https://example.com/name/a/b/PExample001"))
106
+
107
+ def test_rows(self):
108
+ row = parse_search_results(SEARCH)[0].to_row()
109
+ self.assertEqual(list(row), CSV_FIELDS)
110
+ self.assertEqual(row["relatives"], "John Doe | Mary Doe")
111
+
112
+
113
+ class ClientTests(unittest.TestCase):
114
+ def test_needs_token(self):
115
+ with mock.patch.dict(os.environ, {}, clear=True):
116
+ with self.assertRaises(ValueError):
117
+ WhitepagesScraper()
118
+
119
+ def test_search_params_and_pagination(self):
120
+ s = Session([R(200, SEARCH)])
121
+ wp = WhitepagesScraper(token="T", session=s)
122
+ people = wp.search("Jane Doe", "Seattle", "WA", pages=3)
123
+ self.assertEqual(len(people), 3)
124
+ self.assertEqual(len(s.calls), 1) # fewer than 25, so no page 2
125
+ self.assertEqual(s.calls[0]["url"], "https://www.whitepages.com/name/Jane-Doe/Seattle-WA")
126
+ self.assertEqual((s.calls[0]["super"], s.calls[0]["geoCode"]), ("true", "us"))
127
+ self.assertEqual(wp.credits_used, 10)
128
+
129
+ def test_challenge_retried(self):
130
+ s = Session([R(200, CHALLENGE), R(200, PROFILE)])
131
+ with mock.patch("whitepages_scraper.client.time.sleep"):
132
+ p = WhitepagesScraper(token="T", session=s, retries=1).get_profile(PURL)
133
+ self.assertEqual(p.name, "Jane Doe")
134
+ self.assertEqual(len(s.calls), 2)
135
+
136
+ def test_client_error_not_retried(self):
137
+ s = Session([R(401), R(200, PROFILE)])
138
+ with self.assertRaises(ScrapeError) as ctx:
139
+ WhitepagesScraper(token="T", session=s, retries=2).get_profile(PURL)
140
+ self.assertEqual(ctx.exception.status_code, 401)
141
+ self.assertEqual(len(s.calls), 1)
142
+
143
+ def test_rejects_non_profile(self):
144
+ with self.assertRaises(ValueError):
145
+ WhitepagesScraper(token="T", session=Session([])).get_profile("https://example.com/x")
146
+
147
+
148
+ class CliTests(unittest.TestCase):
149
+ def test_search_csv(self):
150
+ out = io.StringIO()
151
+ with mock.patch("whitepages_scraper.client.requests.Session", return_value=Session([R(200, SEARCH)])), \
152
+ mock.patch.object(sys, "stdout", out), mock.patch.object(sys, "stderr", io.StringIO()):
153
+ code = cli.main(["-n", "Jane Doe", "--city", "Seattle", "-s", "WA", "-t", "T", "-f", "csv"])
154
+ self.assertEqual(code, 0)
155
+ rows = list(csv.DictReader(io.StringIO(out.getvalue())))
156
+ self.assertEqual([r["age_range"] for r in rows], ["40s", "50s", "80+"])
157
+
158
+ def test_nothing_to_do(self):
159
+ with mock.patch.object(sys, "stderr", io.StringIO()):
160
+ self.assertEqual(cli.main(["-t", "T"]), 2)
161
+
162
+
163
+ if __name__ == "__main__":
164
+ unittest.main()