From e499a7faef647ee4af9239ce416efdf0934b89f4 Mon Sep 17 00:00:00 2001 From: kasparasizi1 <132673909+kasparasizi1@users.noreply.github.com> Date: Tue, 7 Jul 2026 02:31:04 +0300 Subject: [PATCH] feat: add Leboncoin Scraper API client (0.15.6) --- CHANGELOG.md | 6 + pyproject.toml | 2 +- src/scrapebadger/__init__.py | 93 ++++- src/scrapebadger/_internal/client.py | 2 +- src/scrapebadger/client.py | 30 ++ src/scrapebadger/leboncoin/__init__.py | 83 +++++ src/scrapebadger/leboncoin/ads.py | 80 ++++ src/scrapebadger/leboncoin/client.py | 131 +++++++ src/scrapebadger/leboncoin/models.py | 307 +++++++++++++++ src/scrapebadger/leboncoin/reference.py | 133 +++++++ src/scrapebadger/leboncoin/search.py | 105 ++++++ src/scrapebadger/leboncoin/sellers.py | 89 +++++ tests/test_leboncoin.py | 474 ++++++++++++++++++++++++ uv.lock | 2 +- 14 files changed, 1533 insertions(+), 4 deletions(-) create mode 100644 src/scrapebadger/leboncoin/__init__.py create mode 100644 src/scrapebadger/leboncoin/ads.py create mode 100644 src/scrapebadger/leboncoin/client.py create mode 100644 src/scrapebadger/leboncoin/models.py create mode 100644 src/scrapebadger/leboncoin/reference.py create mode 100644 src/scrapebadger/leboncoin/search.py create mode 100644 src/scrapebadger/leboncoin/sellers.py create mode 100644 tests/test_leboncoin.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b24abb3..c0ffc3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.6] - 2026-07-07 + +### Added + +- Add Leboncoin Scraper API client (France) — 10 endpoints: search, ad detail, similar ads, seller profile + listings, markets/regions/departments/categories/location search. + ## [0.15.5] - 2026-07-02 ### Added diff --git a/pyproject.toml b/pyproject.toml index 25a17ce..f18da3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "scrapebadger" -version = "0.15.5" +version = "0.15.6" 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 5f06747..49382f2 100644 --- a/src/scrapebadger/__init__.py +++ b/src/scrapebadger/__init__.py @@ -154,6 +154,73 @@ async def main(): SellerProfileResponse as EbaySellerProfileResponse, ) from scrapebadger.google.client import GoogleClient +from scrapebadger.leboncoin.client import LeboncoinClient +from scrapebadger.leboncoin.models import ( + Ad as LeboncoinAd, +) +from scrapebadger.leboncoin.models import ( + AdResponse as LeboncoinAdResponse, +) +from scrapebadger.leboncoin.models import ( + Attribute as LeboncoinAttribute, +) +from scrapebadger.leboncoin.models import ( + CategoriesResponse as LeboncoinCategoriesResponse, +) +from scrapebadger.leboncoin.models import ( + Category as LeboncoinCategory, +) +from scrapebadger.leboncoin.models import ( + Department as LeboncoinDepartment, +) +from scrapebadger.leboncoin.models import ( + DepartmentsResponse as LeboncoinDepartmentsResponse, +) +from scrapebadger.leboncoin.models import ( + FeedbackScores as LeboncoinFeedbackScores, +) +from scrapebadger.leboncoin.models import ( + Images as LeboncoinImages, +) +from scrapebadger.leboncoin.models import ( + Location as LeboncoinLocation, +) +from scrapebadger.leboncoin.models import ( + LocationSearchResponse as LeboncoinLocationSearchResponse, +) +from scrapebadger.leboncoin.models import ( + LocationSuggestion as LeboncoinLocationSuggestion, +) +from scrapebadger.leboncoin.models import ( + MarketsResponse as LeboncoinMarketsResponse, +) +from scrapebadger.leboncoin.models import ( + Owner as LeboncoinOwner, +) +from scrapebadger.leboncoin.models import ( + Region as LeboncoinRegion, +) +from scrapebadger.leboncoin.models import ( + RegionsResponse as LeboncoinRegionsResponse, +) +from scrapebadger.leboncoin.models import ( + SearchResponse as LeboncoinSearchResponse, +) +from scrapebadger.leboncoin.models import ( + Seller as LeboncoinSeller, +) +from scrapebadger.leboncoin.models import ( + SellerListingsResponse as LeboncoinSellerListingsResponse, +) +from scrapebadger.leboncoin.models import ( + SellerResponse as LeboncoinSellerResponse, +) +from scrapebadger.leboncoin.models import ( + SimilarResponse as LeboncoinSimilarResponse, +) +from scrapebadger.leboncoin.models import ( + StoreRatingReview as LeboncoinStoreRatingReview, +) from scrapebadger.realtor.client import RealtorClient from scrapebadger.realtor.models import ( Address as RealtorAddress, @@ -481,7 +548,7 @@ async def main(): Video as YoutubeVideo, ) -__version__ = "0.15.5" +__version__ = "0.15.6" __all__ = [ # TikTok core models @@ -542,6 +609,30 @@ async def main(): "GoogleClient", "InsufficientCreditsError", "ItemDetailResponse", + # Leboncoin + "LeboncoinAd", + "LeboncoinAdResponse", + "LeboncoinAttribute", + "LeboncoinCategoriesResponse", + "LeboncoinCategory", + "LeboncoinClient", + "LeboncoinDepartment", + "LeboncoinDepartmentsResponse", + "LeboncoinFeedbackScores", + "LeboncoinImages", + "LeboncoinLocation", + "LeboncoinLocationSearchResponse", + "LeboncoinLocationSuggestion", + "LeboncoinMarketsResponse", + "LeboncoinOwner", + "LeboncoinRegion", + "LeboncoinRegionsResponse", + "LeboncoinSearchResponse", + "LeboncoinSeller", + "LeboncoinSellerListingsResponse", + "LeboncoinSellerResponse", + "LeboncoinSimilarResponse", + "LeboncoinStoreRatingReview", "MarketInfo", "MarketsResponse", "NewReleasesResponse", diff --git a/src/scrapebadger/_internal/client.py b/src/scrapebadger/_internal/client.py index 3a9b469..72af85d 100644 --- a/src/scrapebadger/_internal/client.py +++ b/src/scrapebadger/_internal/client.py @@ -29,7 +29,7 @@ T = TypeVar("T") # User agent for SDK requests -SDK_VERSION = "0.15.5" +SDK_VERSION = "0.15.6" USER_AGENT = f"scrapebadger-python/{SDK_VERSION}" diff --git a/src/scrapebadger/client.py b/src/scrapebadger/client.py index 37320f5..7db4b18 100644 --- a/src/scrapebadger/client.py +++ b/src/scrapebadger/client.py @@ -12,6 +12,7 @@ from scrapebadger.amazon.client import AmazonClient from scrapebadger.ebay.client import EbayClient from scrapebadger.google.client import GoogleClient +from scrapebadger.leboncoin.client import LeboncoinClient from scrapebadger.realtor.client import RealtorClient from scrapebadger.reddit.client import RedditClient from scrapebadger.tiktok.client import TikTokClient @@ -126,6 +127,7 @@ def __init__( self._youtube: YoutubeClient | None = None self._tiktok: TikTokClient | None = None self._realtor: RealtorClient | None = None + self._leboncoin: LeboncoinClient | None = None @property def config(self) -> ClientConfig: @@ -327,6 +329,34 @@ def realtor(self) -> RealtorClient: self._realtor = RealtorClient(self._base_client) return self._realtor + @property + def leboncoin(self) -> LeboncoinClient: + """Access Leboncoin Scraper API operations. + + Returns: + LeboncoinClient providing access to all 10 Leboncoin endpoints + (search, ad detail, similar ads, seller profile/listings, + categories, regions, departments, location search, markets). + + Example: + ```python + # Search for ads + results = await client.leboncoin.search.search("velo") + + # Get ad detail + detail = await client.leboncoin.ads.get_ad(2812345678) + + # Get a seller profile + seller = await client.leboncoin.sellers.get_seller("12345678") + + # Reference regions + regions = await client.leboncoin.reference.list_regions() + ``` + """ + if self._leboncoin is None: + self._leboncoin = LeboncoinClient(self._base_client) + return self._leboncoin + @property def tiktok(self) -> TikTokClient: """Access TikTok Scraper API operations. diff --git a/src/scrapebadger/leboncoin/__init__.py b/src/scrapebadger/leboncoin/__init__.py new file mode 100644 index 0000000..d8eee4a --- /dev/null +++ b/src/scrapebadger/leboncoin/__init__.py @@ -0,0 +1,83 @@ +"""Leboncoin API module for ScrapeBadger SDK. + +This module provides a comprehensive async client for scraping Leboncoin data +through the ScrapeBadger API. All methods are async and return strongly-typed +Pydantic models. + +Example: + ```python + from scrapebadger import ScrapeBadger + + async with ScrapeBadger(api_key="your-key") as client: + # Search for ads + results = await client.leboncoin.search.search("velo") + for ad in results.ads: + print(f"{ad.subject}: {ad.price_eur}") + + # Get ad detail + detail = await client.leboncoin.ads.get_ad(2812345678) + print(detail.ad.subject) + + # Get a seller profile + seller = await client.leboncoin.sellers.get_seller("12345678") + print(f"{seller.seller.name}: {seller.seller.total_ads} ads") + ``` +""" + +from scrapebadger.leboncoin.client import LeboncoinClient +from scrapebadger.leboncoin.models import ( + Ad, + AdResponse, + Attribute, + CategoriesResponse, + Category, + Department, + DepartmentsResponse, + FeedbackScores, + Images, + Location, + LocationSearchResponse, + LocationSuggestion, + MarketsResponse, + Owner, + Region, + RegionsResponse, + SearchResponse, + Seller, + SellerListingsResponse, + SellerResponse, + SimilarResponse, + StoreRatingReview, +) + +__all__ = [ + # Ad + "Ad", + "AdResponse", + "Attribute", + # Reference + "CategoriesResponse", + "Category", + "Department", + "DepartmentsResponse", + # Seller + "FeedbackScores", + # Shared + "Images", + # Client + "LeboncoinClient", + "Location", + "LocationSearchResponse", + "LocationSuggestion", + "MarketsResponse", + "Owner", + "Region", + "RegionsResponse", + # Search + "SearchResponse", + "Seller", + "SellerListingsResponse", + "SellerResponse", + "SimilarResponse", + "StoreRatingReview", +] diff --git a/src/scrapebadger/leboncoin/ads.py b/src/scrapebadger/leboncoin/ads.py new file mode 100644 index 0000000..3b493de --- /dev/null +++ b/src/scrapebadger/leboncoin/ads.py @@ -0,0 +1,80 @@ +"""Leboncoin Ads API client. + +Provides methods for fetching a single ad's full detail and its similar ads. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.leboncoin.models import AdResponse, SimilarResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class AdsClient: + """Client for Leboncoin ad endpoints (detail, similar). + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + detail = await client.leboncoin.ads.get_ad(2812345678) + print(detail.ad.subject) + + similar = await client.leboncoin.ads.get_similar(2812345678) + print(f"{len(similar.ads)} similar ads") + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize ads client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get_ad(self, list_id: int) -> AdResponse: + """Get a single Leboncoin ad's full detail. + + Args: + list_id: The Leboncoin ad id. + + Returns: + Ad detail response including images, attributes, location, and owner. + + Raises: + NotFoundError: If the ad doesn't exist. + AuthenticationError: If the API key is invalid. + + Example: + ```python + detail = await client.leboncoin.ads.get_ad(2812345678) + ad = detail.ad + print(f"{ad.subject}: {ad.price_eur} {ad.currency}") + ``` + """ + response = await self._client.get(f"/v1/leboncoin/ads/{list_id}") + return AdResponse.model_validate(response) + + async def get_similar(self, list_id: int, *, limit: int | None = None) -> SimilarResponse: + """Get ads similar to a given Leboncoin ad. + + Args: + list_id: The Leboncoin ad id. + limit: Maximum number of similar ads to return. + + Returns: + Similar response with the source list id and matching ads. + + Example: + ```python + result = await client.leboncoin.ads.get_similar(2812345678, limit=10) + for ad in result.ads: + print(ad.subject) + ``` + """ + params: dict[str, Any] = {"limit": limit} + response = await self._client.get(f"/v1/leboncoin/ads/{list_id}/similar", params=params) + return SimilarResponse.model_validate(response) diff --git a/src/scrapebadger/leboncoin/client.py b/src/scrapebadger/leboncoin/client.py new file mode 100644 index 0000000..6e0b4eb --- /dev/null +++ b/src/scrapebadger/leboncoin/client.py @@ -0,0 +1,131 @@ +"""Leboncoin API client combining all sub-clients. + +This module provides the main LeboncoinClient class that serves as the +entry point for all Leboncoin API operations. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from scrapebadger.leboncoin.ads import AdsClient +from scrapebadger.leboncoin.reference import ReferenceClient +from scrapebadger.leboncoin.search import SearchClient +from scrapebadger.leboncoin.sellers import SellersClient + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class LeboncoinClient: + """Client for all Leboncoin API operations. + + This class provides access to all Leboncoin scraping endpoints through + organized sub-clients for different resource types. + + Attributes: + search: Client for classified-ad search. + ads: Client for ad detail and similar ads. + sellers: Client for seller profile and listings. + reference: Client for reference data (categories, regions, departments, + locations, markets). + + Example: + ```python + from scrapebadger import ScrapeBadger + + async with ScrapeBadger(api_key="your-key") as client: + # Search for ads + results = await client.leboncoin.search.search("velo") + for ad in results.ads: + print(f"{ad.subject}: {ad.price_eur}") + + # Get ad detail + detail = await client.leboncoin.ads.get_ad(2812345678) + print(detail.ad.subject) + + # Get a seller profile + seller = await client.leboncoin.sellers.get_seller("12345678") + + # Get reference regions + regions = await client.leboncoin.reference.list_regions() + ``` + + Note: + This client is not instantiated directly. Instead, access it through + the `leboncoin` property of the main `ScrapeBadger` client. + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize Leboncoin client with all sub-clients. + + Args: + client: The base HTTP client for making API requests. + """ + self._client = client + + # Initialize sub-clients + self._search = SearchClient(client) + self._ads = AdsClient(client) + self._sellers = SellersClient(client) + self._reference = ReferenceClient(client) + + @property + def search(self) -> SearchClient: + """Access the classified-ad search endpoint. + + Returns: + SearchClient for keyword/filter search. + + Example: + ```python + results = await client.leboncoin.search.search("velo", region_id="12") + ``` + """ + return self._search + + @property + def ads(self) -> AdsClient: + """Access ad detail and similar-ad endpoints. + + Returns: + AdsClient for fetching ad detail and similar ads. + + Example: + ```python + detail = await client.leboncoin.ads.get_ad(2812345678) + similar = await client.leboncoin.ads.get_similar(2812345678) + ``` + """ + return self._ads + + @property + def sellers(self) -> SellersClient: + """Access seller profile and listings endpoints. + + Returns: + SellersClient for seller endpoints. + + Example: + ```python + profile = await client.leboncoin.sellers.get_seller("12345678") + listings = await client.leboncoin.sellers.get_seller_listings("12345678") + ``` + """ + return self._sellers + + @property + def reference(self) -> ReferenceClient: + """Access reference data endpoints. + + Returns: + ReferenceClient for categories, regions, departments, locations, markets. + + Example: + ```python + categories = await client.leboncoin.reference.list_categories() + regions = await client.leboncoin.reference.list_regions() + markets = await client.leboncoin.reference.list_markets() + ``` + """ + return self._reference diff --git a/src/scrapebadger/leboncoin/models.py b/src/scrapebadger/leboncoin/models.py new file mode 100644 index 0000000..12e608c --- /dev/null +++ b/src/scrapebadger/leboncoin/models.py @@ -0,0 +1,307 @@ +"""Pydantic models for Leboncoin API responses. + +These models mirror the backend ``leboncoin_scraper`` response schema +field-for-field. All models are immutable (frozen) and ignore unknown fields +for forward compatibility. Every datetime field ships in BOTH the raw +Leboncoin form (``*_date``) and an ISO-8601 UTC string (``*_at``). +""" + +from __future__ import annotations + +from typing import Any + +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 / nested models +# ============================================================================= + + +class Attribute(_BaseModel): + """A category-specific spec (vehicle mileage, real-estate DPE, etc.).""" + + key: str + key_label: str | None = None + value: str | None = None + value_label: str | None = None + values: list[str] = Field(default_factory=list) + values_label: list[str] = Field(default_factory=list) + generic: bool | None = None + + +class Location(_BaseModel): + """A geographic location (region/department/city/coordinates).""" + + country_id: str | None = None + region_id: str | None = None + region_name: str | None = None + department_id: str | None = None + department_name: str | None = None + city: str | None = None + city_label: str | None = None + zipcode: str | None = None + district: str | None = None + lat: float | None = None + lng: float | None = None + source: str | None = None + provider: str | None = None + is_shape: bool | None = None + + +class Owner(_BaseModel): + """Seller stub embedded in each ad.""" + + user_id: str | None = None + store_id: str | None = None + type: str | None = None # "private" | "pro" + name: str | None = None + siren: str | None = None + no_salesmen: bool | None = None + activity_sector: str | None = None + online_store_id: str | None = None + + +class Images(_BaseModel): + """The image gallery of an ad.""" + + nb_images: int = 0 + thumb_url: str | None = None + small_url: str | None = None + urls: list[str] = Field(default_factory=list) + urls_thumb: list[str] = Field(default_factory=list) + urls_large: list[str] = Field(default_factory=list) + + +# ============================================================================= +# Ad (search summary + detail share this shape) +# ============================================================================= + + +class Ad(_BaseModel): + """A Leboncoin classified ad (search summary + detail share this shape).""" + + list_id: int + subject: str = "" + body: str | None = None + brand: str | None = None + ad_type: str | None = None # "offer" | "demand" + url: str | None = None + status: str | None = None + + category_id: str | None = None + category_name: str | None = None + + price: list[int] = Field(default_factory=list) + price_cents: int | None = None + price_eur: float | None = None + currency: str = "EUR" + + # Dates — raw + ISO + first_publication_date: str | None = None + first_publication_at: str | None = None + index_date: str | None = None + index_at: str | None = None + expiration_date: str | None = None + expiration_at: str | None = None + + has_phone: bool | None = None + favorites: int | None = None + + images: Images = Field(default_factory=Images) + attributes: list[Attribute] = Field(default_factory=list) + location: Location = Field(default_factory=Location) + owner: Owner = Field(default_factory=Owner) + + # Ad options / boosts (present in web payload) + options: dict[str, Any] = Field(default_factory=dict) + + +# ============================================================================= +# Seller / store +# ============================================================================= + + +class FeedbackScores(_BaseModel): + """Aggregate buyer feedback scores for a seller.""" + + overall_score: float | None = None + received_count: int | None = None + category_scores: dict[str, Any] = Field(default_factory=dict) + + +class StoreRatingReview(_BaseModel): + """A single store rating review (pro stores).""" + + author_name: str | None = None + rating_value: float | None = None + text: str | None = None + review_time: str | None = None + + +class Seller(_BaseModel): + """A Leboncoin seller's public profile (/sellers/{user_id}).""" + + user_id: str + store_id: str | None = None + online_store_id: str | None = None + name: str | None = None + account_type: str | None = None # "private" | "pro" + registered_at: str | None = None + total_ads: int | None = None + description: str | None = None + profile_picture_url: str | None = None + location: Location = Field(default_factory=Location) + badges: list[dict[str, Any]] = Field(default_factory=list) + feedback: FeedbackScores | None = None + reply_rate: float | None = None + reply_time_text: str | None = None + presence_status: str | None = None + last_activity: str | None = None + + # Pro store extras (present only when account_type == "pro") + siren: str | None = None + siret: str | None = None + activity_sector: str | None = None + website_url: str | None = None + opening_hours: dict[str, Any] | list[Any] | None = None + store_rating_value: float | None = None + store_ratings_total: int | None = None + store_reviews: list[StoreRatingReview] = Field(default_factory=list) + + +# ============================================================================= +# Reference +# ============================================================================= + + +class Category(_BaseModel): + """A reference category alias (for /categories).""" + + category_id: str + key: str + label: str + parent_id: str | None = None + + +class Region(_BaseModel): + """A reference region (for /regions).""" + + region_id: str + key: str + name: str + + +class Department(_BaseModel): + """A reference department (for /departments).""" + + department_id: str + region_id: str + name: str + + +class LocationSuggestion(_BaseModel): + """A single location autocomplete suggestion (for /locations/search).""" + + label: str + location_type: str | None = None + region_id: str | None = None + department_id: str | None = None + city: str | None = None + zipcode: str | None = None + lat: float | None = None + lng: float | None = None + + +# ============================================================================= +# Response envelopes +# ============================================================================= + + +class SearchResponse(_BaseModel): + """Response for /search.""" + + ads: list[Ad] = Field(default_factory=list) + total: int | None = None + total_all: int | None = None + total_pro: int | None = None + total_private: int | None = None + total_shippable: int | None = None + max_pages: int | None = None + page: int = 1 + limit: int = 35 + source: str = "api" # "api" (finder) or "web" (rendered __NEXT_DATA__) + + +class AdResponse(_BaseModel): + """Response for /ads/{list_id}.""" + + ad: Ad + + +class SimilarResponse(_BaseModel): + """Response for /ads/{list_id}/similar.""" + + list_id: int + ads: list[Ad] = Field(default_factory=list) + + +class SellerResponse(_BaseModel): + """Response for /sellers/{user_id}.""" + + seller: Seller + + +class SellerListingsResponse(_BaseModel): + """Response for /sellers/{user_id}/listings.""" + + user_id: str + ads: list[Ad] = Field(default_factory=list) + total: int | None = None + page: int = 1 + limit: int = 35 + + +class CategoriesResponse(_BaseModel): + """Response for /categories.""" + + categories: list[Category] = Field(default_factory=list) + + +class RegionsResponse(_BaseModel): + """Response for /regions.""" + + regions: list[Region] = Field(default_factory=list) + + +class DepartmentsResponse(_BaseModel): + """Response for /departments.""" + + departments: list[Department] = Field(default_factory=list) + + +class LocationSearchResponse(_BaseModel): + """Response for /locations/search.""" + + query: str + suggestions: list[LocationSuggestion] = Field(default_factory=list) + + +class MarketsResponse(_BaseModel): + """Response for /markets.""" + + markets: list[dict[str, Any]] = Field(default_factory=list) diff --git a/src/scrapebadger/leboncoin/reference.py b/src/scrapebadger/leboncoin/reference.py new file mode 100644 index 0000000..f249819 --- /dev/null +++ b/src/scrapebadger/leboncoin/reference.py @@ -0,0 +1,133 @@ +"""Leboncoin Reference Data API client. + +Provides methods for fetching categories, regions, departments, location +suggestions, and supported markets. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.leboncoin.models import ( + CategoriesResponse, + DepartmentsResponse, + LocationSearchResponse, + MarketsResponse, + RegionsResponse, +) + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class ReferenceClient: + """Client for Leboncoin reference data endpoints. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + categories = await client.leboncoin.reference.list_categories() + for c in categories.categories: + print(f"{c.label} -> {c.category_id}") + + regions = await client.leboncoin.reference.list_regions() + for r in regions.regions: + print(f"{r.name}: {r.region_id}") + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize reference client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def list_categories(self) -> CategoriesResponse: + """Get Leboncoin's reference category list. + + Returns: + Categories response with all categories. + + Example: + ```python + result = await client.leboncoin.reference.list_categories() + for c in result.categories: + print(f"{c.label}: {c.category_id}") + ``` + """ + response = await self._client.get("/v1/leboncoin/categories") + return CategoriesResponse.model_validate(response) + + async def list_regions(self) -> RegionsResponse: + """Get Leboncoin's reference region list. + + Returns: + Regions response with all regions. + + Example: + ```python + result = await client.leboncoin.reference.list_regions() + for r in result.regions: + print(f"{r.name}: {r.region_id}") + ``` + """ + response = await self._client.get("/v1/leboncoin/regions") + return RegionsResponse.model_validate(response) + + async def list_departments(self, *, region_id: str | None = None) -> DepartmentsResponse: + """Get Leboncoin's reference department list. + + Args: + region_id: Optional region id to scope the departments. + + Returns: + Departments response with all matching departments. + + Example: + ```python + result = await client.leboncoin.reference.list_departments(region_id="12") + for d in result.departments: + print(f"{d.name}: {d.department_id}") + ``` + """ + params: dict[str, Any] = {"region_id": region_id} + response = await self._client.get("/v1/leboncoin/departments", params=params) + return DepartmentsResponse.model_validate(response) + + async def search_locations(self, q: str) -> LocationSearchResponse: + """Get location autocomplete suggestions. + + Args: + q: Partial location query. + + Returns: + Location search response with the query and suggestions. + + Example: + ```python + result = await client.leboncoin.reference.search_locations("bordeaux") + for s in result.suggestions: + print(s.label) + ``` + """ + params: dict[str, Any] = {"q": q} + response = await self._client.get("/v1/leboncoin/locations/search", params=params) + return LocationSearchResponse.model_validate(response) + + async def list_markets(self) -> MarketsResponse: + """Get all supported Leboncoin markets. + + Returns: + Markets response with all supported markets. + + Example: + ```python + result = await client.leboncoin.reference.list_markets() + for m in result.markets: + print(m) + ``` + """ + response = await self._client.get("/v1/leboncoin/markets") + return MarketsResponse.model_validate(response) diff --git a/src/scrapebadger/leboncoin/search.py b/src/scrapebadger/leboncoin/search.py new file mode 100644 index 0000000..a400df9 --- /dev/null +++ b/src/scrapebadger/leboncoin/search.py @@ -0,0 +1,105 @@ +"""Leboncoin Search API client. + +Provides keyword/filter search across Leboncoin classified ads. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.leboncoin.models import SearchResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class SearchClient: + """Client for the Leboncoin search endpoint. + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + results = await client.leboncoin.search.search("velo") + for ad in results.ads: + print(f"{ad.subject}: {ad.price_eur}") + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize search client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def search( + self, + text: str, + *, + category: str | None = None, + region_id: str | None = None, + department_id: str | None = None, + city: str | None = None, + zipcode: str | None = None, + price_min: int | None = None, + price_max: int | None = None, + owner_type: str | None = None, + ad_type: str | None = None, + sort: str | None = None, + page: int = 1, + limit: int | None = None, + ) -> SearchResponse: + """Search Leboncoin for classified ads. + + Args: + text: Search keywords. + category: Restrict the search to a category id. + region_id: Restrict the search to a region id. + department_id: Restrict the search to a department id. + city: Restrict the search to a city. + zipcode: Restrict the search to a postal code. + price_min: Minimum price filter (euros). + price_max: Maximum price filter (euros). + owner_type: Seller type ("all", "pro", "private"). + ad_type: Ad type ("offer", "demand"). + sort: Sort order ("relevance", "newest", "oldest", "price_low", "price_high"). + page: Page number (1-indexed). Defaults to 1. + limit: Results per page. + + Returns: + Search response with matching ads and pagination metadata. + + Raises: + AuthenticationError: If the API key is invalid. + ValidationError: If the parameters are invalid. + + Example: + ```python + results = await client.leboncoin.search.search( + "velo", + region_id="12", + price_min=100, + price_max=500, + sort="price_low", + ) + print(f"Page {results.page} of {results.max_pages}") + ``` + """ + params: dict[str, Any] = { + "text": text, + "category": category, + "region_id": region_id, + "department_id": department_id, + "city": city, + "zipcode": zipcode, + "price_min": price_min, + "price_max": price_max, + "owner_type": owner_type, + "ad_type": ad_type, + "sort": sort, + "page": page, + "limit": limit, + } + response = await self._client.get("/v1/leboncoin/search", params=params) + return SearchResponse.model_validate(response) diff --git a/src/scrapebadger/leboncoin/sellers.py b/src/scrapebadger/leboncoin/sellers.py new file mode 100644 index 0000000..c862960 --- /dev/null +++ b/src/scrapebadger/leboncoin/sellers.py @@ -0,0 +1,89 @@ +"""Leboncoin Sellers API client. + +Provides methods for seller profile and seller listings. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from scrapebadger.leboncoin.models import SellerListingsResponse, SellerResponse + +if TYPE_CHECKING: + from scrapebadger._internal.client import BaseClient + + +class SellersClient: + """Client for Leboncoin seller endpoints (profile, listings). + + Example: + ```python + async with ScrapeBadger(api_key="key") as client: + profile = await client.leboncoin.sellers.get_seller("12345678") + print(profile.seller.name) + + listings = await client.leboncoin.sellers.get_seller_listings("12345678") + for ad in listings.ads: + print(ad.subject) + ``` + """ + + def __init__(self, client: BaseClient) -> None: + """Initialize sellers client. + + Args: + client: The base HTTP client. + """ + self._client = client + + async def get_seller(self, user_id: str) -> SellerResponse: + """Get a Leboncoin seller's public profile. + + Args: + user_id: The seller's user id. + + Returns: + Seller profile response. + + Raises: + NotFoundError: If the seller doesn't exist. + AuthenticationError: If the API key is invalid. + + Example: + ```python + profile = await client.leboncoin.sellers.get_seller("12345678") + print(f"{profile.seller.name}: {profile.seller.total_ads} ads") + ``` + """ + response = await self._client.get(f"/v1/leboncoin/sellers/{user_id}") + return SellerResponse.model_validate(response) + + async def get_seller_listings( + self, + user_id: str, + *, + page: int = 1, + limit: int | None = None, + ) -> SellerListingsResponse: + """List the active ads of a single Leboncoin seller. + + Args: + user_id: The seller's user id. + page: Page number (1-indexed). Defaults to 1. + limit: Results per page. + + Returns: + Seller listings response with ads and pagination. + + Example: + ```python + result = await client.leboncoin.sellers.get_seller_listings("12345678", page=2) + for ad in result.ads: + print(ad.subject) + ``` + """ + params: dict[str, Any] = {"page": page, "limit": limit} + response = await self._client.get( + f"/v1/leboncoin/sellers/{user_id}/listings", params=params + ) + return SellerListingsResponse.model_validate(response) diff --git a/tests/test_leboncoin.py b/tests/test_leboncoin.py new file mode 100644 index 0000000..9f84998 --- /dev/null +++ b/tests/test_leboncoin.py @@ -0,0 +1,474 @@ +"""Unit tests for Leboncoin SDK methods and models. + +Tests cover: +- TestLeboncoinModels: Pydantic model construction, validation, immutability +- TestLeboncoinClient: LeboncoinClient sub-client wiring +- TestSearchClient / TestAdsClient / TestSellersClient / TestReferenceClient: + endpoint routing via a mocked HTTP client +- TestLeboncoinImports: public API importability +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from scrapebadger.leboncoin.ads import AdsClient +from scrapebadger.leboncoin.client import LeboncoinClient +from scrapebadger.leboncoin.models import ( + Ad, + AdResponse, + CategoriesResponse, + DepartmentsResponse, + LocationSearchResponse, + MarketsResponse, + RegionsResponse, + SearchResponse, + Seller, + SellerListingsResponse, + SellerResponse, + SimilarResponse, +) +from scrapebadger.leboncoin.reference import ReferenceClient +from scrapebadger.leboncoin.search import SearchClient +from scrapebadger.leboncoin.sellers import SellersClient + +# --------------------------------------------------------------------------- +# Shared sample data +# --------------------------------------------------------------------------- + +SAMPLE_AD: dict[str, Any] = { + "list_id": 2812345678, + "subject": "Velo de course", + "body": "Bon etat", + "ad_type": "offer", + "url": "https://www.leboncoin.fr/ad/2812345678", + "category_id": "22", + "category_name": "Velos", + "price": [250], + "price_cents": 25000, + "price_eur": 250.0, + "currency": "EUR", + "first_publication_date": "2026-06-01 12:00:00", + "first_publication_at": "2026-06-01T12:00:00Z", + "has_phone": True, + "images": { + "nb_images": 2, + "thumb_url": "https://img.leboncoin.fr/thumb.jpg", + "urls": ["https://img.leboncoin.fr/1.jpg", "https://img.leboncoin.fr/2.jpg"], + }, + "attributes": [{"key": "brand", "key_label": "Marque", "value": "Btwin"}], + "location": {"region_id": "12", "region_name": "Nouvelle-Aquitaine", "city": "Bordeaux"}, + "owner": {"user_id": "12345678", "type": "private", "name": "Jean"}, +} + +SAMPLE_SELLER: dict[str, Any] = { + "user_id": "12345678", + "name": "Jean Dupont", + "account_type": "private", + "registered_at": "2020-01-01T00:00:00Z", + "total_ads": 42, + "location": {"region_id": "12", "city": "Bordeaux"}, + "feedback": {"overall_score": 4.8, "received_count": 30}, +} + +SEARCH_RESPONSE: dict[str, Any] = { + "ads": [SAMPLE_AD], + "total": 100, + "total_all": 120, + "total_pro": 20, + "total_private": 100, + "total_shippable": 50, + "max_pages": 3, + "page": 1, + "limit": 35, + "source": "api", +} + +AD_RESPONSE: dict[str, Any] = {"ad": SAMPLE_AD} + +SIMILAR_RESPONSE: dict[str, Any] = {"list_id": 2812345678, "ads": [SAMPLE_AD]} + +SELLER_RESPONSE: dict[str, Any] = {"seller": SAMPLE_SELLER} + +SELLER_LISTINGS_RESPONSE: dict[str, Any] = { + "user_id": "12345678", + "ads": [SAMPLE_AD], + "total": 42, + "page": 1, + "limit": 35, +} + +CATEGORIES_RESPONSE: dict[str, Any] = { + "categories": [{"category_id": "22", "key": "velos", "label": "Velos", "parent_id": "2"}] +} + +REGIONS_RESPONSE: dict[str, Any] = { + "regions": [{"region_id": "12", "key": "nouvelle_aquitaine", "name": "Nouvelle-Aquitaine"}] +} + +DEPARTMENTS_RESPONSE: dict[str, Any] = { + "departments": [{"department_id": "33", "region_id": "12", "name": "Gironde"}] +} + +LOCATION_SEARCH_RESPONSE: dict[str, Any] = { + "query": "bordeaux", + "suggestions": [ + {"label": "Bordeaux 33000", "location_type": "city", "city": "Bordeaux", "zipcode": "33000"} + ], +} + +MARKETS_RESPONSE: dict[str, Any] = {"markets": [{"code": "fr", "name": "Leboncoin France"}]} + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_base_client() -> MagicMock: + """Return a mock BaseClient with AsyncMock methods.""" + client = MagicMock() + client.get = AsyncMock() + client.post = AsyncMock() + return client + + +@pytest.fixture +def leboncoin_client(mock_base_client: MagicMock) -> LeboncoinClient: + return LeboncoinClient(mock_base_client) + + +@pytest.fixture +def search_client(mock_base_client: MagicMock) -> SearchClient: + return SearchClient(mock_base_client) + + +@pytest.fixture +def ads_client(mock_base_client: MagicMock) -> AdsClient: + return AdsClient(mock_base_client) + + +@pytest.fixture +def sellers_client(mock_base_client: MagicMock) -> SellersClient: + return SellersClient(mock_base_client) + + +@pytest.fixture +def reference_client(mock_base_client: MagicMock) -> ReferenceClient: + return ReferenceClient(mock_base_client) + + +# =========================================================================== +# TestLeboncoinModels +# =========================================================================== + + +class TestLeboncoinModels: + """Pydantic model construction, validation, and immutability tests.""" + + def test_ad_full(self) -> None: + ad = Ad.model_validate(SAMPLE_AD) + assert ad.list_id == 2812345678 + assert ad.subject == "Velo de course" + assert ad.price == [250] + assert ad.price_eur == 250.0 + assert ad.images.nb_images == 2 + assert len(ad.attributes) == 1 + assert ad.attributes[0].key == "brand" + assert ad.location.city == "Bordeaux" + assert ad.owner.user_id == "12345678" + + def test_ad_minimal(self) -> None: + ad = Ad(list_id=1) + assert ad.subject == "" + assert ad.price == [] + assert ad.currency == "EUR" + assert ad.images.nb_images == 0 + assert ad.attributes == [] + assert ad.options == {} + + def test_ad_is_frozen(self) -> None: + ad = Ad.model_validate(SAMPLE_AD) + with pytest.raises(Exception): # noqa: B017 + ad.subject = "mutated" # type: ignore[misc] + + def test_seller_full(self) -> None: + seller = Seller.model_validate(SAMPLE_SELLER) + assert seller.user_id == "12345678" + assert seller.account_type == "private" + assert seller.total_ads == 42 + assert seller.feedback is not None + assert seller.feedback.overall_score == 4.8 + + # -- Response envelopes -- + + def test_search_response(self) -> None: + resp = SearchResponse.model_validate(SEARCH_RESPONSE) + assert len(resp.ads) == 1 + assert resp.total == 100 + assert resp.total_pro == 20 + assert resp.max_pages == 3 + assert resp.source == "api" + + def test_search_response_defaults(self) -> None: + resp = SearchResponse.model_validate({}) + assert resp.ads == [] + assert resp.page == 1 + assert resp.limit == 35 + assert resp.source == "api" + + def test_ad_response(self) -> None: + resp = AdResponse.model_validate(AD_RESPONSE) + assert resp.ad.list_id == 2812345678 + + def test_similar_response(self) -> None: + resp = SimilarResponse.model_validate(SIMILAR_RESPONSE) + assert resp.list_id == 2812345678 + assert len(resp.ads) == 1 + + def test_seller_response(self) -> None: + resp = SellerResponse.model_validate(SELLER_RESPONSE) + assert resp.seller.user_id == "12345678" + + def test_seller_listings_response(self) -> None: + resp = SellerListingsResponse.model_validate(SELLER_LISTINGS_RESPONSE) + assert resp.user_id == "12345678" + assert len(resp.ads) == 1 + assert resp.total == 42 + + def test_categories_response(self) -> None: + resp = CategoriesResponse.model_validate(CATEGORIES_RESPONSE) + assert len(resp.categories) == 1 + assert resp.categories[0].category_id == "22" + + def test_regions_response(self) -> None: + resp = RegionsResponse.model_validate(REGIONS_RESPONSE) + assert len(resp.regions) == 1 + assert resp.regions[0].name == "Nouvelle-Aquitaine" + + def test_departments_response(self) -> None: + resp = DepartmentsResponse.model_validate(DEPARTMENTS_RESPONSE) + assert len(resp.departments) == 1 + assert resp.departments[0].department_id == "33" + + def test_location_search_response(self) -> None: + resp = LocationSearchResponse.model_validate(LOCATION_SEARCH_RESPONSE) + assert resp.query == "bordeaux" + assert len(resp.suggestions) == 1 + assert resp.suggestions[0].zipcode == "33000" + + def test_markets_response(self) -> None: + resp = MarketsResponse.model_validate(MARKETS_RESPONSE) + assert len(resp.markets) == 1 + assert resp.markets[0]["code"] == "fr" + + def test_ignores_unknown_fields(self) -> None: + resp = SearchResponse.model_validate({"ads": [], "unexpected": 123}) + assert resp.ads == [] + + +# =========================================================================== +# TestLeboncoinClient +# =========================================================================== + + +class TestLeboncoinClient: + """Tests for LeboncoinClient sub-client wiring.""" + + def test_search_property(self, leboncoin_client: LeboncoinClient) -> None: + assert isinstance(leboncoin_client.search, SearchClient) + + def test_ads_property(self, leboncoin_client: LeboncoinClient) -> None: + assert isinstance(leboncoin_client.ads, AdsClient) + + def test_sellers_property(self, leboncoin_client: LeboncoinClient) -> None: + assert isinstance(leboncoin_client.sellers, SellersClient) + + def test_reference_property(self, leboncoin_client: LeboncoinClient) -> None: + assert isinstance(leboncoin_client.reference, ReferenceClient) + + def test_sub_clients_are_stable(self, leboncoin_client: LeboncoinClient) -> None: + assert leboncoin_client.search is leboncoin_client.search + assert leboncoin_client.ads is leboncoin_client.ads + assert leboncoin_client.sellers is leboncoin_client.sellers + assert leboncoin_client.reference is leboncoin_client.reference + + +# =========================================================================== +# TestSearchClient +# =========================================================================== + + +class TestSearchClient: + async def test_search_default_params( + self, search_client: SearchClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = SEARCH_RESPONSE + result = await search_client.search("velo") + + assert isinstance(result, SearchResponse) + call_args = mock_base_client.get.call_args + assert call_args[0][0] == "/v1/leboncoin/search" + params = call_args[1]["params"] + assert params["text"] == "velo" + assert params["page"] == 1 + assert params["sort"] is None + assert params["owner_type"] is None + + async def test_search_with_filters( + self, search_client: SearchClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = SEARCH_RESPONSE + await search_client.search( + "velo", + category="22", + region_id="12", + department_id="33", + city="Bordeaux", + zipcode="33000", + price_min=100, + price_max=500, + owner_type="private", + ad_type="offer", + sort="price_low", + page=2, + limit=50, + ) + params = mock_base_client.get.call_args[1]["params"] + assert params["category"] == "22" + assert params["region_id"] == "12" + assert params["department_id"] == "33" + assert params["city"] == "Bordeaux" + assert params["zipcode"] == "33000" + assert params["price_min"] == 100 + assert params["price_max"] == 500 + assert params["owner_type"] == "private" + assert params["ad_type"] == "offer" + assert params["sort"] == "price_low" + assert params["page"] == 2 + assert params["limit"] == 50 + + +# =========================================================================== +# TestAdsClient +# =========================================================================== + + +class TestAdsClient: + async def test_get_ad(self, ads_client: AdsClient, mock_base_client: MagicMock) -> None: + mock_base_client.get.return_value = AD_RESPONSE + result = await ads_client.get_ad(2812345678) + assert isinstance(result, AdResponse) + assert result.ad.list_id == 2812345678 + assert mock_base_client.get.call_args[0][0] == "/v1/leboncoin/ads/2812345678" + + async def test_get_similar(self, ads_client: AdsClient, mock_base_client: MagicMock) -> None: + mock_base_client.get.return_value = SIMILAR_RESPONSE + result = await ads_client.get_similar(2812345678, limit=10) + assert isinstance(result, SimilarResponse) + call_args = mock_base_client.get.call_args + assert call_args[0][0] == "/v1/leboncoin/ads/2812345678/similar" + assert call_args[1]["params"]["limit"] == 10 + + +# =========================================================================== +# TestSellersClient +# =========================================================================== + + +class TestSellersClient: + async def test_get_seller( + self, sellers_client: SellersClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = SELLER_RESPONSE + result = await sellers_client.get_seller("12345678") + assert isinstance(result, SellerResponse) + assert mock_base_client.get.call_args[0][0] == "/v1/leboncoin/sellers/12345678" + + async def test_get_seller_listings( + self, sellers_client: SellersClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = SELLER_LISTINGS_RESPONSE + result = await sellers_client.get_seller_listings("12345678", page=2, limit=50) + assert isinstance(result, SellerListingsResponse) + call_args = mock_base_client.get.call_args + assert call_args[0][0] == "/v1/leboncoin/sellers/12345678/listings" + assert call_args[1]["params"]["page"] == 2 + assert call_args[1]["params"]["limit"] == 50 + + +# =========================================================================== +# TestReferenceClient +# =========================================================================== + + +class TestReferenceClient: + async def test_list_categories( + self, reference_client: ReferenceClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = CATEGORIES_RESPONSE + result = await reference_client.list_categories() + assert isinstance(result, CategoriesResponse) + mock_base_client.get.assert_called_once_with("/v1/leboncoin/categories") + + async def test_list_regions( + self, reference_client: ReferenceClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = REGIONS_RESPONSE + result = await reference_client.list_regions() + assert isinstance(result, RegionsResponse) + mock_base_client.get.assert_called_once_with("/v1/leboncoin/regions") + + async def test_list_departments( + self, reference_client: ReferenceClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = DEPARTMENTS_RESPONSE + result = await reference_client.list_departments(region_id="12") + assert isinstance(result, DepartmentsResponse) + call_args = mock_base_client.get.call_args + assert call_args[0][0] == "/v1/leboncoin/departments" + assert call_args[1]["params"]["region_id"] == "12" + + async def test_search_locations( + self, reference_client: ReferenceClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = LOCATION_SEARCH_RESPONSE + result = await reference_client.search_locations("bordeaux") + assert isinstance(result, LocationSearchResponse) + call_args = mock_base_client.get.call_args + assert call_args[0][0] == "/v1/leboncoin/locations/search" + assert call_args[1]["params"]["q"] == "bordeaux" + + async def test_list_markets( + self, reference_client: ReferenceClient, mock_base_client: MagicMock + ) -> None: + mock_base_client.get.return_value = MARKETS_RESPONSE + result = await reference_client.list_markets() + assert isinstance(result, MarketsResponse) + mock_base_client.get.assert_called_once_with("/v1/leboncoin/markets") + + +# =========================================================================== +# TestLeboncoinImports +# =========================================================================== + + +class TestLeboncoinImports: + def test_leboncoin_client_importable(self) -> None: + from scrapebadger.leboncoin import LeboncoinClient as _ # noqa: F401 + + def test_leboncoin_client_top_level_importable(self) -> None: + from scrapebadger import LeboncoinClient as _ # noqa: F401 + + def test_leboncoin_ad_top_level_importable(self) -> None: + from scrapebadger import LeboncoinAd as _ # noqa: F401 + + def test_search_response_importable(self) -> None: + from scrapebadger.leboncoin import SearchResponse as _ # noqa: F401 + + def test_leboncoin_seller_top_level_importable(self) -> None: + from scrapebadger import LeboncoinSeller as _ # noqa: F401 diff --git a/uv.lock b/uv.lock index 54d38fb..b93880d 100644 --- a/uv.lock +++ b/uv.lock @@ -752,7 +752,7 @@ wheels = [ [[package]] name = "scrapebadger" -version = "0.15.5" +version = "0.15.6" source = { editable = "." } dependencies = [ { name = "httpx" },