From 2913141bc01ecef6ea3524c3549073b0491c97c1 Mon Sep 17 00:00:00 2001 From: kasparasizi1 <132673909+kasparasizi1@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:40:56 +0300 Subject: [PATCH 1/2] feat(gumtree): Australia and South Africa markets (SCR-255) Every client.gumtree method takes an optional market (uk default, au, za); location/parent_id are unset by default so each market's root applies. Adds list_markets() and the new response fields (market, currency, short_id, price_type, seller_name, facet id, status, view/reply counts, seller member_since_year, last_active_text, lifetime_listings, total_views). Bumps to 0.60.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + README.md | 2 +- pyproject.toml | 2 +- src/scrapebadger/__init__.py | 10 +- src/scrapebadger/_internal/client.py | 2 +- src/scrapebadger/gumtree/__init__.py | 19 +- src/scrapebadger/gumtree/client.py | 204 +++++-- src/scrapebadger/gumtree/models.py | 91 ++- tests/fixtures/gumtree_samples.json | 795 +++++++++++++++++++++++++++ tests/test_gumtree.py | 201 ++++++- uv.lock | 2 +- 11 files changed, 1248 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa3aaa0..eb40ac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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. +- **`gumtree.list_markets()`** returns each market's domain, country, currency, distance unit, page size and id format (`GumtreeMarketsResponse` / `GumtreeMarketInfo`). +- **Behaviour change:** `search` and `search_filters` no longer send `location="uk"` by default — unset means the whole country of the chosen market. `search_filters` also takes `filters` (za: filters already applied, e.g. `"ma:toyota"` to list Toyota models). +- **New response fields:** `market` on every top-level response; `currency` on `SearchResponse`; `short_id`, `price_type` and `seller_name` on listings; `id` on category facets (the au/za category id); `price_type`, `status`, `view_count` and `reply_count` on `ListingDetail`; `member_since_year`, `last_active_text`, `lifetime_listings` and `total_views` on `Seller`. +- Australian `search` and `search_filters` cost 10 credits and Australian `list_locations` 5 credits (browser-rendered); everything else is priced as before. + ## 0.59.0 — 2026-10-12 - **Gumtree API — 8 endpoints via `client.gumtree`.** UK classifieds (gumtree.com): `search` (keywords, `category` slug, `location` slug/town/postcode with a `distance` radius in miles, `min_price`/`max_price` in GBP, `sort`, `seller_type`, `urgent_only`, `with_photos`, category-specific `filters="key:value,..."`, `page` 1–50), `search_filters` (a category's filters, sort orders and radius options with live counts), `get_listing` (description, every attribute incl. full vehicle spec and history-check flags, images, location, seller with rating, verification and masked phone, similar listings), `get_seller_listings`, `autocomplete`, `location_autocomplete`, `list_locations` and `list_categories`. Dates come as both Unix `*_utc` and ISO 8601 `*_at`. Models are exported top-level with a `Gumtree` prefix (e.g. `GumtreeListingDetail`). diff --git a/README.md b/README.md index ad7a0bb..de1c2ee 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ export SCRAPEBADGER_API_KEY="sb_live_xxxxxxxxxxxxx" | **Apartments.com** | US rental listings with UNIT-LEVEL pricing — search by location with bed/price filters, plus per-unit rent, beds, baths, sqft and availability for every rentable unit | [Apartments Guide](docs/apartments.md) | | **Wallapop** | 10 endpoints across Spain, Italy and Portugal — listing search with Wallapop's own filters (incl. cars: year, km, gearbox, horsepower), listing detail, user profile/listings/reviews, autocomplete, categories, search filters and filter options, markets | [Wallapop Docs](https://docs.scrapebadger.com/wallapop/overview) | | **Walmart** | 11 endpoints (US-only) — search, category browse, deals feed, autocomplete, product detail, reviews, seller profile/products, store detail, markets | [Walmart Guide](docs/walmart.md) | -| **Gumtree** | 8 endpoints (UK-only) — search (category, postcode radius, price, seller type, urgent/photos, category-specific filters), search filters with live counts, listing detail (full vehicle spec, seller rating/verification, masked phone), seller listings, keyword and location autocomplete, location and category trees | [Gumtree Docs](https://docs.scrapebadger.com/gumtree/overview) | +| **Gumtree** | 9 endpoints across UK, Australia and South Africa (`market="uk"` default, `"au"`, `"za"`) — search (category, location/postcode radius, price, seller type, urgent/photos, category-specific filters), search filters with live counts, listing detail (full vehicle spec, seller rating/verification, masked phone), seller listings, keyword and location autocomplete, location and category trees, markets. E.g. `client.gumtree.search(market="au", category="18320", location="3003435", distance=10)` or `client.gumtree.search(market="za", category="9077", location="3100003")` | [Gumtree Docs](https://docs.scrapebadger.com/gumtree/overview) | | **Baidu** | 4 endpoints — web search (language + date filters), news vertical, image search, autocomplete. Results carry the **real target URL**, not just Baidu's tracking redirect | [Baidu Docs](https://docs.scrapebadger.com/baidu/overview) | | **Bing** | 6 endpoints — web search (with ads + related searches), image search, video search, news vertical, autocomplete, markets | [Bing Docs](https://docs.scrapebadger.com/bing/overview) | | **DuckDuckGo** | 7 endpoints — web search (with abstract box), image/news/video search, autocomplete, instant answers, regions | [DuckDuckGo Docs](https://docs.scrapebadger.com/duckduckgo/overview) | diff --git a/pyproject.toml b/pyproject.toml index 10ddabc..f1eee75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scrapebadger" -version = "0.59.0" +version = "0.60.0" 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 fee9d0b..68e579a 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -327,6 +327,12 @@ async def main(): from scrapebadger.gumtree.models import ( LocationSuggestion as GumtreeLocationSuggestion, ) +from scrapebadger.gumtree.models import ( + MarketInfo as GumtreeMarketInfo, +) +from scrapebadger.gumtree.models import ( + MarketsResponse as GumtreeMarketsResponse, +) from scrapebadger.gumtree.models import ( SearchFilter as GumtreeSearchFilter, ) @@ -1329,7 +1335,7 @@ async def main(): ZestimateHistoryPoint as ZillowZestimateHistoryPoint, ) -__version__ = "0.59.0" +__version__ = "0.60.0" __all__ = [ # TikTok core models @@ -1485,6 +1491,8 @@ async def main(): "GumtreeListingSummary", "GumtreeLocationAutocompleteResponse", "GumtreeLocationSuggestion", + "GumtreeMarketInfo", + "GumtreeMarketsResponse", "GumtreeSearchFilter", "GumtreeSearchResponse", "GumtreeSeller", diff --git a/src/scrapebadger/_internal/client.py b/src/scrapebadger/_internal/client.py index c3da701..4a74eac 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.59.0" +SDK_VERSION = "0.60.0" USER_AGENT = f"scrapebadger-python/{SDK_VERSION}" diff --git a/src/scrapebadger/gumtree/__init__.py b/src/scrapebadger/gumtree/__init__.py index 4cc8bcb..30927ef 100644 --- a/src/scrapebadger/gumtree/__init__.py +++ b/src/scrapebadger/gumtree/__init__.py @@ -1,9 +1,9 @@ """Gumtree API module for ScrapeBadger SDK. -This module provides an async client for scraping gumtree.com (United Kingdom) -classifieds through the ScrapeBadger API. All methods are async and return -strongly-typed Pydantic models. Gumtree is UK-only, so no method takes a -market/country parameter. +This module provides an async client for scraping Gumtree classifieds through +the ScrapeBadger API: gumtree.com (``market="uk"``, the default), +gumtree.com.au (``"au"``) and gumtree.co.za (``"za"``). All methods are async +and return strongly-typed Pydantic models. Example: ```python @@ -18,10 +18,14 @@ # Listing detail 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 + au = await client.gumtree.search("road bike", market="au", location="3003435") + za = await client.gumtree.search(market="za", category="9077", location="3100003") ``` """ -from scrapebadger.gumtree.client import GumtreeClient +from scrapebadger.gumtree.client import GumtreeClient, GumtreeMarket from scrapebadger.gumtree.models import ( Attribute, AutocompleteResponse, @@ -34,6 +38,8 @@ ListingSummary, LocationAutocompleteResponse, LocationSuggestion, + MarketInfo, + MarketsResponse, SearchFilter, SearchResponse, Seller, @@ -55,10 +61,13 @@ "FilterOption", "FiltersResponse", "GumtreeClient", + "GumtreeMarket", "ListingDetail", "ListingSummary", "LocationAutocompleteResponse", "LocationSuggestion", + "MarketInfo", + "MarketsResponse", "SearchFilter", "SearchResponse", "Seller", diff --git a/src/scrapebadger/gumtree/client.py b/src/scrapebadger/gumtree/client.py index d43a219..3dcb6a5 100644 --- a/src/scrapebadger/gumtree/client.py +++ b/src/scrapebadger/gumtree/client.py @@ -4,9 +4,15 @@ price, seller-type and category-specific filters), search_filters (the filters, sort orders and radius options a category offers, with live counts), get_listing (full detail incl. vehicle spec and seller), get_seller_listings, -keyword autocomplete, location autocomplete, and the location and category -trees. All methods are async and return strongly-typed Pydantic models. -UK-only: gumtree.com is the single supported market, prices are in GBP. +keyword autocomplete, location autocomplete, the location and category trees, +and list_markets. All methods are async and return strongly-typed Pydantic +models. + +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. """ from __future__ import annotations @@ -18,6 +24,7 @@ FiltersResponse, ListingDetail, LocationAutocompleteResponse, + MarketsResponse, SearchResponse, SellerListingsResponse, TreeResponse, @@ -26,6 +33,7 @@ if TYPE_CHECKING: from scrapebadger._internal.client import BaseClient +GumtreeMarket = Literal["uk", "au", "za"] GumtreeSort = Literal["relevance", "date", "price_asc", "price_desc", "distance"] GumtreeSellerType = Literal["private", "trade"] @@ -38,7 +46,7 @@ class GumtreeClient: from scrapebadger import ScrapeBadger async with ScrapeBadger(api_key="your-key") as client: - # Search used BMWs within 25 miles of a postcode + # Search used BMWs within 25 miles of a UK postcode results = await client.gumtree.search( category="cars", location="SW1A 1AA", @@ -48,12 +56,18 @@ class GumtreeClient: for listing in results.listings: print(listing.title, listing.price_text, listing.distance_text) - # Full listing detail with vehicle spec and seller - detail = await client.gumtree.get_listing("1803293391") + # Australia: numeric ids — cars (18320) within 10 km of Sydney + au = await client.gumtree.search( + 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") + detail = await client.gumtree.get_listing(za.listings[0].id, market="za") print(detail.seller.name if detail.seller else None) - # Browse the category tree - tree = await client.gumtree.list_categories() + # Browse a market's category tree + tree = await client.gumtree.list_categories(market="au") ``` Note: @@ -74,7 +88,7 @@ async def search( query: str | None = None, *, category: str = "all", - location: str = "uk", + location: str | None = None, distance: float | None = None, min_price: int | None = None, max_price: int | None = None, @@ -84,39 +98,51 @@ async def search( with_photos: bool = False, filters: str | None = None, page: int = 1, + market: GumtreeMarket | None = None, ) -> SearchResponse: - """Search gumtree.com listings. + """Search Gumtree listings in the UK, Australia or South Africa. Args: query: Search keywords, e.g. ``"mountain bike"``. Optional — a category alone browses it. - category: Category slug, e.g. ``"cars"``, ``"for-sale"``, - ``"property-to-rent"``, ``"jobs"``, ``"bicycles"``. Browse the + 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"``. - location: Location slug (``"london"``), town name or UK postcode - (``"SW1A 1AA"``). Resolve one with :meth:`location_autocomplete`. - Default ``"uk"`` (nationwide). - distance: Radius in miles around ``location`` (>0, max 1000). - min_price: Minimum price in GBP. Needs a category other than ``"all"``. - max_price: Maximum price in GBP. Needs a category other than ``"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. + 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 + category other than ``"all"``). + max_price: Maximum price in the market's currency (uk: needs a + category other than ``"all"``). sort: ``"relevance"`` (default), ``"date"``, ``"price_asc"``, - ``"price_desc"`` or ``"distance"``. - seller_type: ``"private"`` or ``"trade"`` (dealers, agencies, recruiters). - urgent_only: Only ads marked urgent. - with_photos: Only ads with photos. + ``"price_desc"`` or ``"distance"`` (not za). + seller_type: ``"private"`` or ``"trade"`` (dealers, agencies, + recruiters). uk and za. + urgent_only: uk only: only ads marked urgent. + 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"``. - Repeat a key to select several values. Discover keys with - :meth:`search_filters`. + pairs, e.g. ``"vehicle_make:bmw,min_vehicle_mileage:10000"`` (uk) + or ``"ma:toyota,fu:petrol,max_km:100000"`` (za). Range filters + take ``min_``/``max_``. Discover keys with + :meth:`search_filters` for the same market. page: Page number, 1-50. + market: ``"uk"`` (gumtree.com), ``"au"`` (gumtree.com.au) or ``"za"`` + (gumtree.co.za). Unset = the server default, ``"uk"``. Returns: - SearchResponse with ~25-30 listings per page. Gumtree pins up to 5 - paid featured ads (``is_featured``) above the chosen sort order. + SearchResponse — uk ~25-30 listings per page (Gumtree pins up to 5 + paid featured ads, ``is_featured``, above the chosen sort order), + au 24, za 20. Prices are in ``currency``. Raises: AuthenticationError: If the API key is invalid. - ValidationError: If the parameters are invalid. + ValidationError: If the parameters are invalid, or not supported + by the market. """ params: dict[str, Any] = { "query": query, @@ -131,6 +157,7 @@ async def search( "with_photos": with_photos or None, "filters": filters, "page": page, + "market": market, } response = await self._client.get("/v1/gumtree/search", params=params) return SearchResponse.model_validate(response) @@ -139,8 +166,10 @@ async def search_filters( self, category: str = "all", *, - location: str = "uk", + location: str | None = None, query: str | None = None, + filters: str | None = None, + market: GumtreeMarket | None = None, ) -> FiltersResponse: """The filters, sort orders and radius options Gumtree offers for a category. @@ -148,9 +177,14 @@ async def search_filters( a ``value`` back into :meth:`search` as ``filters="key:value"``. Args: - category: Category slug, e.g. ``"cars"``. Default ``"all"``. - location: Location slug, town or UK postcode. Default ``"uk"``. + 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. query: Optional keywords to scope the counts. + filters: za only: filters already applied, e.g. ``"ma:toyota"`` to + list the Toyota models. + market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``. Returns: FiltersResponse with filters, sort options, distance options and @@ -163,19 +197,30 @@ async def search_filters( print(f.key, [o.value for o in f.options][:5]) ``` """ - params: dict[str, Any] = {"category": category, "location": location, "query": query} + params: dict[str, Any] = { + "category": category, + "location": location, + "query": query, + "filters": filters, + "market": market, + } response = await self._client.get("/v1/gumtree/search/filters", params=params) return FiltersResponse.model_validate(response) - async def get_listing(self, listing_id: str) -> ListingDetail: + async def get_listing( + self, listing_id: str, *, market: GumtreeMarket | None = None + ) -> ListingDetail: """Full listing: description, every attribute, images, location, seller and similar ads. - Motors listings carry the full vehicle spec (make, model, mileage, MPG, - emissions, insurance group, history-check flags). + Motors listings carry the full vehicle spec (uk: make, model, mileage, + MPG, emissions, insurance group, history-check flags; au: make, model, + variant, odometer, VIN, rego). Args: - listing_id: Gumtree listing id — the number at the end of a - ``gumtree.com/p/...`` URL. + 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). + market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``. Returns: ListingDetail. @@ -183,11 +228,13 @@ async def get_listing(self, listing_id: str) -> ListingDetail: Raises: NotFoundError: If the listing does not exist or has expired. """ - response = await self._client.get(f"/v1/gumtree/listings/{listing_id}") + response = await self._client.get( + f"/v1/gumtree/listings/{listing_id}", params={"market": market} + ) return ListingDetail.model_validate(response) async def get_seller_listings( - self, listing_id: str, *, page: int = 1 + self, listing_id: str, *, page: int = 1, market: GumtreeMarket | None = None ) -> SellerListingsResponse: """Every live listing by the seller of ``listing_id``. @@ -195,66 +242,111 @@ async def get_seller_listings( of theirs works. Args: - listing_id: Any Gumtree listing id belonging to the seller. + listing_id: Any Gumtree listing id belonging to the seller (za: the + 26-digit id). page: Page number, 1-50. + market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``. Returns: SellerListingsResponse with the seller's name and listings. """ response = await self._client.get( - f"/v1/gumtree/listings/{listing_id}/seller-listings", params={"page": page} + f"/v1/gumtree/listings/{listing_id}/seller-listings", + params={"page": page, "market": market}, ) return SellerListingsResponse.model_validate(response) - async def autocomplete(self, query: str) -> AutocompleteResponse: + async def autocomplete( + self, query: str, *, market: GumtreeMarket | None = None + ) -> AutocompleteResponse: """Gumtree search-box keyword suggestions, each with the category it maps to. Args: query: Partial search term, e.g. ``"iph"``. + market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``. Returns: AutocompleteResponse. """ - response = await self._client.get("/v1/gumtree/autocomplete", params={"query": query}) + response = await self._client.get( + "/v1/gumtree/autocomplete", params={"query": query, "market": market} + ) return AutocompleteResponse.model_validate(response) - async def location_autocomplete(self, query: str) -> LocationAutocompleteResponse: - """Resolve a place name or postcode prefix to Gumtree locations with coordinates. + async def location_autocomplete( + self, query: str, *, market: GumtreeMarket | None = None + ) -> 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``. Args: query: Town, area or postcode prefix (min 2 chars), e.g. ``"manch"`` or ``"SW1A"``. + market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``. Returns: LocationAutocompleteResponse. """ response = await self._client.get( - "/v1/gumtree/locations/autocomplete", params={"query": query} + "/v1/gumtree/locations/autocomplete", params={"query": query, "market": market} ) return LocationAutocompleteResponse.model_validate(response) - async def list_locations(self, parent_id: int | None = None) -> TreeResponse: - """Browse Gumtree's location tree: UK -> nations -> counties -> towns. + async def list_locations( + self, parent_id: int | None = None, *, market: GumtreeMarket | None = None + ) -> TreeResponse: + """Browse Gumtree's location tree. + + uk: UK -> nations -> counties -> towns. au: states -> regions -> areas. + za: provinces -> towns. Args: - parent_id: Location id to list the children of. Defaults to the - United Kingdom (``10000392``) server-side. + parent_id: Location id to list the children of. Unset = the + market's root (the whole country) server-side. + market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``. Returns: TreeResponse whose ``children`` carry ``id``, ``name`` and ``slug``. """ - response = await self._client.get("/v1/gumtree/locations", params={"parent_id": parent_id}) + response = await self._client.get( + "/v1/gumtree/locations", params={"parent_id": parent_id, "market": market} + ) return TreeResponse.model_validate(response) - async def list_categories(self, parent_id: int | None = None) -> TreeResponse: - """Browse Gumtree's category tree. A node's ``slug`` is the :meth:`search` category. + async def list_categories( + self, parent_id: int | 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. Args: - parent_id: Category id to list the children of. Defaults to all - categories (``1``) server-side. + parent_id: Category id to list the children of. Unset = the + market's root (all categories) server-side. + market: ``"uk"``, ``"au"`` or ``"za"``. Unset = ``"uk"``. Returns: TreeResponse whose ``children`` carry ``id``, ``name`` and ``slug``. """ - response = await self._client.get("/v1/gumtree/categories", params={"parent_id": parent_id}) + response = await self._client.get( + "/v1/gumtree/categories", params={"parent_id": parent_id, "market": market} + ) return TreeResponse.model_validate(response) + + async def list_markets(self) -> MarketsResponse: + """The Gumtree sites this API covers, with their currency and id formats. + + Returns: + MarketsResponse — one entry per market (``uk``, ``au``, ``za``). + + Example: + ```python + result = await client.gumtree.list_markets() + for m in result.markets: + print(m.market, m.domain, m.currency, m.distance_unit) + ``` + """ + response = await self._client.get("/v1/gumtree/markets") + return MarketsResponse.model_validate(response) diff --git a/src/scrapebadger/gumtree/models.py b/src/scrapebadger/gumtree/models.py index 148a658..e26b0c3 100644 --- a/src/scrapebadger/gumtree/models.py +++ b/src/scrapebadger/gumtree/models.py @@ -4,10 +4,13 @@ field-for-field. All models are immutable (frozen) and ignore unknown fields for forward compatibility. -Gumtree is UK-only — gumtree.com is the single supported market. Prices are -in GBP. 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 logged-out visitor. +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. +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 +logged-out visitor. """ from __future__ import annotations @@ -62,12 +65,18 @@ class ListingSummary(_BaseModel): """A listing as it appears on a search / seller / similar-listings grid.""" id: str + short_id: str | None = Field( + default=None, description="za: the 10-digit id Gumtree shows (the `id` is the full ad id)" + ) title: str | None = None url: str | None = None description: str | None = Field(default=None, description="Gumtree's short description") - price: float | None = Field(default=None, description="Numeric price in GBP") + price: float | None = Field(default=None, description="Numeric price in `currency`") 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…" + ) currency: str = "GBP" location: str | None = None location_id: str | None = None @@ -75,7 +84,7 @@ class ListingSummary(_BaseModel): latitude: float | None = None longitude: float | None = None distance_text: str | None = Field( - default=None, description="e.g. '13 miles' from the search location" + 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 @@ -92,6 +101,7 @@ class ListingSummary(_BaseModel): status: str | None = None seller_type: str | None = Field(default=None, description="Private / Trade / Agency") seller_id: str | None = None + seller_name: str | None = None account_id: str | None = None is_pro_seller: bool | None = None is_featured: bool | None = Field(default=None, description="Paid top-slot ad") @@ -114,6 +124,7 @@ 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") name: str | None = None slug: str | None = None count: int | None = None @@ -122,6 +133,9 @@ class CategoryFacet(_BaseModel): class SearchResponse(_BaseModel): """Response from ``GET /v1/gumtree/search``.""" + market: str = Field( + default="uk", description="uk = gumtree.com, au = gumtree.com.au, za = gumtree.co.za" + ) query: str | None = None category: str | None = Field(default=None, description="Category slug the search ran in") category_name: str | None = None @@ -134,6 +148,7 @@ class SearchResponse(_BaseModel): total_pages: int | None = None total_results: int | None = None result_count: int = 0 + currency: str = Field(default="GBP", description="Currency of every price in the results") listings: list[ListingSummary] = Field(default_factory=list) category_facets: list[CategoryFacet] = Field( default_factory=list, description="Sub-categories of the result set, with counts" @@ -175,13 +190,16 @@ class SortOption(_BaseModel): class FiltersResponse(_BaseModel): """Response from ``GET /v1/gumtree/search/filters``.""" + market: str = Field( + default="uk", description="uk = gumtree.com, au = gumtree.com.au, za = gumtree.co.za" + ) category: str | None = None category_name: str | None = None total_results: int | None = None filters: list[SearchFilter] = Field(default_factory=list) sort_options: list[SortOption] = Field(default_factory=list) distance_options: list[float] = Field( - default_factory=list, description="Radius values in miles" + default_factory=list, description="Radius values: miles (uk) or km (au)" ) category_facets: list[CategoryFacet] = Field(default_factory=list) @@ -216,9 +234,13 @@ 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") + 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 = 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") listings_last_year: int | None = None listings_by_category: dict[str, int] = Field(default_factory=dict) reply_speed: str | None = None @@ -235,6 +257,9 @@ class Seller(_BaseModel): class ListingDetail(_BaseModel): """Response from ``GET /v1/gumtree/listings/{listing_id}``.""" + market: str = Field( + default="uk", description="uk = gumtree.com, au = gumtree.com.au, za = gumtree.co.za" + ) id: str title: str | None = None url: str | None = None @@ -242,6 +267,9 @@ class ListingDetail(_BaseModel): price: float | None = None 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…" + ) currency: str = "GBP" vat: str | None = Field(default=None, description="Motors trade listings: 'Inc VAT' / 'No VAT'") location: str | None = None @@ -267,6 +295,9 @@ class ListingDetail(_BaseModel): expires_at: str | None = Field( default=None, description="Date the ad is valid through (YYYY-MM-DD)" ) + status: str | None = None + view_count: int | None = Field(default=None, description="au/za: views of this ad") + reply_count: int | None = Field(default=None, description="au: replies to this ad") is_featured: bool | None = None is_urgent: bool | None = None is_bumped_up: bool | None = None @@ -290,6 +321,9 @@ class ListingDetail(_BaseModel): class SellerListingsResponse(_BaseModel): """Response from ``GET /v1/gumtree/listings/{listing_id}/seller-listings``.""" + market: str = Field( + default="uk", description="uk = gumtree.com, au = gumtree.com.au, za = gumtree.co.za" + ) listing_id: str seller_name: str | None = None total_results: int | None = None @@ -317,6 +351,9 @@ class Suggestion(_BaseModel): class AutocompleteResponse(_BaseModel): """Response from ``GET /v1/gumtree/autocomplete``.""" + market: str = Field( + default="uk", description="uk = gumtree.com, au = gumtree.com.au, za = gumtree.co.za" + ) query: str result_count: int = 0 suggestions: list[Suggestion] = Field(default_factory=list) @@ -327,7 +364,9 @@ class LocationSuggestion(_BaseModel): id: int | None = None name: str | None = None - type: str | None = Field(default=None, description="location / outcode / postcode") + type: str | None = Field( + default=None, description="uk: location / outcode / postcode; au/za: region / area" + ) latitude: float | None = None longitude: float | None = None @@ -335,13 +374,16 @@ class LocationSuggestion(_BaseModel): class LocationAutocompleteResponse(_BaseModel): """Response from ``GET /v1/gumtree/locations/autocomplete``.""" + market: str = Field( + default="uk", description="uk = gumtree.com, au = gumtree.com.au, za = gumtree.co.za" + ) query: str result_count: int = 0 locations: list[LocationSuggestion] = Field(default_factory=list) class TreeNode(_BaseModel): - """A category or location node. ``slug`` is what ``search`` takes.""" + """A category or location node. ``slug`` (uk) or ``id`` (au/za) is what ``search`` takes.""" id: int name: str | None = None @@ -352,8 +394,37 @@ class TreeNode(_BaseModel): class TreeResponse(_BaseModel): """Response from ``GET /v1/gumtree/categories`` and ``GET /v1/gumtree/locations``.""" + market: str = Field( + default="uk", description="uk = gumtree.com, au = gumtree.com.au, za = gumtree.co.za" + ) id: int name: str | None = None slug: str | None = None result_count: int = 0 children: list[TreeNode] = Field(default_factory=list) + + +# ============================================================================= +# Markets +# ============================================================================= + + +class MarketInfo(_BaseModel): + """One Gumtree site this API covers.""" + + market: str = Field(description="uk / au / za — the `market` every method takes") + domain: str + country: str = Field(description="ISO country code, e.g. 'GB'") + currency: str + distance_unit: str | None = Field( + default=None, description="Unit of the search `distance` (None: radius not offered)" + ) + page_size: int = Field(description="Organic results per search page") + id_format: str = Field(description="How search takes `category` and `location`") + + +class MarketsResponse(_BaseModel): + """Response from ``GET /v1/gumtree/markets``.""" + + result_count: int = 0 + markets: list[MarketInfo] = Field(default_factory=list) diff --git a/tests/fixtures/gumtree_samples.json b/tests/fixtures/gumtree_samples.json index 861771f..8ec9f96 100644 --- a/tests/fixtures/gumtree_samples.json +++ b/tests/fixtures/gumtree_samples.json @@ -1,5 +1,6 @@ { "GET /v1/gumtree/search?category=cars": { + "market": "uk", "query": null, "category": "cars", "category_name": "Cars", @@ -12,15 +13,18 @@ "total_pages": 50, "total_results": 139812, "result_count": 30, + "currency": "GBP", "listings": [ { "id": "1803479690", + "short_id": null, "title": "Mitsubishi, OUTLANDER, Estate, 2015, Other, 1998 (cc), 5 doors", "url": "https://www.gumtree.com/p/mitsubishi/mitsubishi-outlander-estate-2015-other-1998-cc-5-doors/1803479690", "description": "MITSUBISHI OUTLANDER PHEV GX4\nH (DK15ZMU)\n2015 Petrol 2.0L Automatic 5 doors Est\nate\nNice family car.. driving is really amazing.fully serviced and changed new tyres all papers included test drive welcome. Any questions please feel free to ask .no o", "price": 4500.0, "price_text": "£4,500.00", "price_frequency": null, + "price_type": null, "currency": "GBP", "location": "London", "location_id": "10000344", @@ -53,6 +57,7 @@ "status": "LIVE", "seller_type": "Private", "seller_id": "103262444", + "seller_name": null, "account_id": "102719651", "is_pro_seller": false, "is_featured": true, @@ -279,12 +284,14 @@ }, { "id": "1802409381", + "short_id": null, "title": "Ford, RANGER, Pick Up, 2024, Automatic, 1996 (cc)", "url": "https://www.gumtree.com/p/ford/ford-ranger-pick-up-2024-automatic-1996-cc-/1802409381", "description": "2024 Ford Ranger Automatic – NO VAT – Only 11,400 Miles\n\nVery clean, low-mileage example of a 2024 Ford Ranger with just 11,400 miles from new. Well looked after and presented in excellent condition inside and out.\n\nFull Ford service history, last se", "price": 29000.0, "price_text": "£29,000.00", "price_frequency": null, + "price_type": null, "currency": "GBP", "location": "Penkridge, Staffordshire", "location_id": "11002225", @@ -327,6 +334,7 @@ "status": "LIVE", "seller_type": "Private", "seller_id": "96740710", + "seller_name": null, "account_id": "96201978", "is_pro_seller": false, "is_featured": true, @@ -544,6 +552,7 @@ "url": "https://www.gumtree.com/search?search_category=cars&search_location=uk" }, "GET /v1/gumtree/listings/1803293391": { + "market": "uk", "id": "1803293391", "title": "Mazda, 2, Hatchback, 2018, Manual, 1496 (cc), 5 doors", "url": null, @@ -551,6 +560,7 @@ "price": 5699.0, "price_text": "£5,699.00", "price_frequency": null, + "price_type": null, "currency": "GBP", "vat": "No VAT", "location": "Larkhall, South Lanarkshire", @@ -620,6 +630,9 @@ "updated_utc": 1791643487.028, "updated_at": "2026-10-10T14:44:47.028000Z", "expires_at": "2026-11-09", + "status": null, + "view_count": null, + "reply_count": null, "is_featured": false, "is_urgent": false, "is_bumped_up": false, @@ -791,9 +804,13 @@ "type": "Trade", "is_pro": true, "posting_since": "4+ months", + "member_since_year": null, + "last_active_text": null, "last_active_utc": null, "last_active_at": null, "total_listings": null, + "lifetime_listings": null, + "total_views": null, "listings_last_year": null, "listings_by_category": {}, "reply_speed": null, @@ -830,12 +847,14 @@ "similar_listings": [ { "id": "1803422383", + "short_id": null, "title": "2017 Mazda MX-5 1.5 SE-L Nav 2dr CONVERTIBLE PETROL Manual", "url": "https://www.gumtree.com/p/mazda/2017-mazda-mx-5-1.5-se-l-nav-2dr-convertible-petrol-manual/1803422383", "description": "A superb example of the Mazda MX5 SEL Nav finished in Ceramic White. Full service history totalling 10 services, all done at the Mazda main dealership. Great spec including DAB Radio and Sat Nav. electric hardtop roof, a beautiful, well cherished ex", "price": 12695.0, "price_text": "£12,695.00", "price_frequency": null, + "price_type": null, "currency": "GBP", "location": "Colchester, Essex", "location_id": "11000115", @@ -893,6 +912,7 @@ "status": "LIVE", "seller_type": "Trade", "seller_id": "53458489", + "seller_name": null, "account_id": "94368145", "is_pro_seller": true, "is_featured": false, @@ -1150,6 +1170,7 @@ ] }, "GET /v1/gumtree/search/filters?category=cars": { + "market": "uk", "category": "cars", "category_name": "Cars", "total_results": 139812, @@ -1244,6 +1265,7 @@ "category_facets": [] }, "GET /v1/gumtree/listings/1803293391/seller-listings": { + "market": "uk", "listing_id": "1803293391", "seller_name": "Allan", "total_results": 12, @@ -1253,12 +1275,14 @@ "listings": [ { "id": "1803761638", + "short_id": null, "title": "Honda, CIVIC, Hatchback, 2016, Manual, 1597 (cc), 5 doors", "url": "https://www.gumtree.com/p/honda/honda-civic-hatchback-2016-manual-1597-cc-5-doors/1803761638", "description": "Welcome to Thomson Twins Motors. We are a family run business based in Larkhall Scotland. We use our years of experience in the motor industry to carefully select the best vehicles and provide them at competitive prices alongside Free warranties and", "price": 5799.0, "price_text": "£5,799.00", "price_frequency": null, + "price_type": null, "currency": "GBP", "location": "Larkhall, South Lanarkshire", "location_id": "11000569", @@ -1297,6 +1321,7 @@ "status": "LIVE", "seller_type": "Trade", "seller_id": "91457907", + "seller_name": null, "account_id": "101852652", "is_pro_seller": true, "is_featured": false, @@ -1548,6 +1573,7 @@ ] }, "GET /v1/gumtree/autocomplete?query=sofa": { + "market": "uk", "query": "sofa", "result_count": 10, "suggestions": [ @@ -1580,6 +1606,7 @@ ] }, "GET /v1/gumtree/locations/autocomplete?query=leed": { + "market": "uk", "query": "leed", "result_count": 2, "locations": [ @@ -1600,6 +1627,7 @@ ] }, "GET /v1/gumtree/categories?parent_id=2551": { + "market": "uk", "id": 2551, "name": "Motors", "slug": "cars-vans-motorbikes", @@ -1632,6 +1660,7 @@ ] }, "GET /v1/gumtree/locations?parent_id=10000393": { + "market": "uk", "id": 10000393, "name": "England", "slug": "england", @@ -1656,5 +1685,771 @@ "has_children": true } ] + }, + "GET /v1/gumtree/markets": { + "result_count": 3, + "markets": [ + { + "market": "uk", + "domain": "www.gumtree.com", + "country": "GB", + "currency": "GBP", + "distance_unit": "miles", + "page_size": 25, + "id_format": "slugs: category 'cars', location 'london' or a UK postcode" + }, + { + "market": "au", + "domain": "www.gumtree.com.au", + "country": "AU", + "currency": "AUD", + "distance_unit": "km", + "page_size": 24, + "id_format": "numeric ids from /categories and /locations (market=au)" + }, + { + "market": "za", + "domain": "www.gumtree.co.za", + "country": "ZA", + "currency": "ZAR", + "distance_unit": null, + "page_size": 20, + "id_format": "numeric ids from /categories and /locations (market=za)" + } + ] + }, + "GET /v1/gumtree/search?market=za&category=9077&location=3100003": { + "market": "za", + "query": null, + "category": "9077", + "category_name": null, + "category_id": 9077, + "location": "3100003", + "location_name": "Cars & Bakkies in Gauteng", + "distance": null, + "sort": null, + "page": 1, + "total_pages": 50, + "total_results": 2964, + "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", + "price_frequency": null, + "price_type": "FIXED", + "currency": "ZAR", + "location": "Inner City / CBD&Bruma", + "location_id": null, + "postcode": null, + "latitude": null, + "longitude": null, + "distance_text": null, + "distance_miles": null, + "category_id": 9077, + "category_name": null, + "category_ids": [], + "image_url": "https://gms.gumtree.co.za/v2/images/za_ads_130629404_261012_6acc7e7f93b58b8535042a5a?size=l", + "image_urls": [ + "https://gms.gumtree.co.za/v2/images/za_ads_130629404_261012_6acc7e7f93b58b8535042a5a?size=l" + ], + "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", + "account_id": null, + "is_pro_seller": false, + "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": "CarYear", + "label": null, + "value": "2023", + "unit": null + }, + { + "key": "CarTransmission", + "label": null, + "value": "Automatic", + "unit": null + }, + { + "key": "MakeOffer", + "label": null, + "value": "1", + "unit": null + }, + { + "key": "ForSaleBy", + "label": null, + "value": "Owner", + "unit": null + }, + { + "key": "FuelType", + "label": null, + "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, + "price": null, + "price_text": "Negotiable", + "price_frequency": null, + "price_type": "MAKE_OFFER", + "currency": "ZAR", + "location": "Dobsonville", + "location_id": null, + "postcode": null, + "latitude": null, + "longitude": null, + "distance_text": null, + "distance_miles": null, + "category_id": 9077, + "category_name": null, + "category_ids": [], + "image_url": "https://gms.gumtree.co.za/v2/images/za_ads_134886807_261011_6acb5c7c93b58b85367bdfe1?size=l", + "image_urls": [ + "https://gms.gumtree.co.za/v2/images/za_ads_134886807_261011_6acb5c7c93b58b85367bdfe1?size=l" + ], + "image_count": 2, + "posted_text": "1d", + "posted_utc": null, + "posted_at": null, + "status": "ACTIVE", + "seller_type": "Private", + "seller_id": null, + "seller_name": "Richard Ngwenya", + "account_id": null, + "is_pro_seller": false, + "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": "CarYear", + "label": null, + "value": "2002", + "unit": null + }, + { + "key": "CarTransmission", + "label": null, + "value": "Automatic", + "unit": null + }, + { + "key": "ForSaleBy", + "label": null, + "value": "Owner", + "unit": null + }, + { + "key": "FuelType", + "label": null, + "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 + }, + { + "key": "CarColor", + "label": null, + "value": "Black", + "unit": null + }, + { + "key": "CarMileageInKms", + "label": null, + "value": "190km", + "unit": null + }, + { + "key": "CarDriveType", + "label": null, + "value": "4x2", + "unit": null + } + ] + } + ], + "category_facets": [], + "url": "https://www.gumtree.co.za/s-x/x/v1c9077l3100003p1" + }, + "GET /v1/gumtree/listings/10013681289561010209976109?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", + "price_frequency": null, + "price_type": "FIXED", + "currency": "ZAR", + "vat": null, + "location": "Centurion", + "area": "Gauteng", + "sub_area": "Pretoria / Tshwane", + "location_id": "3100247", + "postcode": null, + "latitude": -25.862281, + "longitude": 28.088474, + "category_id": 9272, + "category_name": "Beds & Headboards", + "category_ids": [], + "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": "Home & Garden Supplies", + "url": "https://www.gumtree.co.za/s-home-garden/centurion/v1c9175l3100247p1" + }, + { + "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" + } + ], + "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" + ], + "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, + "reply_count": null, + "is_featured": null, + "is_urgent": null, + "is_bumped_up": null, + "is_standout": null, + "favourite_count": null, + "vehicle_history_checked": null, + "attributes": [ + { + "key": "Condition", + "label": null, + "value": "used", + "unit": null + }, + { + "key": "BedMattressesSize", + "label": null, + "value": "single", + "unit": null + }, + { + "key": "Material", + "label": null, + "value": "wood", + "unit": null + }, + { + "key": "FurnitureColour", + "label": null, + "value": "blue", + "unit": null + }, + { + "key": "ForSaleBy", + "label": null, + "value": "delr", + "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, + "last_active_text": null, + "last_active_utc": null, + "last_active_at": null, + "total_listings": 183, + "lifetime_listings": 3100, + "total_views": 263430, + "listings_last_year": null, + "listings_by_category": {}, + "reply_speed": null, + "reply_rate": null, + "rating": null, + "verification": null, + "phone_masked": "068*******", + "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", + "similar_listings": [] + }, + "GET /v1/gumtree/search?market=au": { + "market": "au", + "query": null, + "category": "all", + "category_name": null, + "category_id": null, + "location": null, + "location_name": "Australia", + "distance": null, + "sort": "relevance", + "page": 1, + "total_pages": 50, + "total_results": 1728346, + "result_count": 30, + "currency": "AUD", + "listings": [ + { + "id": "1345095168", + "short_id": null, + "title": "2021 Mitsubishi Triton GLX MR 4X4", + "url": "https://www.gumtree.com.au/web/listing/cars-vans-utes/1345095168", + "description": "2021 Mitsubishi Triton GLX MR 4X4", + "price": 27990.0, + "price_text": "$27,990", + "price_frequency": null, + "price_type": "FIXED", + "currency": "AUD", + "location": "Canning Area, WA", + "location_id": null, + "postcode": null, + "latitude": null, + "longitude": null, + "distance_text": null, + "distance_miles": null, + "category_id": 18320, + "category_name": null, + "category_ids": [], + "image_url": "https://images.gumtree.com.au/image/private/t_$_35/move/840a7abe-61df-4b7d-9cbb-6d878972d457", + "image_urls": [ + "https://images.gumtree.com.au/image/private/t_$_35/move/840a7abe-61df-4b7d-9cbb-6d878972d457", + "https://images.gumtree.com.au/image/private/t_$_74/move/56b83824-b518-4542-b7a5-ce276321531b", + "https://images.gumtree.com.au/image/private/t_$_74/move/d1a3a3b5-4ec7-49ed-a1ad-da91109c3c59", + "https://images.gumtree.com.au/image/private/t_$_74/move/947167f7-0c65-46c3-8bb1-3081b0235565", + "https://images.gumtree.com.au/image/private/t_$_74/move/a99f53f2-a533-4478-b22f-6de9cade5a85" + ], + "image_count": 5, + "posted_text": "4 minutes ago", + "posted_utc": null, + "posted_at": null, + "status": null, + "seller_type": "Dealer used", + "seller_id": null, + "seller_name": "Canning Vale Toyota", + "account_id": null, + "is_pro_seller": null, + "is_featured": true, + "is_urgent": false, + "is_bumped_up": null, + "is_standout": false, + "is_premium": true, + "has_video": false, + "supports_shipping": null, + "favourite_count": null, + "seller_skills": [], + "attributes": [ + { + "key": "engine_capacity_litres", + "label": null, + "value": "2.4L", + "unit": null + }, + { + "key": "cylinder_configuration", + "label": null, + "value": "4 cyl", + "unit": null + }, + { + "key": "carmileageinkms", + "label": null, + "value": "106085 km", + "unit": null + }, + { + "key": "carbodytype", + "label": null, + "value": "Ute", + "unit": null + }, + { + "key": "cartransmission", + "label": null, + "value": "Auto", + "unit": null + } + ] + }, + { + "id": "1344046669", + "short_id": null, + "title": "peppermint bristlenose 4-5cm about 15 left only shipping avaliable ", + "url": "https://www.gumtree.com.au/web/listing/fish/1344046669", + "description": "peppermint bristlenose\n4-5cm $15 each\nTop quality jet-black the parents are jet black no muddy look.\nAll breeders have been carfuly selected and bred to produce quality patterns.\nPickup silverdale NSW near Penrith\nshipping also available contact for details.", + "price": 15.0, + "price_text": "$15", + "price_frequency": null, + "price_type": "FIXED", + "currency": "AUD", + "location": "Wollondilly Area, NSW", + "location_id": null, + "postcode": null, + "latitude": null, + "longitude": null, + "distance_text": null, + "distance_miles": null, + "category_id": 0, + "category_name": null, + "category_ids": [], + "image_url": "https://images.gumtree.com.au/image/private/t_$_35/gumtree/c9fe9704-d000-4bfd-b8b0-b4d03e8e933b.jpg", + "image_urls": [ + "https://images.gumtree.com.au/image/private/t_$_35/gumtree/c9fe9704-d000-4bfd-b8b0-b4d03e8e933b.jpg", + "https://images.gumtree.com.au/image/private/t_$_74/gumtree/1721b70f-4ee8-4bb3-9347-14a2eadc9de7.jpg", + "https://images.gumtree.com.au/image/private/t_$_74/gumtree/eb14272e-15fe-4395-933a-dce0f48956be.jpg", + "https://images.gumtree.com.au/image/private/t_$_74/gumtree/96f8593e-5e92-4d41-a1c3-18c356a8b35d.jpg" + ], + "image_count": 4, + "posted_text": "5 minutes ago", + "posted_utc": null, + "posted_at": null, + "status": null, + "seller_type": null, + "seller_id": null, + "seller_name": null, + "account_id": null, + "is_pro_seller": null, + "is_featured": false, + "is_urgent": false, + "is_bumped_up": null, + "is_standout": false, + "is_premium": false, + "has_video": false, + "supports_shipping": null, + "favourite_count": null, + "seller_skills": [], + "attributes": [ + { + "key": "fish-species", + "label": null, + "value": "Pleco", + "unit": null + } + ] + } + ], + "category_facets": [ + { + "id": 9299, + "name": "Cars & Vehicles", + "slug": null, + "count": 450286 + }, + { + "id": 18397, + "name": "Home & Garden", + "slug": null, + "count": 389561 + }, + { + "id": 9296, + "name": "Real Estate", + "slug": null, + "count": 181693 + }, + { + "id": 18308, + "name": "Clothing & Jewellery", + "slug": null, + "count": 149516 + } + ], + "url": "https://www.gumtree.com.au/s-search.html" + }, + "GET /v1/gumtree/listings/1339574407?market=au": { + "market": "au", + "id": "1339574407", + "title": "No31- YBN S410 Single Speed Bicycle Chain - Compatible with BM", + "url": "https://www.gumtree.com.au/web/listing/bicycle-parts-and-accessories/1339574407", + "description": "YBN S410 Single Speed Bicycle Chain For Bikes- Compatible with BMX& Freestyle\n\n1856A bicycle Chain 1/8\" *110L brown/grey\n\nFills most bikes\nList No.31", + "price": 15.95, + "price_text": "$15.95", + "price_frequency": null, + "price_type": "FIXED", + "currency": "AUD", + "vat": null, + "location": "Knoxfield, VIC", + "area": null, + "sub_area": null, + "location_id": "3001531", + "postcode": null, + "latitude": -37.89044, + "longitude": 145.24924, + "category_id": 18540, + "category_name": "Bicycle Parts and Accessories", + "category_ids": [ + 18314, + 18560, + 18540 + ], + "breadcrumbs": [ + { + "name": "Knoxfield", + "url": "https://www.gumtree.com.au/s-knoxfield-melbourne/l3001531" + }, + { + "name": "Sport & Fitness", + "url": "https://www.gumtree.com.au/s-sport-fitness/knoxfield-melbourne/c18314l3001531" + }, + { + "name": "Bicycles", + "url": "https://www.gumtree.com.au/s-bicycles/c18560" + }, + { + "name": "Bicycle Parts and Accessories", + "url": "https://www.gumtree.com.au/s-bicycle-parts-and-accessories/c18540" + } + ], + "image_urls": [ + "https://images.gumtree.com.au/image/private/t_$_20/gumtree/8f6c6c77-af16-44ee-b7e0-cbd318b5041c.jpg", + "https://images.gumtree.com.au/image/private/t_$_20/gumtree/fcb11483-603c-41c4-9e90-7a649e209faa.jpg", + "https://images.gumtree.com.au/image/private/t_$_20/gumtree/f48ce5a5-f2be-47ec-b294-a5cd0a239e68.jpg" + ], + "video_urls": [], + "posted_text": "06/01/2026", + "posted_utc": 1767657600.0, + "posted_at": "2026-01-06T00:00:00Z", + "created_utc": 1767657600.0, + "created_at": "2026-01-06T00:00:00Z", + "updated_utc": 1787529600.0, + "updated_at": "2026-08-24T00:00:00Z", + "expires_at": null, + "status": "ACTIVE", + "view_count": 88, + "reply_count": 0, + "is_featured": false, + "is_urgent": false, + "is_bumped_up": null, + "is_standout": false, + "favourite_count": null, + "vehicle_history_checked": null, + "attributes": [ + { + "key": "Date Listed", + "label": null, + "value": "06/01/2026", + "unit": null + }, + { + "key": "Last Edited", + "label": null, + "value": "24/08/2026", + "unit": null + }, + { + "key": "Condition", + "label": null, + "value": "New", + "unit": null + }, + { + "key": "Shipping", + "label": null, + "value": "Price on request", + "unit": null + } + ], + "seller": { + "name": "AT BICYCLE", + "user_id": "3094382848742", + "public_id": "1030807455", + "type": "Private", + "is_pro": false, + "posting_since": null, + "member_since_year": 2025, + "last_active_text": "yesterday", + "last_active_utc": null, + "last_active_at": null, + "total_listings": 166, + "lifetime_listings": null, + "total_views": null, + "listings_last_year": null, + "listings_by_category": {}, + "reply_speed": null, + "reply_rate": null, + "rating": { + "average": null, + "total": 0, + "breakdown": {} + }, + "verification": null, + "phone_masked": null, + "website_url": null, + "avatar_url": "https://images.gumtree.com.au/image/private/t_$_74/gumtree/f8ffff25-4f0e-4a28-80f1-dcc5c3315965", + "logo_url": null, + "badges": [] + }, + "seller_skills": [], + "seller_listings_url": "https://www.gumtree.com.au/web/s-user/3094382848742", + "similar_listings": [ + { + "id": "1337354072", + "short_id": null, + "title": "Bike Carrier 4 Bicycle Rear Rack Hitch Mount 2 Towbar Foldable Steel", + "url": "https://www.gumtree.com.au/web/listing/bicycle-parts-and-accessories/1337354072", + "description": "🌟 BRAND NEW | 🚚 Delivery Price Calculated at Checkout\n\nNo Local Pickup - Delivery Only\n\n🚚 Delivery within 2 to 8 Business Days\n🧘🏻♂️ 1 Year Warranty\n📦 In stock in Australia, ready to ship\n\nPayPal | AfterPay | Apple Pay | Cards\n\n\nBike Carrier 4 Bicycle Rear Rack Hitch Mount 2\" Towbar Foldable Steel\n\n\nTake your cycling adventures further with the Giantz 4-Bike Carrier, designed for convenience, strength and versatility. Built from heavy-duty powder-coated steel, this carrier fits any standa", + "price": 151.0, + "price_text": "$151", + "price_frequency": null, + "price_type": "FIXED", + "currency": "AUD", + "location": "Melbourne City, VIC", + "location_id": null, + "postcode": null, + "latitude": null, + "longitude": null, + "distance_text": null, + "distance_miles": null, + "category_id": 0, + "category_name": null, + "category_ids": [], + "image_url": "https://images.gumtree.com.au/image/private/t_$_35/gumtree/88f80e20-6ef8-4138-8c41-15f61c06a5fa.jpg", + "image_urls": [ + "https://images.gumtree.com.au/image/private/t_$_35/gumtree/88f80e20-6ef8-4138-8c41-15f61c06a5fa.jpg", + "https://images.gumtree.com.au/image/private/t_$_74/gumtree/38654861-caae-48eb-8dfa-a253d9956a3a.jpg", + "https://images.gumtree.com.au/image/private/t_$_74/gumtree/542bfb2d-1ae4-44f9-80f1-c84821c06b4d.jpg", + "https://images.gumtree.com.au/image/private/t_$_74/gumtree/404871f9-eb5d-435f-b912-88df8146e9ae.jpg", + "https://images.gumtree.com.au/image/private/t_$_74/gumtree/88ebf471-74b7-4a02-816c-7d00aa29686a.jpg" + ], + "image_count": 5, + "posted_text": "27/09/2025", + "posted_utc": null, + "posted_at": null, + "status": null, + "seller_type": null, + "seller_id": null, + "seller_name": null, + "account_id": null, + "is_pro_seller": null, + "is_featured": false, + "is_urgent": false, + "is_bumped_up": null, + "is_standout": false, + "is_premium": false, + "has_video": false, + "supports_shipping": null, + "favourite_count": null, + "seller_skills": [], + "attributes": [ + { + "key": "offer_shipping", + "label": null, + "value": "Price on request", + "unit": null + } + ] + } + ] } } diff --git a/tests/test_gumtree.py b/tests/test_gumtree.py index 10e49d9..b3e8306 100644 --- a/tests/test_gumtree.py +++ b/tests/test_gumtree.py @@ -2,9 +2,11 @@ Tests cover: - TestGumtreeClient: wiring on ScrapeBadger -- TestRouting: every one of the 8 endpoints routes to the right path/params -- TestGumtreeModels: real API responses (tests/fixtures/gumtree_samples.json) - parse into the models with no field silently dropped, immutability +- TestRouting: every one of the 9 endpoints routes to the right path/params, + and `market` is passed through (unset = the server default, uk) +- TestGumtreeModels: real API responses (tests/fixtures/gumtree_samples.json, + uk + au + za) parse into the models with no field silently dropped, + immutability """ from __future__ import annotations @@ -23,6 +25,7 @@ FiltersResponse, ListingDetail, LocationAutocompleteResponse, + MarketsResponse, SearchResponse, SellerListingsResponse, TreeResponse, @@ -39,6 +42,12 @@ LOCATION_AUTOCOMPLETE = SAMPLES["GET /v1/gumtree/locations/autocomplete?query=leed"] 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"] +AU_SEARCH = SAMPLES["GET /v1/gumtree/search?market=au"] +AU_LISTING = SAMPLES["GET /v1/gumtree/listings/1339574407?market=au"] +ZA_LISTING_ID = "10013681289561010209976109" @pytest.fixture @@ -83,7 +92,7 @@ async def test_search_defaults( assert params == { "query": None, "category": "all", - "location": "uk", + "location": None, "distance": None, "min_price": None, "max_price": None, @@ -93,6 +102,7 @@ async def test_search_defaults( "with_photos": None, "filters": None, "page": 1, + "market": None, } async def test_search_all_params( @@ -129,8 +139,24 @@ async def test_search_all_params( "with_photos": True, "filters": "vehicle_make:bmw,vehicle_fuel_type:petrol", "page": 3, + "market": None, } + 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" + ) + + assert result.market == "za" + path, params = _call(mock_base_client) + 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" + async def test_search_filters( self, gumtree: GumtreeClient, mock_base_client: MagicMock ) -> None: @@ -140,7 +166,30 @@ async def test_search_filters( assert isinstance(result, FiltersResponse) assert _call(mock_base_client) == ( "/v1/gumtree/search/filters", - {"category": "cars", "location": "leeds", "query": "diesel"}, + { + "category": "cars", + "location": "leeds", + "query": "diesel", + "filters": None, + "market": None, + }, + ) + + 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") + + assert _call(mock_base_client) == ( + "/v1/gumtree/search/filters", + { + "category": "9077", + "location": None, + "query": None, + "filters": "ma:toyota", + "market": "za", + }, ) async def test_get_listing(self, gumtree: GumtreeClient, mock_base_client: MagicMock) -> None: @@ -148,7 +197,19 @@ async def test_get_listing(self, gumtree: GumtreeClient, mock_base_client: Magic result = await gumtree.get_listing("1803293391") assert isinstance(result, ListingDetail) - assert _call(mock_base_client) == ("/v1/gumtree/listings/1803293391", None) + assert _call(mock_base_client) == ("/v1/gumtree/listings/1803293391", {"market": None}) + + async def test_get_listing_market( + self, gumtree: GumtreeClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = ZA_LISTING + result = await gumtree.get_listing(ZA_LISTING_ID, market="za") + + assert result.market == "za" + assert _call(mock_base_client) == ( + f"/v1/gumtree/listings/{ZA_LISTING_ID}", + {"market": "za"}, + ) async def test_get_seller_listings( self, gumtree: GumtreeClient, mock_base_client: MagicMock @@ -159,7 +220,7 @@ async def test_get_seller_listings( assert isinstance(result, SellerListingsResponse) assert _call(mock_base_client) == ( "/v1/gumtree/listings/1803293391/seller-listings", - {"page": 2}, + {"page": 2, "market": None}, ) async def test_autocomplete(self, gumtree: GumtreeClient, mock_base_client: MagicMock) -> None: @@ -167,7 +228,10 @@ async def test_autocomplete(self, gumtree: GumtreeClient, mock_base_client: Magi result = await gumtree.autocomplete("sofa") assert isinstance(result, AutocompleteResponse) - assert _call(mock_base_client) == ("/v1/gumtree/autocomplete", {"query": "sofa"}) + assert _call(mock_base_client) == ( + "/v1/gumtree/autocomplete", + {"query": "sofa", "market": None}, + ) async def test_location_autocomplete( self, gumtree: GumtreeClient, mock_base_client: MagicMock @@ -176,7 +240,10 @@ async def test_location_autocomplete( result = await gumtree.location_autocomplete("leed") assert isinstance(result, LocationAutocompleteResponse) - assert _call(mock_base_client) == ("/v1/gumtree/locations/autocomplete", {"query": "leed"}) + assert _call(mock_base_client) == ( + "/v1/gumtree/locations/autocomplete", + {"query": "leed", "market": None}, + ) async def test_list_locations( self, gumtree: GumtreeClient, mock_base_client: MagicMock @@ -185,7 +252,10 @@ async def test_list_locations( result = await gumtree.list_locations(10000393) assert isinstance(result, TreeResponse) - assert _call(mock_base_client) == ("/v1/gumtree/locations", {"parent_id": 10000393}) + assert _call(mock_base_client) == ( + "/v1/gumtree/locations", + {"parent_id": 10000393, "market": None}, + ) async def test_list_categories_default_root( self, gumtree: GumtreeClient, mock_base_client: MagicMock @@ -194,8 +264,70 @@ async def test_list_categories_default_root( result = await gumtree.list_categories() assert isinstance(result, TreeResponse) - # None is dropped by BaseClient, so the server default (1 = all) applies. - assert _call(mock_base_client) == ("/v1/gumtree/categories", {"parent_id": None}) + # None is dropped by BaseClient, so the server defaults (the uk root) apply. + assert _call(mock_base_client) == ( + "/v1/gumtree/categories", + {"parent_id": None, "market": None}, + ) + + @pytest.mark.parametrize( + ("method", "args", "path", "params"), + [ + ( + "get_seller_listings", + (ZA_LISTING_ID,), + f"/v1/gumtree/listings/{ZA_LISTING_ID}/seller-listings", + {"page": 1, "market": "au"}, + ), + ( + "autocomplete", + ("bik",), + "/v1/gumtree/autocomplete", + {"query": "bik", "market": "au"}, + ), + ( + "location_autocomplete", + ("sydney",), + "/v1/gumtree/locations/autocomplete", + {"query": "sydney", "market": "au"}, + ), + ("list_locations", (), "/v1/gumtree/locations", {"parent_id": None, "market": "au"}), + ( + "list_categories", + (9299,), + "/v1/gumtree/categories", + {"parent_id": 9299, "market": "au"}, + ), + ], + ) + async def test_market_passed_through( + self, + gumtree: GumtreeClient, + mock_base_client: MagicMock, + method: str, + args: tuple[Any, ...], + path: str, + params: dict[str, Any], + ) -> None: + mock_base_client.get.return_value = {"query": "x", "listing_id": "1", "id": 1} + await getattr(gumtree, method)(*args, market="au") + + assert _call(mock_base_client) == (path, params) + + async def test_list_markets(self, gumtree: GumtreeClient, mock_base_client: MagicMock) -> None: + mock_base_client.get.return_value = MARKETS + result = await gumtree.list_markets() + + assert isinstance(result, MarketsResponse) + assert _call(mock_base_client) == ("/v1/gumtree/markets", None) + assert [m.market for m in result.markets] == ["uk", "au", "za"] + za = result.markets[2] + assert (za.domain, za.currency, za.distance_unit, za.page_size) == ( + "www.gumtree.co.za", + "ZAR", + None, + 20, + ) def _assert_round_trips(payload: Any, dumped: Any, path: str = "$") -> None: @@ -230,6 +362,11 @@ class TestGumtreeModels: (LocationAutocompleteResponse, LOCATION_AUTOCOMPLETE), (TreeResponse, CATEGORIES), (TreeResponse, LOCATIONS), + (MarketsResponse, MARKETS), + (SearchResponse, ZA_SEARCH), + (ListingDetail, ZA_LISTING), + (SearchResponse, AU_SEARCH), + (ListingDetail, AU_LISTING), ], ) def test_real_responses_round_trip(self, model: type[BaseModel], payload: Any) -> None: @@ -249,6 +386,46 @@ def test_listing_detail_nesting(self) -> None: assert listing.seller.verification is not None assert listing.seller.verification.status == "VERIFIED" + def test_uk_responses_default_to_uk(self) -> None: + search = SearchResponse.model_validate(SEARCH) + assert (search.market, search.currency) == ("uk", "GBP") + # A response without the new fields (pre-0.60 API) still parses as uk. + legacy = SearchResponse.model_validate({"page": 1}) + assert (legacy.market, legacy.currency) == ("uk", "GBP") + + def test_za_fields(self) -> None: + search = SearchResponse.model_validate(ZA_SEARCH) + assert (search.market, search.currency, search.total_results) == ("za", "ZAR", 2964) + 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" + + listing = ListingDetail.model_validate(ZA_LISTING) + assert listing.market == "za" + assert (listing.price_type, listing.status, listing.view_count) == ("FIXED", "ACTIVE", 65) + assert listing.seller is not None + assert listing.seller.lifetime_listings == 3100 + assert listing.seller.total_views == 263430 + assert listing.seller.phone_masked == "068*******" + + def test_au_fields(self) -> None: + search = SearchResponse.model_validate(AU_SEARCH) + assert (search.market, search.currency) == ("au", "AUD") + assert search.listings[0].seller_name == "Canning Vale Toyota" + assert search.category_facets[0].id == 9299 + assert search.category_facets[0].name == "Cars & Vehicles" + + listing = ListingDetail.model_validate(AU_LISTING) + assert (listing.market, listing.currency, listing.price) == ("au", "AUD", 15.95) + assert (listing.view_count, listing.reply_count) == (88, 0) + assert listing.seller is not None + assert listing.seller.member_since_year == 2025 + assert listing.seller.last_active_text == "yesterday" + def test_filters_and_trees(self) -> None: filters = FiltersResponse.model_validate(FILTERS) assert filters.filters[0].key == "vehicle_make" diff --git a/uv.lock b/uv.lock index de1b04f..9129ce2 100644 --- a/uv.lock +++ b/uv.lock @@ -930,7 +930,7 @@ wheels = [ [[package]] name = "scrapebadger" -version = "0.59.0" +version = "0.60.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 5e0f2125a26c6949bca04aba59f1365f06a45a14 Mon Sep 17 00:00:00 2001 From: kasparasizi1 <132673909+kasparasizi1@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:49:17 +0300 Subject: [PATCH 2/2] feat(gumtree): seller listings has_next (za gives no total) Co-Authored-By: Claude Opus 5.5 --- src/scrapebadger/gumtree/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/scrapebadger/gumtree/models.py b/src/scrapebadger/gumtree/models.py index e26b0c3..110a5f9 100644 --- a/src/scrapebadger/gumtree/models.py +++ b/src/scrapebadger/gumtree/models.py @@ -329,6 +329,9 @@ 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)" + ) result_count: int = 0 listings: list[ListingSummary] = Field(default_factory=list)