Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.60.1 — 2026-10-13

- **Gumtree South Africa ids are refs.** gumtree.co.za re-platformed on 2026-10-12 and the API now serves `market="za"` from the new site: category, location and listing ids are refs such as `"p6dnh"` (Cars & Bakkies), `"dkp8y"` (Gauteng) and `"ls26bvmcpnsqmz7l9jz"` (a listing), e.g. `search(market="za", category="p6dnh", location="dkp8y", filters="make:toyota")`. 0.60.0 typed those ids as `int`, so za responses failed validation: `TreeNode.id`, `TreeResponse.id`, `CategoryRef.id`, `CategoryFacet.id`, `LocationSuggestion.id`, `category_id` and `category_ids` are now `int | str` (uk/au still return ints), and `list_categories` / `list_locations` take `parent_id: int | str`. za filter keys are the site's own (`make`, `model`, `fuelType`, `year`, `mileageKm` …), and za search pages through the first 375 matches.

## 0.60.0 — 2026-10-12

- **Gumtree Australia + South Africa.** Every `client.gumtree` method takes an optional `market`: `"uk"` (gumtree.com, GBP — the default when unset, so existing calls are unchanged), `"au"` (gumtree.com.au, AUD) or `"za"` (gumtree.co.za, ZAR). uk takes slugs for `category`/`location`; au and za take the numeric ids (as strings) from `list_categories`, `list_locations` and `location_autocomplete` of the same market, e.g. `search(market="za", category="9077", location="3100003")`. `search` `distance` is miles (uk) or km (au) and not offered by za; `urgent_only`/`with_photos` are uk only; `seller_type` is uk and za. za listing ids are the full 26-digit ad id.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "scrapebadger"
version = "0.60.0"
version = "0.60.1"
description = "Official Python SDK for ScrapeBadger - Async web scraping APIs for Twitter and more"
readme = "README.md"
license = { text = "MIT" }
Expand Down
2 changes: 1 addition & 1 deletion src/scrapebadger/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1335,7 +1335,7 @@ async def main():
ZestimateHistoryPoint as ZillowZestimateHistoryPoint,
)

__version__ = "0.60.0"
__version__ = "0.60.1"

