diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f79965..fa3aaa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 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`). + ## 0.58.0 — 2026-10-11 - **Wallapop API — 10 endpoints via `client.wallapop`.** A typed client for the Spanish, Italian and Portuguese second-hand marketplace (`market`: `es`, `it`, `pt`): `search` (40 listings per page with Wallapop's own filters — location and radius, category, price, condition, brand, model, colour, listing age, shipping, warranty, refurbished, and the cars filters year, km, gearbox and horsepower; cursor pagination via `next_page`), `get_item`, `get_user`, `get_user_items`, `get_user_reviews`, `autocomplete`, `list_categories`, `list_filters`, `list_filter_options` and `list_markets`. Boolean filters are only sent when `True`. Models are exported top-level with a `Wallapop` prefix (`WallapopSearchResponse`, `WallapopItemDetail`, `WallapopUserProfile`, ...). diff --git a/README.md b/README.md index 11d3e54..ad7a0bb 100644 --- a/README.md +++ b/README.md @@ -99,6 +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) | | **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/src/scrapebadger/__init__.py b/src/scrapebadger/__init__.py index ea4f56e..ed49f0c 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -293,6 +293,70 @@ async def main(): SearchResult as GeminiSearchResult, ) from scrapebadger.google.client import GoogleClient +from scrapebadger.gumtree.client import GumtreeClient +from scrapebadger.gumtree.models import ( + Attribute as GumtreeAttribute, +) +from scrapebadger.gumtree.models import ( + AutocompleteResponse as GumtreeAutocompleteResponse, +) +from scrapebadger.gumtree.models import ( + Breadcrumb as GumtreeBreadcrumb, +) +from scrapebadger.gumtree.models import ( + CategoryFacet as GumtreeCategoryFacet, +) +from scrapebadger.gumtree.models import ( + CategoryRef as GumtreeCategoryRef, +) +from scrapebadger.gumtree.models import ( + FilterOption as GumtreeFilterOption, +) +from scrapebadger.gumtree.models import ( + FiltersResponse as GumtreeFiltersResponse, +) +from scrapebadger.gumtree.models import ( + ListingDetail as GumtreeListingDetail, +) +from scrapebadger.gumtree.models import ( + ListingSummary as GumtreeListingSummary, +) +from scrapebadger.gumtree.models import ( + LocationAutocompleteResponse as GumtreeLocationAutocompleteResponse, +) +from scrapebadger.gumtree.models import ( + LocationSuggestion as GumtreeLocationSuggestion, +) +from scrapebadger.gumtree.models import ( + SearchFilter as GumtreeSearchFilter, +) +from scrapebadger.gumtree.models import ( + SearchResponse as GumtreeSearchResponse, +) +from scrapebadger.gumtree.models import ( + Seller as GumtreeSeller, +) +from scrapebadger.gumtree.models import ( + SellerListingsResponse as GumtreeSellerListingsResponse, +) +from scrapebadger.gumtree.models import ( + SellerRating as GumtreeSellerRating, +) +from scrapebadger.gumtree.models import ( + SellerVerification as GumtreeSellerVerification, +) +from scrapebadger.gumtree.models import ( + SortOption as GumtreeSortOption, +) +from scrapebadger.gumtree.models import ( + Suggestion as GumtreeSuggestion, +) +from scrapebadger.gumtree.models import ( + TreeNode as GumtreeTreeNode, +) +from scrapebadger.gumtree.models import ( + TreeResponse as GumtreeTreeResponse, +) from scrapebadger.immobiliare.client import ImmobiliareClient from scrapebadger.immobiliare.models import ( Agency as ImmobiliareAgency, @@ -1408,6 +1472,29 @@ async def main(): "GeminiSearchResult", # Google Scraper "GoogleClient", + # Gumtree + "GumtreeAttribute", + "GumtreeAutocompleteResponse", + "GumtreeBreadcrumb", + "GumtreeCategoryFacet", + "GumtreeCategoryRef", + "GumtreeClient", + "GumtreeFilterOption", + "GumtreeFiltersResponse", + "GumtreeListingDetail", + "GumtreeListingSummary", + "GumtreeLocationAutocompleteResponse", + "GumtreeLocationSuggestion", + "GumtreeSearchFilter", + "GumtreeSearchResponse", + "GumtreeSeller", + "GumtreeSellerListingsResponse", + "GumtreeSellerRating", + "GumtreeSellerVerification", + "GumtreeSortOption", + "GumtreeSuggestion", + "GumtreeTreeNode", + "GumtreeTreeResponse", "IPNotAllowedError", # Immobiliare "ImmobiliareAgency", diff --git a/src/scrapebadger/client.py b/src/scrapebadger/client.py index 2dbe9a4..f6ddac2 100644 --- a/src/scrapebadger/client.py +++ b/src/scrapebadger/client.py @@ -25,6 +25,7 @@ from scrapebadger.gemini.client import GeminiClient from scrapebadger.google.client import GoogleClient from scrapebadger.google_play.client import GooglePlayClient +from scrapebadger.gumtree.client import GumtreeClient from scrapebadger.idealista.client import IdealistaClient from scrapebadger.immobiliare.client import ImmobiliareClient from scrapebadger.instagram.client import InstagramClient @@ -173,6 +174,7 @@ def __init__( self._gemini: GeminiClient | None = None self._instagram: InstagramClient | None = None self._walmart: WalmartClient | None = None + self._gumtree: GumtreeClient | None = None self._duckduckgo: DuckDuckGoClient | None = None self._baidu: BaiduClient | None = None self._bing: BingClient | None = None @@ -498,6 +500,30 @@ def walmart(self) -> WalmartClient: self._walmart = WalmartClient(self._base_client) return self._walmart + @property + def gumtree(self) -> GumtreeClient: + """Access Gumtree Scraper API operations. + + Returns: + GumtreeClient providing access to all 8 Gumtree endpoints. + UK-only: gumtree.com is the single supported market. + + Example: + ```python + # Search + results = await client.gumtree.search("mountain bike", category="bicycles") + + # Listing detail + detail = await client.gumtree.get_listing("1803293391") + + # The seller's other listings + others = await client.gumtree.get_seller_listings("1803293391") + ``` + """ + if self._gumtree is None: + self._gumtree = GumtreeClient(self._base_client) + return self._gumtree + @property def duckduckgo(self) -> DuckDuckGoClient: """Access DuckDuckGo Search API operations. diff --git a/src/scrapebadger/gumtree/__init__.py b/src/scrapebadger/gumtree/__init__.py new file mode 100644 index 0000000..4cc8bcb --- /dev/null +++ b/src/scrapebadger/gumtree/__init__.py @@ -0,0 +1,72 @@ +"""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. + +Example: + ```python + from scrapebadger import ScrapeBadger + + async with ScrapeBadger(api_key="your-key") as client: + # Search + results = await client.gumtree.search("mountain bike", category="bicycles") + for listing in results.listings: + print(listing.title, listing.price_text) + + # Listing detail + detail = await client.gumtree.get_listing("1803293391") + print(detail.title, detail.seller.name if detail.seller else None) + ``` +""" + +from scrapebadger.gumtree.client import GumtreeClient +from scrapebadger.gumtree.models import ( + Attribute, + AutocompleteResponse, + Breadcrumb, + CategoryFacet, + CategoryRef, + FilterOption, + FiltersResponse, + ListingDetail, + ListingSummary, + LocationAutocompleteResponse, + LocationSuggestion, + SearchFilter, + SearchResponse, + Seller, + SellerListingsResponse, + SellerRating, + SellerVerification, + SortOption, + Suggestion, + TreeNode, + TreeResponse, +) + +__all__ = [ + "Attribute", + "AutocompleteResponse", + "Breadcrumb", + "CategoryFacet", + "CategoryRef", + "FilterOption", + "FiltersResponse", + "GumtreeClient", + "ListingDetail", + "ListingSummary", + "LocationAutocompleteResponse", + "LocationSuggestion", + "SearchFilter", + "SearchResponse", + "Seller", + "SellerListingsResponse", + "SellerRating", + "SellerVerification", + "SortOption", + "Suggestion", + "TreeNode", + "TreeResponse", +] diff --git a/src/scrapebadger/gumtree/client.py b/src/scrapebadger/gumtree/client.py new file mode 100644 index 0000000..d43a219 --- /dev/null +++ b/src/scrapebadger/gumtree/client.py @@ -0,0 +1,260 @@ +"""Gumtree API client. + +Gumtree endpoints: search (listings with category, location/postcode radius, +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. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from scrapebadger.gumtree.models import ( + AutocompleteResponse, + FiltersResponse, + ListingDetail, + LocationAutocompleteResponse, + SearchResponse, + SellerListingsResponse, + TreeResponse, +) + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + +GumtreeSort = Literal["relevance", "date", "price_asc", "price_desc", "distance"] +GumtreeSellerType = Literal["private", "trade"] + + +class GumtreeClient: + """Client for all Gumtree API operations. + + Example: + ```python + from scrapebadger import ScrapeBadger + + async with ScrapeBadger(api_key="your-key") as client: + # Search used BMWs within 25 miles of a postcode + results = await client.gumtree.search( + category="cars", + location="SW1A 1AA", + distance=25, + filters="vehicle_make:bmw,vehicle_fuel_type:petrol", + ) + 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") + print(detail.seller.name if detail.seller else None) + + # Browse the category tree + tree = await client.gumtree.list_categories() + ``` + + Note: + This client is not instantiated directly. Instead, access it through + the `gumtree` property of the main `ScrapeBadger` client. + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize Gumtree client. + + Args: + client: The base HTTP client for making API requests. + """ + self._client = client + + async def search( + self, + query: str | None = None, + *, + category: str = "all", + location: str = "uk", + distance: float | None = None, + min_price: int | None = None, + max_price: int | None = None, + sort: GumtreeSort | None = None, + seller_type: GumtreeSellerType | None = None, + urgent_only: bool = False, + with_photos: bool = False, + filters: str | None = None, + page: int = 1, + ) -> SearchResponse: + """Search gumtree.com listings. + + 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 + 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"``. + 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. + 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`. + page: Page number, 1-50. + + Returns: + SearchResponse with ~25-30 listings per page. Gumtree pins up to 5 + paid featured ads (``is_featured``) above the chosen sort order. + + Raises: + AuthenticationError: If the API key is invalid. + ValidationError: If the parameters are invalid. + """ + params: dict[str, Any] = { + "query": query, + "category": category, + "location": location, + "distance": distance, + "min_price": min_price, + "max_price": max_price, + "sort": sort, + "seller_type": seller_type, + "urgent_only": urgent_only or None, + "with_photos": with_photos or None, + "filters": filters, + "page": page, + } + response = await self._client.get("/v1/gumtree/search", params=params) + return SearchResponse.model_validate(response) + + async def search_filters( + self, + category: str = "all", + *, + location: str = "uk", + query: str | None = None, + ) -> FiltersResponse: + """The filters, sort orders and radius options Gumtree offers for a category. + + Each filter lists its values with live result counts. Feed a ``key`` and + 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"``. + query: Optional keywords to scope the counts. + + Returns: + FiltersResponse with filters, sort options, distance options and + category facets. + + Example: + ```python + result = await client.gumtree.search_filters("cars") + for f in result.filters: + print(f.key, [o.value for o in f.options][:5]) + ``` + """ + params: dict[str, Any] = {"category": category, "location": location, "query": query} + 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: + """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). + + Args: + listing_id: Gumtree listing id — the number at the end of a + ``gumtree.com/p/...`` URL. + + Returns: + ListingDetail. + + Raises: + NotFoundError: If the listing does not exist or has expired. + """ + response = await self._client.get(f"/v1/gumtree/listings/{listing_id}") + return ListingDetail.model_validate(response) + + async def get_seller_listings( + self, listing_id: str, *, page: int = 1 + ) -> SellerListingsResponse: + """Every live listing by the seller of ``listing_id``. + + Gumtree keys a seller's ad list by one of their ads, so any listing id + of theirs works. + + Args: + listing_id: Any Gumtree listing id belonging to the seller. + page: Page number, 1-50. + + 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} + ) + return SellerListingsResponse.model_validate(response) + + async def autocomplete(self, query: str) -> AutocompleteResponse: + """Gumtree search-box keyword suggestions, each with the category it maps to. + + Args: + query: Partial search term, e.g. ``"iph"``. + + Returns: + AutocompleteResponse. + """ + response = await self._client.get("/v1/gumtree/autocomplete", params={"query": query}) + 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. + + Args: + query: Town, area or postcode prefix (min 2 chars), e.g. ``"manch"`` + or ``"SW1A"``. + + Returns: + LocationAutocompleteResponse. + """ + response = await self._client.get( + "/v1/gumtree/locations/autocomplete", params={"query": query} + ) + 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. + + Args: + parent_id: Location id to list the children of. Defaults to the + United Kingdom (``10000392``) server-side. + + Returns: + TreeResponse whose ``children`` carry ``id``, ``name`` and ``slug``. + """ + response = await self._client.get("/v1/gumtree/locations", params={"parent_id": parent_id}) + 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. + + Args: + parent_id: Category id to list the children of. Defaults to all + categories (``1``) server-side. + + Returns: + TreeResponse whose ``children`` carry ``id``, ``name`` and ``slug``. + """ + response = await self._client.get("/v1/gumtree/categories", params={"parent_id": parent_id}) + return TreeResponse.model_validate(response) diff --git a/src/scrapebadger/gumtree/models.py b/src/scrapebadger/gumtree/models.py new file mode 100644 index 0000000..148a658 --- /dev/null +++ b/src/scrapebadger/gumtree/models.py @@ -0,0 +1,359 @@ +"""Pydantic models for Gumtree API responses. + +These models mirror the backend ``gumtree_scraper`` response schema +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. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +# ============================================================================= +# Base Configuration +# ============================================================================= + + +class _BaseModel(BaseModel): + """Base model with common configuration.""" + + model_config = ConfigDict( + frozen=True, + populate_by_name=True, + extra="ignore", + ) + + +# ============================================================================= +# Shared value objects +# ============================================================================= + + +class Attribute(_BaseModel): + """One listing attribute — e.g. ``vehicle_mileage`` = ``117628``.""" + + key: str + label: str | None = None + value: str | None = None + unit: str | None = None + + +class CategoryRef(_BaseModel): + """A category reference attached to an autocomplete suggestion.""" + + id: int | None = None + name: str | None = None + slug: str | None = None + + +class Breadcrumb(_BaseModel): + """One step of a listing's category breadcrumb trail.""" + + name: str | None = None + url: str | None = None + + +class ListingSummary(_BaseModel): + """A listing as it appears on a search / seller / similar-listings grid.""" + + id: str + 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_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") + currency: str = "GBP" + location: str | None = None + location_id: str | None = None + postcode: str | None = None + latitude: float | None = None + longitude: float | None = None + distance_text: str | None = Field( + default=None, description="e.g. '13 miles' from the search location" + ) + distance_miles: float | None = None + category_id: int | None = None + category_name: str | None = None + category_ids: list[int] = Field(default_factory=list, description="Root-to-leaf category ids") + image_url: str | None = None + image_urls: list[str] = Field(default_factory=list) + image_count: int | None = None + posted_text: str | None = Field( + default=None, description="Relative age as shown, e.g. '3 days ago'" + ) + posted_utc: float | None = None + posted_at: str | None = None + status: str | None = None + seller_type: str | None = Field(default=None, description="Private / Trade / Agency") + seller_id: 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") + is_urgent: bool | None = None + is_bumped_up: bool | None = None + is_standout: bool | None = None + is_premium: bool | None = None + has_video: bool | None = None + supports_shipping: bool | None = None + favourite_count: int | None = None + seller_skills: list[str] = Field(default_factory=list, description="Services listings only") + attributes: list[Attribute] = Field(default_factory=list) + + +# ============================================================================= +# Search +# ============================================================================= + + +class CategoryFacet(_BaseModel): + """A sub-category of a result set, with its listing count.""" + + name: str | None = None + slug: str | None = None + count: int | None = None + + +class SearchResponse(_BaseModel): + """Response from ``GET /v1/gumtree/search``.""" + + 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 + location: str | None = Field(default=None, description="Location slug / postcode searched") + location_name: str | None = None + distance: float | None = None + sort: str | None = None + page: int = 1 + total_pages: int | None = None + total_results: int | None = None + result_count: int = 0 + listings: list[ListingSummary] = Field(default_factory=list) + category_facets: list[CategoryFacet] = Field( + default_factory=list, description="Sub-categories of the result set, with counts" + ) + url: str | None = None + + +class FilterOption(_BaseModel): + """One selectable value of a search filter, with its live result count.""" + + label: str | None = None + value: str | None = None + count: int | None = None + + +class SearchFilter(_BaseModel): + """One filter Gumtree offers for the category, as ``filters=key:value`` input.""" + + key: str + label: str | None = None + type: str | None = Field( + default=None, + description="enum / multiple_selection / enum_range / range_freeform / boolean", + ) + min_key: str | None = Field(default=None, description="Range filters: the lower-bound key") + max_key: str | None = Field(default=None, description="Range filters: the upper-bound key") + options: list[FilterOption] = Field(default_factory=list) + min_options: list[FilterOption] = Field(default_factory=list) + max_options: list[FilterOption] = Field(default_factory=list) + + +class SortOption(_BaseModel): + """A sort order Gumtree offers for the category.""" + + value: str + label: str | None = None + + +class FiltersResponse(_BaseModel): + """Response from ``GET /v1/gumtree/search/filters``.""" + + 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" + ) + category_facets: list[CategoryFacet] = Field(default_factory=list) + + +# ============================================================================= +# Listing detail +# ============================================================================= + + +class SellerRating(_BaseModel): + """Seller star rating with a per-star breakdown (``"1"``..``"5"``).""" + + average: float | None = None + total: int | None = None + breakdown: dict[str, int] = Field(default_factory=dict) + + +class SellerVerification(_BaseModel): + """Gumtree's business-verification status and the checks it passed.""" + + status: str | None = None + label: str | None = None + checks: list[str] = Field(default_factory=list) + + +class Seller(_BaseModel): + """The seller of a listing.""" + + name: str | None = None + user_id: str | None = None + public_id: str | None = None + 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'") + last_active_utc: float | None = None + last_active_at: str | None = None + total_listings: int | None = None + listings_last_year: int | None = None + listings_by_category: dict[str, int] = Field(default_factory=dict) + reply_speed: str | None = None + reply_rate: str | None = None + rating: SellerRating | None = None + verification: SellerVerification | None = None + phone_masked: str | None = Field(default=None, description="e.g. '0794997XXXX'") + website_url: str | None = None + avatar_url: str | None = None + logo_url: str | None = None + badges: list[str] = Field(default_factory=list) + + +class ListingDetail(_BaseModel): + """Response from ``GET /v1/gumtree/listings/{listing_id}``.""" + + id: str + title: str | None = None + url: str | None = None + description: str | None = None + price: float | None = None + price_text: str | None = None + price_frequency: str | None = None + currency: str = "GBP" + vat: str | None = Field(default=None, description="Motors trade listings: 'Inc VAT' / 'No VAT'") + location: str | None = None + area: str | None = None + sub_area: str | None = None + location_id: str | None = None + postcode: str | None = None + latitude: float | None = None + longitude: float | None = None + category_id: int | None = None + category_name: str | None = None + category_ids: list[int] = 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) + posted_text: str | None = None + posted_utc: float | None = Field(default=None, description="Last (re)posted — moves on a bump") + posted_at: str | None = None + created_utc: float | None = Field(default=None, description="When the ad was first created") + created_at: str | None = None + updated_utc: float | None = None + updated_at: str | None = None + expires_at: str | None = Field( + default=None, description="Date the ad is valid through (YYYY-MM-DD)" + ) + is_featured: bool | None = None + is_urgent: bool | None = None + is_bumped_up: bool | None = None + is_standout: bool | None = None + favourite_count: int | None = None + vehicle_history_checked: bool | None = Field( + default=None, description="Motors: Gumtree's vehicle history check ran on this car" + ) + attributes: list[Attribute] = Field(default_factory=list) + seller: Seller | None = None + seller_skills: list[str] = Field(default_factory=list, description="Services listings only") + seller_listings_url: str | None = None + similar_listings: list[ListingSummary] = Field(default_factory=list) + + +# ============================================================================= +# Seller listings +# ============================================================================= + + +class SellerListingsResponse(_BaseModel): + """Response from ``GET /v1/gumtree/listings/{listing_id}/seller-listings``.""" + + listing_id: str + seller_name: str | None = None + total_results: int | None = None + page: int = 1 + total_pages: int | None = None + result_count: int = 0 + listings: list[ListingSummary] = Field(default_factory=list) + + +# ============================================================================= +# Reference data +# ============================================================================= + + +class Suggestion(_BaseModel): + """One search-box keyword suggestion.""" + + query: str + highlighted: str | None = None + categories: list[CategoryRef] = Field( + default_factory=list, description="Category path Gumtree attaches to the suggestion" + ) + + +class AutocompleteResponse(_BaseModel): + """Response from ``GET /v1/gumtree/autocomplete``.""" + + query: str + result_count: int = 0 + suggestions: list[Suggestion] = Field(default_factory=list) + + +class LocationSuggestion(_BaseModel): + """A Gumtree location matched by name or postcode prefix.""" + + id: int | None = None + name: str | None = None + type: str | None = Field(default=None, description="location / outcode / postcode") + latitude: float | None = None + longitude: float | None = None + + +class LocationAutocompleteResponse(_BaseModel): + """Response from ``GET /v1/gumtree/locations/autocomplete``.""" + + 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.""" + + id: int + name: str | None = None + slug: str | None = None + has_children: bool | None = None + + +class TreeResponse(_BaseModel): + """Response from ``GET /v1/gumtree/categories`` and ``GET /v1/gumtree/locations``.""" + + id: int + name: str | None = None + slug: str | None = None + result_count: int = 0 + children: list[TreeNode] = Field(default_factory=list) diff --git a/tests/fixtures/gumtree_samples.json b/tests/fixtures/gumtree_samples.json new file mode 100644 index 0000000..861771f --- /dev/null +++ b/tests/fixtures/gumtree_samples.json @@ -0,0 +1,1660 @@ +{ + "GET /v1/gumtree/search?category=cars": { + "query": null, + "category": "cars", + "category_name": "Cars", + "category_id": 9311, + "location": "uk", + "location_name": "United Kingdom", + "distance": null, + "sort": "relevance", + "page": 1, + "total_pages": 50, + "total_results": 139812, + "result_count": 30, + "listings": [ + { + "id": "1803479690", + "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, + "currency": "GBP", + "location": "London", + "location_id": "10000344", + "postcode": "NW107JD", + "latitude": 51.530848, + "longitude": -0.282636, + "distance_text": "88 miles", + "distance_miles": 88.0, + "category_id": 10316, + "category_name": "Mitsubishi", + "category_ids": [ + 2551, + 9311, + 10316 + ], + "image_url": "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/ff96d1746579438497_035587c5abe9/86", + "image_urls": [ + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/ff96d1746579438497_035587c5abe9/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/3cd6027b8751409394_89ae7d1dba43/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/06022b4a1bfd4bd899_83fa550b2606/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/12142a17dcf348359e_4b8089efcfc5/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/816c7db8603142c49d_a4298ded4256/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/5f1d0ff8204d4dad97_346331b688f7/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/ea6a0271fd4b4bcfb5_0529e40fe796/86" + ], + "image_count": 7, + "posted_text": "7 days ago", + "posted_utc": 1791109924.168, + "posted_at": "2026-10-04T10:32:04.168000Z", + "status": "LIVE", + "seller_type": "Private", + "seller_id": "103262444", + "account_id": "102719651", + "is_pro_seller": false, + "is_featured": true, + "is_urgent": false, + "is_bumped_up": false, + "is_standout": false, + "is_premium": false, + "has_video": false, + "supports_shipping": false, + "favourite_count": 1, + "seller_skills": [], + "attributes": [ + { + "key": "vehicle_cap_code", + "label": "Cap Code", + "value": "MIOU20GX45EXIA4 3", + "unit": null + }, + { + "key": "vehicle_estimated_mileage", + "label": "Estimated Mileage", + "value": "122193", + "unit": null + }, + { + "key": "vehicle_cap_id", + "label": "Cap Id", + "value": "59072", + "unit": null + }, + { + "key": "vehicle_make", + "label": "Make", + "value": "Mitsubishi", + "unit": null + }, + { + "key": "vrn", + "label": "Vehicle Registration Number", + "value": "DK15ZMU", + "unit": null + }, + { + "key": "vehicle_model", + "label": "Model", + "value": "OUTLANDER", + "unit": null + }, + { + "key": "vehicle_registration_year", + "label": "Year", + "value": "2015", + "unit": null + }, + { + "key": "vehicle_mileage", + "label": "Mileage", + "value": "123000", + "unit": "miles" + }, + { + "key": "seller_type", + "label": "Seller type", + "value": "Private", + "unit": null + }, + { + "key": "vehicle_body_type", + "label": "Body type", + "value": "Estate", + "unit": null + }, + { + "key": "vehicle_transmission", + "label": "Transmission", + "value": "Other", + "unit": null + }, + { + "key": "vehicle_brochure_engine_size", + "label": "Brochure Engine size", + "value": "2.0", + "unit": "L" + }, + { + "key": "vehicle_fuel_type", + "label": "Fuel type", + "value": "Hybrid Electric", + "unit": null + }, + { + "key": "vehicle_average_mpg", + "label": "Fuel Consumption", + "value": "148", + "unit": "mpg" + }, + { + "key": "vehicle_insurance_group", + "label": "Insurance Group", + "value": "27E", + "unit": null + }, + { + "key": "vehicle_not_stolen", + "label": "Not stolen", + "value": "true", + "unit": null + }, + { + "key": "vehicle_emission", + "label": "CO2 emissions", + "value": "44", + "unit": "g/km" + }, + { + "key": "vehicle_not_scrapped", + "label": "Not reported scrapped", + "value": "true", + "unit": null + }, + { + "key": "vehicle_euro_emission", + "label": "Euro Emissions", + "value": "Euro 5", + "unit": null + }, + { + "key": "vehicle_engine_size", + "label": "Engine size", + "value": "1998", + "unit": "cc" + }, + { + "key": "vehicle_not_exported", + "label": "Not reported exported", + "value": "true", + "unit": null + }, + { + "key": "vehicle_engine_power", + "label": "Engine Power", + "value": "119.4", + "unit": "bhp" + }, + { + "key": "vehicle_road_worthy", + "label": "Roadworthy", + "value": "true", + "unit": null + }, + { + "key": "vehicle_acceleration_0_62", + "label": "Acceleration (0-62mph)", + "value": "11", + "unit": "seconds" + }, + { + "key": "vehicle_not_writeoff", + "label": "Not written-off", + "value": "true", + "unit": null + }, + { + "key": "vehicle_top_speed", + "label": "Top Speed", + "value": "106", + "unit": "mph" + }, + { + "key": "vehicle_seats", + "label": "Seats", + "value": "5", + "unit": null + }, + { + "key": "vehicle_doors", + "label": "Doors", + "value": "5", + "unit": null + }, + { + "key": "vehicle_original_number_plate", + "label": "Original number plate", + "value": "true", + "unit": null + }, + { + "key": "vehicle_colour", + "label": "Colour", + "value": "White", + "unit": null + }, + { + "key": "vehicle_original_colour", + "label": "Original colour", + "value": "true", + "unit": null + }, + { + "key": "vehicle_vhc_checked", + "label": "Vehicle history checked", + "value": "true", + "unit": null + }, + { + "key": "vehicle_fuel_capacity", + "label": "Fuel Capacity", + "value": "45", + "unit": "litres" + }, + { + "key": "vehicle_previous_keepers", + "label": "Previous Keepers", + "value": "3", + "unit": null + }, + { + "key": "vehicle_derivative", + "label": "Derivative", + "value": "GX4", + "unit": null + } + ] + }, + { + "id": "1802409381", + "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, + "currency": "GBP", + "location": "Penkridge, Staffordshire", + "location_id": "11002225", + "postcode": "ST195DU", + "latitude": 52.7257, + "longitude": -2.10523, + "distance_text": "30 miles", + "distance_miles": 30.0, + "category_id": 10306, + "category_name": "Ford", + "category_ids": [ + 2551, + 9311, + 10306 + ], + "image_url": "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/c77d793ecce84e13a9_a54724b51d3e/86", + "image_urls": [ + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/c77d793ecce84e13a9_a54724b51d3e/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/deee82b490b34320bc_357e6025aea6/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/81897ca3b748464db7_36b529d154f6/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/2655894711164faeac_ede486d608a8/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/fa120eb1795b4801a6_459d45e3549f/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/7d382d5b8e7e4773b6_3c498818bf52/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/b0a1fdb116854be3aa_68c3f9969f78/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/c10b75030fab49119a_a8ed83495d43/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/1cf52dc699e34a0fba_0d7abbe23e06/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/258ccebc6a1240abbb_bbe90dd15718/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/c4ef21fae2534cab9f_6e33bc4003b0/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/d953fdd9b252462d9c_42f1db15b3b7/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/cec5aae4d9124379b8_117a2eeee272/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/2acb2840d0714c54b5_75b671c87f43/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/2c9774ae31fd45a382_3191b22f97be/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/9ba018df018844a488_950842b5bd44/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/6079a8c424f44b9db7_2d0084e9a9b7/86" + ], + "image_count": 17, + "posted_text": "4 days ago", + "posted_utc": 1791395043.988, + "posted_at": "2026-10-07T17:44:03.988000Z", + "status": "LIVE", + "seller_type": "Private", + "seller_id": "96740710", + "account_id": "96201978", + "is_pro_seller": false, + "is_featured": true, + "is_urgent": true, + "is_bumped_up": false, + "is_standout": false, + "is_premium": false, + "has_video": false, + "supports_shipping": false, + "favourite_count": 1, + "seller_skills": [], + "attributes": [ + { + "key": "vehicle_cap_code", + "label": "Cap Code", + "value": "FORA20WILD7DTA4 7 L", + "unit": null + }, + { + "key": "vehicle_cap_id", + "label": "Cap Id", + "value": "56276", + "unit": null + }, + { + "key": "vehicle_make", + "label": "Make", + "value": "Ford", + "unit": null + }, + { + "key": "vrn", + "label": "Vehicle Registration Number", + "value": "SK24XMG", + "unit": null + }, + { + "key": "vehicle_model", + "label": "Model", + "value": "RANGER", + "unit": null + }, + { + "key": "vehicle_registration_year", + "label": "Year", + "value": "2024", + "unit": null + }, + { + "key": "vehicle_mileage", + "label": "Mileage", + "value": "11400", + "unit": "miles" + }, + { + "key": "seller_type", + "label": "Seller type", + "value": "Private", + "unit": null + }, + { + "key": "vehicle_body_type", + "label": "Body type", + "value": "Pick Up", + "unit": null + }, + { + "key": "vehicle_transmission", + "label": "Transmission", + "value": "Automatic", + "unit": null + }, + { + "key": "vehicle_brochure_engine_size", + "label": "Brochure Engine size", + "value": "74", + "unit": "L" + }, + { + "key": "vehicle_fuel_type", + "label": "Fuel type", + "value": "Diesel", + "unit": null + }, + { + "key": "vehicle_urban_mpg", + "label": "Urban mpg", + "value": "44.1", + "unit": "mpg" + }, + { + "key": "vehicle_extra_urban_mpg", + "label": "Extra Urban mpg", + "value": "62.8", + "unit": "mpg" + }, + { + "key": "vehicle_average_mpg", + "label": "Fuel Consumption", + "value": "32.1", + "unit": "mpg" + }, + { + "key": "vehicle_insurance_group", + "label": "Insurance Group", + "value": "Not Available", + "unit": null + }, + { + "key": "vehicle_not_stolen", + "label": "Not stolen", + "value": "true", + "unit": null + }, + { + "key": "vehicle_emission", + "label": "CO2 emissions", + "value": "230", + "unit": "g/km" + }, + { + "key": "vehicle_not_scrapped", + "label": "Not reported scrapped", + "value": "true", + "unit": null + }, + { + "key": "vehicle_euro_emission", + "label": "Euro Emissions", + "value": "Euro 6", + "unit": null + }, + { + "key": "vehicle_engine_size", + "label": "Engine size", + "value": "1996", + "unit": "cc" + }, + { + "key": "vehicle_not_exported", + "label": "Not reported exported", + "value": "true", + "unit": null + }, + { + "key": "vehicle_engine_power", + "label": "Engine Power", + "value": "205", + "unit": "bhp" + }, + { + "key": "vehicle_road_worthy", + "label": "Roadworthy", + "value": "true", + "unit": null + }, + { + "key": "vehicle_not_writeoff", + "label": "Not written-off", + "value": "true", + "unit": null + }, + { + "key": "vehicle_top_speed", + "label": "Top Speed", + "value": "111", + "unit": "mph" + }, + { + "key": "vehicle_seats", + "label": "Seats", + "value": "5", + "unit": null + }, + { + "key": "vehicle_original_number_plate", + "label": "Original number plate", + "value": "false", + "unit": null + }, + { + "key": "vehicle_colour", + "label": "Colour", + "value": "White", + "unit": null + }, + { + "key": "vehicle_original_colour", + "label": "Original colour", + "value": "true", + "unit": null + }, + { + "key": "vehicle_vhc_checked", + "label": "Vehicle history checked", + "value": "true", + "unit": null + }, + { + "key": "vehicle_previous_keepers", + "label": "Previous Keepers", + "value": "4", + "unit": null + }, + { + "key": "vehicle_derivative", + "label": "Derivative", + "value": "Wildtrak", + "unit": null + } + ] + } + ], + "category_facets": [], + "url": "https://www.gumtree.com/search?search_category=cars&search_location=uk" + }, + "GET /v1/gumtree/listings/1803293391": { + "id": "1803293391", + "title": "Mazda, 2, Hatchback, 2018, Manual, 1496 (cc), 5 doors", + "url": null, + "description": "Welcome to Thomson Twins Motors. We are based in Larkhall, Scotland. Our cars come with a Fresh Service, Valeted inside and long MOT's and we provide 6 Months Warranty for FREE. If you require any extra information regarding our vehicles then contact our friendly team directly . Please contact us direct for Finance options. Cards Accepted. Presenting a Mazda 2 1.5 Petrol Skyactive G-Sport Nav. Long MOT 27/9/2027, New Service, 2 Keys, we provide a 6 Month Warranty with this Mazda. This vehicle comes in Metallic grey with 16 inch two tone alloys and privacy glass in the rear. Interior is black cloth with white stitching in excellent condition. The Specification on this model includes 7in Colour Touch Screen Display, AM-FM-DAB Radio - Single CD Player-MP3 Audio System with 6 Speakers, AUX and 2 USB Inputs, Integrated Bluetooth System, Satellite Navigation System, Cruise Control,Gear Shift Indicator, Lane Departure Warning System, Multimedia Commander, Rear Parking Sensors, Trip Computer,\nTyre Pressure Monitoring System, 16in Alloy Wheels, Body Coloured Door Mirrors, Door Handles - Body Coloured, Door Mirrors - Electrically Adjustable - Heated - Power Folding with Integrated Indicator Lights, Electric Windows - Front and Rear,, Front Grille Insert - Body-coloured, Privacy Glass - Rear and Rear Side Windows, Rear Roof Spoiler, Dusk Sensing Automatic Headlights, Halogen Daytime Running Lights, Headlights - Halogen, Air Conditioning - Climate Control, Dashboard Panel - Black - Soft-touch with Red Stitching, Engine Start-Stop Button, Front Passengers Seat - Manual Slide and Recline Adjustment, Gear Knob - Leather, Handbrake - Leather Wrapped,\nLuggage Compartment - Light, Rain-Sensing Front Wipers, Seats - 60-40 Split Folding Rear, Seats - Drivers Manual Slide-Recline and Height Adjustment\nSteering Wheel - Leather, Mounted Audio and Bluetooth Controls, Tilt and Height Adjustment, Three Height Adjustable Rear Head Restraints, Adjustable Speed Limiter\nElectronic Power Assisted Steering, ABS - Anti Lock Braking System,Airbags - Front Passenger Deactivation System,Dual Front Airbags\nDynamic Stability Control - DSC - with Traction Control System - TCS, Emergency Stop Signalling System - ESS, Front Side Airbags, Front and Rear Curtain Airbags, Hill Hold Assist, ISOFIX Child Seat Anchorage for Outer Rear Seats, Seatbelt Reminder, Smart City Brake Support, Smart Keyless Entry, Thatcham Category 1 Alarm and Immobiliser. This vehicle comes with MOT until 27/9/2027 with green pass no advisories, had a new Service completed, provided with a 6 month warranty. We accept card payments and please contact us directly for beat finance option. We welcome Part Exchange vehicles. CONTACT US DIRECTLY FOR FINANCE OPTIONS Baird Avenue Larkhall ML92PJ", + "price": 5699.0, + "price_text": "£5,699.00", + "price_frequency": null, + "currency": "GBP", + "vat": "No VAT", + "location": "Larkhall, South Lanarkshire", + "area": "South Lanarkshire", + "sub_area": "Larkhall", + "location_id": "11000569", + "postcode": "ML93BQ", + "latitude": 55.7269, + "longitude": -3.92624, + "category_id": 10312, + "category_name": "Mazda", + "category_ids": [ + 2551, + 9311, + 10312 + ], + "breadcrumbs": [ + { + "name": "Home", + "url": "https://www.gumtree.com/" + }, + { + "name": "Motors", + "url": "https://www.gumtree.com/cars-vans-motorbikes/uk/larkhall" + }, + { + "name": "Cars", + "url": "https://www.gumtree.com/cars-vans-motorbikes/cars/uk/larkhall" + }, + { + "name": "Mazda", + "url": "https://www.gumtree.com/cars-vans-motorbikes/cars/mazda/uk/larkhall" + }, + { + "name": "Mazda 2", + "url": "https://www.gumtree.com/cars-vans-motorbikes/cars/mazda/2/uk/larkhall" + } + ], + "image_urls": [ + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/cba6556cbcb7464a97_78d604ec62db/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/b5a807b290974290b8_5c9645400e3e/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/36d5f36d0994440cb3_2a0439ce4d0f/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/e1063ffdddfa468b86_c13b00b90a60/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/93d845a615894a9696_6a2f37359295/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/ac2a1cfd15b14235bc_fa6b4dd7a511/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/4ebec44e3cbf463999_b06c8aba2f22/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/c83f5348f56e4a8f91_431f5df57511/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/fdceb778c6f0400ca0_8f25a4930e10/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/579820ca40a946f3b1_d49eea46e901/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/3fd5c443f2854cd8a0_fa57f47c4f1d/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/1407362c0cc647c6b2_fd9a5d85f29f/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/d8346e7178de4ef0a0_6fed3078eaeb/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/e9ae746a12ec46e7a8_01e183584b8d/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/3f8e8d290cfe4fb799_50a9bf64a057/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/f4876d54176c4532af_b42695c44ddc/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/2098bb8db9664e6d95_7afb0b64d05c/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/61866eab605142daa8_c91aecca7da7/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/24329ebffc6c446c93_2abd33652a5f/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/6e0bfedf42ed430280_0353d5898fab/86" + ], + "video_urls": [], + "posted_text": "1 day ago", + "posted_utc": 1791643486.952, + "posted_at": "2026-10-10T14:44:46.952000Z", + "created_utc": 1790675543.83, + "created_at": "2026-09-29T09:52:23.830000Z", + "updated_utc": 1791643487.028, + "updated_at": "2026-10-10T14:44:47.028000Z", + "expires_at": "2026-11-09", + "is_featured": false, + "is_urgent": false, + "is_bumped_up": false, + "is_standout": false, + "favourite_count": 2, + "vehicle_history_checked": true, + "attributes": [ + { + "key": "vehicle_make", + "label": "Make", + "value": "Mazda", + "unit": null + }, + { + "key": "vehicle_model", + "label": "Model", + "value": "2", + "unit": null + }, + { + "key": "vehicle_registration_year", + "label": "Year", + "value": "2018", + "unit": null + }, + { + "key": "vehicle_mileage", + "label": "Mileage", + "value": "94130", + "unit": "miles" + }, + { + "key": "seller_type", + "label": "Seller type", + "value": "Trade", + "unit": null + }, + { + "key": "vehicle_body_type", + "label": "Body type", + "value": "Hatchback", + "unit": null + }, + { + "key": "vehicle_transmission", + "label": "Transmission", + "value": "Manual", + "unit": null + }, + { + "key": "vehicle_brochure_engine_size", + "label": "Brochure Engine size", + "value": "1.5", + "unit": "L" + }, + { + "key": "vehicle_fuel_type", + "label": "Fuel type", + "value": "Petrol", + "unit": null + }, + { + "key": "vehicle_urban_mpg", + "label": "Urban mpg", + "value": "47.9", + "unit": "mpg" + }, + { + "key": "vehicle_extra_urban_mpg", + "label": "Extra Urban mpg", + "value": "76.3", + "unit": "mpg" + }, + { + "key": "vehicle_average_mpg", + "label": "Fuel Consumption", + "value": "62.8", + "unit": "mpg" + }, + { + "key": "vehicle_insurance_group", + "label": "Insurance Group", + "value": "16E", + "unit": null + }, + { + "key": "vehicle_emission", + "label": "CO2 emissions", + "value": "105", + "unit": "g/km" + }, + { + "key": "vehicle_euro_emission", + "label": "Euro Emissions", + "value": "Euro 6", + "unit": null + }, + { + "key": "vehicle_engine_size", + "label": "Engine size", + "value": "1496", + "unit": "cc" + }, + { + "key": "vehicle_engine_power", + "label": "Engine Power", + "value": "88.5", + "unit": "bhp" + }, + { + "key": "vehicle_acceleration_0_62", + "label": "Acceleration (0-62mph)", + "value": "9.4", + "unit": "seconds" + }, + { + "key": "vehicle_top_speed", + "label": "Top Speed", + "value": "114", + "unit": "mph" + }, + { + "key": "vehicle_seats", + "label": "Seats", + "value": "5", + "unit": null + }, + { + "key": "vehicle_doors", + "label": "Doors", + "value": "5", + "unit": null + }, + { + "key": "vehicle_colour", + "label": "Colour", + "value": "Grey", + "unit": null + }, + { + "key": "vehicle_fuel_capacity", + "label": "Fuel Capacity", + "value": "44", + "unit": "litres" + }, + { + "key": "vehicle_luggage_capacity", + "label": "Luggage Capacity (seats up)", + "value": "280", + "unit": "litres" + }, + { + "key": "vehicle_derivative", + "label": "Derivative", + "value": "Sport", + "unit": null + }, + { + "key": "vat", + "label": "VAT", + "value": "No VAT", + "unit": null + } + ], + "seller": { + "name": "Thomson Twins Motors Ltd", + "user_id": "91457907", + "public_id": "41d401e0ec9e0dfa128b17775c8f2f9d", + "type": "Trade", + "is_pro": true, + "posting_since": "4+ months", + "last_active_utc": null, + "last_active_at": null, + "total_listings": null, + "listings_last_year": null, + "listings_by_category": {}, + "reply_speed": null, + "reply_rate": null, + "rating": { + "average": 0.0, + "total": 0, + "breakdown": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0 + } + }, + "verification": { + "status": "VERIFIED", + "label": "Business Verified", + "checks": [ + "Age verification (maximum)", + "Age verification (minimum)", + "Directors", + "Director p" + ] + }, + "phone_masked": null, + "website_url": null, + "avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocL53WVnGjpLqSKwpMtVgP0DWJhISVEYDGxqUG7CKA65GEpPrHk=s96-c", + "logo_url": null, + "badges": [] + }, + "seller_skills": [], + "seller_listings_url": "https://www.gumtree.com/sellerads/1803293391?userId=91457907&publicUserId=41d401e0ec9e0dfa128b17775c8f2f9d", + "similar_listings": [ + { + "id": "1803422383", + "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, + "currency": "GBP", + "location": "Colchester, Essex", + "location_id": "11000115", + "postcode": "CO27QB", + "latitude": null, + "longitude": null, + "distance_text": null, + "distance_miles": null, + "category_id": 10312, + "category_name": null, + "category_ids": [], + "image_url": "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/476feee298e74f1c83_9c1f539543d4/86", + "image_urls": [ + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/476feee298e74f1c83_9c1f539543d4/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/1fed988b819c4919bf_28e464fec79d/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/a1094e28cdde40b18b_d83daf1d77e0/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/b896eceba03b4ec497_c95778d36471/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/27cac9bd0e6643898d_4211292118eb/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/91d3ca3cec8d4dc281_e4e6c73a06fb/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/446486d8d2b745ec8a_ad0536768d57/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/6415a72cb6dd49a889_d39824e131c5/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/113ad4b5ba93482897_82e5bcb4ab61/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/8f1b4a7273ab489b85_edf91e3750f0/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/0117ad6cf12d43d894_5a0a0c2a3e38/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/f8b4d4ba360e4ba6a3_4628f6c8af3a/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/8d6228e2f3fc4d00bf_1720770de353/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/4ec7a5f5f2a04284b9_f7da132c278d/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/c8c04045423e41e6b5_e5512eb67c7e/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/2e09f0454aff4ca7b7_cafdf15dd819/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/0223692ff69a457cb5_d685acb84cc9/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/76f0eb9687df42a9a0_3d169025e2af/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/8a25701ae98b4d628b_faf724dd2dee/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/0fbbde3c49574a55b6_c90232a2f5ff/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/3c2be5332e83434891_7f7f6b86ceef/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/2e56b81474344e4490_716a9c5a6068/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/bc4550c7d9574934a7_90acb5efeb4e/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/6f695d4427894cb598_f52418bd0b59/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/a91957b8f8564f04aa_8918413fac03/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/11610da2eb1049d18b_f2f36e3afe1c/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/b0969ee7e7fc47c38a_a97d409c0f0b/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/4507218e6681483583_63416f70003e/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/fc4b0c787d2d48f9be_3518abf08547/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/4926b619b972422784_7bbddeddb3f7/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/ac063a6b2e1a4d70a4_84617a7473aa/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/b236fc74b1c44266a8_cdf37fd7e31e/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/7550d53ea74d49ad93_ebe877fb5eff/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/0cd4b9decd7e4964b6_52ad339fe0b5/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/614210d9398f46c5a5_37dbeb512762/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/962af34c25b24514b6_ff84d39f1431/86" + ], + "image_count": 36, + "posted_text": "Just now", + "posted_utc": 1791756016.233, + "posted_at": "2026-10-11T22:00:16.233000Z", + "status": "LIVE", + "seller_type": "Trade", + "seller_id": "53458489", + "account_id": "94368145", + "is_pro_seller": true, + "is_featured": false, + "is_urgent": false, + "is_bumped_up": false, + "is_standout": false, + "is_premium": false, + "has_video": false, + "supports_shipping": false, + "favourite_count": null, + "seller_skills": [], + "attributes": [ + { + "key": "vehicle_cap_code", + "label": "Cap Code", + "value": null, + "unit": null + }, + { + "key": "radio_with_speakers", + "label": "Radio CD with Speakers", + "value": "Yes", + "unit": null + }, + { + "key": "navigation_system", + "label": "Navigation System", + "value": "Yes", + "unit": null + }, + { + "key": "safety_belt_pretensioners", + "label": "Safety Belt Pretensioners", + "value": "Yes", + "unit": null + }, + { + "key": "vehicle_cap_id", + "label": "Cap Id", + "value": null, + "unit": null + }, + { + "key": "vrn", + "label": "Vehicle Registration Number", + "value": null, + "unit": null + }, + { + "key": "vehicle_make", + "label": "Make", + "value": "Mazda", + "unit": null + }, + { + "key": "vehicle_model", + "label": "Model", + "value": "MX-5", + "unit": null + }, + { + "key": "vehicle_registration_year", + "label": "Year", + "value": "2017", + "unit": null + }, + { + "key": "vehicle_mileage", + "label": "Mileage", + "value": "46000", + "unit": "miles" + }, + { + "key": "seller_type", + "label": "Seller type", + "value": "Trade", + "unit": null + }, + { + "key": "vehicle_body_type", + "label": "Body type", + "value": "Convertible", + "unit": null + }, + { + "key": "vehicle_transmission", + "label": "Transmission", + "value": "Manual", + "unit": null + }, + { + "key": "vehicle_brochure_engine_size", + "label": "Brochure Engine size", + "value": "1.5", + "unit": "L" + }, + { + "key": "vehicle_fuel_type", + "label": "Fuel type", + "value": "Petrol", + "unit": null + }, + { + "key": "vehicle_urban_mpg", + "label": "Urban mpg", + "value": "34.9", + "unit": "mpg" + }, + { + "key": "vehicle_extra_urban_mpg", + "label": "Extra Urban mpg", + "value": "57.6", + "unit": "mpg" + }, + { + "key": "vehicle_average_mpg", + "label": "Fuel Consumption", + "value": "46.3", + "unit": "mpg" + }, + { + "key": "vehicle_insurance_group", + "label": "Insurance Group", + "value": "24E", + "unit": null + }, + { + "key": "vehicle_not_stolen", + "label": "Not stolen", + "value": "true", + "unit": null + }, + { + "key": "vehicle_emission", + "label": "CO2 emissions", + "value": "142", + "unit": "g/km" + }, + { + "key": "vehicle_not_scrapped", + "label": "Not reported scrapped", + "value": "true", + "unit": null + }, + { + "key": "vehicle_euro_emission", + "label": "Euro Emissions", + "value": "Euro 6", + "unit": null + }, + { + "key": "vehicle_engine_size", + "label": "Engine size", + "value": "1496", + "unit": "cc" + }, + { + "key": "vehicle_not_exported", + "label": "Not reported exported", + "value": "true", + "unit": null + }, + { + "key": "vehicle_engine_power", + "label": "Engine Power", + "value": "128.7", + "unit": "bhp" + }, + { + "key": "vehicle_road_worthy", + "label": "Roadworthy", + "value": "true", + "unit": null + }, + { + "key": "vehicle_acceleration_0_62", + "label": "Acceleration (0-62mph)", + "value": "8.6", + "unit": "seconds" + }, + { + "key": "vehicle_not_writeoff", + "label": "Not written-off", + "value": "true", + "unit": null + }, + { + "key": "vehicle_top_speed", + "label": "Top Speed", + "value": "126", + "unit": "mph" + }, + { + "key": "vehicle_seats", + "label": "Seats", + "value": "2", + "unit": null + }, + { + "key": "vehicle_doors", + "label": "Doors", + "value": "2", + "unit": null + }, + { + "key": "vehicle_original_number_plate", + "label": "Original number plate", + "value": "true", + "unit": null + }, + { + "key": "vehicle_colour", + "label": "Colour", + "value": "Silver", + "unit": null + }, + { + "key": "vehicle_original_colour", + "label": "Original colour", + "value": "true", + "unit": null + }, + { + "key": "vehicle_fuel_capacity", + "label": "Fuel Capacity", + "value": "45", + "unit": "litres" + }, + { + "key": "vehicle_vhc_checked", + "label": "Vehicle history checked", + "value": "true", + "unit": null + }, + { + "key": "vehicle_luggage_capacity", + "label": "Luggage Capacity (seats up)", + "value": "127", + "unit": "litres" + }, + { + "key": "vehicle_previous_keepers", + "label": "Previous Keepers", + "value": "3", + "unit": null + }, + { + "key": "vehicle_derivative", + "label": "Derivative", + "value": "SE-L Nav", + "unit": null + } + ] + } + ] + }, + "GET /v1/gumtree/search/filters?category=cars": { + "category": "cars", + "category_name": "Cars", + "total_results": 139812, + "filters": [ + { + "key": "vehicle_make", + "label": "Make", + "type": "enum", + "min_key": null, + "max_key": null, + "options": [ + { + "label": "Abarth", + "value": "abarth", + "count": 265 + }, + { + "label": "AC", + "value": "ac", + "count": 1 + }, + { + "label": "Aixam", + "value": "aixam", + "count": 1 + } + ], + "min_options": [], + "max_options": [] + }, + { + "key": "vehicle_transmission", + "label": "Transmission", + "type": "multiple_selection", + "min_key": null, + "max_key": null, + "options": [ + { + "label": "Automatic", + "value": "automatic", + "count": 45316 + }, + { + "label": "Manual", + "value": "manual", + "count": 83436 + }, + { + "label": "Semi-Auto", + "value": "semi_auto", + "count": 6852 + } + ], + "min_options": [], + "max_options": [] + } + ], + "sort_options": [ + { + "value": "relevance", + "label": "Most relevant first" + }, + { + "value": "date", + "label": "Most recent first" + }, + { + "value": "price_lowest_first", + "label": "Price: Low to High" + }, + { + "value": "price_highest_first", + "label": "Price: High to Low" + }, + { + "value": "distance", + "label": "Nearest first" + } + ], + "distance_options": [ + 1.0, + 3.0, + 5.0, + 10.0, + 15.0, + 30.0, + 50.0, + 75.0, + 100.0, + 1000.0 + ], + "category_facets": [] + }, + "GET /v1/gumtree/listings/1803293391/seller-listings": { + "listing_id": "1803293391", + "seller_name": "Allan", + "total_results": 12, + "page": 1, + "total_pages": 1, + "result_count": 12, + "listings": [ + { + "id": "1803761638", + "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, + "currency": "GBP", + "location": "Larkhall, South Lanarkshire", + "location_id": "11000569", + "postcode": "ML93BQ", + "latitude": null, + "longitude": null, + "distance_text": null, + "distance_miles": null, + "category_id": 10307, + "category_name": null, + "category_ids": [], + "image_url": "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/f0018e42e2c44a579d_150de0fec609/86", + "image_urls": [ + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/f0018e42e2c44a579d_150de0fec609/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/98ca7af6680041d8b0_24da9aa44baa/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/cb468934c11c4f39a7_193af3e1f4ce/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/7b044c89303540189a_6ace9c8af893/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/8f6a6961e33e478687_720ec21685d1/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/a90268c094224ff2a0_7d0b0984c9bc/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/f817343b26114cf3b1_ed3a3bdc93e0/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/021744aedcb6461296_e4e82a394c20/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/c44cf5594f2f469ebc_5aa0411d9cf9/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/9bb5395159a44453b0_35f3d7f6c5fa/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/5d0229c513234bab84_dd5686808541/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/e6cf261a517343659c_c33fa0696dcb/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/0a680de055984c77a3_1e8d44b22fba/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/d5ac8237118a40dc8e_2192ca167ae8/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/19c2455389004c959e_03f36eb1ab10/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/458ffd3199ff4e5ab2_d734fcf190e1/86", + "https://img.gumtree.com/ePR8PyKf84wPHx7_RYmEag/14d06d8ea17d4c3493_763746313288/86" + ], + "image_count": 17, + "posted_text": "3 hours ago", + "posted_utc": 1791743982.768, + "posted_at": "2026-10-11T18:39:42.768000Z", + "status": "LIVE", + "seller_type": "Trade", + "seller_id": "91457907", + "account_id": "101852652", + "is_pro_seller": true, + "is_featured": false, + "is_urgent": false, + "is_bumped_up": false, + "is_standout": false, + "is_premium": false, + "has_video": false, + "supports_shipping": false, + "favourite_count": null, + "seller_skills": [], + "attributes": [ + { + "key": "vehicle_cap_code", + "label": "Cap Code", + "value": "HOCI16SPV5HDTM 5", + "unit": null + }, + { + "key": "vehicle_estimated_mileage", + "label": "Estimated Mileage", + "value": "92132", + "unit": null + }, + { + "key": "vehicle_cap_id", + "label": "Cap Id", + "value": "67809", + "unit": null + }, + { + "key": "vrn", + "label": "Vehicle Registration Number", + "value": "SJ66XSV", + "unit": null + }, + { + "key": "vehicle_make", + "label": "Make", + "value": "Honda", + "unit": null + }, + { + "key": "vehicle_model", + "label": "Model", + "value": "CIVIC", + "unit": null + }, + { + "key": "vehicle_registration_year", + "label": "Year", + "value": "2016", + "unit": null + }, + { + "key": "vehicle_mileage", + "label": "Mileage", + "value": "92500", + "unit": "miles" + }, + { + "key": "seller_type", + "label": "Seller type", + "value": "Trade", + "unit": null + }, + { + "key": "vehicle_body_type", + "label": "Body type", + "value": "Hatchback", + "unit": null + }, + { + "key": "vehicle_transmission", + "label": "Transmission", + "value": "Manual", + "unit": null + }, + { + "key": "vehicle_brochure_engine_size", + "label": "Brochure Engine size", + "value": "1.6", + "unit": "L" + }, + { + "key": "vehicle_fuel_type", + "label": "Fuel type", + "value": "Diesel", + "unit": null + }, + { + "key": "vehicle_urban_mpg", + "label": "Urban mpg", + "value": "72.4", + "unit": "mpg" + }, + { + "key": "vehicle_extra_urban_mpg", + "label": "Extra Urban mpg", + "value": "83.1", + "unit": "mpg" + }, + { + "key": "vehicle_average_mpg", + "label": "Fuel Consumption", + "value": "78.5", + "unit": "mpg" + }, + { + "key": "vehicle_insurance_group", + "label": "Insurance Group", + "value": "15E", + "unit": null + }, + { + "key": "vehicle_not_stolen", + "label": "Not stolen", + "value": "true", + "unit": null + }, + { + "key": "vehicle_emission", + "label": "CO2 emissions", + "value": "94", + "unit": "g/km" + }, + { + "key": "vehicle_not_scrapped", + "label": "Not reported scrapped", + "value": "true", + "unit": null + }, + { + "key": "vehicle_euro_emission", + "label": "Euro Emissions", + "value": "Euro 6", + "unit": null + }, + { + "key": "vehicle_engine_size", + "label": "Engine size", + "value": "1597", + "unit": "cc" + }, + { + "key": "vehicle_not_exported", + "label": "Not reported exported", + "value": "true", + "unit": null + }, + { + "key": "vehicle_engine_power", + "label": "Engine Power", + "value": "118", + "unit": "bhp" + }, + { + "key": "vehicle_road_worthy", + "label": "Roadworthy", + "value": "true", + "unit": null + }, + { + "key": "vehicle_acceleration_0_62", + "label": "Acceleration (0-62mph)", + "value": "10.5", + "unit": "seconds" + }, + { + "key": "vehicle_not_writeoff", + "label": "Not written-off", + "value": "true", + "unit": null + }, + { + "key": "vehicle_top_speed", + "label": "Top Speed", + "value": "129", + "unit": "mph" + }, + { + "key": "vehicle_seats", + "label": "Seats", + "value": "5", + "unit": null + }, + { + "key": "vehicle_doors", + "label": "Doors", + "value": "5", + "unit": null + }, + { + "key": "vehicle_original_number_plate", + "label": "Original number plate", + "value": "true", + "unit": null + }, + { + "key": "vehicle_colour", + "label": "Colour", + "value": "Silver", + "unit": null + }, + { + "key": "vehicle_original_colour", + "label": "Original colour", + "value": "true", + "unit": null + }, + { + "key": "vehicle_fuel_capacity", + "label": "Fuel Capacity", + "value": "50", + "unit": "litres" + }, + { + "key": "vehicle_vhc_checked", + "label": "Vehicle history checked", + "value": "true", + "unit": null + }, + { + "key": "vehicle_luggage_capacity", + "label": "Luggage Capacity (seats up)", + "value": "477", + "unit": "litres" + }, + { + "key": "vehicle_previous_keepers", + "label": "Previous Keepers", + "value": "1", + "unit": null + }, + { + "key": "vehicle_derivative", + "label": "Derivative", + "value": "SE Plus", + "unit": null + }, + { + "key": "vat", + "label": "VAT", + "value": "No VAT", + "unit": null + } + ] + } + ] + }, + "GET /v1/gumtree/autocomplete?query=sofa": { + "query": "sofa", + "result_count": 10, + "suggestions": [ + { + "query": "sofa", + "highlighted": "sofa", + "categories": [ + { + "id": 12494, + "name": "Sofas & Couches", + "slug": "sofas-couches" + }, + { + "id": 679, + "name": "Sofa Beds & Futons", + "slug": "sofa-beds-futons" + } + ] + }, + { + "query": "sofa bed", + "highlighted": "sofa bed", + "categories": [] + }, + { + "query": "sofa set", + "highlighted": "sofa set", + "categories": [] + } + ] + }, + "GET /v1/gumtree/locations/autocomplete?query=leed": { + "query": "leed", + "result_count": 2, + "locations": [ + { + "id": 10000360, + "name": "Leeds", + "type": "location", + "latitude": 53.808019, + "longitude": -1.541784 + }, + { + "id": 52026, + "name": "Leeds City Centre, West Yorkshire", + "type": "location", + "latitude": 53.797899, + "longitude": -1.548119 + } + ] + }, + "GET /v1/gumtree/categories?parent_id=2551": { + "id": 2551, + "name": "Motors", + "slug": "cars-vans-motorbikes", + "result_count": 12, + "children": [ + { + "id": 11986, + "name": "Other Categories", + "slug": "other-vehicle-categories", + "has_children": false + }, + { + "id": 9311, + "name": "Cars", + "slug": "cars", + "has_children": false + }, + { + "id": 10442, + "name": "Motorbikes & Scooters", + "slug": "motorbikes-scooters", + "has_children": false + }, + { + "id": 94, + "name": "Vans", + "slug": "vans", + "has_children": false + } + ] + }, + "GET /v1/gumtree/locations?parent_id=10000393": { + "id": 10000393, + "name": "England", + "slug": "england", + "result_count": 47, + "children": [ + { + "id": 11000000, + "name": "Bedfordshire", + "slug": "bedfordshire", + "has_children": true + }, + { + "id": 11000001, + "name": "Berkshire", + "slug": "berkshire", + "has_children": true + }, + { + "id": 10000348, + "name": "Bristol", + "slug": "bristol", + "has_children": true + } + ] + } +} diff --git a/tests/test_gumtree.py b/tests/test_gumtree.py new file mode 100644 index 0000000..10e49d9 --- /dev/null +++ b/tests/test_gumtree.py @@ -0,0 +1,268 @@ +"""Unit tests for Gumtree SDK methods and models. + +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 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import BaseModel + +from scrapebadger.gumtree.client import GumtreeClient +from scrapebadger.gumtree.models import ( + AutocompleteResponse, + FiltersResponse, + ListingDetail, + LocationAutocompleteResponse, + SearchResponse, + SellerListingsResponse, + TreeResponse, +) + +SAMPLES: dict[str, Any] = json.loads( + (Path(__file__).parent / "fixtures" / "gumtree_samples.json").read_text(encoding="utf-8") +) +SEARCH = SAMPLES["GET /v1/gumtree/search?category=cars"] +LISTING = SAMPLES["GET /v1/gumtree/listings/1803293391"] +FILTERS = SAMPLES["GET /v1/gumtree/search/filters?category=cars"] +SELLER_LISTINGS = SAMPLES["GET /v1/gumtree/listings/1803293391/seller-listings"] +AUTOCOMPLETE = SAMPLES["GET /v1/gumtree/autocomplete?query=sofa"] +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"] + + +@pytest.fixture +def mock_base_client() -> MagicMock: + """Return a mock BaseClient with AsyncMock methods.""" + client = MagicMock() + client.get = AsyncMock() + return client + + +@pytest.fixture +def gumtree(mock_base_client: MagicMock) -> GumtreeClient: + return GumtreeClient(mock_base_client) + + +def _call(mock_base_client: MagicMock) -> tuple[str, dict[str, Any] | None]: + args, kwargs = mock_base_client.get.call_args + return args[0], kwargs.get("params") + + +class TestGumtreeClient: + def test_exposed_on_main_client(self) -> None: + from scrapebadger import GumtreeClient as Exported + from scrapebadger import ScrapeBadger + + client = ScrapeBadger(api_key="test-key") + assert isinstance(client.gumtree, GumtreeClient) + assert client.gumtree is client.gumtree + assert Exported is GumtreeClient + + +class TestRouting: + async def test_search_defaults( + self, gumtree: GumtreeClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = SEARCH + result = await gumtree.search() + + assert isinstance(result, SearchResponse) + path, params = _call(mock_base_client) + assert path == "/v1/gumtree/search" + assert params == { + "query": None, + "category": "all", + "location": "uk", + "distance": None, + "min_price": None, + "max_price": None, + "sort": None, + "seller_type": None, + "urgent_only": None, + "with_photos": None, + "filters": None, + "page": 1, + } + + async def test_search_all_params( + self, gumtree: GumtreeClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = SEARCH + await gumtree.search( + "bmw 3 series", + category="cars", + location="SW1A 1AA", + distance=25, + min_price=1000, + max_price=9000, + sort="price_asc", + seller_type="private", + urgent_only=True, + with_photos=True, + filters="vehicle_make:bmw,vehicle_fuel_type:petrol", + page=3, + ) + + path, params = _call(mock_base_client) + assert path == "/v1/gumtree/search" + assert params == { + "query": "bmw 3 series", + "category": "cars", + "location": "SW1A 1AA", + "distance": 25, + "min_price": 1000, + "max_price": 9000, + "sort": "price_asc", + "seller_type": "private", + "urgent_only": True, + "with_photos": True, + "filters": "vehicle_make:bmw,vehicle_fuel_type:petrol", + "page": 3, + } + + async def test_search_filters( + self, gumtree: GumtreeClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = FILTERS + result = await gumtree.search_filters("cars", location="leeds", query="diesel") + + assert isinstance(result, FiltersResponse) + assert _call(mock_base_client) == ( + "/v1/gumtree/search/filters", + {"category": "cars", "location": "leeds", "query": "diesel"}, + ) + + async def test_get_listing(self, gumtree: GumtreeClient, mock_base_client: MagicMock) -> None: + mock_base_client.get.return_value = LISTING + result = await gumtree.get_listing("1803293391") + + assert isinstance(result, ListingDetail) + assert _call(mock_base_client) == ("/v1/gumtree/listings/1803293391", None) + + async def test_get_seller_listings( + self, gumtree: GumtreeClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = SELLER_LISTINGS + result = await gumtree.get_seller_listings("1803293391", page=2) + + assert isinstance(result, SellerListingsResponse) + assert _call(mock_base_client) == ( + "/v1/gumtree/listings/1803293391/seller-listings", + {"page": 2}, + ) + + async def test_autocomplete(self, gumtree: GumtreeClient, mock_base_client: MagicMock) -> None: + mock_base_client.get.return_value = AUTOCOMPLETE + result = await gumtree.autocomplete("sofa") + + assert isinstance(result, AutocompleteResponse) + assert _call(mock_base_client) == ("/v1/gumtree/autocomplete", {"query": "sofa"}) + + async def test_location_autocomplete( + self, gumtree: GumtreeClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = LOCATION_AUTOCOMPLETE + result = await gumtree.location_autocomplete("leed") + + assert isinstance(result, LocationAutocompleteResponse) + assert _call(mock_base_client) == ("/v1/gumtree/locations/autocomplete", {"query": "leed"}) + + async def test_list_locations( + self, gumtree: GumtreeClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = LOCATIONS + result = await gumtree.list_locations(10000393) + + assert isinstance(result, TreeResponse) + assert _call(mock_base_client) == ("/v1/gumtree/locations", {"parent_id": 10000393}) + + async def test_list_categories_default_root( + self, gumtree: GumtreeClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = CATEGORIES + 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}) + + +def _assert_round_trips(payload: Any, dumped: Any, path: str = "$") -> None: + """Every key and value in the API payload must survive model parsing. + + `extra="ignore"` drops undeclared fields silently, so this is what pins + the models to the real response shape. + """ + if isinstance(payload, dict): + assert isinstance(dumped, dict), path + for key, value in payload.items(): + assert key in dumped, f"{path}.{key} dropped by the model" + _assert_round_trips(value, dumped[key], f"{path}.{key}") + elif isinstance(payload, list): + assert isinstance(dumped, list), path + assert len(payload) == len(dumped), path + for i, (p, d) in enumerate(zip(payload, dumped, strict=True)): + _assert_round_trips(p, d, f"{path}[{i}]") + else: + assert payload == dumped, f"{path}: {payload!r} != {dumped!r}" + + +class TestGumtreeModels: + @pytest.mark.parametrize( + ("model", "payload"), + [ + (SearchResponse, SEARCH), + (ListingDetail, LISTING), + (FiltersResponse, FILTERS), + (SellerListingsResponse, SELLER_LISTINGS), + (AutocompleteResponse, AUTOCOMPLETE), + (LocationAutocompleteResponse, LOCATION_AUTOCOMPLETE), + (TreeResponse, CATEGORIES), + (TreeResponse, LOCATIONS), + ], + ) + def test_real_responses_round_trip(self, model: type[BaseModel], payload: Any) -> None: + _assert_round_trips(payload, model.model_validate(payload).model_dump()) + + def test_listing_detail_nesting(self) -> None: + listing = ListingDetail.model_validate(LISTING) + + assert listing.price == 5699.0 + assert listing.currency == "GBP" + assert listing.breadcrumbs[1].name == "Motors" + assert listing.attributes[0].key == "vehicle_make" + assert listing.seller is not None + assert listing.seller.type == "Trade" + assert listing.seller.rating is not None + assert listing.seller.rating.breakdown["5"] == 0 + assert listing.seller.verification is not None + assert listing.seller.verification.status == "VERIFIED" + + def test_filters_and_trees(self) -> None: + filters = FiltersResponse.model_validate(FILTERS) + assert filters.filters[0].key == "vehicle_make" + assert filters.filters[0].options[0].count is not None + assert filters.sort_options[0].value == "relevance" + + categories = TreeResponse.model_validate(CATEGORIES) + assert categories.id == 2551 + assert any(child.slug == "cars" for child in categories.children) + + def test_models_are_frozen_and_ignore_unknown_fields(self) -> None: + listing = ListingDetail.model_validate({"id": "1", "a_new_gumtree_field": 1}) + + assert listing.id == "1" + assert listing.currency == "GBP" + with pytest.raises(Exception, match="frozen"): + listing.title = "other" # type: ignore[misc]