From 248ffb501a7dc938c1fc0ae4400c6e2518f3c6fe Mon Sep 17 00:00:00 2001 From: kasparasizi1 <132673909+kasparasizi1@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:59:29 +0300 Subject: [PATCH] =?UTF-8?q?fix(gumtree):=20South=20Africa=20ids=20are=20re?= =?UTF-8?q?fs=20=E2=80=94=20widen=20id=20types,=200.60.1=20(SCR-295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gumtree.co.za re-platformed on 2026-10-12 and the API now serves market="za" from the new site, whose category, location and listing ids are refs ('p6dnh', 'dkp8y', 'ls26bvmcpnsqmz7l9jz'). 0.60.0 typed those ids as int, so every za tree / facet / suggestion response failed validation. - TreeNode.id, TreeResponse.id, CategoryRef.id, CategoryFacet.id, LocationSuggestion.id, category_id and category_ids are int | str (uk/au still return ints; int callers are unaffected) - list_categories / list_locations take parent_id: int | str - docstrings and examples use real za refs and filter keys (make:toyota …) - za fixtures re-captured from the live API on 2026-10-13 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + pyproject.toml | 2 +- src/scrapebadger/__init__.py | 2 +- src/scrapebadger/_internal/client.py | 2 +- src/scrapebadger/gumtree/__init__.py | 4 +- src/scrapebadger/gumtree/client.py | 73 ++-- src/scrapebadger/gumtree/models.py | 48 ++- tests/fixtures/gumtree_samples.json | 528 ++++++++++++++++----------- tests/test_gumtree.py | 67 ++-- uv.lock | 2 +- 10 files changed, 433 insertions(+), 299 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb40ac2..b1f4591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index f1eee75..a8b4677 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/scrapebadger/__init__.py b/src/scrapebadger/__init__.py index 68e579a..cafe7a4 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -1335,7 +1335,7 @@ async def main(): ZestimateHistoryPoint as ZillowZestimateHistoryPoint, ) -__version__ = "0.60.0" +__version__ = "0.60.1" __all__ = [ # TikTok core models diff --git a/src/scrapebadger/_internal/client.py b/src/scrapebadger/_internal/client.py index 4a74eac..91d0dc1 100644 --- a/src/scrapebadger/_internal/client.py +++ b/src/scrapebadger/_internal/client.py @@ -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}" diff --git a/src/scrapebadger/gumtree/__init__.py b/src/scrapebadger/gumtree/__init__.py index 30927ef..82bd128 100644 --- a/src/scrapebadger/gumtree/__init__.py +++ b/src/scrapebadger/gumtree/__init__.py @@ -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") ``` """ diff --git a/src/scrapebadger/gumtree/client.py b/src/scrapebadger/gumtree/client.py index 3dcb6a5..4ba50fb 100644 --- a/src/scrapebadger/gumtree/client.py +++ b/src/scrapebadger/gumtree/client.py @@ -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 @@ -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) @@ -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 @@ -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_``/``max_``. 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"``. @@ -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: @@ -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: @@ -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"``. @@ -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"`` @@ -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"``. @@ -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"``. diff --git a/src/scrapebadger/gumtree/models.py b/src/scrapebadger/gumtree/models.py index 110a5f9..018bd52 100644 --- a/src/scrapebadger/gumtree/models.py +++ b/src/scrapebadger/gumtree/models.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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'") @@ -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) @@ -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) @@ -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" @@ -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 @@ -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 diff --git a/tests/fixtures/gumtree_samples.json b/tests/fixtures/gumtree_samples.json index 8ec9f96..436801b 100644 --- a/tests/fixtures/gumtree_samples.json +++ b/tests/fixtures/gumtree_samples.json @@ -1714,61 +1714,65 @@ "currency": "ZAR", "distance_unit": null, "page_size": 20, - "id_format": "numeric ids from /categories and /locations (market=za)" + "id_format": "refs like 'p6dnh' from /categories and /locations (market=za)" } ] }, - "GET /v1/gumtree/search?market=za&category=9077&location=3100003": { + "GET /v1/gumtree/search?market=za&category=p6dnh&location=dkp8y&filters=make:toyota&sort=date": { "market": "za", "query": null, - "category": "9077", - "category_name": null, - "category_id": 9077, - "location": "3100003", - "location_name": "Cars & Bakkies in Gauteng", + "category": "p6dnh", + "category_name": "Cars & Bakkies", + "category_id": "p6dnh", + "location": "dkp8y", + "location_name": "Gauteng", "distance": null, - "sort": null, + "sort": "date", "page": 1, - "total_pages": 50, - "total_results": 2964, + "total_pages": 19, + "total_results": 429, "result_count": 20, "currency": "ZAR", "listings": [ { - "id": "10013681279811013062940409", - "short_id": "1368127981", - "title": "2023 Toyota Corolla cross 1.8i for sale", - "url": "https://www.gumtree.co.za/a-cars-bakkies/inner-city-cbd-bruma/2023-toyota-corolla-cross-1-8i-for-sale/10013681279811013062940409", - "description": null, - "price": 248000.0, - "price_text": "R 248,000 NEGOTIABLE", + "id": "ls26bvmcpnsqmz7l9jz", + "short_id": null, + "title": "Toyota Yaris", + "url": "https://www.gumtree.co.za/listing/ls26bvmcpnsqmz7l9jz", + "description": "2018 Toyota Yaris 1.5XS CVT Automatic 85000km FSH with Toyota (service books including all...", + "price": 190000.0, + "price_text": "R190,000", "price_frequency": null, - "price_type": "FIXED", + "price_type": "ASKING_PRICE", "currency": "ZAR", - "location": "Inner City / CBD&Bruma", - "location_id": null, + "location": "Weltevreden Park", + "location_id": "mxm68", "postcode": null, "latitude": null, "longitude": null, "distance_text": null, "distance_miles": null, - "category_id": 9077, + "category_id": "p6dnh", "category_name": null, - "category_ids": [], - "image_url": "https://gms.gumtree.co.za/v2/images/za_ads_130629404_261012_6acc7e7f93b58b8535042a5a?size=l", + "category_ids": [ + "kyzkk", + "l4dq6", + "p6dnh" + ], + "image_url": "https://media.gumtree.co.za/v1/images/za_listing_s16zcgy0ntjnvg2_261013_6ace6b509064a60001fdf52c", "image_urls": [ - "https://gms.gumtree.co.za/v2/images/za_ads_130629404_261012_6acc7e7f93b58b8535042a5a?size=l" + "https://media.gumtree.co.za/v1/images/za_listing_s16zcgy0ntjnvg2_261013_6ace6b509064a60001fdf52c" ], - "image_count": 12, - "posted_text": "1h", - "posted_utc": null, - "posted_at": null, - "status": "ACTIVE", - "seller_type": "Private", - "seller_id": null, - "seller_name": "Kevin Noel Ngandu", + "image_count": 1, + "posted_text": null, + "posted_utc": 1791912864.365, + "posted_at": "2026-10-13T17:34:24.365000Z", + "status": null, + "seller_type": null, + "seller_id": "s16zcgy0ntjnvg2", + "seller_name": "Thanuj", "account_id": null, - "is_pro_seller": false, + "is_pro_seller": null, "is_featured": null, "is_urgent": null, "is_bumped_up": null, @@ -1780,102 +1784,64 @@ "seller_skills": [], "attributes": [ { - "key": "CarYear", - "label": null, - "value": "2023", + "key": "mileageKm", + "label": "Kilometres", + "value": "85000", "unit": null }, { - "key": "CarTransmission", - "label": null, + "key": "transmission", + "label": "Transmission", "value": "Automatic", "unit": null }, { - "key": "MakeOffer", - "label": null, - "value": "1", - "unit": null - }, - { - "key": "ForSaleBy", - "label": null, - "value": "Owner", - "unit": null - }, - { - "key": "FuelType", - "label": null, + "key": "fuelType", + "label": "Fuel Type", "value": "Petrol", "unit": null - }, - { - "key": "CarBodyType", - "label": null, - "value": "SUV", - "unit": null - }, - { - "key": "CarMake", - "label": null, - "value": "Toyota", - "unit": null - }, - { - "key": "CarModel", - "label": null, - "value": "Corolla Cross", - "unit": null - }, - { - "key": "CarColor", - "label": null, - "value": "White", - "unit": null - }, - { - "key": "CarMileageInKms", - "label": null, - "value": "85,400km", - "unit": null } ] }, { - "id": "10013680889901013488680709", - "short_id": "1368088990", - "title": "Vw golf4 1.6 automatic", - "url": "https://www.gumtree.co.za/a-cars-bakkies/dobsonville/vw-golf4-1-6-automatic/10013680889901013488680709", - "description": null, + "id": "ls26cpwv7p4f8fb98p6", + "short_id": null, + "title": "2007 Toyota Yaris", + "url": "https://www.gumtree.co.za/listing/ls26cpwv7p4f8fb98p6", + "description": "2007 Toyota Yaris 1.3i for Sale VVT-i T3 A/C R28,500 Call/WhatsApp 072 626 6659 Petrol Manual...", "price": null, - "price_text": "Negotiable", + "price_text": "POA", "price_frequency": null, - "price_type": "MAKE_OFFER", + "price_type": "POA", "currency": "ZAR", - "location": "Dobsonville", - "location_id": null, + "location": "Hammanskraal", + "location_id": "gkk41", "postcode": null, "latitude": null, "longitude": null, "distance_text": null, "distance_miles": null, - "category_id": 9077, + "category_id": "p6dnh", "category_name": null, - "category_ids": [], - "image_url": "https://gms.gumtree.co.za/v2/images/za_ads_134886807_261011_6acb5c7c93b58b85367bdfe1?size=l", + "category_ids": [ + "kyzkk", + "l4dq6", + "p6dnh" + ], + "image_url": "https://media.gumtree.co.za/v1/images/za_listing_s16f1m78tnlbb6k_261013_6ace559548903500011e5fa9", "image_urls": [ - "https://gms.gumtree.co.za/v2/images/za_ads_134886807_261011_6acb5c7c93b58b85367bdfe1?size=l" + "https://media.gumtree.co.za/v1/images/za_listing_s16f1m78tnlbb6k_261013_6ace559548903500011e5fa9" ], - "image_count": 2, - "posted_text": "1d", - "posted_utc": null, - "posted_at": null, - "status": "ACTIVE", - "seller_type": "Private", - "seller_id": null, - "seller_name": "Richard Ngwenya", + "image_count": 1, + "posted_text": null, + "posted_utc": 1791907542.222, + "posted_at": "2026-10-13T16:05:42.222000Z", + "status": null, + "seller_type": null, + "seller_id": "s16f1m78tnlbb6k", + "seller_name": "mr golf", "account_id": null, - "is_pro_seller": false, + "is_pro_seller": null, "is_featured": null, "is_urgent": null, "is_bumped_up": null, @@ -1887,135 +1853,159 @@ "seller_skills": [], "attributes": [ { - "key": "CarYear", - "label": null, - "value": "2002", - "unit": null - }, - { - "key": "CarTransmission", - "label": null, - "value": "Automatic", + "key": "mileageKm", + "label": "Kilometres", + "value": "179000", "unit": null }, { - "key": "ForSaleBy", - "label": null, - "value": "Owner", + "key": "transmission", + "label": "Transmission", + "value": "Manual", "unit": null }, { - "key": "FuelType", - "label": null, + "key": "fuelType", + "label": "Fuel Type", "value": "Petrol", "unit": null - }, - { - "key": "CarBodyType", - "label": null, - "value": "Hatchback", - "unit": null - }, - { - "key": "CarMake", - "label": null, - "value": "Volkswagen", - "unit": null - }, - { - "key": "CarModel", - "label": null, - "value": "Golf", - "unit": null - }, + } + ] + }, + { + "id": "ls269wf5j1z7p6wrrwf", + "short_id": null, + "title": "2014 Toyota Quantum MPV/Bus", + "url": "https://www.gumtree.co.za/listing/ls269wf5j1z7p6wrrwf", + "description": "2014 Toyota Quantum 2.5D-4D GL 14-Seater. •Cash Price R75,000 •Accident free •Full franchise...", + "price": null, + "price_text": "POA", + "price_frequency": null, + "price_type": "POA", + "currency": "ZAR", + "location": "Johannesburg", + "location_id": "bm0kj", + "postcode": null, + "latitude": null, + "longitude": null, + "distance_text": null, + "distance_miles": null, + "category_id": "p6dnh", + "category_name": null, + "category_ids": [ + "kyzkk", + "l4dq6", + "p6dnh" + ], + "image_url": "https://media.gumtree.co.za/v1/images/za_listing_s265lks2w7q4wjb_261013_6ace4ec748903500011e5e3e", + "image_urls": [ + "https://media.gumtree.co.za/v1/images/za_listing_s265lks2w7q4wjb_261013_6ace4ec748903500011e5e3e" + ], + "image_count": 1, + "posted_text": null, + "posted_utc": 1791905779.817, + "posted_at": "2026-10-13T15:36:19.817000Z", + "status": null, + "seller_type": null, + "seller_id": "s265lks2w7q4wjb", + "seller_name": "Patience Kupiso", + "account_id": null, + "is_pro_seller": null, + "is_featured": null, + "is_urgent": null, + "is_bumped_up": null, + "is_standout": null, + "is_premium": null, + "has_video": null, + "supports_shipping": null, + "favourite_count": null, + "seller_skills": [], + "attributes": [ { - "key": "CarColor", - "label": null, - "value": "Black", + "key": "mileageKm", + "label": "Kilometres", + "value": "121000", "unit": null }, { - "key": "CarMileageInKms", - "label": null, - "value": "190km", + "key": "transmission", + "label": "Transmission", + "value": "Manual", "unit": null }, { - "key": "CarDriveType", - "label": null, - "value": "4x2", + "key": "fuelType", + "label": "Fuel Type", + "value": "Diesel", "unit": null } ] } ], "category_facets": [], - "url": "https://www.gumtree.co.za/s-x/x/v1c9077l3100003p1" + "url": "https://www.gumtree.co.za/search" }, - "GET /v1/gumtree/listings/10013681289561010209976109?market=za": { + "GET /v1/gumtree/listings/ls26bvmcpnsqmz7l9jz?market=za": { "market": "za", - "id": "10013681289561010209976109", - "title": "Bunkbed Pine Wood Single R2600", - "url": "https://www.gumtree.co.za/a-beds-headboards/centurion/bunkbed-pine-wood-single-r2600/10013681289561010209976109", - "description": "Second hand Single Size Bunkbed in Painted Pine Wood with matching Pedestal available in lyttelton Centurion.", - "price": 2600.0, - "price_text": "R 2,600", + "id": "ls26bvmcpnsqmz7l9jz", + "title": "Toyota Yaris", + "url": "https://www.gumtree.co.za/listing/ls26bvmcpnsqmz7l9jz", + "description": "2018 Toyota Yaris\n1.5XS CVT\nAutomatic\n85000km\nFSH with Toyota (service books including all manuals)\nSpare key\nKeyless entry\nNavigation\nSmash and grab tint", + "price": 190000.0, + "price_text": "R190,000", "price_frequency": null, - "price_type": "FIXED", + "price_type": "ASKING_PRICE", "currency": "ZAR", "vat": null, - "location": "Centurion", + "location": "Weltevreden Park", "area": "Gauteng", - "sub_area": "Pretoria / Tshwane", - "location_id": "3100247", + "sub_area": null, + "location_id": "mxm68", "postcode": null, - "latitude": -25.862281, - "longitude": 28.088474, - "category_id": 9272, - "category_name": "Beds & Headboards", - "category_ids": [], + "latitude": null, + "longitude": null, + "category_id": "p6dnh", + "category_name": "Cars & Bakkies", + "category_ids": [ + "kyzkk", + "l4dq6", + "p6dnh" + ], "breadcrumbs": [ { - "name": "Gauteng", - "url": "https://www.gumtree.co.za/s-gauteng/v1l3100003p1" - }, - { - "name": "Pretoria / Tshwane", - "url": "https://www.gumtree.co.za/s-pretoria-tshwane/v1l3100094p1" + "name": "All Categories", + "url": null }, { - "name": "Home & Garden Supplies", - "url": "https://www.gumtree.co.za/s-home-garden/centurion/v1c9175l3100247p1" + "name": "Vehicles", + "url": null }, { - "name": "Furniture", - "url": "https://www.gumtree.co.za/s-furniture/centurion/v1c9181l3100247p1" - }, - { - "name": "Bedroom Furniture", - "url": "https://www.gumtree.co.za/s-bedroom-furniture/centurion/v1c9699l3100247p1" - }, - { - "name": "Beds & Headboards", - "url": "https://www.gumtree.co.za/s-beds-headboards/centurion/v1c9272l3100247p1" + "name": "Cars & Bakkies", + "url": null } ], "image_urls": [ - "https://gms.gumtree.co.za/v2/images/za_ads_102099761_261012_6acc81ab93b58b8535042afc?size=l", - "https://gms.gumtree.co.za/v2/images/za_ads_102099761_261012_6acc81ad93b58b8535042afd?size=l", - "https://gms.gumtree.co.za/v2/images/za_ads_102099761_261012_6acc81b093b58b8535042afe?size=l" + "https://media.gumtree.co.za/v1/images/za_listing_s16zcgy0ntjnvg2_261013_6ace6b509064a60001fdf52c", + "https://media.gumtree.co.za/v1/images/za_listing_s16zcgy0ntjnvg2_261013_6ace6b539064a60001fdf531", + "https://media.gumtree.co.za/v1/images/za_listing_s16zcgy0ntjnvg2_261013_6ace6b559064a60001fdf537", + "https://media.gumtree.co.za/v1/images/za_listing_s16zcgy0ntjnvg2_261013_6ace6b589064a60001fdf539", + "https://media.gumtree.co.za/v1/images/za_listing_s16zcgy0ntjnvg2_261013_6ace6b5a9064a60001fdf53a", + "https://media.gumtree.co.za/v1/images/za_listing_s16zcgy0ntjnvg2_261013_6ace6b5d9064a60001fdf53b", + "https://media.gumtree.co.za/v1/images/za_listing_s16zcgy0ntjnvg2_261013_6ace6b609064a60001fdf53c", + "https://media.gumtree.co.za/v1/images/za_listing_s16zcgy0ntjnvg2_261013_6ace6b649064a60001fdf53d" ], "video_urls": [], - "posted_text": "1 hour ago", - "posted_utc": 1791787539.0, - "posted_at": "2026-10-12T06:45:39Z", - "created_utc": 1791787539.0, - "created_at": "2026-10-12T06:45:39Z", - "updated_utc": 1791787539.0, - "updated_at": "2026-10-12T06:45:39Z", - "expires_at": "2026-11-11", - "status": "ACTIVE", - "view_count": 65, + "posted_text": null, + "posted_utc": 1791912864.365, + "posted_at": "2026-10-13T17:34:24.365000Z", + "created_utc": null, + "created_at": null, + "updated_utc": null, + "updated_at": null, + "expires_at": "2026-11-12", + "status": "PUBLISHED", + "view_count": 3, "reply_count": null, "is_featured": null, "is_urgent": null, @@ -2025,66 +2015,160 @@ "vehicle_history_checked": null, "attributes": [ { - "key": "Condition", - "label": null, - "value": "used", + "key": "sellerType", + "label": "Seller Type", + "value": "Owner", "unit": null }, { - "key": "BedMattressesSize", - "label": null, - "value": "single", + "key": "make", + "label": "Make", + "value": "Toyota", "unit": null }, { - "key": "Material", - "label": null, - "value": "wood", + "key": "model", + "label": "Model", + "value": "Yaris", "unit": null }, { - "key": "FurnitureColour", - "label": null, - "value": "blue", + "key": "bodyType", + "label": "Body Type", + "value": "Hatchback", "unit": null }, { - "key": "ForSaleBy", - "label": null, - "value": "delr", + "key": "year", + "label": "Year", + "value": "2018", + "unit": null + }, + { + "key": "mileageKm", + "label": "Kilometres", + "value": "85000", + "unit": null + }, + { + "key": "driveType", + "label": "Drive Type", + "value": "4x2", + "unit": null + }, + { + "key": "transmission", + "label": "Transmission", + "value": "Automatic", + "unit": null + }, + { + "key": "fuelType", + "label": "Fuel Type", + "value": "Petrol", + "unit": null + }, + { + "key": "colour", + "label": "Colour", + "value": "Blue", "unit": null } ], "seller": { - "name": "Paul", - "user_id": "102099761", - "public_id": null, - "type": "Trade", - "is_pro": false, - "posting_since": "Selling for 12+ years", - "member_since_year": null, + "name": "Thanuj", + "user_id": "s16zcgy0ntjnvg2", + "public_id": "s16zcgy0ntjnvg2", + "type": "Private", + "is_pro": null, + "posting_since": null, + "member_since_year": 2016, "last_active_text": null, "last_active_utc": null, "last_active_at": null, - "total_listings": 183, - "lifetime_listings": 3100, - "total_views": 263430, + "total_listings": null, + "lifetime_listings": null, + "total_views": null, "listings_last_year": null, "listings_by_category": {}, "reply_speed": null, "reply_rate": null, "rating": null, - "verification": null, - "phone_masked": "068*******", + "verification": { + "status": null, + "label": null, + "checks": [ + "identity", + "mobileNumber" + ] + }, + "phone_masked": null, "website_url": null, "avatar_url": null, "logo_url": null, "badges": [] }, "seller_skills": [], - "seller_listings_url": "https://www.gumtree.co.za/u-seller-listings/x/v1u102099761p1", + "seller_listings_url": null, "similar_listings": [] }, + "GET /v1/gumtree/categories?market=za&parent_id=l4dq6": { + "market": "za", + "id": "l4dq6", + "name": "Vehicles", + "slug": "vehicles", + "result_count": 8, + "children": [ + { + "id": "c07q9", + "name": "Auto Parts & Accessories", + "slug": "auto-parts-accessories", + "has_children": true + }, + { + "id": "f5ph8", + "name": "Caravans & Trailers", + "slug": "caravans-trailers", + "has_children": false + }, + { + "id": "p6dnh", + "name": "Cars & Bakkies", + "slug": "cars-bakkies", + "has_children": false + }, + { + "id": "hpjhk", + "name": "Classic & Vintage Cars", + "slug": "classic-vintage-cars", + "has_children": false + }, + { + "id": "k6xjq", + "name": "Farm Vehicles & Equipment", + "slug": "farm-vehicles-equipment", + "has_children": false + }, + { + "id": "zw82s", + "name": "Medium & Heavy Commercials", + "slug": "medium-heavy-commercials", + "has_children": true + }, + { + "id": "v062d", + "name": "Motorcycle Parts & Accessories", + "slug": "motorcycle-parts-accessories", + "has_children": true + }, + { + "id": "b8stt", + "name": "Motorcycles & Scooters", + "slug": "motorcycles-scooters", + "has_children": false + } + ] + }, "GET /v1/gumtree/search?market=au": { "market": "au", "query": null, diff --git a/tests/test_gumtree.py b/tests/test_gumtree.py index b3e8306..ceedbc1 100644 --- a/tests/test_gumtree.py +++ b/tests/test_gumtree.py @@ -43,11 +43,14 @@ CATEGORIES = SAMPLES["GET /v1/gumtree/categories?parent_id=2551"] LOCATIONS = SAMPLES["GET /v1/gumtree/locations?parent_id=10000393"] MARKETS = SAMPLES["GET /v1/gumtree/markets"] -ZA_SEARCH = SAMPLES["GET /v1/gumtree/search?market=za&category=9077&location=3100003"] -ZA_LISTING = SAMPLES["GET /v1/gumtree/listings/10013681289561010209976109?market=za"] +ZA_SEARCH = SAMPLES[ + "GET /v1/gumtree/search?market=za&category=p6dnh&location=dkp8y&filters=make:toyota&sort=date" +] +ZA_LISTING = SAMPLES["GET /v1/gumtree/listings/ls26bvmcpnsqmz7l9jz?market=za"] +ZA_CATEGORIES = SAMPLES["GET /v1/gumtree/categories?market=za&parent_id=l4dq6"] AU_SEARCH = SAMPLES["GET /v1/gumtree/search?market=au"] AU_LISTING = SAMPLES["GET /v1/gumtree/listings/1339574407?market=au"] -ZA_LISTING_ID = "10013681289561010209976109" +ZA_LISTING_ID = "ls26bvmcpnsqmz7l9jz" @pytest.fixture @@ -145,7 +148,7 @@ async def test_search_all_params( async def test_search_market(self, gumtree: GumtreeClient, mock_base_client: MagicMock) -> None: mock_base_client.get.return_value = ZA_SEARCH result = await gumtree.search( - market="za", category="9077", location="3100003", filters="ma:toyota" + market="za", category="p6dnh", location="dkp8y", filters="make:toyota" ) assert result.market == "za" @@ -153,9 +156,9 @@ async def test_search_market(self, gumtree: GumtreeClient, mock_base_client: Mag assert path == "/v1/gumtree/search" assert params is not None assert params["market"] == "za" - assert params["category"] == "9077" - assert params["location"] == "3100003" - assert params["filters"] == "ma:toyota" + assert params["category"] == "p6dnh" + assert params["location"] == "dkp8y" + assert params["filters"] == "make:toyota" async def test_search_filters( self, gumtree: GumtreeClient, mock_base_client: MagicMock @@ -179,15 +182,15 @@ async def test_search_filters_market( self, gumtree: GumtreeClient, mock_base_client: MagicMock ) -> None: mock_base_client.get.return_value = FILTERS - await gumtree.search_filters("9077", filters="ma:toyota", market="za") + await gumtree.search_filters("p6dnh", filters="make:toyota", market="za") assert _call(mock_base_client) == ( "/v1/gumtree/search/filters", { - "category": "9077", + "category": "p6dnh", "location": None, "query": None, - "filters": "ma:toyota", + "filters": "make:toyota", "market": "za", }, ) @@ -365,6 +368,7 @@ class TestGumtreeModels: (MarketsResponse, MARKETS), (SearchResponse, ZA_SEARCH), (ListingDetail, ZA_LISTING), + (TreeResponse, ZA_CATEGORIES), (SearchResponse, AU_SEARCH), (ListingDetail, AU_LISTING), ], @@ -394,23 +398,44 @@ def test_uk_responses_default_to_uk(self) -> None: assert (legacy.market, legacy.currency) == ("uk", "GBP") def test_za_fields(self) -> None: + # gumtree.co.za re-platformed on 2026-10-12: ids are refs (strings). search = SearchResponse.model_validate(ZA_SEARCH) - assert (search.market, search.currency, search.total_results) == ("za", "ZAR", 2964) + assert (search.market, search.currency, search.total_results) == ("za", "ZAR", 429) + assert (search.category_id, search.total_pages) == ("p6dnh", 19) car = search.listings[0] - assert car.id == "10013681279811013062940409" - assert car.short_id == "1368127981" - assert car.price == 248000 - assert car.price_type == "FIXED" - assert car.seller_name == "Kevin Noel Ngandu" - assert search.listings[1].price_type == "MAKE_OFFER" + assert car.id == "ls26bvmcpnsqmz7l9jz" + assert car.short_id is None + assert (car.price, car.price_text, car.price_type) == (190000, "R190,000", "ASKING_PRICE") + assert (car.category_id, car.category_ids) == ("p6dnh", ["kyzkk", "l4dq6", "p6dnh"]) + assert car.location_id == "mxm68" listing = ListingDetail.model_validate(ZA_LISTING) assert listing.market == "za" - assert (listing.price_type, listing.status, listing.view_count) == ("FIXED", "ACTIVE", 65) + assert (listing.price_type, listing.status) == ("ASKING_PRICE", "PUBLISHED") + assert listing.category_id == "p6dnh" assert listing.seller is not None - assert listing.seller.lifetime_listings == 3100 - assert listing.seller.total_views == 263430 - assert listing.seller.phone_masked == "068*******" + assert listing.seller.member_since_year == 2016 + assert listing.seller.phone_masked is None + + def test_za_tree_ids_are_refs(self) -> None: + tree = TreeResponse.model_validate(ZA_CATEGORIES) + assert (tree.id, tree.name) == ("l4dq6", "Vehicles") + cars = next(c for c in tree.children if c.name == "Cars & Bakkies") + assert (cars.id, cars.slug) == ("p6dnh", "cars-bakkies") + # uk/au ids stay ints + assert TreeResponse.model_validate(CATEGORIES).id == 2551 + + async def test_za_parent_id_is_a_ref( + self, gumtree: GumtreeClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = ZA_CATEGORIES + result = await gumtree.list_categories("l4dq6", market="za") + + assert result.id == "l4dq6" + assert _call(mock_base_client) == ( + "/v1/gumtree/categories", + {"parent_id": "l4dq6", "market": "za"}, + ) def test_au_fields(self) -> None: search = SearchResponse.model_validate(AU_SEARCH) diff --git a/uv.lock b/uv.lock index 9129ce2..62695e4 100644 --- a/uv.lock +++ b/uv.lock @@ -930,7 +930,7 @@ wheels = [ [[package]] name = "scrapebadger" -version = "0.60.0" +version = "0.60.1" source = { editable = "." } dependencies = [ { name = "httpx" },