domain-com-au-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.
- domain_com_au_scraper-0.1.0/LICENSE +21 -0
- domain_com_au_scraper-0.1.0/MANIFEST.in +3 -0
- domain_com_au_scraper-0.1.0/PKG-INFO +94 -0
- domain_com_au_scraper-0.1.0/README.md +72 -0
- domain_com_au_scraper-0.1.0/examples/sold_prices.py +21 -0
- domain_com_au_scraper-0.1.0/pyproject.toml +33 -0
- domain_com_au_scraper-0.1.0/setup.cfg +4 -0
- domain_com_au_scraper-0.1.0/src/domain_com_au_scraper.egg-info/PKG-INFO +94 -0
- domain_com_au_scraper-0.1.0/src/domain_com_au_scraper.egg-info/SOURCES.txt +19 -0
- domain_com_au_scraper-0.1.0/src/domain_com_au_scraper.egg-info/dependency_links.txt +1 -0
- domain_com_au_scraper-0.1.0/src/domain_com_au_scraper.egg-info/entry_points.txt +2 -0
- domain_com_au_scraper-0.1.0/src/domain_com_au_scraper.egg-info/requires.txt +2 -0
- domain_com_au_scraper-0.1.0/src/domain_com_au_scraper.egg-info/top_level.txt +1 -0
- domain_com_au_scraper-0.1.0/src/domain_scraper/__init__.py +23 -0
- domain_com_au_scraper-0.1.0/src/domain_scraper/cli.py +80 -0
- domain_com_au_scraper-0.1.0/src/domain_scraper/client.py +106 -0
- domain_com_au_scraper-0.1.0/src/domain_scraper/parser.py +333 -0
- domain_com_au_scraper-0.1.0/tests/fixtures/blocked.html +1 -0
- domain_com_au_scraper-0.1.0/tests/fixtures/listing.html +1 -0
- domain_com_au_scraper-0.1.0/tests/fixtures/search_bondi.html +1 -0
- domain_com_au_scraper-0.1.0/tests/test_domain.py +140 -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,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: domain-com-au-scraper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scrape Domain.com.au property listings for sale, for rent and sold (price, beds, address, agency, suburb medians) with Python.
|
|
5
|
+
Author: Buğrahan Saka
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bugrahansss/domain-com-au-scraper
|
|
8
|
+
Project-URL: Source, https://github.com/bugrahansss/domain-com-au-scraper
|
|
9
|
+
Project-URL: Issues, https://github.com/bugrahansss/domain-com-au-scraper/issues
|
|
10
|
+
Keywords: domain.com.au,domain,real estate,property,australia,web scraping,scraper,scrapedo
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: requests>=2.25
|
|
20
|
+
Requires-Dist: beautifulsoup4>=4.9
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Domain.com.au Scraper
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/domain-com-au-scraper/)
|
|
26
|
+
[](https://pypi.org/project/domain-com-au-scraper/)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
|
|
29
|
+
Scrape [Domain.com.au](https://www.domain.com.au), one of Australia's two big property portals, into structured data. Search any suburb **for sale, for rent or sold** and get **price text and parsed price range, sale status and date, beds, baths, parking, land size, property type, address with coordinates, agency and agents, auction and inspection times**. Open a listing for its **description, features, agents' phone numbers, listing dates and Domain's suburb medians** (median sale and rent price, days on market, auction clearance). JSON or CSV.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install domain-com-au-scraper
|
|
33
|
+
export SCRAPEDO_TOKEN=your_token
|
|
34
|
+
domain-scraper "Bondi Beach NSW 2026" --mode sold --pages 3 -f csv -o bondi_sold.csv
|
|
35
|
+
domain-scraper "Sydney NSW 2000" --details --limit 20 -o sydney.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Why plain requests fail
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import requests
|
|
42
|
+
|
|
43
|
+
r = requests.get("https://www.domain.com.au/sale/sydney-nsw-2000/")
|
|
44
|
+
print(r.status_code) # 403
|
|
45
|
+
print(r.text[:60]) # <HTML><HEAD><TITLE>Access Denied</TITLE>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Domain sits behind Akamai, which answers a script with `403 Access Denied` before any HTML. This package sends each request through [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=domain-com-au-scraper) with `super=true` (residential proxies), which returns the real page. Each page costs 10 credits.
|
|
49
|
+
|
|
50
|
+
## Where the data is
|
|
51
|
+
|
|
52
|
+
Domain is a Next.js site, so every page carries its data in the `__NEXT_DATA__` script. No HTML parsing:
|
|
53
|
+
|
|
54
|
+
* **Search pages** (`/sale/<suburb>-<state>-<postcode>/`, `/rent/...`, `/sold-listings/...`, `?page=N`): `props.pageProps.componentProps.listingsMap` holds about 20 listings per page, plus `totalListings` and `totalPages`. New-development "projects" sit in the same map and are skipped. Domain stops at page 50.
|
|
55
|
+
* **Listing pages** (`/<address-slug>-<listingId>`): the same object holds the description, structured features, the price guide with agents, `createdOn`/`modifiedOn`, the gallery and `suburbInsights`.
|
|
56
|
+
|
|
57
|
+
In a live test: Sydney NSW 2000 had 934 listings for sale over 46 pages; Bondi Beach had 16,626 sold listings, 20 per page, each with its sale status and date ("Sold at auction 10 Nov 2026"). Many prices are text ("Price Withheld", "Contact Agent"); `price_min`/`price_max` are filled only when the text has a number.
|
|
58
|
+
|
|
59
|
+
## Python
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from domain_scraper import DomainScraper
|
|
63
|
+
|
|
64
|
+
dm = DomainScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
|
|
65
|
+
|
|
66
|
+
for l in dm.search("Bondi Beach NSW 2026", mode="sold", pages=2):
|
|
67
|
+
print(l.status, l.price_text, l.beds, l.address, l.agency)
|
|
68
|
+
|
|
69
|
+
detail = dm.get_listing("https://www.domain.com.au/<address-slug>-<listingId>")
|
|
70
|
+
print(detail.description[:200], detail.features, detail.suburb_median_price)
|
|
71
|
+
print(dm.credits_used)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Fields
|
|
75
|
+
|
|
76
|
+
**Search result:** `listing_id`, `url`, `price_text`, `price_min`, `price_max`, `status`, `property_type`, `beds`, `baths`, `parking`, `land_size`, `land_unit`, `street`, `suburb`, `state`, `postcode`, `latitude`, `longitude`, `agency`, `agency_id`, `agents`, `auction`, `inspection_open`, `inspection_close`, `images`, `promo_type`, `address`.
|
|
77
|
+
|
|
78
|
+
**Listing page:** `listing_id`, `url`, `title` (price text), `headline`, `listing_type`, `status`, `method`, `property_type`, `beds`, `baths`, `parking`, `land_area`, `address`, `unit_number`, `street_number`, `street`, `suburb`, `state`, `postcode`, `latitude`, `longitude`, `description`, `features`, `agency`, `agents` (name, phone, mobile, profile URL), `inspection`, `created_on`, `modified_on`, `images`, `suburb_median_price`, `suburb_median_rent`, `suburb_days_on_market`, `suburb_auction_clearance`, `suburb_sold_this_year`.
|
|
79
|
+
|
|
80
|
+
## Use it responsibly
|
|
81
|
+
|
|
82
|
+
Listings are public, but agents are real people: use their contact details only for legitimate purposes, respect the Australian Privacy Act and Spam Act, and Domain's terms. Don't republish listing photos or descriptions. This is not legal advice.
|
|
83
|
+
|
|
84
|
+
## Tests
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The fixtures are fictional ("Example Realty", "Jane Doe", example.com).
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Domain.com.au Scraper
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/domain-com-au-scraper/)
|
|
4
|
+
[](https://pypi.org/project/domain-com-au-scraper/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
Scrape [Domain.com.au](https://www.domain.com.au), one of Australia's two big property portals, into structured data. Search any suburb **for sale, for rent or sold** and get **price text and parsed price range, sale status and date, beds, baths, parking, land size, property type, address with coordinates, agency and agents, auction and inspection times**. Open a listing for its **description, features, agents' phone numbers, listing dates and Domain's suburb medians** (median sale and rent price, days on market, auction clearance). JSON or CSV.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install domain-com-au-scraper
|
|
11
|
+
export SCRAPEDO_TOKEN=your_token
|
|
12
|
+
domain-scraper "Bondi Beach NSW 2026" --mode sold --pages 3 -f csv -o bondi_sold.csv
|
|
13
|
+
domain-scraper "Sydney NSW 2000" --details --limit 20 -o sydney.json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Why plain requests fail
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
r = requests.get("https://www.domain.com.au/sale/sydney-nsw-2000/")
|
|
22
|
+
print(r.status_code) # 403
|
|
23
|
+
print(r.text[:60]) # <HTML><HEAD><TITLE>Access Denied</TITLE>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Domain sits behind Akamai, which answers a script with `403 Access Denied` before any HTML. This package sends each request through [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=domain-com-au-scraper) with `super=true` (residential proxies), which returns the real page. Each page costs 10 credits.
|
|
27
|
+
|
|
28
|
+
## Where the data is
|
|
29
|
+
|
|
30
|
+
Domain is a Next.js site, so every page carries its data in the `__NEXT_DATA__` script. No HTML parsing:
|
|
31
|
+
|
|
32
|
+
* **Search pages** (`/sale/<suburb>-<state>-<postcode>/`, `/rent/...`, `/sold-listings/...`, `?page=N`): `props.pageProps.componentProps.listingsMap` holds about 20 listings per page, plus `totalListings` and `totalPages`. New-development "projects" sit in the same map and are skipped. Domain stops at page 50.
|
|
33
|
+
* **Listing pages** (`/<address-slug>-<listingId>`): the same object holds the description, structured features, the price guide with agents, `createdOn`/`modifiedOn`, the gallery and `suburbInsights`.
|
|
34
|
+
|
|
35
|
+
In a live test: Sydney NSW 2000 had 934 listings for sale over 46 pages; Bondi Beach had 16,626 sold listings, 20 per page, each with its sale status and date ("Sold at auction 10 Nov 2026"). Many prices are text ("Price Withheld", "Contact Agent"); `price_min`/`price_max` are filled only when the text has a number.
|
|
36
|
+
|
|
37
|
+
## Python
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from domain_scraper import DomainScraper
|
|
41
|
+
|
|
42
|
+
dm = DomainScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
|
|
43
|
+
|
|
44
|
+
for l in dm.search("Bondi Beach NSW 2026", mode="sold", pages=2):
|
|
45
|
+
print(l.status, l.price_text, l.beds, l.address, l.agency)
|
|
46
|
+
|
|
47
|
+
detail = dm.get_listing("https://www.domain.com.au/<address-slug>-<listingId>")
|
|
48
|
+
print(detail.description[:200], detail.features, detail.suburb_median_price)
|
|
49
|
+
print(dm.credits_used)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Fields
|
|
53
|
+
|
|
54
|
+
**Search result:** `listing_id`, `url`, `price_text`, `price_min`, `price_max`, `status`, `property_type`, `beds`, `baths`, `parking`, `land_size`, `land_unit`, `street`, `suburb`, `state`, `postcode`, `latitude`, `longitude`, `agency`, `agency_id`, `agents`, `auction`, `inspection_open`, `inspection_close`, `images`, `promo_type`, `address`.
|
|
55
|
+
|
|
56
|
+
**Listing page:** `listing_id`, `url`, `title` (price text), `headline`, `listing_type`, `status`, `method`, `property_type`, `beds`, `baths`, `parking`, `land_area`, `address`, `unit_number`, `street_number`, `street`, `suburb`, `state`, `postcode`, `latitude`, `longitude`, `description`, `features`, `agency`, `agents` (name, phone, mobile, profile URL), `inspection`, `created_on`, `modified_on`, `images`, `suburb_median_price`, `suburb_median_rent`, `suburb_days_on_market`, `suburb_auction_clearance`, `suburb_sold_this_year`.
|
|
57
|
+
|
|
58
|
+
## Use it responsibly
|
|
59
|
+
|
|
60
|
+
Listings are public, but agents are real people: use their contact details only for legitimate purposes, respect the Australian Privacy Act and Spam Act, and Domain's terms. Don't republish listing photos or descriptions. This is not legal advice.
|
|
61
|
+
|
|
62
|
+
## Tests
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The fixtures are fictional ("Example Realty", "Jane Doe", example.com).
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Recent sales in a suburb to CSV: price, sale type and date, beds, address.
|
|
2
|
+
|
|
3
|
+
export SCRAPEDO_TOKEN=your_token
|
|
4
|
+
python examples/sold_prices.py "Bondi Beach NSW 2026" 3
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import csv
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from domain_scraper import DomainScraper
|
|
11
|
+
|
|
12
|
+
suburb = sys.argv[1] if len(sys.argv) > 1 else "Bondi Beach NSW 2026"
|
|
13
|
+
pages = int(sys.argv[2]) if len(sys.argv) > 2 else 2
|
|
14
|
+
|
|
15
|
+
dm = DomainScraper()
|
|
16
|
+
with open("sold.csv", "w", newline="", encoding="utf-8") as f:
|
|
17
|
+
writer = csv.writer(f)
|
|
18
|
+
writer.writerow(["status", "price_text", "price_min", "beds", "baths", "property_type", "address", "agency", "url"])
|
|
19
|
+
for l in dm.search(suburb, mode="sold", pages=pages):
|
|
20
|
+
writer.writerow([l.status, l.price_text, l.price_min, l.beds, l.baths, l.property_type, l.address, l.agency, l.url])
|
|
21
|
+
print(f"done, {dm.credits_used} credits")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "domain-com-au-scraper"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Scrape Domain.com.au property listings for sale, for rent and sold (price, beds, address, agency, suburb medians) 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 = ["domain.com.au", "domain", "real estate", "property", "australia", "web scraping", "scraper", "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/domain-com-au-scraper"
|
|
26
|
+
Source = "https://github.com/bugrahansss/domain-com-au-scraper"
|
|
27
|
+
Issues = "https://github.com/bugrahansss/domain-com-au-scraper/issues"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
domain-scraper = "domain_scraper.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: domain-com-au-scraper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scrape Domain.com.au property listings for sale, for rent and sold (price, beds, address, agency, suburb medians) with Python.
|
|
5
|
+
Author: Buğrahan Saka
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/bugrahansss/domain-com-au-scraper
|
|
8
|
+
Project-URL: Source, https://github.com/bugrahansss/domain-com-au-scraper
|
|
9
|
+
Project-URL: Issues, https://github.com/bugrahansss/domain-com-au-scraper/issues
|
|
10
|
+
Keywords: domain.com.au,domain,real estate,property,australia,web scraping,scraper,scrapedo
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: requests>=2.25
|
|
20
|
+
Requires-Dist: beautifulsoup4>=4.9
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# Domain.com.au Scraper
|
|
24
|
+
|
|
25
|
+
[](https://pypi.org/project/domain-com-au-scraper/)
|
|
26
|
+
[](https://pypi.org/project/domain-com-au-scraper/)
|
|
27
|
+
[](LICENSE)
|
|
28
|
+
|
|
29
|
+
Scrape [Domain.com.au](https://www.domain.com.au), one of Australia's two big property portals, into structured data. Search any suburb **for sale, for rent or sold** and get **price text and parsed price range, sale status and date, beds, baths, parking, land size, property type, address with coordinates, agency and agents, auction and inspection times**. Open a listing for its **description, features, agents' phone numbers, listing dates and Domain's suburb medians** (median sale and rent price, days on market, auction clearance). JSON or CSV.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install domain-com-au-scraper
|
|
33
|
+
export SCRAPEDO_TOKEN=your_token
|
|
34
|
+
domain-scraper "Bondi Beach NSW 2026" --mode sold --pages 3 -f csv -o bondi_sold.csv
|
|
35
|
+
domain-scraper "Sydney NSW 2000" --details --limit 20 -o sydney.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Why plain requests fail
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import requests
|
|
42
|
+
|
|
43
|
+
r = requests.get("https://www.domain.com.au/sale/sydney-nsw-2000/")
|
|
44
|
+
print(r.status_code) # 403
|
|
45
|
+
print(r.text[:60]) # <HTML><HEAD><TITLE>Access Denied</TITLE>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Domain sits behind Akamai, which answers a script with `403 Access Denied` before any HTML. This package sends each request through [Scrape.do](https://scrape.do/?utm_source=github&utm_medium=readme&utm_campaign=domain-com-au-scraper) with `super=true` (residential proxies), which returns the real page. Each page costs 10 credits.
|
|
49
|
+
|
|
50
|
+
## Where the data is
|
|
51
|
+
|
|
52
|
+
Domain is a Next.js site, so every page carries its data in the `__NEXT_DATA__` script. No HTML parsing:
|
|
53
|
+
|
|
54
|
+
* **Search pages** (`/sale/<suburb>-<state>-<postcode>/`, `/rent/...`, `/sold-listings/...`, `?page=N`): `props.pageProps.componentProps.listingsMap` holds about 20 listings per page, plus `totalListings` and `totalPages`. New-development "projects" sit in the same map and are skipped. Domain stops at page 50.
|
|
55
|
+
* **Listing pages** (`/<address-slug>-<listingId>`): the same object holds the description, structured features, the price guide with agents, `createdOn`/`modifiedOn`, the gallery and `suburbInsights`.
|
|
56
|
+
|
|
57
|
+
In a live test: Sydney NSW 2000 had 934 listings for sale over 46 pages; Bondi Beach had 16,626 sold listings, 20 per page, each with its sale status and date ("Sold at auction 10 Nov 2026"). Many prices are text ("Price Withheld", "Contact Agent"); `price_min`/`price_max` are filled only when the text has a number.
|
|
58
|
+
|
|
59
|
+
## Python
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from domain_scraper import DomainScraper
|
|
63
|
+
|
|
64
|
+
dm = DomainScraper(token="YOUR_SCRAPEDO_TOKEN") # or set SCRAPEDO_TOKEN
|
|
65
|
+
|
|
66
|
+
for l in dm.search("Bondi Beach NSW 2026", mode="sold", pages=2):
|
|
67
|
+
print(l.status, l.price_text, l.beds, l.address, l.agency)
|
|
68
|
+
|
|
69
|
+
detail = dm.get_listing("https://www.domain.com.au/<address-slug>-<listingId>")
|
|
70
|
+
print(detail.description[:200], detail.features, detail.suburb_median_price)
|
|
71
|
+
print(dm.credits_used)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Fields
|
|
75
|
+
|
|
76
|
+
**Search result:** `listing_id`, `url`, `price_text`, `price_min`, `price_max`, `status`, `property_type`, `beds`, `baths`, `parking`, `land_size`, `land_unit`, `street`, `suburb`, `state`, `postcode`, `latitude`, `longitude`, `agency`, `agency_id`, `agents`, `auction`, `inspection_open`, `inspection_close`, `images`, `promo_type`, `address`.
|
|
77
|
+
|
|
78
|
+
**Listing page:** `listing_id`, `url`, `title` (price text), `headline`, `listing_type`, `status`, `method`, `property_type`, `beds`, `baths`, `parking`, `land_area`, `address`, `unit_number`, `street_number`, `street`, `suburb`, `state`, `postcode`, `latitude`, `longitude`, `description`, `features`, `agency`, `agents` (name, phone, mobile, profile URL), `inspection`, `created_on`, `modified_on`, `images`, `suburb_median_price`, `suburb_median_rent`, `suburb_days_on_market`, `suburb_auction_clearance`, `suburb_sold_this_year`.
|
|
79
|
+
|
|
80
|
+
## Use it responsibly
|
|
81
|
+
|
|
82
|
+
Listings are public, but agents are real people: use their contact details only for legitimate purposes, respect the Australian Privacy Act and Spam Act, and Domain's terms. Don't republish listing photos or descriptions. This is not legal advice.
|
|
83
|
+
|
|
84
|
+
## Tests
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The fixtures are fictional ("Example Realty", "Jane Doe", example.com).
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
pyproject.toml
|
|
5
|
+
examples/sold_prices.py
|
|
6
|
+
src/domain_com_au_scraper.egg-info/PKG-INFO
|
|
7
|
+
src/domain_com_au_scraper.egg-info/SOURCES.txt
|
|
8
|
+
src/domain_com_au_scraper.egg-info/dependency_links.txt
|
|
9
|
+
src/domain_com_au_scraper.egg-info/entry_points.txt
|
|
10
|
+
src/domain_com_au_scraper.egg-info/requires.txt
|
|
11
|
+
src/domain_com_au_scraper.egg-info/top_level.txt
|
|
12
|
+
src/domain_scraper/__init__.py
|
|
13
|
+
src/domain_scraper/cli.py
|
|
14
|
+
src/domain_scraper/client.py
|
|
15
|
+
src/domain_scraper/parser.py
|
|
16
|
+
tests/test_domain.py
|
|
17
|
+
tests/fixtures/blocked.html
|
|
18
|
+
tests/fixtures/listing.html
|
|
19
|
+
tests/fixtures/search_bondi.html
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
domain_scraper
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Scrape Domain.com.au listings (for sale, for rent, sold) with Python."""
|
|
2
|
+
|
|
3
|
+
from .client import DomainScraper, ScrapeError
|
|
4
|
+
from .parser import (
|
|
5
|
+
DETAIL_FIELDS,
|
|
6
|
+
LISTING_FIELDS,
|
|
7
|
+
Agent,
|
|
8
|
+
Listing,
|
|
9
|
+
ListingDetail,
|
|
10
|
+
SearchPage,
|
|
11
|
+
listing_url,
|
|
12
|
+
next_data,
|
|
13
|
+
parse_listing,
|
|
14
|
+
parse_price,
|
|
15
|
+
parse_search,
|
|
16
|
+
search_url,
|
|
17
|
+
suburb_slug,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = ["DETAIL_FIELDS", "LISTING_FIELDS", "Agent", "DomainScraper", "Listing", "ListingDetail", "ScrapeError",
|
|
21
|
+
"SearchPage", "listing_url", "next_data", "parse_listing", "parse_price", "parse_search", "search_url",
|
|
22
|
+
"suburb_slug"]
|
|
23
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
domain-scraper "Bondi Beach NSW 2026" --mode sold --pages 3 -f csv -o bondi_sold.csv
|
|
4
|
+
domain-scraper "Sydney NSW 2000" --details --limit 20 -o sydney.json
|
|
5
|
+
domain-scraper https://www.domain.com.au/2702-91-liverpool-street-sydney-nsw-2000-2020997448
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import csv
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any, List, Optional
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .client import TOKEN_ENV, DomainScraper, ScrapeError
|
|
18
|
+
from .parser import DETAIL_FIELDS, LISTING_FIELDS, is_listing_url
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
p = argparse.ArgumentParser(prog="domain-scraper", description="Scrape Domain.com.au listings: for sale, for rent and sold.")
|
|
23
|
+
p.add_argument("targets", nargs="+", help='Suburbs ("Bondi Beach NSW 2026" or bondi-beach-nsw-2026) or listing URLs')
|
|
24
|
+
p.add_argument("--mode", choices=("sale", "rent", "sold"), default="sale", help="Search type (default: sale)")
|
|
25
|
+
p.add_argument("--pages", type=int, default=1, help="Result pages per suburb, about 20 listings each (max 50)")
|
|
26
|
+
p.add_argument("--details", action="store_true", help="Open every listing page (10 credits each)")
|
|
27
|
+
p.add_argument("--limit", type=int, default=0, help="Stop after this many listings")
|
|
28
|
+
p.add_argument("-t", "--token", help=f"Scrape.do API token (default: ${TOKEN_ENV})")
|
|
29
|
+
p.add_argument("-f", "--format", choices=("json", "csv"), default="json")
|
|
30
|
+
p.add_argument("-o", "--output", help="Write to this file instead of stdout")
|
|
31
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
32
|
+
return p
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
36
|
+
args = build_parser().parse_args(argv)
|
|
37
|
+
try:
|
|
38
|
+
dm = DomainScraper(token=args.token)
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
print(exc, file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
items: List[Any] = []
|
|
43
|
+
failures = 0
|
|
44
|
+
for target in args.targets:
|
|
45
|
+
if args.limit and len(items) >= args.limit:
|
|
46
|
+
break
|
|
47
|
+
try:
|
|
48
|
+
if is_listing_url(target):
|
|
49
|
+
items.append(dm.get_listing(target))
|
|
50
|
+
continue
|
|
51
|
+
found = 0
|
|
52
|
+
for listing in dm.search(target, args.mode, args.pages):
|
|
53
|
+
if args.limit and len(items) >= args.limit:
|
|
54
|
+
break
|
|
55
|
+
items.append(dm.get_listing(listing.url) if args.details else listing)
|
|
56
|
+
found += 1
|
|
57
|
+
print(f"{target}: {found} listings", file=sys.stderr)
|
|
58
|
+
except (ScrapeError, ValueError) as exc:
|
|
59
|
+
failures += 1
|
|
60
|
+
print(f"fail {target}: {exc}", file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
stream = open(args.output, "w", encoding="utf-8", newline="") if args.output else sys.stdout
|
|
63
|
+
try:
|
|
64
|
+
if args.format == "csv":
|
|
65
|
+
fields = DETAIL_FIELDS if items and hasattr(items[0], "headline") else LISTING_FIELDS + ["address"]
|
|
66
|
+
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
|
|
67
|
+
writer.writeheader()
|
|
68
|
+
writer.writerows(i.to_row() for i in items)
|
|
69
|
+
else:
|
|
70
|
+
json.dump([i.to_dict() for i in items], stream, indent=2, ensure_ascii=False)
|
|
71
|
+
stream.write("\n")
|
|
72
|
+
finally:
|
|
73
|
+
if args.output:
|
|
74
|
+
stream.close()
|
|
75
|
+
print(f"Saved {len(items)} items to {args.output} ({dm.credits_used} credits)", file=sys.stderr)
|
|
76
|
+
return 1 if failures and not items else 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Fetch Domain.com.au pages through the Scrape.do API.
|
|
2
|
+
|
|
3
|
+
Domain sits behind Akamai: a plain ``requests.get`` from a script gets
|
|
4
|
+
``403 Access Denied`` before any HTML. Sending the request through Scrape.do
|
|
5
|
+
with ``super=true`` (residential proxies) returns the real page with its
|
|
6
|
+
``__NEXT_DATA__``. Each page costs 10 Scrape.do credits.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import time
|
|
13
|
+
from typing import Iterator, List, Optional
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .parser import (
|
|
18
|
+
ListingDetail,
|
|
19
|
+
Listing,
|
|
20
|
+
listing_url,
|
|
21
|
+
parse_listing,
|
|
22
|
+
parse_search,
|
|
23
|
+
search_url,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
API_URL = "https://api.scrape.do/"
|
|
27
|
+
TOKEN_ENV = "SCRAPEDO_TOKEN"
|
|
28
|
+
MAX_PAGES = 50 # Domain stops at page 50
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ScrapeError(RuntimeError):
|
|
32
|
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DomainScraper:
|
|
38
|
+
"""Search Domain.com.au by suburb (for sale, for rent, sold) and read listing pages.
|
|
39
|
+
|
|
40
|
+
>>> dm = DomainScraper(token="YOUR_SCRAPEDO_TOKEN")
|
|
41
|
+
>>> for listing in dm.search("Bondi Beach NSW 2026", mode="sold", pages=2):
|
|
42
|
+
... print(listing.price_text, listing.status, listing.address)
|
|
43
|
+
>>> detail = dm.get_listing(listing.url)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, token: Optional[str] = None, timeout: float = 90.0, retries: int = 2,
|
|
47
|
+
geo_code: str = "", session: Optional[requests.Session] = None):
|
|
48
|
+
self.token = token or os.environ.get(TOKEN_ENV, "")
|
|
49
|
+
if not self.token:
|
|
50
|
+
raise ValueError(f"A Scrape.do API token is required. Pass token=... or set {TOKEN_ENV}.")
|
|
51
|
+
self.timeout = timeout
|
|
52
|
+
self.retries = max(0, retries)
|
|
53
|
+
self.geo_code = geo_code
|
|
54
|
+
self.session = session or requests.Session()
|
|
55
|
+
self.requests_made = 0
|
|
56
|
+
|
|
57
|
+
def fetch(self, url: str) -> str:
|
|
58
|
+
params = {"token": self.token, "url": url, "super": "true"}
|
|
59
|
+
if self.geo_code:
|
|
60
|
+
params["geoCode"] = self.geo_code
|
|
61
|
+
last: Optional[ScrapeError] = None
|
|
62
|
+
for attempt in range(self.retries + 1):
|
|
63
|
+
try:
|
|
64
|
+
response = self.session.get(API_URL, params=params, timeout=self.timeout)
|
|
65
|
+
except requests.RequestException as exc:
|
|
66
|
+
last = ScrapeError(f"Request failed: {exc}")
|
|
67
|
+
else:
|
|
68
|
+
if response.status_code == 200 and "__NEXT_DATA__" in response.text:
|
|
69
|
+
self.requests_made += 1
|
|
70
|
+
return response.text
|
|
71
|
+
if response.status_code == 200:
|
|
72
|
+
self.requests_made += 1
|
|
73
|
+
last = ScrapeError(f"Blocked page for {url} (no __NEXT_DATA__)", 200)
|
|
74
|
+
else:
|
|
75
|
+
last = ScrapeError(f"Request for {url} failed with status {response.status_code}",
|
|
76
|
+
response.status_code)
|
|
77
|
+
if response.status_code in (400, 401, 404, 410):
|
|
78
|
+
break
|
|
79
|
+
if attempt < self.retries:
|
|
80
|
+
time.sleep(2 * (attempt + 1))
|
|
81
|
+
assert last is not None
|
|
82
|
+
raise last
|
|
83
|
+
|
|
84
|
+
def search(self, location: str, mode: str = "sale", pages: int = 1) -> Iterator[Listing]:
|
|
85
|
+
"""Listings for a suburb (``"Bondi Beach NSW 2026"``), ``mode`` = ``sale``, ``rent`` or ``sold``."""
|
|
86
|
+
seen = set()
|
|
87
|
+
last_page = None
|
|
88
|
+
for page in range(1, min(max(1, pages), MAX_PAGES) + 1):
|
|
89
|
+
if last_page is not None and page > last_page:
|
|
90
|
+
break
|
|
91
|
+
result = parse_search(self.fetch(search_url(location, mode, page)))
|
|
92
|
+
last_page = result.total_pages or page
|
|
93
|
+
fresh = [l for l in result.listings if l.listing_id not in seen]
|
|
94
|
+
if not fresh:
|
|
95
|
+
break
|
|
96
|
+
for listing in fresh:
|
|
97
|
+
seen.add(listing.listing_id)
|
|
98
|
+
yield listing
|
|
99
|
+
|
|
100
|
+
def get_listing(self, url: str) -> ListingDetail:
|
|
101
|
+
url = listing_url(url)
|
|
102
|
+
return parse_listing(self.fetch(url), url=url)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def credits_used(self) -> int:
|
|
106
|
+
return self.requests_made * 10
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Parse Domain.com.au pages.
|
|
2
|
+
|
|
3
|
+
Domain is a Next.js site: every search page and every listing page carries its
|
|
4
|
+
data in the ``__NEXT_DATA__`` script, so there is no HTML to pick apart.
|
|
5
|
+
|
|
6
|
+
* A **search page** (``/sale/<suburb-state-postcode>/``, ``/rent/...``,
|
|
7
|
+
``/sold-listings/...``) has ``componentProps.listingsMap``: about 20 listings
|
|
8
|
+
per page with price text, beds, baths, parking, land size, property type,
|
|
9
|
+
address with latitude/longitude, sale tags ("Sold by private treaty 26 Oct
|
|
10
|
+
2026"), auction and inspection times, agency and agents. ``totalListings``
|
|
11
|
+
and ``totalPages`` give the size of the result (Domain stops at 50 pages).
|
|
12
|
+
Projects (new developments) sit in the same map and are skipped.
|
|
13
|
+
* A **listing page** (``/<address-slug>-<listingId>``) adds the description,
|
|
14
|
+
structured features, the price guide with agents' phone numbers, the dates
|
|
15
|
+
the listing was created and modified, the gallery, and Domain's suburb
|
|
16
|
+
insights (median sale and rent prices, days on market, auction clearance).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import asdict, dataclass, field
|
|
24
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
BASE_URL = "https://www.domain.com.au"
|
|
27
|
+
NEXT_RE = re.compile(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)
|
|
28
|
+
LISTING_URL_RE = re.compile(r"^(?:https?://(?:www\.)?domain\.com\.au)?/([a-z0-9-]+-(\d{6,}))/?(?:[?#].*)?$")
|
|
29
|
+
SLUG_RE = re.compile(r"^[a-z0-9-]+-(?:nsw|vic|qld|wa|sa|tas|act|nt)-\d{4}$")
|
|
30
|
+
MODES = {"sale": "sale", "buy": "sale", "rent": "rent", "sold": "sold-listings", "sold-listings": "sold-listings"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def next_data(html: str) -> Dict[str, Any]:
|
|
34
|
+
"""The ``__NEXT_DATA__`` JSON of a Domain page."""
|
|
35
|
+
match = NEXT_RE.search(html or "")
|
|
36
|
+
if not match:
|
|
37
|
+
raise ValueError("No __NEXT_DATA__ on this page (blocked or not a Domain page)")
|
|
38
|
+
return json.loads(match.group(1))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _props(html: str) -> Dict[str, Any]:
|
|
42
|
+
return next_data(html).get("props", {}).get("pageProps", {}).get("componentProps", {}) or {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def suburb_slug(location: str) -> str:
|
|
46
|
+
"""``Bondi Beach NSW 2026`` or ``bondi-beach-nsw-2026`` -> ``bondi-beach-nsw-2026``."""
|
|
47
|
+
slug = re.sub(r"[^a-z0-9]+", "-", (location or "").strip().lower()).strip("-")
|
|
48
|
+
if not SLUG_RE.match(slug):
|
|
49
|
+
raise ValueError(f"Use a suburb with state and postcode, e.g. 'Bondi Beach NSW 2026' (got {location!r})")
|
|
50
|
+
return slug
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def search_url(location: str, mode: str = "sale", page: int = 1) -> str:
|
|
54
|
+
"""Search URL for a suburb: ``mode`` is ``sale``, ``rent`` or ``sold``."""
|
|
55
|
+
path = MODES.get((mode or "sale").lower())
|
|
56
|
+
if not path:
|
|
57
|
+
raise ValueError("mode must be 'sale', 'rent' or 'sold'")
|
|
58
|
+
url = f"{BASE_URL}/{path}/{suburb_slug(location)}/"
|
|
59
|
+
return url + (f"?page={page}" if page > 1 else "")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def listing_url(value: str) -> str:
|
|
63
|
+
match = LISTING_URL_RE.match((value or "").strip())
|
|
64
|
+
if not match:
|
|
65
|
+
raise ValueError(f"Not a Domain listing URL: {value}")
|
|
66
|
+
return f"{BASE_URL}/{match.group(1)}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_listing_url(value: str) -> bool:
|
|
70
|
+
return bool(LISTING_URL_RE.match((value or "").strip()))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_price(text: str) -> Tuple[Optional[int], Optional[int]]:
|
|
74
|
+
"""Numbers in a price text: ``$675,000 to $725,000`` -> (675000, 725000); ``$1,100,000`` -> (1100000, 1100000)."""
|
|
75
|
+
nums = []
|
|
76
|
+
for raw, suffix in re.findall(r"\$\s?([\d,]+(?:\.\d+)?)\s*([mk])?\b", text or "", re.I):
|
|
77
|
+
try:
|
|
78
|
+
value = float(raw.replace(",", ""))
|
|
79
|
+
except ValueError:
|
|
80
|
+
continue
|
|
81
|
+
value *= {"m": 1_000_000, "k": 1_000}.get(suffix.lower(), 1) if suffix else 1
|
|
82
|
+
nums.append(int(value))
|
|
83
|
+
if not nums:
|
|
84
|
+
return None, None
|
|
85
|
+
return min(nums), max(nums)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Listing:
|
|
90
|
+
"""One listing from a search page (and, after ``get_listing``, its page)."""
|
|
91
|
+
|
|
92
|
+
listing_id: int = 0
|
|
93
|
+
url: str = ""
|
|
94
|
+
price_text: str = ""
|
|
95
|
+
price_min: Optional[int] = None
|
|
96
|
+
price_max: Optional[int] = None
|
|
97
|
+
status: str = ""
|
|
98
|
+
property_type: str = ""
|
|
99
|
+
beds: Optional[int] = None
|
|
100
|
+
baths: Optional[int] = None
|
|
101
|
+
parking: Optional[int] = None
|
|
102
|
+
land_size: Optional[float] = None
|
|
103
|
+
land_unit: str = ""
|
|
104
|
+
street: str = ""
|
|
105
|
+
suburb: str = ""
|
|
106
|
+
state: str = ""
|
|
107
|
+
postcode: str = ""
|
|
108
|
+
latitude: Optional[float] = None
|
|
109
|
+
longitude: Optional[float] = None
|
|
110
|
+
agency: str = ""
|
|
111
|
+
agency_id: Optional[int] = None
|
|
112
|
+
agents: List[str] = field(default_factory=list)
|
|
113
|
+
auction: str = ""
|
|
114
|
+
inspection_open: str = ""
|
|
115
|
+
inspection_close: str = ""
|
|
116
|
+
images: List[str] = field(default_factory=list)
|
|
117
|
+
promo_type: str = ""
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def address(self) -> str:
|
|
121
|
+
parts = [self.street, self.suburb, f"{self.state} {self.postcode}".strip()]
|
|
122
|
+
return ", ".join(p for p in parts if p)
|
|
123
|
+
|
|
124
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
125
|
+
out = asdict(self)
|
|
126
|
+
out["address"] = self.address
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
def to_row(self) -> Dict[str, Any]:
|
|
130
|
+
row = self.to_dict()
|
|
131
|
+
row["agents"] = " | ".join(self.agents)
|
|
132
|
+
row["images"] = len(self.images)
|
|
133
|
+
return row
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class SearchPage:
|
|
138
|
+
listings: List[Listing]
|
|
139
|
+
total_listings: int = 0
|
|
140
|
+
total_pages: int = 0
|
|
141
|
+
current_page: int = 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _int(value: Any) -> Optional[int]:
|
|
145
|
+
try:
|
|
146
|
+
return int(value) if value not in (None, "") else None
|
|
147
|
+
except (TypeError, ValueError):
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _listing_id(url: str) -> int:
|
|
152
|
+
match = re.search(r"-(\d{6,})/?$", url or "")
|
|
153
|
+
return int(match.group(1)) if match else 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def listing_from_model(model: Dict[str, Any]) -> Listing:
|
|
157
|
+
features = model.get("features") or {}
|
|
158
|
+
address = model.get("address") or {}
|
|
159
|
+
branding = model.get("branding") or {}
|
|
160
|
+
inspection = model.get("inspection") or {}
|
|
161
|
+
tags = model.get("tags") or {}
|
|
162
|
+
url = model.get("url") or ""
|
|
163
|
+
listing = Listing(
|
|
164
|
+
listing_id=_listing_id(url),
|
|
165
|
+
url=BASE_URL + url if url.startswith("/") else url,
|
|
166
|
+
price_text=(model.get("price") or "").strip(),
|
|
167
|
+
status=(tags.get("tagText") or "").strip(),
|
|
168
|
+
property_type=features.get("propertyTypeFormatted") or features.get("propertyType") or "",
|
|
169
|
+
beds=_int(features.get("beds")),
|
|
170
|
+
baths=_int(features.get("baths")),
|
|
171
|
+
parking=_int(features.get("parking")),
|
|
172
|
+
land_size=features.get("landSize") or None,
|
|
173
|
+
land_unit=features.get("landUnit") or "" if features.get("landSize") else "",
|
|
174
|
+
street=address.get("street") or "",
|
|
175
|
+
suburb=address.get("suburb") or "",
|
|
176
|
+
state=address.get("state") or "",
|
|
177
|
+
postcode=str(address.get("postcode") or ""),
|
|
178
|
+
latitude=address.get("lat"),
|
|
179
|
+
longitude=address.get("lng"),
|
|
180
|
+
agency=branding.get("brandName") or "",
|
|
181
|
+
agency_id=_int(branding.get("agencyId")),
|
|
182
|
+
agents=[a.get("agentName", "") for a in branding.get("agents") or [] if a.get("agentName")],
|
|
183
|
+
auction=model.get("auction") or "",
|
|
184
|
+
inspection_open=inspection.get("openTime") or "",
|
|
185
|
+
inspection_close=inspection.get("closeTime") or "",
|
|
186
|
+
images=list(model.get("images") or []),
|
|
187
|
+
promo_type=model.get("promoType") or "",
|
|
188
|
+
)
|
|
189
|
+
listing.price_min, listing.price_max = parse_price(listing.price_text)
|
|
190
|
+
return listing
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_search(html: str) -> SearchPage:
|
|
194
|
+
"""Listings on a search page, in the order Domain shows them (projects skipped)."""
|
|
195
|
+
props = _props(html)
|
|
196
|
+
listings_map = props.get("listingsMap") or {}
|
|
197
|
+
order = [str(i) for i in props.get("listingSearchResultIds") or []] or list(listings_map)
|
|
198
|
+
listings = []
|
|
199
|
+
for key in order:
|
|
200
|
+
item = listings_map.get(key) or listings_map.get(_int(key))
|
|
201
|
+
if not item or item.get("listingType") != "listing":
|
|
202
|
+
continue
|
|
203
|
+
listings.append(listing_from_model(item.get("listingModel") or {}))
|
|
204
|
+
return SearchPage(
|
|
205
|
+
listings=listings,
|
|
206
|
+
total_listings=_int(props.get("totalListings")) or 0,
|
|
207
|
+
total_pages=_int(props.get("totalPages")) or 0,
|
|
208
|
+
current_page=_int(props.get("currentPage")) or 1,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class Agent:
|
|
214
|
+
name: str = ""
|
|
215
|
+
phone: str = ""
|
|
216
|
+
mobile: str = ""
|
|
217
|
+
profile_url: str = ""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class ListingDetail:
|
|
222
|
+
"""Everything on a listing page."""
|
|
223
|
+
|
|
224
|
+
listing_id: int = 0
|
|
225
|
+
url: str = ""
|
|
226
|
+
title: str = ""
|
|
227
|
+
headline: str = ""
|
|
228
|
+
listing_type: str = ""
|
|
229
|
+
status: str = ""
|
|
230
|
+
method: str = ""
|
|
231
|
+
property_type: str = ""
|
|
232
|
+
beds: Optional[int] = None
|
|
233
|
+
baths: Optional[int] = None
|
|
234
|
+
parking: Optional[int] = None
|
|
235
|
+
land_area: Optional[float] = None
|
|
236
|
+
address: str = ""
|
|
237
|
+
unit_number: str = ""
|
|
238
|
+
street_number: str = ""
|
|
239
|
+
street: str = ""
|
|
240
|
+
suburb: str = ""
|
|
241
|
+
state: str = ""
|
|
242
|
+
postcode: str = ""
|
|
243
|
+
latitude: Optional[float] = None
|
|
244
|
+
longitude: Optional[float] = None
|
|
245
|
+
description: str = ""
|
|
246
|
+
features: List[str] = field(default_factory=list)
|
|
247
|
+
agency: str = ""
|
|
248
|
+
agents: List[Agent] = field(default_factory=list)
|
|
249
|
+
inspection: str = ""
|
|
250
|
+
created_on: str = ""
|
|
251
|
+
modified_on: str = ""
|
|
252
|
+
images: List[str] = field(default_factory=list)
|
|
253
|
+
suburb_median_price: Optional[int] = None
|
|
254
|
+
suburb_median_rent: Optional[int] = None
|
|
255
|
+
suburb_days_on_market: Optional[int] = None
|
|
256
|
+
suburb_auction_clearance: Optional[int] = None
|
|
257
|
+
suburb_sold_this_year: Optional[int] = None
|
|
258
|
+
|
|
259
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
260
|
+
return asdict(self)
|
|
261
|
+
|
|
262
|
+
def to_row(self) -> Dict[str, Any]:
|
|
263
|
+
row = self.to_dict()
|
|
264
|
+
row["features"] = " | ".join(self.features)
|
|
265
|
+
row["agents"] = " | ".join(a.name for a in self.agents)
|
|
266
|
+
row["images"] = len(self.images)
|
|
267
|
+
return row
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def parse_listing(html: str, url: str = "") -> ListingDetail:
|
|
271
|
+
"""Parse a listing page."""
|
|
272
|
+
cp = _props(html)
|
|
273
|
+
if not cp.get("listingId"):
|
|
274
|
+
raise ValueError("No listing on this page")
|
|
275
|
+
summary = cp.get("listingSummary") or {}
|
|
276
|
+
insights = cp.get("suburbInsights") or {}
|
|
277
|
+
mapdata = cp.get("map") or {}
|
|
278
|
+
inspection = cp.get("inspection") or {}
|
|
279
|
+
stats = {s.get("key"): s.get("value") for s in summary.get("stats") or [] if isinstance(s, dict)}
|
|
280
|
+
description = cp.get("description") or ""
|
|
281
|
+
if isinstance(description, list):
|
|
282
|
+
description = "\n".join(p for p in description if p)
|
|
283
|
+
images = []
|
|
284
|
+
for slide in (cp.get("gallery") or {}).get("slides") or []:
|
|
285
|
+
original = (slide.get("images") or {}).get("original") or {}
|
|
286
|
+
src = original.get("url") if isinstance(original, dict) else original
|
|
287
|
+
if src and slide.get("mediaType", "image") == "image":
|
|
288
|
+
images.append(src)
|
|
289
|
+
features = [f.get("name") for f in cp.get("structuredFeatures") or [] if f.get("name")] or list(cp.get("features") or [])
|
|
290
|
+
agents = []
|
|
291
|
+
for a in (cp.get("priceGuide") or {}).get("agents") or cp.get("agents") or []:
|
|
292
|
+
agents.append(Agent(name=a.get("name", ""), phone=a.get("phone") or "", mobile=a.get("mobile") or "",
|
|
293
|
+
profile_url=a.get("agentProfileUrl") or ""))
|
|
294
|
+
return ListingDetail(
|
|
295
|
+
listing_id=_int(cp.get("listingId")) or 0,
|
|
296
|
+
url=url or cp.get("listingUrl") or "",
|
|
297
|
+
title=(summary.get("title") or "").strip(),
|
|
298
|
+
headline=(cp.get("headline") or "").strip(),
|
|
299
|
+
listing_type={"buy": "sale"}.get(summary.get("mode") or "", summary.get("mode") or ""),
|
|
300
|
+
status=summary.get("status") or "",
|
|
301
|
+
method=summary.get("method") or "",
|
|
302
|
+
property_type=summary.get("propertyType") or cp.get("propertyType") or "",
|
|
303
|
+
beds=_int(summary.get("beds")),
|
|
304
|
+
baths=_int(summary.get("baths")),
|
|
305
|
+
parking=_int(summary.get("parking")),
|
|
306
|
+
land_area=stats.get("landArea"),
|
|
307
|
+
address=summary.get("address") or cp.get("address") or "",
|
|
308
|
+
unit_number=str(cp.get("unitNumber") or ""),
|
|
309
|
+
street_number=str(cp.get("streetNumber") or ""),
|
|
310
|
+
street=cp.get("street") or "",
|
|
311
|
+
suburb=cp.get("suburb") or "",
|
|
312
|
+
state=(cp.get("stateAbbreviation") or "").upper(),
|
|
313
|
+
postcode=str(cp.get("postcode") or ""),
|
|
314
|
+
latitude=mapdata.get("latitude"),
|
|
315
|
+
longitude=mapdata.get("longitude"),
|
|
316
|
+
description=description.strip(),
|
|
317
|
+
features=features,
|
|
318
|
+
agency=(cp.get("priceGuide") or {}).get("agencyName") or cp.get("agencyName") or "",
|
|
319
|
+
agents=agents,
|
|
320
|
+
inspection=(inspection.get("inspectionText") or "").strip(),
|
|
321
|
+
created_on=cp.get("createdOn") or "",
|
|
322
|
+
modified_on=cp.get("modifiedOn") or "",
|
|
323
|
+
images=images,
|
|
324
|
+
suburb_median_price=_int(insights.get("medianPrice")),
|
|
325
|
+
suburb_median_rent=_int(insights.get("medianRentPrice")),
|
|
326
|
+
suburb_days_on_market=_int(insights.get("avgDaysOnMarket")),
|
|
327
|
+
suburb_auction_clearance=_int(insights.get("auctionClearance")),
|
|
328
|
+
suburb_sold_this_year=_int(insights.get("nrSoldThisYear")),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
LISTING_FIELDS = list(Listing().to_dict().keys())
|
|
333
|
+
DETAIL_FIELDS = list(ListingDetail().to_dict().keys())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<HTML><HEAD><TITLE>Access Denied</TITLE></HEAD><BODY>Access Denied</BODY></HTML>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!DOCTYPE html><html><head><title>Domain</title></head><body><div id="__next"></div><script id="__NEXT_DATA__" type="application/json">{"props": {"pageProps": {"componentProps": {"listingId": 2099000001, "listingUrl": "https://www.domain.com.au/1-example-street-bondi-beach-nsw-2026-2099000001", "headline": "Bright apartment by the beach", "listingSummary": {"title": "$1,250,000 - $1,350,000", "mode": "buy", "status": "live", "method": "auction", "propertyType": "Apartment / Unit / Flat", "beds": 2, "baths": 1, "parking": 1, "address": "1/1 Example Street, Bondi Beach NSW 2026", "stats": [{"key": "landArea", "value": 85}]}, "unitNumber": "1", "streetNumber": "1", "street": "Example Street", "suburb": "Bondi Beach", "stateAbbreviation": "nsw", "postcode": "2026", "map": {"latitude": -33.89, "longitude": 151.27}, "description": ["First paragraph.", "", "Second paragraph."], "structuredFeatures": [{"name": "Balcony"}, {"name": "Air conditioning"}], "priceGuide": {"agencyName": "Example Realty", "agents": [{"name": "Jane Doe", "phone": "02 5550 0000", "mobile": "0400 000 000", "agentProfileUrl": "https://www.domain.com.au/real-estate-agent/jane-doe-1"}]}, "inspection": {"inspectionText": "Sat 21 Nov, 10:00am"}, "createdOn": "2026-10-01T09:00:00", "modifiedOn": "2026-11-10T09:00:00", "gallery": {"slides": [{"mediaType": "image", "images": {"original": {"url": "https://example.com/a.jpg"}}}, {"mediaType": "video", "images": {"original": {"url": "https://example.com/v.jpg"}}}]}, "suburbInsights": {"medianPrice": 1500000, "medianRentPrice": 900, "avgDaysOnMarket": 30, "auctionClearance": 70, "nrSoldThisYear": 200}}}}}</script></body></html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!DOCTYPE html><html><head><title>Domain</title></head><body><div id="__next"></div><script id="__NEXT_DATA__" type="application/json">{"props": {"pageProps": {"componentProps": {"listingsMap": {"2099000001": {"id": 2099000001, "listingType": "listing", "listingModel": {"url": "/1-example-street-bondi-beach-nsw-2026-2099000001", "price": "$1,250,000 - $1,350,000", "images": ["https://example.com/a.jpg", "https://example.com/b.jpg"], "features": {"beds": 2, "baths": 1, "parking": 1, "propertyType": "ApartmentUnitFlat", "propertyTypeFormatted": "Apartment / Unit / Flat", "landSize": 0, "landUnit": "m\u00b2"}, "inspection": {"openTime": "2026-11-21T10:00:00", "closeTime": "2026-11-21T10:30:00"}, "tags": {"tagText": "Sold at auction 10 Nov 2026", "tagClassName": "is-sold"}, "address": {"street": "1/1 Example Street", "suburb": "Bondi Beach", "state": "NSW", "postcode": "2026", "lat": -33.89, "lng": 151.27}, "branding": {"agencyId": 999, "brandName": "Example Realty", "agents": [{"agentName": "Jane Doe"}]}, "auction": "2026-11-10T10:00:00", "promoType": "premium"}}, "2099000002": {"id": 2099000002, "listingType": "listing", "listingModel": {"url": "/7-sample-road-bondi-beach-nsw-2026-2099000002", "price": "Price Withheld", "images": [], "features": {"beds": 4, "baths": 3, "parking": 2, "propertyTypeFormatted": "House", "landSize": 450, "landUnit": "m\u00b2"}, "inspection": {"openTime": null, "closeTime": null}, "tags": {"tagText": null}, "address": {"street": "7 Sample Road", "suburb": "Bondi Beach", "state": "NSW", "postcode": "2026", "lat": -33.88, "lng": 151.26}}}, "1": {"id": 1, "listingType": "project", "listingModel": {"url": "/project/1/x/", "projectName": "Example Tower"}}}, "listingSearchResultIds": [2099000002, 1, 2099000001], "totalListings": 3, "totalPages": 1, "currentPage": 1}}}}</script></body></html>
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
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 domain_scraper import DomainScraper, ScrapeError, listing_url, parse_listing, parse_price, parse_search, search_url, suburb_slug # noqa: E402
|
|
12
|
+
from domain_scraper import cli # noqa: E402
|
|
13
|
+
|
|
14
|
+
FIX = Path(__file__).parent / "fixtures"
|
|
15
|
+
SEARCH = (FIX / "search_bondi.html").read_text(encoding="utf-8")
|
|
16
|
+
LISTING = (FIX / "listing.html").read_text(encoding="utf-8")
|
|
17
|
+
BLOCKED = (FIX / "blocked.html").read_text(encoding="utf-8")
|
|
18
|
+
URL1 = "https://www.domain.com.au/1-example-street-bondi-beach-nsw-2026-2099000001"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class R:
|
|
22
|
+
def __init__(self, status, text=""):
|
|
23
|
+
self.status_code, self.text = status, text
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Session:
|
|
27
|
+
def __init__(self, responses):
|
|
28
|
+
self.responses, self.calls = list(responses), []
|
|
29
|
+
|
|
30
|
+
def get(self, url, params=None, timeout=None):
|
|
31
|
+
self.calls.append(dict(params))
|
|
32
|
+
return self.responses.pop(0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UrlTests(unittest.TestCase):
|
|
36
|
+
def test_slug_and_urls(self):
|
|
37
|
+
self.assertEqual(suburb_slug("Bondi Beach NSW 2026"), "bondi-beach-nsw-2026")
|
|
38
|
+
self.assertEqual(search_url("bondi-beach-nsw-2026", "sold", 2), "https://www.domain.com.au/sold-listings/bondi-beach-nsw-2026/?page=2")
|
|
39
|
+
self.assertEqual(search_url("Sydney NSW 2000", "rent"), "https://www.domain.com.au/rent/sydney-nsw-2000/")
|
|
40
|
+
with self.assertRaises(ValueError):
|
|
41
|
+
suburb_slug("Bondi")
|
|
42
|
+
with self.assertRaises(ValueError):
|
|
43
|
+
search_url("Sydney NSW 2000", "lease")
|
|
44
|
+
self.assertEqual(listing_url(URL1 + "/"), URL1)
|
|
45
|
+
with self.assertRaises(ValueError):
|
|
46
|
+
listing_url("https://example.com/x")
|
|
47
|
+
|
|
48
|
+
def test_price(self):
|
|
49
|
+
self.assertEqual(parse_price("$675,000 to $725,000"), (675000, 725000))
|
|
50
|
+
self.assertEqual(parse_price("$1,100,000"), (1100000, 1100000))
|
|
51
|
+
self.assertEqual(parse_price("$1700 per week"), (1700, 1700))
|
|
52
|
+
self.assertEqual(parse_price("Auction guide $1.2m"), (1200000, 1200000))
|
|
53
|
+
self.assertEqual(parse_price("Price Withheld"), (None, None))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ParseTests(unittest.TestCase):
|
|
57
|
+
def test_search(self):
|
|
58
|
+
page = parse_search(SEARCH)
|
|
59
|
+
self.assertEqual((page.total_listings, page.total_pages), (3, 1))
|
|
60
|
+
self.assertEqual([l.listing_id for l in page.listings], [2099000002, 2099000001])
|
|
61
|
+
l = page.listings[1]
|
|
62
|
+
self.assertEqual((l.price_min, l.price_max), (1250000, 1350000))
|
|
63
|
+
self.assertEqual(l.status, "Sold at auction 10 Nov 2026")
|
|
64
|
+
self.assertEqual((l.beds, l.baths, l.parking), (2, 1, 1))
|
|
65
|
+
self.assertEqual(l.address, "1/1 Example Street, Bondi Beach, NSW 2026")
|
|
66
|
+
self.assertEqual((l.agency, l.agents), ("Example Realty", ["Jane Doe"]))
|
|
67
|
+
self.assertEqual(l.url, URL1)
|
|
68
|
+
self.assertEqual(page.listings[0].land_size, 450)
|
|
69
|
+
self.assertIsNone(page.listings[0].price_min)
|
|
70
|
+
|
|
71
|
+
def test_listing(self):
|
|
72
|
+
d = parse_listing(LISTING)
|
|
73
|
+
self.assertEqual(d.listing_id, 2099000001)
|
|
74
|
+
self.assertEqual((d.title, d.listing_type, d.method), ("$1,250,000 - $1,350,000", "sale", "auction"))
|
|
75
|
+
self.assertEqual(d.description, "First paragraph.\nSecond paragraph.")
|
|
76
|
+
self.assertEqual(d.features, ["Balcony", "Air conditioning"])
|
|
77
|
+
self.assertEqual(d.agents[0].mobile, "0400 000 000")
|
|
78
|
+
self.assertEqual((d.state, d.land_area, d.images), ("NSW", 85, ["https://example.com/a.jpg"]))
|
|
79
|
+
self.assertEqual((d.suburb_median_price, d.suburb_auction_clearance), (1500000, 70))
|
|
80
|
+
with self.assertRaises(ValueError):
|
|
81
|
+
parse_listing(BLOCKED)
|
|
82
|
+
with self.assertRaises(ValueError):
|
|
83
|
+
parse_listing(SEARCH)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ClientTests(unittest.TestCase):
|
|
87
|
+
def test_token_required(self):
|
|
88
|
+
with mock.patch.dict("os.environ", {}, clear=True):
|
|
89
|
+
with self.assertRaises(ValueError):
|
|
90
|
+
DomainScraper()
|
|
91
|
+
|
|
92
|
+
def test_search_super(self):
|
|
93
|
+
s = Session([R(200, SEARCH)])
|
|
94
|
+
dm = DomainScraper(token="t", session=s)
|
|
95
|
+
out = list(dm.search("Bondi Beach NSW 2026", "sold", pages=5))
|
|
96
|
+
self.assertEqual(len(out), 2)
|
|
97
|
+
self.assertEqual(len(s.calls), 1) # total_pages = 1
|
|
98
|
+
self.assertEqual(s.calls[0]["super"], "true")
|
|
99
|
+
self.assertEqual(dm.credits_used, 10)
|
|
100
|
+
|
|
101
|
+
@mock.patch("domain_scraper.client.time.sleep")
|
|
102
|
+
def test_blocked_retried(self, _):
|
|
103
|
+
s = Session([R(200, BLOCKED), R(403, "x"), R(200, LISTING)])
|
|
104
|
+
d = DomainScraper(token="t", session=s).get_listing(URL1)
|
|
105
|
+
self.assertEqual(d.agency, "Example Realty")
|
|
106
|
+
self.assertEqual(len(s.calls), 3)
|
|
107
|
+
|
|
108
|
+
def test_401_stops(self):
|
|
109
|
+
s = Session([R(401)])
|
|
110
|
+
with self.assertRaises(ScrapeError):
|
|
111
|
+
DomainScraper(token="t", session=s, retries=3).get_listing(URL1)
|
|
112
|
+
self.assertEqual(len(s.calls), 1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class CliTests(unittest.TestCase):
|
|
116
|
+
def run_cli(self, argv, responses):
|
|
117
|
+
s = Session(responses)
|
|
118
|
+
out, err = io.StringIO(), io.StringIO()
|
|
119
|
+
with mock.patch("domain_scraper.client.requests.Session", return_value=s), \
|
|
120
|
+
mock.patch.object(sys, "stdout", out), mock.patch.object(sys, "stderr", err):
|
|
121
|
+
code = cli.main(argv + ["-t", "t"])
|
|
122
|
+
return code, out.getvalue()
|
|
123
|
+
|
|
124
|
+
def test_csv(self):
|
|
125
|
+
code, out = self.run_cli(["Bondi Beach NSW 2026", "--mode", "sold", "-f", "csv"], [R(200, SEARCH)])
|
|
126
|
+
rows = list(csv.DictReader(io.StringIO(out)))
|
|
127
|
+
self.assertEqual(code, 0)
|
|
128
|
+
self.assertEqual(rows[1]["agency"], "Example Realty")
|
|
129
|
+
|
|
130
|
+
def test_details_limit(self):
|
|
131
|
+
code, out = self.run_cli(["Bondi Beach NSW 2026", "--details", "--limit", "1"], [R(200, SEARCH), R(200, LISTING)])
|
|
132
|
+
self.assertEqual(len(json.loads(out)), 1)
|
|
133
|
+
|
|
134
|
+
def test_listing_url(self):
|
|
135
|
+
code, out = self.run_cli([URL1], [R(200, LISTING)])
|
|
136
|
+
self.assertEqual(json.loads(out)[0]["suburb"], "Bondi Beach")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
unittest.main()
|