__all__ = [
# TikTok core models
Expand Down
2 changes: 1 addition & 1 deletion src/scrapebadger/_internal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
T = TypeVar("T")

# User agent for SDK requests
SDK_VERSION = "0.60.0"
SDK_VERSION = "0.60.1"
USER_AGENT = f"scrapebadger-python/{SDK_VERSION}"


Expand Down
4 changes: 2 additions & 2 deletions src/scrapebadger/gumtree/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
detail = await client.gumtree.get_listing("1803293391")
print(detail.title, detail.seller.name if detail.seller else None)

# Australia and South Africa take numeric category / location ids
# Australia takes numeric ids, South Africa refs (Cars & Bakkies in Gauteng)
au = await client.gumtree.search("road bike", market="au", location="3003435")
za = await client.gumtree.search(market="za", category="9077", location="3100003")
za = await client.gumtree.search(market="za", category="p6dnh", location="dkp8y")
```
"""

Expand Down
73 changes: 42 additions & 31 deletions src/scrapebadger/gumtree/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
Three markets, chosen per call with ``market``: ``"uk"`` (gumtree.com, GBP —
the server default), ``"au"`` (gumtree.com.au, AUD) and ``"za"``
(gumtree.co.za, ZAR). They are separate sites: uk takes slugs for category
and location, au/za take numeric ids from :meth:`GumtreeClient.list_categories`
and :meth:`GumtreeClient.list_locations` of the same market.
and location, au takes numeric ids and za takes refs (``"p6dnh"``) from
:meth:`GumtreeClient.list_categories` and :meth:`GumtreeClient.list_locations`
of the same market.
"""

from __future__ import annotations
Expand Down Expand Up @@ -61,8 +62,10 @@ class GumtreeClient:
market="au", category="18320", location="3003435", distance=10
)

# South Africa: cars (9077) in Gauteng (3100003); ids are 26 digits
za = await client.gumtree.search(market="za", category="9077", location="3100003")
# South Africa: refs — Cars & Bakkies (p6dnh) in Gauteng (dkp8y)
za = await client.gumtree.search(
market="za", category="p6dnh", location="dkp8y", filters="make:toyota"
)
detail = await client.gumtree.get_listing(za.listings[0].id, market="za")
print(detail.seller.name if detail.seller else None)

Expand Down Expand Up @@ -106,13 +109,15 @@ async def search(
query: Search keywords, e.g. ``"mountain bike"``. Optional — a
category alone browses it.
category: uk: category slug, e.g. ``"cars"``, ``"for-sale"``,
``"property-to-rent"``. au/za: numeric category id as a string,
e.g. ``"18320"`` (au cars) or ``"9077"`` (za cars). Browse the
tree with :meth:`list_categories`. Default ``"all"``.
``"property-to-rent"``. au: numeric category id as a string,
e.g. ``"18320"`` (cars). za: category ref, e.g. ``"p6dnh"``
(Cars & Bakkies). Browse the tree with :meth:`list_categories`.
Default ``"all"``.
location: uk: location slug (``"london"``), town name or UK postcode
(``"SW1A 1AA"``). au/za: numeric location id as a string, e.g.
``"3003435"`` (au Sydney) or ``"3100003"`` (za Gauteng). Resolve
one with :meth:`location_autocomplete`. Default: the whole country.
(``"SW1A 1AA"``). au: numeric location id as a string, e.g.
``"3003435"`` (Sydney). za: location ref, e.g. ``"dkp8y"``
(Gauteng). Resolve one with :meth:`location_autocomplete`.
Default: the whole country.
distance: Radius around ``location`` (>0, max 1000): miles (uk) or
km (au). Not offered by za.
min_price: Minimum price in the market's currency (uk: needs a
Expand All @@ -127,10 +132,12 @@ async def search(
with_photos: uk only: only ads with photos.
filters: Category-specific filters as comma-separated ``key:value``
pairs, e.g. ``"vehicle_make:bmw,min_vehicle_mileage:10000"`` (uk)
or ``"ma:toyota,fu:petrol,max_km:100000"`` (za). Range filters
or ``"make:toyota,fuelType:petrol,max_mileageKm:100000"`` (za).
Range filters
take ``min_<key>``/``max_<key>``. Discover keys with
:meth:`search_filters` for the same market.
page: Page number, 1-50.
page: Page number, 1-50 (za: only the first 375 matches are
reachable; a page past them comes back empty).
market: ``"uk"`` (gumtree.com), ``"au"`` (gumtree.com.au) or ``"za"``
(gumtree.co.za). Unset = the server default, ``"uk"``.

Expand Down Expand Up @@ -177,13 +184,14 @@ async def search_filters(
a ``value`` back into :meth:`search` as ``filters="key:value"``.

Args:
category: uk: category slug, e.g. ``"cars"``; au/za: numeric
category id. Default ``"all"``.
location: uk: location slug, town or UK postcode; au/za: numeric
location id. Default: the whole country.
category: uk: category slug, e.g. ``"cars"``; au: numeric category
id; za: category ref, e.g. ``"p6dnh"``. Default ``"all"``.
location: uk: location slug, town or UK postcode; au: numeric
location id; za: location ref, e.g. ``"dkp8y"``. Default: the
whole country.
query: Optional keywords to scope the counts.
filters: za only: filters already applied, e.g. ``"ma:toyota"`` to
list the Toyota models.
filters: za only: filters already applied, e.g. ``"make:toyota"``
to narrow the model counts to Toyota's models.
market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``.

Returns:
Expand Down Expand Up @@ -217,9 +225,9 @@ async def get_listing(
variant, odometer, VIN, rego).

Args:
listing_id: Gumtree listing id — the number at the end of the
listing URL (uk/au: ~10 digits; za: the full 26-digit ad id,
not the short one).
listing_id: Gumtree listing id from the end of the listing URL
(uk/au: ~10 digits; za: the listing ref, e.g.
``"ls26bvmcpnsqmz7l9jz"``).
market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``.

Returns:
Expand All @@ -242,8 +250,8 @@ async def get_seller_listings(
of theirs works.

Args:
listing_id: Any Gumtree listing id belonging to the seller (za: the
26-digit id).
listing_id: Any Gumtree listing id belonging to the seller (za: a
listing ref, e.g. ``"ls26bvmcpnsqmz7l9jz"``).
page: Page number, 1-50.
market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``.

Expand Down Expand Up @@ -278,8 +286,8 @@ async def location_autocomplete(
) -> LocationAutocompleteResponse:
"""Resolve a place name or postcode prefix to Gumtree locations.

The ``id`` (au/za) or ``name`` (uk) is what :meth:`search` takes as
``location``.
The ``id`` (au: a number; za: a ref such as ``"pfw8k"``) or ``name``
(uk) is what :meth:`search` takes as ``location``.

Args:
query: Town, area or postcode prefix (min 2 chars), e.g. ``"manch"``
Expand All @@ -295,15 +303,16 @@ async def location_autocomplete(
return LocationAutocompleteResponse.model_validate(response)

async def list_locations(
self, parent_id: int | None = None, *, market: GumtreeMarket | None = None
self, parent_id: int | str | None = None, *, market: GumtreeMarket | None = None
) -> TreeResponse:
"""Browse Gumtree's location tree.

uk: UK -> nations -> counties -> towns. au: states -> regions -> areas.
za: provinces -> towns.
za: provinces -> towns and suburbs.

Args:
parent_id: Location id to list the children of. Unset = the
parent_id: Location id to list the children of — a number on uk/au,
a ref on za (e.g. ``"dkp8y"`` for Gauteng). Unset = the
market's root (the whole country) server-side.
market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``.

Expand All @@ -316,14 +325,16 @@ async def list_locations(
return TreeResponse.model_validate(response)

async def list_categories(
self, parent_id: int | None = None, *, market: GumtreeMarket | None = None
self, parent_id: int | str | None = None, *, market: GumtreeMarket | None = None
) -> TreeResponse:
"""Browse Gumtree's category tree.

A node's ``slug`` (uk) or ``id`` (au/za) is the :meth:`search` category.
A node's ``slug`` (uk) or ``id`` (au: a number; za: a ref such as
``"p6dnh"``) is the :meth:`search` category.

Args:
parent_id: Category id to list the children of. Unset = the
parent_id: Category id to list the children of — a number on uk/au,
a ref on za (e.g. ``"l4dq6"`` for Vehicles). Unset = the
market's root (all categories) server-side.
market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``.

Expand Down
48 changes: 29 additions & 19 deletions src/scrapebadger/gumtree/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
Three markets share these models: ``uk`` (gumtree.com, GBP), ``au``
(gumtree.com.au, AUD) and ``za`` (gumtree.co.za, ZAR). They are separate
platforms, so a field one market does not publish is simply ``None`` there.
Category, location and tree ids are ints on uk/au and gumtree.co.za refs
(strings such as ``"p6dnh"``) on za, so those fields are ``int | str``.
Every top-level response carries the ``market`` it came from. Every datetime
ships in BOTH forms: ``*_utc`` (Unix seconds) and ``*_at`` (ISO 8601 UTC
string). Phone numbers are only ever the MASKED form Gumtree shows a
Expand Down Expand Up @@ -49,7 +51,7 @@ class Attribute(_BaseModel):
class CategoryRef(_BaseModel):
"""A category reference attached to an autocomplete suggestion."""

id: int | None = None
id: int | str | None = Field(default=None, description="za: a ref such as 'p6dnh'")
name: str | None = None
slug: str | None = None

Expand All @@ -66,7 +68,7 @@ class ListingSummary(_BaseModel):

id: str
short_id: str | None = Field(
default=None, description="za: the 10-digit id Gumtree shows (the `id` is the full ad id)"
default=None, description="Not published by any market at present (za's old short id)"
)
title: str | None = None
url: str | None = None
Expand All @@ -75,7 +77,7 @@ class ListingSummary(_BaseModel):
price_text: str | None = Field(default=None, description="Price as shown, e.g. '£1,850.00pm'")
price_frequency: str | None = Field(default=None, description="pm / pw for rentals")
price_type: str | None = Field(
default=None, description="au/za: FIXED, NEGOTIABLE / MAKE_OFFER, FREE, SWAP_TRADE…"
default=None, description="au: FIXED, NEGOTIABLE, FREE, SWAP_TRADE…; za: ASKING_PRICE, POA…"
)
currency: str = "GBP"
location: str | None = None
Expand All @@ -87,9 +89,11 @@ class ListingSummary(_BaseModel):
default=None, description="e.g. '13 miles' (uk) or '4 km' (au) from the search location"
)
distance_miles: float | None = None
category_id: int | None = None
category_id: int | str | None = Field(default=None, description="za: a ref such as 'p6dnh'")
category_name: str | None = None
category_ids: list[int] = Field(default_factory=list, description="Root-to-leaf category ids")
category_ids: list[int | str] = Field(
default_factory=list, description="Root-to-leaf category ids (refs on za)"
)
image_url: str | None = None
image_urls: list[str] = Field(default_factory=list)
image_count: int | None = None
Expand Down Expand Up @@ -124,7 +128,9 @@ class ListingSummary(_BaseModel):
class CategoryFacet(_BaseModel):
"""A sub-category of a result set, with its listing count."""

id: int | None = Field(default=None, description="au/za: the category id `search` takes")
id: int | str | None = Field(
default=None, description="au: the numeric category id `search` takes; za: its ref"
)
name: str | None = None
slug: str | None = None
count: int | None = None
Expand All @@ -139,7 +145,7 @@ class SearchResponse(_BaseModel):
query: str | None = None
category: str | None = Field(default=None, description="Category slug the search ran in")
category_name: str | None = None
category_id: int | None = None
category_id: int | str | None = Field(default=None, description="za: a ref such as 'p6dnh'")
location: str | None = Field(default=None, description="Location slug / postcode searched")
location_name: str | None = None
distance: float | None = None
Expand Down Expand Up @@ -234,13 +240,19 @@ class Seller(_BaseModel):
type: str | None = Field(default=None, description="Private / Trade / Agency")
is_pro: bool | None = None
posting_since: str | None = Field(default=None, description="e.g. '6+ years'")
member_since_year: int | None = Field(default=None, description="au: year the account opened")
member_since_year: int | None = Field(
default=None, description="au/za: year the account opened"
)
last_active_text: str | None = Field(default=None, description="au: e.g. 'yesterday'")
last_active_utc: float | None = None
last_active_at: str | None = None
total_listings: int | None = Field(default=None, description="Live listings right now")
lifetime_listings: int | None = Field(default=None, description="za: ads ever posted")
total_views: int | None = Field(default=None, description="za: views across all their ads")
lifetime_listings: int | None = Field(
default=None, description="Ads ever posted (no market publishes it at present)"
)
total_views: int | None = Field(
default=None, description="Views across all their ads (no market publishes it at present)"
)
listings_last_year: int | None = None
listings_by_category: dict[str, int] = Field(default_factory=dict)
reply_speed: str | None = None
Expand Down Expand Up @@ -268,7 +280,7 @@ class ListingDetail(_BaseModel):
price_text: str | None = None
price_frequency: str | None = None
price_type: str | None = Field(
default=None, description="au/za: FIXED, NEGOTIABLE / MAKE_OFFER, FREE, SWAP_TRADE…"
default=None, description="au: FIXED, NEGOTIABLE, FREE, SWAP_TRADE…; za: ASKING_PRICE, POA…"
)
currency: str = "GBP"
vat: str | None = Field(default=None, description="Motors trade listings: 'Inc VAT' / 'No VAT'")
Expand All @@ -279,9 +291,9 @@ class ListingDetail(_BaseModel):
postcode: str | None = None
latitude: float | None = None
longitude: float | None = None
category_id: int | None = None
category_id: int | str | None = Field(default=None, description="za: a ref such as 'p6dnh'")
category_name: str | None = None
category_ids: list[int] = Field(default_factory=list)
category_ids: list[int | str] = Field(default_factory=list)
breadcrumbs: list[Breadcrumb] = Field(default_factory=list)
image_urls: list[str] = Field(default_factory=list)
video_urls: list[str] = Field(default_factory=list)
Expand Down Expand Up @@ -329,9 +341,7 @@ class SellerListingsResponse(_BaseModel):
total_results: int | None = None
page: int = 1
total_pages: int | None = None
has_next: bool | None = Field(
default=None, description="za: another page follows (the site gives no total upfront)"
)
has_next: bool | None = Field(default=None, description="za: another page follows")
result_count: int = 0
listings: list[ListingSummary] = Field(default_factory=list)

Expand Down Expand Up @@ -365,7 +375,7 @@ class AutocompleteResponse(_BaseModel):
class LocationSuggestion(_BaseModel):
"""A Gumtree location matched by name or postcode prefix."""

id: int | None = None
id: int | str | None = Field(default=None, description="za: a ref such as 'pfw8k'")
name: str | None = None
type: str | None = Field(
default=None, description="uk: location / outcode / postcode; au/za: region / area"
Expand All @@ -388,7 +398,7 @@ class LocationAutocompleteResponse(_BaseModel):
class TreeNode(_BaseModel):
"""A category or location node. ``slug`` (uk) or ``id`` (au/za) is what ``search`` takes."""

id: int
id: int | str = Field(description="int on uk/au, a ref such as 'p6dnh' on za")
name: str | None = None
slug: str | None = None
has_children: bool | None = None
Expand All @@ -400,7 +410,7 @@ class TreeResponse(_BaseModel):
market: str = Field(
default="uk", description="uk = gumtree.com, au = gumtree.com.au, za = gumtree.co.za"
)
id: int
id: int | str = Field(description="int on uk/au, a ref such as 'p6dnh' on za")
name: str | None = None
slug: str | None = None
result_count: int = 0
Expand Down
Loading
Loading