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.
- whitepages_scraper-0.1.0/LICENSE +21 -0
- whitepages_scraper-0.1.0/MANIFEST.in +3 -0
- whitepages_scraper-0.1.0/PKG-INFO +115 -0
- whitepages_scraper-0.1.0/README.md +92 -0
- whitepages_scraper-0.1.0/examples/search_to_csv.py +25 -0
- whitepages_scraper-0.1.0/pyproject.toml +34 -0
- whitepages_scraper-0.1.0/setup.cfg +4 -0
- whitepages_scraper-0.1.0/src/whitepages_scraper/__init__.py +18 -0
- whitepages_scraper-0.1.0/src/whitepages_scraper/cli.py +100 -0
- whitepages_scraper-0.1.0/src/whitepages_scraper/client.py +110 -0
- whitepages_scraper-0.1.0/src/whitepages_scraper/parser.py +274 -0
- whitepages_scraper-0.1.0/src/whitepages_scraper.egg-info/PKG-INFO +115 -0
- whitepages_scraper-0.1.0/src/whitepages_scraper.egg-info/SOURCES.txt +19 -0
- whitepages_scraper-0.1.0/src/whitepages_scraper.egg-info/dependency_links.txt +1 -0
- whitepages_scraper-0.1.0/src/whitepages_scraper.egg-info/entry_points.txt +2 -0
- whitepages_scraper-0.1.0/src/whitepages_scraper.egg-info/requires.txt +2 -0
- whitepages_scraper-0.1.0/src/whitepages_scraper.egg-info/top_level.txt +1 -0
- whitepages_scraper-0.1.0/tests/fixtures/challenge.html +1 -0
- whitepages_scraper-0.1.0/tests/fixtures/profile_jane_doe.html +3 -0
- whitepages_scraper-0.1.0/tests/fixtures/search_jane_doe.html +1 -0
- whitepages_scraper-0.1.0/tests/test_parser.py +164 -0
|
@@ -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,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
|
+
[](https://pypi.org/project/whitepages-scraper/)
|
|
27
|
+
[](https://pypi.org/project/whitepages-scraper/)
|
|
28
|
+
[](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
|
+
[](https://pypi.org/project/whitepages-scraper/)
|
|
4
|
+
[](https://pypi.org/project/whitepages-scraper/)
|
|
5
|
+
[](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,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
|
+
[](https://pypi.org/project/whitepages-scraper/)
|
|
27
|
+
[](https://pypi.org/project/whitepages-scraper/)
|
|
28
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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()
|