Skip to content

Commit 4fc4b93

Browse files
committed
Houzz professionals scraper in Python
0 parents  commit 4fc4b93

12 files changed

Lines changed: 551 additions & 0 deletions

File tree

‎.env.example‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Your Apify API token: https://console.apify.com/settings/integrations
2+
APIFY_TOKEN=

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
test:
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
python-version: ["3.11", "3.13"]
16+
steps:
17+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
19+
with:
20+
python-version: ${{ matrix.python-version }}
21+
- run: pip install -r requirements-dev.txt
22+
- run: ruff check .
23+
- run: ruff format --check .
24+
- run: pytest -q

‎.gitignore‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.env
2+
results.json
3+
results.csv
4+
__pycache__/
5+
.venv/
6+
.pytest_cache/
7+
.ruff_cache/

‎LICENSE‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Devil Scrapes
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.

‎README.md‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
<p align="center"><a href="https://apify.com/DevilScrapes/houzz-professionals-scraper"><img src="assets/banner.svg" alt="Houzz Professionals Scraper — Python example" width="100%"></a></p>
2+
3+
# Houzz Professionals Scraper in Python: contractor and designer leads for pandas
4+
5+
<p align="center">
6+
<a href="https://apify.com/DevilScrapes/houzz-professionals-scraper"><img alt="Run on Apify" src="https://img.shields.io/badge/Run%20on-Apify-DC2626?logo=apify&logoColor=white"></a>
7+
<img alt="Python" src="https://img.shields.io/badge/Python-3.11%2B-3776AB?logo=python&logoColor=white">
8+
<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-0A0606"></a>
9+
<a href="https://github.com/devilscrapes/houzz-professionals-scraper-python/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/devilscrapes/houzz-professionals-scraper-python/actions/workflows/ci.yml/badge.svg"></a>
10+
</p>
11+
12+
This repository is a compact, tested Python wrapper around the **[Houzz Professionals Scraper](https://apify.com/DevilScrapes/houzz-professionals-scraper)** on Apify. Choose one trade, for instance `general-contractor` or `interior-designer`, add a `City, ST` filter if you only care about one market, and the script hands back the matching Houzz pros as a table: business name, trade, city and state, phone, star rating, review count, and, from each pro's own page, the license number, website and number of portfolio photos. The crawl happens on Apify's servers, so your laptop never requests a Houzz page. What you keep locally is `results.json` and `results.csv`, shaped for a DataFrame, a notebook or the CRM import you run on Mondays.
13+
14+
> **Actor:** [Houzz Professionals Scraper](https://apify.com/DevilScrapes/houzz-professionals-scraper) · **Publisher:** [Devil Scrapes](https://apify.com/DevilScrapes) · **Also in JavaScript:** [houzz-professionals-scraper-javascript](https://github.com/devilscrapes/houzz-professionals-scraper-javascript)
15+
16+
## 🔥 What you get
17+
18+
- **Twenty trades selectable by slug**: general contractors, architects, interior designers, roofers, HVAC, plumbing, solar installers and more, each mapped to the directory page Houzz itself uses for that trade.
19+
- **A city filter you can reason about**: `location` keeps only rows whose city, and optionally state, equal what you typed, ignoring case. When the page cap arrives before enough matches, the run's status message reports the shortfall in plain numbers.
20+
- **Optional second pass per pro**: with `fetchProfileDetails` on, every kept professional's own Houzz page is opened to add `license_number`, `website` and `project_or_photo_count`; switch it off and those three stay `null` at a lower price.
21+
- **One row per Houzz ID**: the numeric `houzz_professional_id` deduplicates pros that show up on more than one listing page, so a contractor is never billed twice in one run.
22+
- **Blank means blank**: a badge, rating or license number that Houzz does not show stays `null`. `average_rating` is empty for pros with zero reviews instead of a misleading 0.
23+
- **Loud failure over quiet emptiness**: if every listing page fails, or pages load but contain no professionals at all, the run ends as failed instead of reporting success with nothing in it. Throttled or flaky pages are retried a few times with growing pauses first.
24+
25+
## 🎯 Use cases
26+
27+
### Score a trade before a sales team calls it
28+
29+
Pull one category, then let pandas compute what share of pros list a license, how many reviews the median firm has and who carries a phone number. Reps get a call sheet sorted by evidence, not by the order Houzz happened to display.
30+
31+
### Compare markets for a franchise or acquisition thesis
32+
33+
Run the same trade with different `location` values and stack the CSVs. Counts of pros, rating spread and portfolio depth per city give an analyst a first read on where a roll-up or a new territory has room.
34+
35+
### Watch a category change month to month
36+
37+
A cron entry or a scheduled notebook can repeat the run each month and append the rows with their `scraped_at` stamp. Review counts that climb, new IDs that appear and licenses that get added all become a short `merge` away.
38+
39+
### Build a sample for home-services research
40+
41+
Survey designers, insurers and materials suppliers often need a list of real firms in one trade to sample from. Houzz IDs make the draw reproducible, and the profile link lets a researcher check any row by hand.
42+
43+
## ⚡ Quick start
44+
45+
You need an Apify account and an API token from [Console → Settings → Integrations](https://console.apify.com/settings/integrations). Apify's free plan includes monthly usage credit; this Actor's event prices are listed under Pricing below.
46+
47+
```bash
48+
git clone https://github.com/devilscrapes/houzz-professionals-scraper-python.git
49+
cd houzz-professionals-scraper-python
50+
python -m venv .venv && source .venv/bin/activate
51+
pip install -r requirements.txt
52+
export APIFY_TOKEN=your_token_here
53+
python main.py # uses input.json
54+
python main.py my-input.json --out data --max-charge 2
55+
```
56+
57+
The script starts the [Houzz Professionals Scraper](https://apify.com/DevilScrapes/houzz-professionals-scraper), waits for it to finish, prints a link to the run in Apify Console and saves every result to `results.json` and `results.csv`. It exits with code 1 and the run's status message if the run fails.
58+
59+
## 🧩 The core call
60+
61+
Stripped to the minimum, the integration is the snippet below. `apify-client` launches the Actor, waits for the finished status, and yields the dataset rows one at a time.
62+
63+
```python
64+
from apify_client import ApifyClient
65+
66+
client = ApifyClient("YOUR_APIFY_TOKEN")
67+
run = client.actor("DevilScrapes/houzz-professionals-scraper").call(run_input={'category': 'general-contractor', 'maxResults': 5, 'maxPages': 1, 'fetchProfileDetails': True})
68+
for item in client.dataset(run.default_dataset_id).iterate_items():
69+
print(item)
70+
```
71+
72+
## 📥 Input
73+
74+
`main.py` forwards `input.json` exactly as written. The bundled example asks for general contractors nationwide, reads a single listing page and stops at five profiles, so a first test costs a few cents. Houzz paginates its trade directory nationwide rather than by city, which is why a city filter can need more pages than a bare category run; raise `maxPages` when a smaller town comes back short.
75+
76+
```json
77+
{
78+
"category": "general-contractor",
79+
"maxResults": 5,
80+
"maxPages": 1,
81+
"fetchProfileDetails": true
82+
}
83+
```
84+
85+
| Field | Type | Required | Default | Allowed values |
86+
|---|---|---|---|---|
87+
| `category` | string | yes | `"general-contractor"` | `architect`, `carpenter`, `custom-closet-designers`, `design-build`, `electrical-contractors`, `general-contractor`, `handyman`, `home-builders`, `hvac-contractors`, `interior-designer`, `kitchen-and-bath-remodelers`, `kitchen-remodelers`, `landscape-architect`, `landscape-contractors`, `painters`, `plumbing-contractors`, `pools-and-spas`, `roofing-and-gutter`, `solar-energy-contractors`, `tile-and-stone-contractors` |
88+
| `location` | string | no | — | — |
89+
| `maxResults` | integer | no | `200` | — |
90+
| `maxPages` | integer | no | `20` | — |
91+
| `fetchProfileDetails` | boolean | no | `true` | — |
92+
| `proxyConfiguration` | object | no | `{"useApifyProxy": false}` | — |
93+
94+
## 📤 Output
95+
96+
Each item is a single professional. Fields that depend on the profile pass are `null` when `fetchProfileDetails` is off or that pro's page could not be read; the listing fields are still there. Phone numbers keep Houzz's own formatting, and ratings run from 0 to 5.
97+
98+
| Field | Type | Always present |
99+
|---|---|---|
100+
| `houzz_professional_id` | integer | yes |
101+
| `name` | string | yes |
102+
| `category` | string | yes |
103+
| `city` | string or null | no |
104+
| `region` | string or null | no |
105+
| `phone` | string or null | no |
106+
| `website` | string or null | no |
107+
| `review_count` | integer | yes |
108+
| `average_rating` | number or null | no |
109+
| `project_or_photo_count` | integer or null | no |
110+
| `years_in_business` | integer or null | no |
111+
| `license_number` | string or null | no |
112+
| `houzz_profile_url` | string | yes |
113+
| `scraped_at` | string | yes |
114+
115+
## 💰 Pricing
116+
117+
| Event | Price (USD) |
118+
|---|---|
119+
| Actor start | $0.02 |
120+
| Result emitted | $0.002 |
121+
| Profile page scraped | $0.0005 |
122+
123+
Prices as configured on 2026-08-06. The [Actor page](https://apify.com/DevilScrapes/houzz-professionals-scraper) always shows the current price. Runs are billed to the Apify account that owns the token. Use `--max-charge` to cap what a single run can cost.
124+
125+
## 🛠️ Working with the data in Python
126+
127+
The pandas snippet below (`pip install pandas`) does two jobs: it summarises license coverage and review depth per state, then writes `call_list.csv` with only the pros that list a phone number, licensed firms first.
128+
129+
```python
130+
import json
131+
132+
import pandas as pd
133+
134+
leads = pd.DataFrame(json.load(open("results.json", encoding="utf-8")))
135+
leads["licensed"] = leads["license_number"].notna()
136+
leads["rating"] = leads["average_rating"].fillna(0)
137+
138+
coverage = leads.groupby("region").agg(
139+
pros=("houzz_professional_id", "nunique"),
140+
licensed_share=("licensed", "mean"),
141+
median_reviews=("review_count", "median"),
142+
)
143+
print(coverage.sort_values("pros", ascending=False).round(2).to_string())
144+
145+
callable_leads = leads[leads["phone"].notna()].sort_values(
146+
["licensed", "review_count", "rating"], ascending=False
147+
)
148+
keep = ["name", "city", "region", "phone", "website", "license_number", "review_count", "rating", "houzz_profile_url"]
149+
callable_leads[keep].to_csv("call_list.csv", index=False)
150+
print(f"{len(callable_leads)} leads with a phone number written to call_list.csv")
151+
```
152+
153+
## ❓ FAQ
154+
155+
### Do I need a Houzz account or a Houzz Pro subscription?
156+
157+
No. The Actor reads the directory pages anyone can open in a browser. All you supply is an Apify API token; nothing about your Houzz account, if you have one, is involved.
158+
159+
### Why are there no email addresses?
160+
161+
Houzz does not publish them on directory or profile pages, so the dataset has no email column rather than a guessed one. Pair the `website` field with your own enrichment tool when you need inboxes.
162+
163+
### Can I collect a trade that is not in the category list?
164+
165+
Not yet. Houzz has roughly 78 trades and the input currently accepts the 20 listed in the schema. Any other value fails input validation before a single page is requested.
166+
167+
### Does it cover Houzz sites outside the United States?
168+
169+
No. The Actor reads the US directory on houzz.com only. Regional sites such as the UK or Australian Houzz are out of scope for this version.
170+
171+
## 🧯 Troubleshooting
172+
173+
- **`Set APIFY_TOKEN first`**: export the token in the same shell, or put it in a local `.env` file your shell loads. Never commit it.
174+
- **Run finished with `FAILED` or `ABORTED`**: open the printed Console link; the run log says why. Input validation errors name the field.
175+
- **Fewer rows than expected**: a `--max-charge` cap also limits how many results can be charged. Raise the cap or lower the limits in your input.
176+
- **Something looks wrong in the data**: open an issue on the [Actor page](https://apify.com/DevilScrapes/houzz-professionals-scraper) so the maintainers see it.
177+
178+
## 🔗 More from Devil Scrapes
179+
180+
- [Houzz Professionals Scraper on Apify Store](https://apify.com/DevilScrapes/houzz-professionals-scraper)
181+
- [JavaScript version of this example](https://github.com/devilscrapes/houzz-professionals-scraper-javascript)
182+
- [All Devil Scrapes Actors](https://apify.com/DevilScrapes)
183+
- [Apify web scraping cookbook](https://github.com/devilscrapes/apify-web-scraping-cookbook)
184+
185+
Related Actors:
186+
187+
- [Professional License Lookup Scraper — DE + CO Roster Export](https://apify.com/DevilScrapes/professional-license-lookup-scraper) — cross-check a contractor's license against a state roster.
188+
- [US Building Permit Leads Scraper (Socrata)](https://apify.com/DevilScrapes/us-building-permit-leads-scraper) — find who is pulling permits for new jobs in a city right now.
189+
- [New Business Filings — US State Registry Leads Feed](https://apify.com/DevilScrapes/new-business-filings-leads-scraper) — catch trade businesses the week they register with a state.
190+
191+
---
192+
193+
MIT licensed example code. The Actor itself runs on [Apify](https://apify.com/DevilScrapes/houzz-professionals-scraper) and is maintained by [Devil Scrapes](https://apify.com/DevilScrapes). Not affiliated with Houzz.

‎assets/banner.svg‎

Lines changed: 39 additions & 0 deletions
Loading

‎input.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"category": "general-contractor",
3+
"maxResults": 5,
4+
"maxPages": 1,
5+
"fetchProfileDetails": true
6+
}

‎main.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Run an Apify Actor and save its results as JSON and CSV.
2+
3+
Actor: Houzz Professionals Scraper
4+
5+
Actor page: https://apify.com/DevilScrapes/houzz-professionals-scraper
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import json
12+
import os
13+
import sys
14+
from decimal import Decimal
15+
from pathlib import Path
16+
17+
from apify_client import ApifyClient
18+
19+
ACTOR_ID = "DevilScrapes/houzz-professionals-scraper"
20+
ACTOR_TITLE = "Houzz Professionals Scraper"
21+
TERMINAL_OK = "SUCCEEDED"
22+
23+
24+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
25+
parser = argparse.ArgumentParser(description=f"Run the {ACTOR_TITLE} on Apify.")
26+
parser.add_argument("input", nargs="?", default="input.json", help="Actor input JSON file (default: input.json)")
27+
parser.add_argument("--out", default=".", help="Folder for results.json and results.csv (default: here)")
28+
parser.add_argument("--max-charge", type=Decimal, help="Stop the run once it has cost this many USD")
29+
parser.add_argument("--memory", type=int, help="Run memory in MB (default: the Actor's own setting)")
30+
return parser.parse_args(argv)
31+
32+
33+
def load_input(path: str) -> dict:
34+
try:
35+
data = json.loads(Path(path).read_text(encoding="utf-8"))
36+
except FileNotFoundError:
37+
sys.exit(f"Input file not found: {path}")
38+
except json.JSONDecodeError as error:
39+
sys.exit(f"Input file {path} is not valid JSON: {error}")
40+
if not isinstance(data, dict):
41+
sys.exit(f"Input file {path} must contain a JSON object")
42+
return data
43+
44+
45+
def main(argv: list[str] | None = None) -> int:
46+
args = parse_args(argv)
47+
token = os.environ.get("APIFY_TOKEN")
48+
if not token:
49+
sys.exit("Set APIFY_TOKEN first. Get a token at https://console.apify.com/settings/integrations")
50+
51+
run_input = load_input(args.input)
52+
client = ApifyClient(token, api_url=os.environ.get("APIFY_API_URL", "https://api.apify.com"))
53+
54+
print(f"Starting {ACTOR_ID} ...")
55+
run = client.actor(ACTOR_ID).call(
56+
run_input=run_input,
57+
max_total_charge_usd=args.max_charge,
58+
memory_mbytes=args.memory,
59+
)
60+
if run is None:
61+
sys.exit("The run could not be started.")
62+
63+
print(f"Run {run.id} finished: {run.status}")
64+
print(f"Details: https://console.apify.com/actors/runs/{run.id}")
65+
if run.status != TERMINAL_OK:
66+
print(run.status_message or "No status message.", file=sys.stderr)
67+
return 1
68+
69+
dataset = client.dataset(run.default_dataset_id)
70+
out = Path(args.out)
71+
out.mkdir(parents=True, exist_ok=True)
72+
items = json.loads(dataset.get_items_as_bytes(item_format="json", clean=True))
73+
(out / "results.json").write_text(json.dumps(items, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
74+
(out / "results.csv").write_bytes(dataset.get_items_as_bytes(item_format="csv", clean=True))
75+
76+
print(f"Saved {len(items)} items to {out / 'results.json'} and {out / 'results.csv'}")
77+
return 0
78+
79+
80+
if __name__ == "__main__":
81+
sys.exit(main())

0 commit comments

Comments
 (0)