From f0b3e1417f81a9c4b474d10661270a47d9c8c7e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hidefj=C3=A4ll?= <50925050+dojje@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:04:51 +0200 Subject: [PATCH 1/5] refactor: extract shared param builders to _params.py --- blocket_api/_params.py | 176 +++++++++++++++++++++++++++++++++++++++++ blocket_api/blocket.py | 158 +++++++++++++++--------------------- 2 files changed, 238 insertions(+), 96 deletions(-) create mode 100644 blocket_api/_params.py diff --git a/blocket_api/_params.py b/blocket_api/_params.py new file mode 100644 index 0000000..4890a8d --- /dev/null +++ b/blocket_api/_params.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +from .constants import ( + SITE_URL, + BoatType, + CarColor, + CarModel, + CarSortOrder, + CarTransmission, + CarWheelDrive, + Category, + Location, + McModel, + McSortOrder, + McType, + SortOrder, + SubCategory, +) + +_ParamValue = str | int | float | bool | None + + +def _build_params( + defaults: dict[str, _ParamValue], + extras: Sequence[tuple[str, _ParamValue]], +) -> list[tuple[str, _ParamValue]]: + params: list[tuple[str, _ParamValue]] = [ + (k, v) for k, v in defaults.items() if v is not None + ] + params.extend(extras) + return params + + +def build_search_params( + query: str, + *, + page: int = 1, + sort_order: SortOrder = SortOrder.RELEVANCE, + locations: list[Location] = [], + category: Category | None = None, + sub_category: SubCategory | None = None, +) -> tuple[str, list[tuple[str, _ParamValue]]]: + if category and sub_category: + raise AssertionError("Cannot specify both category and sub_categories") + + url = f"{SITE_URL}/recommerce/forsale/search/api/search/SEARCH_ID_BAP_COMMON" + + defaults: dict[str, _ParamValue] = { + "q": query, + "page": page, + "sort": sort_order.value, + "category": category.value if category else None, + "sub_category": sub_category.value if sub_category else None, + } + + extras = [("location", loc.value) for loc in locations] + + return url, _build_params(defaults, extras) + + +def build_car_params( + query: str | None = None, + *, + page: int = 1, + sort_order: CarSortOrder = CarSortOrder.RELEVANCE, + locations: list[Location] = [], + models: list[CarModel] = [], + price_from: int | None = None, + price_to: int | None = None, + year_from: int | None = None, + year_to: int | None = None, + milage_from: int | None = None, + milage_to: int | None = None, + colors: list[CarColor] = [], + transmissions: list[CarTransmission] = [], + horsepower_from: int | None = None, + horsepower_to: int | None = None, + wheel_drive: list[CarWheelDrive] = [], + org_id: int | None = None, +) -> tuple[str, list[tuple[str, _ParamValue]]]: + url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_CAR_USED" + + defaults: dict[str, _ParamValue] = { + "q": query, + "page": page, + "sort": sort_order.value, + "price_from": price_from, + "price_to": price_to, + "year_from": year_from, + "year_to": year_to, + "milage_from": milage_from, + "milage_to": milage_to, + "engine_effect_from": horsepower_from, + "engine_effect_to": horsepower_to, + "orgId": org_id, + } + + extras: list[tuple[str, _ParamValue]] = [] + extras.extend(("location", loc.value) for loc in locations) + extras.extend(("make", model.value) for model in models) + extras.extend(("exterior_colour", color.value) for color in colors) + extras.extend(("transmission", t.value) for t in transmissions) + extras.extend(("wheel_drive", w.value) for w in wheel_drive) + + return url, _build_params(defaults, extras) + + +def build_boat_params( + query: str | None = None, + *, + page: int = 1, + sort_order: CarSortOrder = CarSortOrder.RELEVANCE, + types: list[BoatType] = [], + locations: list[Location] = [], + price_from: int | None = None, + price_to: int | None = None, + length_from: int | None = None, + length_to: int | None = None, + org_id: int | None = None, +) -> tuple[str, list[tuple[str, _ParamValue]]]: + url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_BOAT_USED" + + defaults: dict[str, _ParamValue] = { + "q": query, + "page": page, + "sort": sort_order.value, + "price_from": price_from, + "price_to": price_to, + "length_feet_from": length_from, + "length_feet_to": length_to, + "orgId": org_id, + } + + extras: list[tuple[str, _ParamValue]] = [] + extras.extend(("class", t.value) for t in types) + extras.extend(("location", loc.value) for loc in locations) + + return url, _build_params(defaults, extras) + + +def build_mc_params( + query: str | None = None, + *, + page: int = 1, + sort_order: McSortOrder = McSortOrder.RELEVANCE, + models: list[McModel] = [], + types: list[McType] = [], + locations: list[Location] = [], + price_from: int | None = None, + price_to: int | None = None, + engine_volume_from: int | None = None, + engine_volume_to: int | None = None, + org_id: int | None = None, +) -> tuple[str, list[tuple[str, _ParamValue]]]: + url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_MC_USED" + + defaults: dict[str, _ParamValue] = { + "q": query, + "page": page, + "sort": sort_order.value, + "price_from": price_from, + "price_to": price_to, + "engine_volume_from": engine_volume_from, + "engine_volume_to": engine_volume_to, + "orgId": org_id, + } + + extras: list[tuple[str, _ParamValue]] = [] + extras.extend(("make", m.value) for m in models) + extras.extend(("location", loc.value) for loc in locations) + extras.extend(("type", t.value) for t in types) + + return url, _build_params(defaults, extras) diff --git a/blocket_api/blocket.py b/blocket_api/blocket.py index 2f8fda4..913cf77 100644 --- a/blocket_api/blocket.py +++ b/blocket_api/blocket.py @@ -1,15 +1,19 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any import httpx -from httpx import Response +from ._params import ( + _ParamValue, + build_boat_params, + build_car_params, + build_mc_params, + build_search_params, +) from .ad_parser import BoatAd, CarAd, McAd, RecommerceAd +from .constants import HEADERS from .constants import ( - HEADERS, - SITE_URL, BoatType, CarColor, CarModel, @@ -26,23 +30,12 @@ ) -@dataclass(frozen=True) -class QueryParam: - name: str - value: str | int - - -def _request(*, url: str, params: list[QueryParam]) -> Response: - response = httpx.get( - url, - headers=HEADERS, - params=[(param.name, param.value) for param in params], - ) +def _request(*, url: str, params: list[tuple[str, _ParamValue]]) -> httpx.Response: + response = httpx.get(url, headers=HEADERS, params=params) response.raise_for_status() return response -@dataclass(frozen=True) class BlocketAPI: def search( self, @@ -54,23 +47,14 @@ def search( category: Category | None = None, sub_category: SubCategory | None = None, ) -> dict[str, Any]: - if category and sub_category: - raise AssertionError("Cannot specify both category and sub_categories") - - url = f"{SITE_URL}/recommerce/forsale/search/api/search/SEARCH_ID_BAP_COMMON" - - param_dict: dict[str, str | int | None] = { - "q": query, - "page": page, - "sort": sort_order.value, - "category": category.value if category else None, - "sub_category": sub_category.value if sub_category else None, - } - - params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None] - - params.extend(QueryParam("location", loc.value) for loc in locations) - + url, params = build_search_params( + query, + page=page, + sort_order=sort_order, + locations=locations, + category=category, + sub_category=sub_category, + ) return _request(url=url, params=params).json() def search_car( @@ -94,31 +78,25 @@ def search_car( wheel_drive: list[CarWheelDrive] = [], org_id: int | None = None, ) -> dict[str, Any]: - url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_CAR_USED" - - param_dict: dict[str, str | int | None] = { - "q": query, - "page": page, - "sort": sort_order.value, - "price_from": price_from, - "price_to": price_to, - "year_from": year_from, - "year_to": year_to, - "milage_from": milage_from, - "milage_to": milage_to, - "engine_effect_from": horsepower_from, - "engine_effect_to": horsepower_to, - "orgId": org_id, - } - - params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None] - - params.extend(QueryParam("location", loc.value) for loc in locations) - params.extend(QueryParam("make", model.value) for model in models) - params.extend(QueryParam("exterior_colour", color.value) for color in colors) - params.extend(QueryParam("transmission", t.value) for t in transmissions) - params.extend(QueryParam("wheel_drive", w.value) for w in wheel_drive) - + url, params = build_car_params( + query, + page=page, + sort_order=sort_order, + locations=locations, + models=models, + price_from=price_from, + price_to=price_to, + year_from=year_from, + year_to=year_to, + milage_from=milage_from, + milage_to=milage_to, + colors=colors, + transmissions=transmissions, + horsepower_from=horsepower_from, + horsepower_to=horsepower_to, + wheel_drive=wheel_drive, + org_id=org_id, + ) return _request(url=url, params=params).json() def search_boat( @@ -135,24 +113,18 @@ def search_boat( length_to: int | None = None, org_id: int | None = None, ) -> Any: - url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_BOAT_USED" - - param_dict: dict[str, str | int | None] = { - "q": query, - "page": page, - "sort": sort_order.value, - "price_from": price_from, - "price_to": price_to, - "length_feet_from": length_from, - "length_feet_to": length_to, - "orgId": org_id, - } - - params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None] - - params.extend(QueryParam("class", t.value) for t in types) - params.extend(QueryParam("location", loc.value) for loc in locations) - + url, params = build_boat_params( + query, + page=page, + sort_order=sort_order, + types=types, + locations=locations, + price_from=price_from, + price_to=price_to, + length_from=length_from, + length_to=length_to, + org_id=org_id, + ) return _request(url=url, params=params).json() def search_mc( @@ -170,25 +142,19 @@ def search_mc( engine_volume_to: int | None = None, org_id: int | None = None, ) -> dict[str, Any]: - url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_MC_USED" - - param_dict: dict[str, str | int | None] = { - "q": query, - "page": page, - "sort": sort_order.value, - "price_from": price_from, - "price_to": price_to, - "engine_volume_from": engine_volume_from, - "engine_volume_to": engine_volume_to, - "orgId": org_id, - } - - params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None] - - params.extend(QueryParam("make", m.value) for m in models) - params.extend(QueryParam("location", loc.value) for loc in locations) - params.extend(QueryParam("type", t.value) for t in types) - + url, params = build_mc_params( + query, + page=page, + sort_order=sort_order, + models=models, + types=types, + locations=locations, + price_from=price_from, + price_to=price_to, + engine_volume_from=engine_volume_from, + engine_volume_to=engine_volume_to, + org_id=org_id, + ) return _request(url=url, params=params).json() def get_ad(self, ad: RecommerceAd | CarAd | BoatAd | McAd) -> dict[str, Any]: From 0065d257c0d664ddff22cda7ecaceb1a30c24c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hidefj=C3=A4ll?= <50925050+dojje@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:04:54 +0200 Subject: [PATCH 2/5] feat: add AsyncBlocketAPI for async/await support --- blocket_api/__init__.py | 2 + blocket_api/async_blocket.py | 179 ++++++++++++++++++ pyproject.toml | 2 + tests/async_requests.py | 356 +++++++++++++++++++++++++++++++++++ uv.lock | 25 +++ 5 files changed, 564 insertions(+) create mode 100644 blocket_api/async_blocket.py create mode 100644 tests/async_requests.py diff --git a/blocket_api/__init__.py b/blocket_api/__init__.py index 85717ff..2b28071 100644 --- a/blocket_api/__init__.py +++ b/blocket_api/__init__.py @@ -1,4 +1,5 @@ from .ad_parser import BoatAd, CarAd, McAd, RecommerceAd +from .async_blocket import AsyncBlocketAPI from .blocket import BlocketAPI, Location from .constants import ( BoatSortOrder, @@ -17,6 +18,7 @@ ) __all__ = [ + "AsyncBlocketAPI", "BlocketAPI", "Location", "BoatAd", diff --git a/blocket_api/async_blocket.py b/blocket_api/async_blocket.py new file mode 100644 index 0000000..fda3221 --- /dev/null +++ b/blocket_api/async_blocket.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from typing import Any + +import httpx + +from ._params import ( + build_boat_params, + build_car_params, + build_mc_params, + build_search_params, +) +from .ad_parser import BoatAd, CarAd, McAd, RecommerceAd +from .constants import HEADERS +from .constants import ( + BoatType, + CarColor, + CarModel, + CarSortOrder, + CarTransmission, + CarWheelDrive, + Category, + Location, + McModel, + McSortOrder, + McType, + SortOrder, + SubCategory, +) + + +class AsyncBlocketAPI: + def __init__(self, client: httpx.AsyncClient | None = None) -> None: + self._client = client or httpx.AsyncClient() + self._owns_client = client is None + + async def __aenter__(self) -> AsyncBlocketAPI: + return self + + async def __aexit__(self, *_: Any) -> None: + await self.aclose() + + async def aclose(self) -> None: + await self._client.aclose() + + async def search( + self, + query: str, + *, + page: int = 1, + sort_order: SortOrder = SortOrder.RELEVANCE, + locations: list[Location] = [], + category: Category | None = None, + sub_category: SubCategory | None = None, + ) -> dict[str, Any]: + url, params = build_search_params( + query, + page=page, + sort_order=sort_order, + locations=locations, + category=category, + sub_category=sub_category, + ) + response = await self._client.get(url, headers=HEADERS, params=params) + response.raise_for_status() + return response.json() + + async def search_car( + self, + query: str | None = None, + *, + page: int = 1, + sort_order: CarSortOrder = CarSortOrder.RELEVANCE, + locations: list[Location] = [], + models: list[CarModel] = [], + price_from: int | None = None, + price_to: int | None = None, + year_from: int | None = None, + year_to: int | None = None, + milage_from: int | None = None, + milage_to: int | None = None, + colors: list[CarColor] = [], + transmissions: list[CarTransmission] = [], + horsepower_from: int | None = None, + horsepower_to: int | None = None, + wheel_drive: list[CarWheelDrive] = [], + org_id: int | None = None, + ) -> dict[str, Any]: + url, params = build_car_params( + query, + page=page, + sort_order=sort_order, + locations=locations, + models=models, + price_from=price_from, + price_to=price_to, + year_from=year_from, + year_to=year_to, + milage_from=milage_from, + milage_to=milage_to, + colors=colors, + transmissions=transmissions, + horsepower_from=horsepower_from, + horsepower_to=horsepower_to, + wheel_drive=wheel_drive, + org_id=org_id, + ) + response = await self._client.get(url, headers=HEADERS, params=params) + response.raise_for_status() + return response.json() + + async def search_boat( + self, + query: str | None = None, + *, + page: int = 1, + sort_order: CarSortOrder = CarSortOrder.RELEVANCE, + types: list[BoatType] = [], + locations: list[Location] = [], + price_from: int | None = None, + price_to: int | None = None, + length_from: int | None = None, + length_to: int | None = None, + org_id: int | None = None, + ) -> Any: + url, params = build_boat_params( + query, + page=page, + sort_order=sort_order, + types=types, + locations=locations, + price_from=price_from, + price_to=price_to, + length_from=length_from, + length_to=length_to, + org_id=org_id, + ) + response = await self._client.get(url, headers=HEADERS, params=params) + response.raise_for_status() + return response.json() + + async def search_mc( + self, + query: str | None = None, + *, + page: int = 1, + sort_order: McSortOrder = McSortOrder.RELEVANCE, + models: list[McModel] = [], + types: list[McType] = [], + locations: list[Location] = [], + price_from: int | None = None, + price_to: int | None = None, + engine_volume_from: int | None = None, + engine_volume_to: int | None = None, + org_id: int | None = None, + ) -> dict[str, Any]: + url, params = build_mc_params( + query, + page=page, + sort_order=sort_order, + models=models, + types=types, + locations=locations, + price_from=price_from, + price_to=price_to, + engine_volume_from=engine_volume_from, + engine_volume_to=engine_volume_to, + org_id=org_id, + ) + response = await self._client.get(url, headers=HEADERS, params=params) + response.raise_for_status() + return response.json() + + async def get_ad( + self, ad: RecommerceAd | CarAd | BoatAd | McAd + ) -> dict[str, Any]: + response = await self._client.get(ad.url, headers=HEADERS) + response.raise_for_status() + return ad.parse(response) diff --git a/pyproject.toml b/pyproject.toml index 1a0a9aa..ce4ee26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ dev = [ "pre-commit>=4.5.1", "pytest>=9.0.2", + "pytest-asyncio>=0.26.0", "respx>=0.22.0", "mypy>=1.19.1", "ruff>=0.9.0", @@ -27,3 +28,4 @@ dev = [ testpaths = [ "tests/*", ] +asyncio_mode = "auto" diff --git a/tests/async_requests.py b/tests/async_requests.py new file mode 100644 index 0000000..84229b5 --- /dev/null +++ b/tests/async_requests.py @@ -0,0 +1,356 @@ +import httpx +import respx + +from blocket_api import ( + AsyncBlocketAPI, + BoatType, + CarAd, + CarModel, + CarSortOrder, + CarTransmission, + CarWheelDrive, + Category, + Location, + McAd, + McModel, + McType, + RecommerceAd, + SortOrder, + SubCategory, +) +from blocket_api.constants import SITE_URL + + +class Test_AsyncSearch: + @respx.mock + async def test_search(self) -> None: + expected_url = ( + f"{SITE_URL}/recommerce/forsale/search/api/search/SEARCH_ID_BAP_COMMON" + "?q=audi+q5" + "&page=1" + "&sort=RELEVANCE" + ) + respx.get(expected_url).mock( + return_value=httpx.Response(200, json={"status": "ok"}) + ) + async with AsyncBlocketAPI() as api: + result = await api.search("audi q5") + assert result == {"status": "ok"} + + @respx.mock + async def test_search_sortorder_published_desc(self) -> None: + expected_url = ( + f"{SITE_URL}/recommerce/forsale/search/api/search/SEARCH_ID_BAP_COMMON" + "?q=audi+q5" + "&page=1" + "&sort=PUBLISHED_DESC" + ) + respx.get(expected_url).mock( + return_value=httpx.Response(200, json={"status": "ok"}) + ) + async with AsyncBlocketAPI() as api: + result = await api.search( + "audi q5", sort_order=SortOrder.PUBLISHED_DESC + ) + assert result == {"status": "ok"} + + @respx.mock + async def test_search_location(self) -> None: + expected_url = ( + f"{SITE_URL}/recommerce/forsale/search/api/search/SEARCH_ID_BAP_COMMON" + "?q=audi+q5" + "&page=1" + "&sort=PUBLISHED_DESC" + "&location=0.300003" + "&location=0.300001" + ) + respx.get(expected_url).mock( + return_value=httpx.Response(200, json={"status": "ok"}) + ) + async with AsyncBlocketAPI() as api: + result = await api.search( + "audi q5", + sort_order=SortOrder.PUBLISHED_DESC, + locations=[Location.UPPSALA, Location.STOCKHOLM], + ) + assert result == {"status": "ok"} + + @respx.mock + async def test_search_category(self) -> None: + expected_url = ( + f"{SITE_URL}/recommerce/forsale/search/api/search/SEARCH_ID_BAP_COMMON" + "?q=audi+q5" + "&page=1" + "&sort=PUBLISHED_DESC" + "&category=0.90" + ) + respx.get(expected_url).mock( + return_value=httpx.Response(200, json={"status": "ok"}) + ) + async with AsyncBlocketAPI() as api: + result = await api.search( + "audi q5", + sort_order=SortOrder.PUBLISHED_DESC, + category=Category.FORDONSTILLBEHOR, + ) + assert result == {"status": "ok"} + + @respx.mock + async def test_sub_category(self) -> None: + expected_url = ( + f"{SITE_URL}/recommerce/forsale/search/api/search/SEARCH_ID_BAP_COMMON" + "?q=hammare" + "&page=1" + "&sort=RELEVANCE" + "&sub_category=1.67.5219" + ) + respx.get(expected_url).mock( + return_value=httpx.Response(200, json={"status": "ok"}) + ) + async with AsyncBlocketAPI() as api: + result = await api.search( + "hammare", + sub_category=SubCategory.VERKTYG, + ) + assert result == {"status": "ok"} + + +class Test_AsyncSearchCar: + @respx.mock + async def test_search_car(self) -> None: + expected_url = ( + f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_CAR_USED" + "?sort=MILEAGE_ASC" + "&page=1" + "&make=0.744" + "&price_from=1000" + "&price_to=50000" + "&transmission=2" + "&wheel_drive=3" + "&engine_effect_from=200" + "&engine_effect_to=300" + "&orgId=1337" + ) + respx.get(expected_url).mock( + return_value=httpx.Response(200, json={"status": "ok"}) + ) + async with AsyncBlocketAPI() as api: + result = await api.search_car( + sort_order=CarSortOrder.MILEAGE_ASC, + models=[CarModel.AUDI], + price_from=1000, + price_to=50000, + transmissions=[CarTransmission.AUTOMATIC], + wheel_drive=[CarWheelDrive.FWD], + horsepower_from=200, + horsepower_to=300, + org_id=1337, + ) + assert result == {"status": "ok"} + + +class Test_AsyncSearchBoat: + @respx.mock + async def test_search_boat(self) -> None: + expected_url = ( + f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_BOAT_USED" + "?q=Mercury" + "&page=1" + "&sort=RELEVANCE" + "&class=2184" + "&location=0.300001" + "&price_from=20000" + "&price_to=90000" + "&length_feet_from=10" + "&length_feet_to=15" + "&orgId=1337" + ) + respx.get(expected_url).mock( + return_value=httpx.Response(200, json={"status": "ok"}) + ) + async with AsyncBlocketAPI() as api: + result = await api.search_boat( + "Mercury", + types=[BoatType.DAYCRUISER], + locations=[Location.STOCKHOLM], + length_from=10, + length_to=15, + price_from=20000, + price_to=90000, + org_id=1337, + ) + assert result == {"status": "ok"} + + +class Test_AsyncMcSearch: + @respx.mock + async def test_search_mc(self) -> None: + expected_url = ( + f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_MC_USED" + "?q=snabb" + "&page=1" + "&sort=RELEVANCE" + "&price_from=20000" + "&price_to=90000" + "&engine_volume_from=10" + "&engine_volume_to=15" + "&make=1484" + "&location=0.300001" + "&type=11" + "&orgId=1337" + ) + respx.get(expected_url).mock( + return_value=httpx.Response(200, json={"status": "ok"}) + ) + async with AsyncBlocketAPI() as api: + result = await api.search_mc( + "snabb", + types=[McType.SPORT], + locations=[Location.STOCKHOLM], + models=[McModel.DUCATI], + price_from=20000, + price_to=90000, + engine_volume_from=10, + engine_volume_to=15, + org_id=1337, + ) + assert result == {"status": "ok"} + + +class Test_AsyncGetAd: + @respx.mock + async def test_get_ad_recommerce(self) -> None: + expected_url = f"{SITE_URL}/recommerce/forsale/item/12345567" + respx.get(expected_url).mock( + return_value=httpx.Response( + 200, + content=b'', + ) + ) + async with AsyncBlocketAPI() as api: + result = await api.get_ad(RecommerceAd(12345567)) + assert result == {"some": "json here"} + + @respx.mock + async def test_get_ad_car(self) -> None: + expected_url = f"{SITE_URL}/mobility/item/123456" + html = """ +
+ +

Volvo V60

+

D4 AWD Momentum

+ +
+
+ Modellår +

2020

+
+
+ Miltal +

4500

+
+
+ +
+

Pris

+ 389 000 kr +
+ +
+

Beskrivning

+
Mycket fin bil i nyskick.
+
+ +
+

Utrustning

+ +
+ +
+ +
Återförsäljare
+ +
+

Annons-ID

+

123456

+
+ """ + respx.get(expected_url).mock( + return_value=httpx.Response(200, content=html.encode("utf-8")) + ) + async with AsyncBlocketAPI() as api: + result = await api.get_ad(CarAd(123456)) + assert result == { + "url": "https://www.blocket.se/mobility/item/123456", + "title": "Volvo V60", + "subtitle": "D4 AWD Momentum", + "model_year": "2020", + "mileage": "4500", + "price": "389 000 kr", + "description": "Mycket fin bil i nyskick.", + "equipment": ["ACC Klimatanläggning", "Adaptiv farthållare"], + "seller_type": "dealer", + "ad_id": "123456", + } + + @respx.mock + async def test_get_mc_ad(self) -> None: + expected_url = f"{SITE_URL}/mobility/item/123456" + html = """ +
+ +

Ducati

+

Snabb båge

+ +
+
+ Modellår +

2020

+
+
+ Miltal +

4500

+
+
+ Motorvolym +

150 cc

+
+
+ +
+

Pris

+ 110 000 kr +
+ +
+

Beskrivning

+
Snabb båge
+
+ +
+ +
+

Annons-ID

+

123456

+
+ """ + respx.get(expected_url).mock( + return_value=httpx.Response(200, content=html.encode("utf-8")) + ) + async with AsyncBlocketAPI() as api: + result = await api.get_ad(McAd(123456)) + assert result == { + "url": "https://www.blocket.se/mobility/item/123456", + "title": "Ducati", + "subtitle": "Snabb båge", + "model_year": "2020", + "miltal": "4500", + "engine_volume": "150 cc", + "price": "110 000 kr", + "description": "Snabb båge", + "seller_type": "private", + "ad_id": "123456", + } diff --git a/uv.lock b/uv.lock index 81b9392..0d53981 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -56,6 +65,7 @@ dev = [ { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "respx" }, { name = "ruff" }, ] @@ -70,6 +80,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.1" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.26.0" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.22.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, ] @@ -572,6 +583,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" From 2e296533e06c9e0ce16461b53d2113eabff36302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hidefj=C3=A4ll?= <50925050+dojje@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:16:52 +0200 Subject: [PATCH 3/5] refactor: extract search URL constants, params functions return params only --- blocket_api/_params.py | 25 ++++++++----------------- blocket_api/async_blocket.py | 24 +++++++++++++++--------- blocket_api/blocket.py | 24 +++++++++++++++--------- blocket_api/constants.py | 5 +++++ 4 files changed, 43 insertions(+), 35 deletions(-) diff --git a/blocket_api/_params.py b/blocket_api/_params.py index 4890a8d..816625d 100644 --- a/blocket_api/_params.py +++ b/blocket_api/_params.py @@ -4,7 +4,6 @@ from typing import Any from .constants import ( - SITE_URL, BoatType, CarColor, CarModel, @@ -42,12 +41,10 @@ def build_search_params( locations: list[Location] = [], category: Category | None = None, sub_category: SubCategory | None = None, -) -> tuple[str, list[tuple[str, _ParamValue]]]: +) -> list[tuple[str, _ParamValue]]: if category and sub_category: raise AssertionError("Cannot specify both category and sub_categories") - url = f"{SITE_URL}/recommerce/forsale/search/api/search/SEARCH_ID_BAP_COMMON" - defaults: dict[str, _ParamValue] = { "q": query, "page": page, @@ -58,7 +55,7 @@ def build_search_params( extras = [("location", loc.value) for loc in locations] - return url, _build_params(defaults, extras) + return _build_params(defaults, extras) def build_car_params( @@ -80,9 +77,7 @@ def build_car_params( horsepower_to: int | None = None, wheel_drive: list[CarWheelDrive] = [], org_id: int | None = None, -) -> tuple[str, list[tuple[str, _ParamValue]]]: - url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_CAR_USED" - +) -> list[tuple[str, _ParamValue]]: defaults: dict[str, _ParamValue] = { "q": query, "page": page, @@ -105,7 +100,7 @@ def build_car_params( extras.extend(("transmission", t.value) for t in transmissions) extras.extend(("wheel_drive", w.value) for w in wheel_drive) - return url, _build_params(defaults, extras) + return _build_params(defaults, extras) def build_boat_params( @@ -120,9 +115,7 @@ def build_boat_params( length_from: int | None = None, length_to: int | None = None, org_id: int | None = None, -) -> tuple[str, list[tuple[str, _ParamValue]]]: - url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_BOAT_USED" - +) -> list[tuple[str, _ParamValue]]: defaults: dict[str, _ParamValue] = { "q": query, "page": page, @@ -138,7 +131,7 @@ def build_boat_params( extras.extend(("class", t.value) for t in types) extras.extend(("location", loc.value) for loc in locations) - return url, _build_params(defaults, extras) + return _build_params(defaults, extras) def build_mc_params( @@ -154,9 +147,7 @@ def build_mc_params( engine_volume_from: int | None = None, engine_volume_to: int | None = None, org_id: int | None = None, -) -> tuple[str, list[tuple[str, _ParamValue]]]: - url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_MC_USED" - +) -> list[tuple[str, _ParamValue]]: defaults: dict[str, _ParamValue] = { "q": query, "page": page, @@ -173,4 +164,4 @@ def build_mc_params( extras.extend(("location", loc.value) for loc in locations) extras.extend(("type", t.value) for t in types) - return url, _build_params(defaults, extras) + return _build_params(defaults, extras) diff --git a/blocket_api/async_blocket.py b/blocket_api/async_blocket.py index fda3221..1c945e2 100644 --- a/blocket_api/async_blocket.py +++ b/blocket_api/async_blocket.py @@ -11,7 +11,13 @@ build_search_params, ) from .ad_parser import BoatAd, CarAd, McAd, RecommerceAd -from .constants import HEADERS +from .constants import ( + BOAT_SEARCH_URL, + CAR_SEARCH_URL, + HEADERS, + MC_SEARCH_URL, + SEARCH_URL, +) from .constants import ( BoatType, CarColor, @@ -53,7 +59,7 @@ async def search( category: Category | None = None, sub_category: SubCategory | None = None, ) -> dict[str, Any]: - url, params = build_search_params( + params = build_search_params( query, page=page, sort_order=sort_order, @@ -61,7 +67,7 @@ async def search( category=category, sub_category=sub_category, ) - response = await self._client.get(url, headers=HEADERS, params=params) + response = await self._client.get(SEARCH_URL, headers=HEADERS, params=params) response.raise_for_status() return response.json() @@ -86,7 +92,7 @@ async def search_car( wheel_drive: list[CarWheelDrive] = [], org_id: int | None = None, ) -> dict[str, Any]: - url, params = build_car_params( + params = build_car_params( query, page=page, sort_order=sort_order, @@ -105,7 +111,7 @@ async def search_car( wheel_drive=wheel_drive, org_id=org_id, ) - response = await self._client.get(url, headers=HEADERS, params=params) + response = await self._client.get(CAR_SEARCH_URL, headers=HEADERS, params=params) response.raise_for_status() return response.json() @@ -123,7 +129,7 @@ async def search_boat( length_to: int | None = None, org_id: int | None = None, ) -> Any: - url, params = build_boat_params( + params = build_boat_params( query, page=page, sort_order=sort_order, @@ -135,7 +141,7 @@ async def search_boat( length_to=length_to, org_id=org_id, ) - response = await self._client.get(url, headers=HEADERS, params=params) + response = await self._client.get(BOAT_SEARCH_URL, headers=HEADERS, params=params) response.raise_for_status() return response.json() @@ -154,7 +160,7 @@ async def search_mc( engine_volume_to: int | None = None, org_id: int | None = None, ) -> dict[str, Any]: - url, params = build_mc_params( + params = build_mc_params( query, page=page, sort_order=sort_order, @@ -167,7 +173,7 @@ async def search_mc( engine_volume_to=engine_volume_to, org_id=org_id, ) - response = await self._client.get(url, headers=HEADERS, params=params) + response = await self._client.get(MC_SEARCH_URL, headers=HEADERS, params=params) response.raise_for_status() return response.json() diff --git a/blocket_api/blocket.py b/blocket_api/blocket.py index 913cf77..eea0bf5 100644 --- a/blocket_api/blocket.py +++ b/blocket_api/blocket.py @@ -12,7 +12,13 @@ build_search_params, ) from .ad_parser import BoatAd, CarAd, McAd, RecommerceAd -from .constants import HEADERS +from .constants import ( + BOAT_SEARCH_URL, + CAR_SEARCH_URL, + HEADERS, + MC_SEARCH_URL, + SEARCH_URL, +) from .constants import ( BoatType, CarColor, @@ -47,7 +53,7 @@ def search( category: Category | None = None, sub_category: SubCategory | None = None, ) -> dict[str, Any]: - url, params = build_search_params( + params = build_search_params( query, page=page, sort_order=sort_order, @@ -55,7 +61,7 @@ def search( category=category, sub_category=sub_category, ) - return _request(url=url, params=params).json() + return _request(url=SEARCH_URL, params=params).json() def search_car( self, @@ -78,7 +84,7 @@ def search_car( wheel_drive: list[CarWheelDrive] = [], org_id: int | None = None, ) -> dict[str, Any]: - url, params = build_car_params( + params = build_car_params( query, page=page, sort_order=sort_order, @@ -97,7 +103,7 @@ def search_car( wheel_drive=wheel_drive, org_id=org_id, ) - return _request(url=url, params=params).json() + return _request(url=CAR_SEARCH_URL, params=params).json() def search_boat( self, @@ -113,7 +119,7 @@ def search_boat( length_to: int | None = None, org_id: int | None = None, ) -> Any: - url, params = build_boat_params( + params = build_boat_params( query, page=page, sort_order=sort_order, @@ -125,7 +131,7 @@ def search_boat( length_to=length_to, org_id=org_id, ) - return _request(url=url, params=params).json() + return _request(url=BOAT_SEARCH_URL, params=params).json() def search_mc( self, @@ -142,7 +148,7 @@ def search_mc( engine_volume_to: int | None = None, org_id: int | None = None, ) -> dict[str, Any]: - url, params = build_mc_params( + params = build_mc_params( query, page=page, sort_order=sort_order, @@ -155,7 +161,7 @@ def search_mc( engine_volume_to=engine_volume_to, org_id=org_id, ) - return _request(url=url, params=params).json() + return _request(url=MC_SEARCH_URL, params=params).json() def get_ad(self, ad: RecommerceAd | CarAd | BoatAd | McAd) -> dict[str, Any]: response = _request(url=ad.url, params=[]) diff --git a/blocket_api/constants.py b/blocket_api/constants.py index 37ff7fe..c670cd9 100644 --- a/blocket_api/constants.py +++ b/blocket_api/constants.py @@ -1,6 +1,11 @@ from enum import IntEnum, StrEnum SITE_URL = "https://www.blocket.se" +SEARCH_URL = f"{SITE_URL}/recommerce/forsale/search/api/search/SEARCH_ID_BAP_COMMON" +CAR_SEARCH_URL = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_CAR_USED" +BOAT_SEARCH_URL = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_BOAT_USED" +MC_SEARCH_URL = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_MC_USED" + HEADERS = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0" } From 371afc75ff330596bc7a6dac360c370ab606e411 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hidefj=C3=A4ll?= <50925050+dojje@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:22:31 +0200 Subject: [PATCH 4/5] chore: apply pre-commit formatting fixes --- blocket_api/_params.py | 1 - blocket_api/async_blocket.py | 12 +++++++----- tests/async_requests.py | 4 +--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/blocket_api/_params.py b/blocket_api/_params.py index 816625d..5cf115e 100644 --- a/blocket_api/_params.py +++ b/blocket_api/_params.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Any from .constants import ( BoatType, diff --git a/blocket_api/async_blocket.py b/blocket_api/async_blocket.py index 1c945e2..d192129 100644 --- a/blocket_api/async_blocket.py +++ b/blocket_api/async_blocket.py @@ -111,7 +111,9 @@ async def search_car( wheel_drive=wheel_drive, org_id=org_id, ) - response = await self._client.get(CAR_SEARCH_URL, headers=HEADERS, params=params) + response = await self._client.get( + CAR_SEARCH_URL, headers=HEADERS, params=params + ) response.raise_for_status() return response.json() @@ -141,7 +143,9 @@ async def search_boat( length_to=length_to, org_id=org_id, ) - response = await self._client.get(BOAT_SEARCH_URL, headers=HEADERS, params=params) + response = await self._client.get( + BOAT_SEARCH_URL, headers=HEADERS, params=params + ) response.raise_for_status() return response.json() @@ -177,9 +181,7 @@ async def search_mc( response.raise_for_status() return response.json() - async def get_ad( - self, ad: RecommerceAd | CarAd | BoatAd | McAd - ) -> dict[str, Any]: + async def get_ad(self, ad: RecommerceAd | CarAd | BoatAd | McAd) -> dict[str, Any]: response = await self._client.get(ad.url, headers=HEADERS) response.raise_for_status() return ad.parse(response) diff --git a/tests/async_requests.py b/tests/async_requests.py index 84229b5..f83de46 100644 --- a/tests/async_requests.py +++ b/tests/async_requests.py @@ -49,9 +49,7 @@ async def test_search_sortorder_published_desc(self) -> None: return_value=httpx.Response(200, json={"status": "ok"}) ) async with AsyncBlocketAPI() as api: - result = await api.search( - "audi q5", sort_order=SortOrder.PUBLISHED_DESC - ) + result = await api.search("audi q5", sort_order=SortOrder.PUBLISHED_DESC) assert result == {"status": "ok"} @respx.mock From 2f8084013784c932d27c04c190e34a80f481639c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hidefj=C3=A4ll?= <50925050+dojje@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:26:50 +0200 Subject: [PATCH 5/5] docs: add async example to README --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 694128e..3547117 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,19 @@ api.get_ad(BoatAd(12345678)) api.get_ad(McAd(12345678)) ``` +### Async support + +```py +import asyncio +from blocket_api import AsyncBlocketAPI + +async def main(): + async with AsyncBlocketAPI() as api: + results = await api.search("iPhone 15") + +asyncio.run(main()) +``` + ## 📝 Notes - REST API: https://blocket-api.se