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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Buğrahan Saka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include README.md LICENSE
2
+ recursive-include tests *.py *.html
3
+ recursive-include examples *.py
@@ -0,0 +1,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
+ [![PyPI](https://img.shields.io/pypi/v/domain-com-au-scraper)](https://pypi.org/project/domain-com-au-scraper/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/domain-com-au-scraper)](https://pypi.org/project/domain-com-au-scraper/)
27
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
+ [![PyPI](https://img.shields.io/pypi/v/domain-com-au-scraper)](https://pypi.org/project/domain-com-au-scraper/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/domain-com-au-scraper)](https://pypi.org/project/domain-com-au-scraper/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/domain-com-au-scraper)](https://pypi.org/project/domain-com-au-scraper/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/domain-com-au-scraper)](https://pypi.org/project/domain-com-au-scraper/)
27
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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,2 @@
1
+ [console_scripts]
2
+ domain-scraper = domain_scraper.cli:main
@@ -0,0 +1,2 @@
1
+ requests>=2.25
2
+ beautifulsoup4>=4.9
@@ -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()