blocket-api 0.3.92__tar.gz → 0.3.94__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blocket_api
3
- Version: 0.3.92
3
+ Version: 0.3.94
4
4
  Summary: A python API wrapper for blocket.se
5
5
  Author-email: dunderrrrrr <emil.bjorkroth@gmail.com>
6
6
  Requires-Python: >=3.10
@@ -17,7 +17,7 @@ Requires-Dist: mkdocs-material>=9.6.21
17
17
  Requires-Dist: mkdocs-swagger-ui-tag>=0.7.2
18
18
  Requires-Dist: websockets>=15.0.1
19
19
  Requires-Dist: beautifulsoup4>=4.14.2
20
- Requires-Dist: ruff>=0.14.5
20
+ Requires-Dist: ruff>=0.14.7
21
21
  Dynamic: license-file
22
22
 
23
23
  <p align="center">
@@ -32,9 +32,6 @@ BlocketAPI allows users to search [blocket.se](https://blocket.se/) for ads.
32
32
 
33
33
  > Blocket is one of Sweden's largest online marketplaces. It was founded in 1996 and allows users to buy and sell a wide range of items, including cars, real estate, jobs, services, and second-hand goods. The platform is known for its extensive reach and user-friendly interface, making it a popular choice for Swedes looking to purchase or sell items quickly and efficiently.
34
34
 
35
- > [!NOTE]
36
- > Blocket recently released a new version of their site which caused this package to break. But do not fear, we are actively working to try and restore all functionality. More info in [issue #43](https://github.com/dunderrrrrr/blocket_api/issues/41).
37
-
38
35
  ## 🧑‍💻️ Install
39
36
 
40
37
  Install BlocketAPI via PyPI...
@@ -62,13 +59,13 @@ api = BlocketAPI()
62
59
 
63
60
  # search all of blocket
64
61
  api.search(
65
- "Vinterdäck Audi",
62
+ "Tamagotchi",
66
63
  sort_order=SortOrder.PRICE_ASC,
67
64
  locations=[Location.STOCKHOLM, Location.UPPSALA],
68
- category=Category.FORDONSTILLBEHOR,
65
+ category=Category.FRITID_HOBBY_OCH_UNDERHALLNING,
69
66
  )
70
67
 
71
- # search for cars only
68
+ # search for cars
72
69
  api.search_car(
73
70
  "Audi", # query is optional
74
71
  sort_order=CarSortOrder.MILEAGE_ASC,
@@ -80,11 +77,41 @@ api.search_car(
80
77
  locations=[Location.STOCKHOLM],
81
78
  )
82
79
 
83
- # Get a recommerce or car ad
84
- from blocket_api import CarAd, RecommerceAd
80
+ # search for boats
81
+ from blocket_api import BoatType
82
+
83
+ api.search_boat(
84
+ "Mercury", # query is optional
85
+ types=[BoatType.DAYCRUISER],
86
+ locations=[Location.STOCKHOLM],
87
+ length_from=10,
88
+ length_to=15,
89
+ price_from=20000,
90
+ price_to=90000,
91
+ )
92
+
93
+ # search for motorcycles
94
+ from blocket_api import McType, McModel
95
+
96
+ api.search_mc(
97
+ "TC 150", # query is optional
98
+ types=[McType.SPORT],
99
+ locations=[Location.STOCKHOLM],
100
+ models=[McModel.DUCATI],
101
+ price_from=20000,
102
+ price_to=90000,
103
+ engine_volume_from=100,
104
+ engine_volume_to=200,
105
+ )
106
+
107
+
108
+ # get ad details
109
+ from blocket_api import CarAd, RecommerceAd, BoatAd, McAd
85
110
 
86
111
  api.get_ad(RecommerceAd(12345678))
87
112
  api.get_ad(CarAd(12345678))
113
+ api.get_ad(BoatAd(12345678))
114
+ api.get_ad(McAd(12345678))
88
115
  ```
89
116
 
90
117
  ## 📝 Notes
@@ -10,9 +10,6 @@ BlocketAPI allows users to search [blocket.se](https://blocket.se/) for ads.
10
10
 
11
11
  > Blocket is one of Sweden's largest online marketplaces. It was founded in 1996 and allows users to buy and sell a wide range of items, including cars, real estate, jobs, services, and second-hand goods. The platform is known for its extensive reach and user-friendly interface, making it a popular choice for Swedes looking to purchase or sell items quickly and efficiently.
12
12
 
13
- > [!NOTE]
14
- > Blocket recently released a new version of their site which caused this package to break. But do not fear, we are actively working to try and restore all functionality. More info in [issue #43](https://github.com/dunderrrrrr/blocket_api/issues/41).
15
-
16
13
  ## 🧑‍💻️ Install
17
14
 
18
15
  Install BlocketAPI via PyPI...
@@ -40,13 +37,13 @@ api = BlocketAPI()
40
37
 
41
38
  # search all of blocket
42
39
  api.search(
43
- "Vinterdäck Audi",
40
+ "Tamagotchi",
44
41
  sort_order=SortOrder.PRICE_ASC,
45
42
  locations=[Location.STOCKHOLM, Location.UPPSALA],
46
- category=Category.FORDONSTILLBEHOR,
43
+ category=Category.FRITID_HOBBY_OCH_UNDERHALLNING,
47
44
  )
48
45
 
49
- # search for cars only
46
+ # search for cars
50
47
  api.search_car(
51
48
  "Audi", # query is optional
52
49
  sort_order=CarSortOrder.MILEAGE_ASC,
@@ -58,11 +55,41 @@ api.search_car(
58
55
  locations=[Location.STOCKHOLM],
59
56
  )
60
57
 
61
- # Get a recommerce or car ad
62
- from blocket_api import CarAd, RecommerceAd
58
+ # search for boats
59
+ from blocket_api import BoatType
60
+
61
+ api.search_boat(
62
+ "Mercury", # query is optional
63
+ types=[BoatType.DAYCRUISER],
64
+ locations=[Location.STOCKHOLM],
65
+ length_from=10,
66
+ length_to=15,
67
+ price_from=20000,
68
+ price_to=90000,
69
+ )
70
+
71
+ # search for motorcycles
72
+ from blocket_api import McType, McModel
73
+
74
+ api.search_mc(
75
+ "TC 150", # query is optional
76
+ types=[McType.SPORT],
77
+ locations=[Location.STOCKHOLM],
78
+ models=[McModel.DUCATI],
79
+ price_from=20000,
80
+ price_to=90000,
81
+ engine_volume_from=100,
82
+ engine_volume_to=200,
83
+ )
84
+
85
+
86
+ # get ad details
87
+ from blocket_api import CarAd, RecommerceAd, BoatAd, McAd
63
88
 
64
89
  api.get_ad(RecommerceAd(12345678))
65
90
  api.get_ad(CarAd(12345678))
91
+ api.get_ad(BoatAd(12345678))
92
+ api.get_ad(McAd(12345678))
66
93
  ```
67
94
 
68
95
  ## 📝 Notes
@@ -0,0 +1,37 @@
1
+ from .ad_parser import BoatAd, CarAd, McAd, RecommerceAd
2
+ from .blocket import BlocketAPI, Location
3
+ from .constants import (
4
+ BoatSortOrder,
5
+ BoatType,
6
+ CarColor,
7
+ CarModel,
8
+ CarSortOrder,
9
+ CarTransmission,
10
+ Category,
11
+ McModel,
12
+ McSortOrder,
13
+ McType,
14
+ SortOrder,
15
+ SubCategory,
16
+ )
17
+
18
+ __all__ = [
19
+ "BlocketAPI",
20
+ "Location",
21
+ "BoatAd",
22
+ "CarAd",
23
+ "BoatSortOrder",
24
+ "BoatType",
25
+ "CarColor",
26
+ "CarModel",
27
+ "CarSortOrder",
28
+ "CarTransmission",
29
+ "Category",
30
+ "SortOrder",
31
+ "SubCategory",
32
+ "RecommerceAd",
33
+ "McModel",
34
+ "McSortOrder",
35
+ "McType",
36
+ "McAd",
37
+ ]
@@ -0,0 +1,199 @@
1
+ import json
2
+ import re
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from bs4 import BeautifulSoup, NavigableString, Tag
7
+ from httpx import Response
8
+
9
+ from .constants import SITE_URL
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class RecommerceAd:
14
+ id: int
15
+
16
+ @property
17
+ def url(self) -> str:
18
+ return f"{SITE_URL}/recommerce/forsale/item/{self.id}"
19
+
20
+ def parse(self, response: Response) -> Any:
21
+ soup = BeautifulSoup(response.content, "html.parser")
22
+ json_script_tag = soup.select_one(
23
+ 'script:-soup-contains("window.__staticRouterHydrationData")'
24
+ )
25
+ if not json_script_tag:
26
+ return {}
27
+
28
+ if match := re.search(r'JSON\.parse\("(.+)"\)', json_script_tag.text):
29
+ raw_json = match.group(1)
30
+ escaped = raw_json.encode("utf-8").decode("unicode_escape")
31
+ return json.loads(escaped.encode("latin1").decode("utf-8"))
32
+ else:
33
+ return {}
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class MobilityAd:
38
+ id: int
39
+
40
+ @property
41
+ def url(self) -> str:
42
+ return f"{SITE_URL}/mobility/item/{self.id}"
43
+
44
+ def parse(self, response: Response) -> dict:
45
+ soup = BeautifulSoup(response.content, "html.parser")
46
+ grid = soup.find("div", class_="grid grid-cols-1 md:grid-cols-3 md:gap-x-32")
47
+
48
+ if not grid:
49
+ return {}
50
+
51
+ data: dict[str, Any] = {"url": self.url}
52
+
53
+ self._extract_title_and_subtitle(grid, data)
54
+ self._extract_quick_specs(grid, data)
55
+ self._extract_price(grid, data)
56
+ self._extract_description(grid, data)
57
+ self._extract_specifications(grid, data)
58
+ self._extract_seller_type(soup, data)
59
+ self._extract_ad_id(soup, data)
60
+
61
+ self.extend(data, soup, grid)
62
+
63
+ return data
64
+
65
+ def _extract_title_and_subtitle(self, grid: Tag, data: dict) -> None:
66
+ if title := grid.find("h1", class_=lambda x: x and "t1" in x):
67
+ data["title"] = title.get_text(strip=True)
68
+
69
+ if subtitle := grid.find("p", class_=lambda x: x and "s-text-subtle" in x):
70
+ data["subtitle"] = subtitle.get_text(strip=True)
71
+
72
+ def _extract_quick_specs(self, grid: Tag, data: dict) -> None:
73
+ if specs_grid := grid.find(
74
+ "div", class_=lambda x: x and "grid" in x and "gap-24" in x
75
+ ):
76
+ for item in specs_grid.find_all("div", class_="flex gap-16 hyphens-auto"):
77
+ label = item.find("span", class_="s-text-subtle")
78
+ value = item.find("p", class_="m-0 font-bold")
79
+ if label and value:
80
+ label_text = label.get_text(strip=True)
81
+ value_text = value.get_text(strip=True)
82
+ key = getattr(self, "quick_spec_mapping", {}).get(
83
+ label_text, label_text.lower().replace(" ", "_")
84
+ )
85
+ data[key] = value_text
86
+
87
+ def _extract_price(self, grid: Tag, data: dict) -> None:
88
+ if price_section := grid.find("div", class_="border-t pt-40 mt-40"):
89
+ price_elem = price_section.find("span", class_="t2")
90
+ if price_elem:
91
+ data["price"] = price_elem.get_text(strip=True)
92
+
93
+ def _extract_description(self, grid: Tag, data: dict) -> None:
94
+ for section in grid.find_all("section", class_="border-t mt-40 pt-40"):
95
+ h2 = section.find("h2", class_="t3 mb-0")
96
+ if h2 and "beskrivning" in h2.get_text(strip=True).lower():
97
+ desc_div = section.find("div", class_="whitespace-pre-wrap")
98
+ if desc_div:
99
+ data["description"] = desc_div.get_text(strip=True)
100
+ return
101
+
102
+ def _extract_specifications(self, grid: Tag, data: dict) -> None:
103
+ specs_section = grid.find("section", class_="key-info-section")
104
+ if not specs_section:
105
+ return
106
+ dl = specs_section.find("dl")
107
+ if not dl:
108
+ return
109
+
110
+ specifications = {}
111
+ for div in dl.find_all("div", style="break-inside:avoid-column"):
112
+ dt = div.find("dt")
113
+ dd = div.find("dd")
114
+ if dt and dd:
115
+ specifications[dt.get_text(strip=True)] = dd.get_text(strip=True)
116
+
117
+ if specifications:
118
+ data["specifications"] = specifications
119
+
120
+ def _extract_seller_type(self, soup: BeautifulSoup, data: dict) -> None:
121
+ # Look for a div whose class contains "dealer"
122
+ data["seller_type"] = (
123
+ "dealer"
124
+ if soup.find("div", class_=lambda x: x and "dealer" in x.lower())
125
+ else "private"
126
+ )
127
+
128
+ def _extract_ad_id(self, soup: BeautifulSoup, data: dict) -> None:
129
+ label = next(
130
+ (
131
+ p
132
+ for p in soup.find_all("p")
133
+ if isinstance(p.string, NavigableString) and "Annons-ID" in p.string
134
+ ),
135
+ None,
136
+ )
137
+ if label:
138
+ elem = label.find_next("p")
139
+ if elem:
140
+ data["ad_id"] = elem.get_text(strip=True)
141
+
142
+ def extend(self, data: dict, soup: BeautifulSoup, grid: Tag) -> None:
143
+ pass
144
+
145
+
146
+ class CarAd(MobilityAd):
147
+ quick_spec_mapping = {
148
+ "Modellår": "model_year",
149
+ "Miltal": "mileage",
150
+ "Växellåda": "transmission",
151
+ "Drivmedel": "fuel",
152
+ }
153
+
154
+ def extend(self, data: dict, soup: BeautifulSoup, grid: Tag) -> None:
155
+ equip_section = next(
156
+ (
157
+ h2
158
+ for h2 in grid.find_all("h2")
159
+ if isinstance(h2.string, NavigableString) and "Utrustning" in h2.string
160
+ ),
161
+ None,
162
+ )
163
+ if equip_section:
164
+ parent = equip_section.find_parent("section")
165
+ if parent:
166
+ items = [li.get_text(strip=True) for li in parent.find_all("li")]
167
+ if items:
168
+ data["equipment"] = items
169
+
170
+
171
+ class BoatAd(MobilityAd):
172
+ quick_spec_mapping = {
173
+ "Modellår": "model_year",
174
+ "Längd": "length",
175
+ "Motortyp": "engine_type",
176
+ "Säten": "seats",
177
+ }
178
+
179
+ def extend(self, data: dict, soup: BeautifulSoup, grid: Tag) -> None:
180
+ if place_h2 := soup.find(
181
+ "h2", text=lambda t: isinstance(t, NavigableString) and "Plats" in t
182
+ ):
183
+ if link := place_h2.find_parent():
184
+ data["location"] = link.get_text(strip=True)
185
+
186
+
187
+ class McAd(MobilityAd):
188
+ quick_spec_mapping = {
189
+ "Modellår": "model_year",
190
+ "Motorvolym": "engine_volume",
191
+ "Typ": "type",
192
+ }
193
+
194
+ def extend(self, data: dict, soup: BeautifulSoup, grid: Tag) -> None:
195
+ if place_h2 := soup.find(
196
+ "h2", text=lambda t: isinstance(t, NavigableString) and "Plats" in t
197
+ ):
198
+ if link := place_h2.find_parent():
199
+ data["location"] = link.get_text(strip=True)
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import httpx
7
+ from httpx import Response
8
+
9
+ from .ad_parser import BoatAd, CarAd, McAd, RecommerceAd
10
+ from .constants import (
11
+ HEADERS,
12
+ SITE_URL,
13
+ BoatType,
14
+ CarColor,
15
+ CarModel,
16
+ CarSortOrder,
17
+ CarTransmission,
18
+ Category,
19
+ Location,
20
+ McModel,
21
+ McSortOrder,
22
+ McType,
23
+ SortOrder,
24
+ SubCategory,
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class QueryParam:
30
+ name: str
31
+ value: str | int
32
+
33
+
34
+ def _request(*, url: str, params: list[QueryParam]) -> Response:
35
+ response = httpx.get(
36
+ url,
37
+ headers=HEADERS,
38
+ params=[(param.name, param.value) for param in params],
39
+ )
40
+ response.raise_for_status()
41
+ return response
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class BlocketAPI:
46
+ def search(
47
+ self,
48
+ query: str,
49
+ *,
50
+ page: int = 1,
51
+ sort_order: SortOrder = SortOrder.RELEVANCE,
52
+ locations: list[Location] = [],
53
+ category: Category | None = None,
54
+ sub_category: SubCategory | None = None,
55
+ ) -> Any:
56
+ if category and sub_category:
57
+ raise AssertionError("Cannot specify both category and sub_categories")
58
+
59
+ url = f"{SITE_URL}/recommerce-search-page/api/search/SEARCH_ID_BAP_COMMON"
60
+
61
+ param_dict: dict[str, str | int | None] = {
62
+ "q": query,
63
+ "page": page,
64
+ "sort": sort_order.value,
65
+ "category": category.value if category else None,
66
+ "sub_category": sub_category.value if sub_category else None,
67
+ }
68
+
69
+ params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None]
70
+
71
+ params.extend(QueryParam("location", loc.value) for loc in locations)
72
+
73
+ return _request(url=url, params=params).json()
74
+
75
+ def search_car(
76
+ self,
77
+ query: str | None = None,
78
+ *,
79
+ page: int = 1,
80
+ sort_order: CarSortOrder = CarSortOrder.RELEVANCE,
81
+ locations: list[Location] = [],
82
+ models: list[CarModel] = [],
83
+ price_from: int | None = None,
84
+ price_to: int | None = None,
85
+ year_from: int | None = None,
86
+ year_to: int | None = None,
87
+ milage_from: int | None = None,
88
+ milage_to: int | None = None,
89
+ colors: list[CarColor] = [],
90
+ transmissions: list[CarTransmission] = [],
91
+ ) -> Any:
92
+ url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_CAR_USED"
93
+
94
+ param_dict: dict[str, str | int | None] = {
95
+ "q": query,
96
+ "page": page,
97
+ "sort": sort_order.value,
98
+ "price_from": price_from,
99
+ "price_to": price_to,
100
+ "year_from": year_from,
101
+ "year_to": year_to,
102
+ "milage_from": milage_from,
103
+ "milage_to": milage_to,
104
+ }
105
+
106
+ params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None]
107
+
108
+ # Multi-value params
109
+ params.extend(QueryParam("location", loc.value) for loc in locations)
110
+ params.extend(QueryParam("make", model.value) for model in models)
111
+ params.extend(QueryParam("exterior_colour", color.value) for color in colors)
112
+ params.extend(QueryParam("transmission", t.value) for t in transmissions)
113
+
114
+ return _request(url=url, params=params).json()
115
+
116
+ def search_boat(
117
+ self,
118
+ query: str | None = None,
119
+ *,
120
+ page: int = 1,
121
+ sort_order: CarSortOrder = CarSortOrder.RELEVANCE,
122
+ types: list[BoatType] = [],
123
+ locations: list[Location] = [],
124
+ price_from: int | None = None,
125
+ price_to: int | None = None,
126
+ length_from: int | None = None,
127
+ length_to: int | None = None,
128
+ ) -> Any:
129
+ url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_BOAT_USED"
130
+
131
+ param_dict: dict[str, str | int | None] = {
132
+ "q": query,
133
+ "page": page,
134
+ "sort": sort_order.value,
135
+ "price_from": price_from,
136
+ "price_to": price_to,
137
+ "length_feet_from": length_from,
138
+ "length_feet_to": length_to,
139
+ }
140
+
141
+ params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None]
142
+
143
+ params.extend(QueryParam("class", t.value) for t in types)
144
+ params.extend(QueryParam("location", loc.value) for loc in locations)
145
+
146
+ return _request(url=url, params=params).json()
147
+
148
+ def search_mc(
149
+ self,
150
+ query: str | None = None,
151
+ *,
152
+ page: int = 1,
153
+ sort_order: McSortOrder = McSortOrder.RELEVANCE,
154
+ models: list[McModel] = [],
155
+ types: list[McType] = [],
156
+ locations: list[Location] = [],
157
+ price_from: int | None = None,
158
+ price_to: int | None = None,
159
+ engine_volume_from: int | None = None,
160
+ engine_volume_to: int | None = None,
161
+ ) -> Any:
162
+ url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_MC_USED"
163
+
164
+ param_dict: dict[str, str | int | None] = {
165
+ "q": query,
166
+ "page": page,
167
+ "sort": sort_order.value,
168
+ "price_from": price_from,
169
+ "price_to": price_to,
170
+ "engine_volume_from": engine_volume_from,
171
+ "engine_volume_to": engine_volume_to,
172
+ }
173
+
174
+ params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None]
175
+
176
+ params.extend(QueryParam("make", m.value) for m in models)
177
+ params.extend(QueryParam("location", loc.value) for loc in locations)
178
+ params.extend(QueryParam("type", t.value) for t in types)
179
+
180
+ return _request(url=url, params=params).json()
181
+
182
+ def get_ad(self, ad: RecommerceAd | CarAd | BoatAd | McAd) -> dict:
183
+ response = _request(url=ad.url, params=[])
184
+ return ad.parse(response)
@@ -21,7 +21,7 @@ class CarSortOrder(StrEnum):
21
21
  PUBLISHED_DESC = "PUBLISHED_DESC"
22
22
  PUBLISHED_ASC = "PUBLISHED_ASC"
23
23
 
24
- # Car specific
24
+ # car specific
25
25
  MILEAGE_DESC = "MILEAGE_DESC"
26
26
  MILEAGE_ASC = "MILEAGE_ASC"
27
27
  MODEL = "MODEL"
@@ -29,6 +29,37 @@ class CarSortOrder(StrEnum):
29
29
  YEAR_ASC = "YEAR_ASC"
30
30
 
31
31
 
32
+ class McSortOrder(StrEnum):
33
+ RELEVANCE = "RELEVANCE"
34
+ PRICE_DESC = "PRICE_DESC"
35
+ PRICE_ASC = "PRICE_ASC"
36
+ PUBLISHED_DESC = "PUBLISHED_DESC"
37
+ PUBLISHED_ASC = "PUBLISHED_ASC"
38
+
39
+ # mc specific
40
+ MILAGE_DESC = "MILAGE_DESC"
41
+ MILAGE_ASC = "MILAGE_ASC"
42
+ MODEL = "MODEL"
43
+ YEAR_DESC = "YEAR_DESC"
44
+ YEAR_ASC = "YEAR_ASC"
45
+
46
+
47
+ class BoatSortOrder(StrEnum):
48
+ RELEVANCE = "RELEVANCE"
49
+ PRICE_DESC = "PRICE_DESC"
50
+ PRICE_ASC = "PRICE_ASC"
51
+ PUBLISHED_DESC = "PUBLISHED_DESC"
52
+ PUBLISHED_ASC = "PUBLISHED_ASC"
53
+
54
+ # boat specific
55
+ LENGTH_DESC = "LENGTH_DESC"
56
+ LENGTH_ASC = "LENGTH_ASC"
57
+ SPEED_DESC = "SPEED_DESC"
58
+ SPEED_ASC = "SPEED_ASC"
59
+ YEAR_DESC = "YEAR_DESC"
60
+ YEAR_ASC = "YEAR_ASC"
61
+
62
+
32
63
  class Location(StrEnum):
33
64
  BLEKINGE = "0.300010"
34
65
  DALARNA = "0.300020"
@@ -348,3 +379,139 @@ class CarColor(IntEnum):
348
379
  class CarTransmission(IntEnum):
349
380
  AUTOMATIC = 2
350
381
  MANUAL = 1
382
+
383
+
384
+ class BoatType(IntEnum):
385
+ BOWRIDER = 7961
386
+ DAYCRUISER = 2184
387
+ FISKEBAT_ARBETSBAT = 6921
388
+ HYTTBAT = 7962
389
+ KABINBAT = 6923
390
+ POWERBOAT = 7960
391
+ RIB = 7343
392
+ SEGELBAT_MOTORSEGLARE = 2188
393
+ SMABAT_GUMMIBAT = 2186
394
+ SNIPA = 6922
395
+ STYRPULPETBAT = 3827
396
+ VATTENSKOTER = 2190
397
+ YACHT = 7959
398
+ ANNAT = 2258
399
+
400
+
401
+ class McModel(IntEnum):
402
+ ADLY = 7902
403
+ AGIRRA = 8306
404
+ AIXAM = 8257
405
+ AJP = 8411
406
+ AJS = 1528
407
+ AMERICAN_IRON_HORSE = 8337
408
+ APRILIA = 2998
409
+ BAJAJ = 6722
410
+ BAOTIAN = 1546
411
+ BAROSSA = 1534
412
+ BENDA = 482
413
+ BENGHE = 7904
414
+ BETA = 8254
415
+ BIG_DOG = 8335
416
+ BIMOTA = 2997
417
+ BMW = 1481
418
+ BOSS_HOSS = 8008
419
+ BSA = 8114
420
+ BUELL = 7333
421
+ BULTACO = 7907
422
+ CAGIVA = 4328
423
+ CAKE = 8500
424
+ CAN_AM = 8245
425
+ CFMOTO = 8251
426
+ COBRA = 8004
427
+ DUCATI = 1484
428
+ ENDURO = 7918
429
+ ENERGICA = 8368
430
+ EUROSCOOTER = 7927
431
+ FANTIC = 1522
432
+ GASGAS = 8256
433
+ GILERA = 7354
434
+ GOES = 8310
435
+ HARLEY_DAVIDSON = 1486
436
+ HERO = 7903
437
+ HONDA = 1487
438
+ HUSABERG = 7356
439
+ HUSQVARNA = 7357
440
+ HYOSUNG = 6725
441
+ INDIAN = 8313
442
+ ITALJET = 8369
443
+ JAWA_CZ = 1488
444
+ KAWASAKI = 1489
445
+ KTM = 7771
446
+ KYMCO = 7363
447
+ LAMBRETTA = 8360
448
+ LIFAN = 8357
449
+ LIGIER = 8331
450
+ LYNX = 7920
451
+ MOTO_GUZZI = 1492
452
+ MV_AGUSTA = 7365
453
+ MZ = 8085
454
+ NIU = 8385
455
+ NORTON = 7367
456
+ OSET = 8401
457
+ PEUGEOT = 1494
458
+ PIAGGIO = 1495
459
+ POLARIS = 7922
460
+ QINGQI = 8236
461
+ QUADRO = 8378
462
+ REGAL_RAPTOR = 8210
463
+ RENAULT = 481
464
+ RIEJU = 7912
465
+ ROMET = 8362
466
+ ROYAL_ENFIELD = 8336
467
+ SEGWAY = 8407
468
+ SHERCO = 8255
469
+ SILENCE = 8508
470
+ SKI_DOO = 7926
471
+ SMC = 8242
472
+ STARK = 1192
473
+ SUPER_SOCO = 8388
474
+ SUR_RON = 8379
475
+ SUZUKI = 1497
476
+ SWM = 8394
477
+ SYM = 7915
478
+ TALARIA = 8519
479
+ TEN7 = 8502
480
+ TGB = 8205
481
+ THUNDER_BY_CITY_WHEELS = 8400
482
+ TM = 1516
483
+ TOMOS = 8235
484
+ TRIUMPH = 1500
485
+ URAL = 8512
486
+ V8_CHOPPERS = 8397
487
+ VERTIGO = 8408
488
+ VESPA = 7901
489
+ VIARELLI = 8366
490
+ VICTORY = 8317
491
+ VOGE = 8390
492
+ X_PRO = 8404
493
+ YADEA = 8503
494
+ YAMAHA = 1502
495
+ ZERO = 8351
496
+ ZONTES = 8516
497
+ ZUNDAPP = 7911
498
+ OVRIGA = 2045
499
+
500
+
501
+ class McType(IntEnum):
502
+ ADVENTURE = 20
503
+ CHOPPER = 17
504
+ CROSS_ENDURO_TRIAL = 3
505
+ CRUISER = 16
506
+ CUSTOM = 4
507
+ KLASSISK_NAKEN = 2
508
+ LATT_MC = 5
509
+ MC_SCOOTER = 18
510
+ OFFROAD_MOTARD = 7
511
+ SPORT = 11
512
+ SUPERMOTO = 1547
513
+ TOURING = 12
514
+ TREHJULIG = 1549
515
+ TRIKE = 13
516
+ VETERAN = 14
517
+ ANNAT = 15
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blocket_api
3
- Version: 0.3.92
3
+ Version: 0.3.94
4
4
  Summary: A python API wrapper for blocket.se
5
5
  Author-email: dunderrrrrr <emil.bjorkroth@gmail.com>
6
6
  Requires-Python: >=3.10
@@ -17,7 +17,7 @@ Requires-Dist: mkdocs-material>=9.6.21
17
17
  Requires-Dist: mkdocs-swagger-ui-tag>=0.7.2
18
18
  Requires-Dist: websockets>=15.0.1
19
19
  Requires-Dist: beautifulsoup4>=4.14.2
20
- Requires-Dist: ruff>=0.14.5
20
+ Requires-Dist: ruff>=0.14.7
21
21
  Dynamic: license-file
22
22
 
23
23
  <p align="center">
@@ -32,9 +32,6 @@ BlocketAPI allows users to search [blocket.se](https://blocket.se/) for ads.
32
32
 
33
33
  > Blocket is one of Sweden's largest online marketplaces. It was founded in 1996 and allows users to buy and sell a wide range of items, including cars, real estate, jobs, services, and second-hand goods. The platform is known for its extensive reach and user-friendly interface, making it a popular choice for Swedes looking to purchase or sell items quickly and efficiently.
34
34
 
35
- > [!NOTE]
36
- > Blocket recently released a new version of their site which caused this package to break. But do not fear, we are actively working to try and restore all functionality. More info in [issue #43](https://github.com/dunderrrrrr/blocket_api/issues/41).
37
-
38
35
  ## 🧑‍💻️ Install
39
36
 
40
37
  Install BlocketAPI via PyPI...
@@ -62,13 +59,13 @@ api = BlocketAPI()
62
59
 
63
60
  # search all of blocket
64
61
  api.search(
65
- "Vinterdäck Audi",
62
+ "Tamagotchi",
66
63
  sort_order=SortOrder.PRICE_ASC,
67
64
  locations=[Location.STOCKHOLM, Location.UPPSALA],
68
- category=Category.FORDONSTILLBEHOR,
65
+ category=Category.FRITID_HOBBY_OCH_UNDERHALLNING,
69
66
  )
70
67
 
71
- # search for cars only
68
+ # search for cars
72
69
  api.search_car(
73
70
  "Audi", # query is optional
74
71
  sort_order=CarSortOrder.MILEAGE_ASC,
@@ -80,11 +77,41 @@ api.search_car(
80
77
  locations=[Location.STOCKHOLM],
81
78
  )
82
79
 
83
- # Get a recommerce or car ad
84
- from blocket_api import CarAd, RecommerceAd
80
+ # search for boats
81
+ from blocket_api import BoatType
82
+
83
+ api.search_boat(
84
+ "Mercury", # query is optional
85
+ types=[BoatType.DAYCRUISER],
86
+ locations=[Location.STOCKHOLM],
87
+ length_from=10,
88
+ length_to=15,
89
+ price_from=20000,
90
+ price_to=90000,
91
+ )
92
+
93
+ # search for motorcycles
94
+ from blocket_api import McType, McModel
95
+
96
+ api.search_mc(
97
+ "TC 150", # query is optional
98
+ types=[McType.SPORT],
99
+ locations=[Location.STOCKHOLM],
100
+ models=[McModel.DUCATI],
101
+ price_from=20000,
102
+ price_to=90000,
103
+ engine_volume_from=100,
104
+ engine_volume_to=200,
105
+ )
106
+
107
+
108
+ # get ad details
109
+ from blocket_api import CarAd, RecommerceAd, BoatAd, McAd
85
110
 
86
111
  api.get_ad(RecommerceAd(12345678))
87
112
  api.get_ad(CarAd(12345678))
113
+ api.get_ad(BoatAd(12345678))
114
+ api.get_ad(McAd(12345678))
88
115
  ```
89
116
 
90
117
  ## 📝 Notes
@@ -9,4 +9,4 @@ mkdocs-material>=9.6.21
9
9
  mkdocs-swagger-ui-tag>=0.7.2
10
10
  websockets>=15.0.1
11
11
  beautifulsoup4>=4.14.2
12
- ruff>=0.14.5
12
+ ruff>=0.14.7
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "blocket_api"
3
- version = "0.3.92"
3
+ version = "0.3.94"
4
4
  description = "A python API wrapper for blocket.se"
5
5
  authors = [{name = "dunderrrrrr", email = "emil.bjorkroth@gmail.com"}]
6
6
  readme = "README.md"
@@ -17,7 +17,7 @@ dependencies = [
17
17
  "mkdocs-swagger-ui-tag>=0.7.2",
18
18
  "websockets>=15.0.1",
19
19
  "beautifulsoup4>=4.14.2",
20
- "ruff>=0.14.5",
20
+ "ruff>=0.14.7",
21
21
  ]
22
22
 
23
23
 
@@ -1,11 +0,0 @@
1
- from .ad_parser import CarAd as CarAd
2
- from .ad_parser import RecommerceAd as RecommerceAd
3
- from .blocket import BlocketAPI as BlocketAPI
4
- from .blocket import Location as Location
5
- from .constants import CarColor as CarColor
6
- from .constants import CarModel as CarModel
7
- from .constants import CarSortOrder as CarSortOrder
8
- from .constants import CarTransmission as CarTransmission
9
- from .constants import Category as Category
10
- from .constants import SortOrder as SortOrder
11
- from .constants import SubCategory as SubCategory
@@ -1,182 +0,0 @@
1
- import json
2
- import re
3
- from dataclasses import dataclass
4
- from typing import Any
5
-
6
- from bs4 import BeautifulSoup, Tag
7
- from httpx import Response
8
-
9
- from .constants import SITE_URL
10
-
11
-
12
- @dataclass(frozen=True)
13
- class RecommerceAd:
14
- id: int
15
-
16
- @property
17
- def url(self) -> str:
18
- return f"{SITE_URL}/recommerce/forsale/item/{self.id}"
19
-
20
- def parse(self, response: Response) -> Any:
21
- soup = BeautifulSoup(response.content, "html.parser")
22
- json_script_tag = soup.select_one(
23
- 'script:-soup-contains("window.__staticRouterHydrationData")'
24
- )
25
- if not json_script_tag:
26
- return {}
27
-
28
- if match := re.search(r'JSON\.parse\("(.+)"\)', json_script_tag.text):
29
- raw_json = match.group(1)
30
- escaped = raw_json.encode("utf-8").decode("unicode_escape")
31
- return json.loads(escaped.encode("latin1").decode("utf-8"))
32
- else:
33
- return {}
34
-
35
-
36
- @dataclass(frozen=True)
37
- class CarAd:
38
- id: int
39
-
40
- @property
41
- def url(self) -> str:
42
- return f"{SITE_URL}/mobility/item/{self.id}"
43
-
44
- def parse(self, response: Response) -> dict:
45
- soup = BeautifulSoup(response.content, "html.parser")
46
- grid = soup.find("div", class_="grid grid-cols-1 md:grid-cols-3 md:gap-x-32")
47
-
48
- if not grid:
49
- return {}
50
-
51
- data: dict[str, Any] = {"url": self.url}
52
-
53
- self._extract_title_and_subtitle(grid, data)
54
- self._extract_quick_specs(grid, data)
55
- self._extract_price(grid, data)
56
- self._extract_description(grid, data)
57
- self._extract_specifications(grid, data)
58
- self._extract_equipment(grid, data)
59
- self._extract_seller_type(soup, data)
60
- self._extract_ad_id(soup, data)
61
-
62
- return data
63
-
64
- def _extract_title_and_subtitle(self, grid: Tag, data: dict[str, Any]) -> None:
65
- if title := grid.find("h1", class_=lambda x: x and "t1" in x):
66
- data["title"] = title.get_text(strip=True)
67
-
68
- if subtitle := grid.find(
69
- "p", class_=lambda x: x and "s-text-subtle" in x and "mt-8" in x
70
- ):
71
- data["subtitle"] = subtitle.get_text(strip=True)
72
-
73
- def _extract_quick_specs(self, grid: Tag, data: dict[str, Any]) -> None:
74
- if specs_grid := grid.find(
75
- "div", class_=lambda x: x and "grid" in x and "gap-24" in x
76
- ):
77
- spec_items = specs_grid.find_all("div", class_="flex gap-16 hyphens-auto")
78
- for item in spec_items:
79
- label = item.find("span", class_="s-text-subtle")
80
- value = item.find("p", class_="m-0 font-bold")
81
- if label and value:
82
- label_text = label.get_text(strip=True)
83
- value_text = value.get_text(strip=True)
84
-
85
- key_mapping = {
86
- "Modellår": "model_year",
87
- "Miltal": "mileage",
88
- "Växellåda": "transmission",
89
- "Drivmedel": "fuel",
90
- }
91
- key = key_mapping.get(
92
- label_text, label_text.lower().replace(" ", "_")
93
- )
94
- data[key] = value_text
95
-
96
- def _extract_price(self, grid: Tag, data: dict[str, Any]) -> None:
97
- if price_section := grid.find("div", class_="border-t pt-40 mt-40"):
98
- price_labels = price_section.find_all("p", class_="s-text-subtle mb-0")
99
- for price_label in price_labels:
100
- label_text = price_label.get_text(strip=True).lower()
101
- if "pris" in label_text:
102
- price_elem = price_section.find("span", class_="t2")
103
- if price_elem:
104
- data["price"] = price_elem.get_text(strip=True)
105
- break
106
- elif "månadskostnad" in label_text:
107
- monthly_elem = price_section.find("h2", class_="t2")
108
- if monthly_elem:
109
- data["monthly_cost"] = monthly_elem.get_text(strip=True)
110
- break
111
-
112
- def _extract_description(self, grid: Tag, data: dict[str, Any]) -> None:
113
- desc_sections = grid.find_all("section", class_="border-t mt-40 pt-40")
114
- for section in desc_sections:
115
- h2 = section.find("h2", class_="t3 mb-0")
116
- if h2 and "beskrivning" in h2.get_text(strip=True).lower():
117
- desc_div = section.find("div", class_="whitespace-pre-wrap")
118
- if desc_div:
119
- data["description"] = desc_div.get_text(strip=True)
120
- break
121
-
122
- def _extract_specifications(self, grid: Tag, data: dict[str, Any]) -> None:
123
- specs_section = grid.find("section", class_="key-info-section")
124
- if not specs_section:
125
- return
126
-
127
- dl = specs_section.find("dl")
128
- if not dl:
129
- return
130
-
131
- specifications: dict[str, str] = {}
132
- divs = dl.find_all("div", style="break-inside:avoid-column")
133
- for div in divs:
134
- dt = div.find("dt")
135
- dd = div.find("dd")
136
- if dt and dd:
137
- key_text = dt.get_text(strip=True)
138
- value_text = dd.get_text(strip=True)
139
- specifications[key_text] = value_text
140
-
141
- if specifications:
142
- data["specifications"] = specifications
143
-
144
- def _extract_equipment(self, grid: Tag, data: dict[str, Any]) -> None:
145
- equipment_section: Tag | None = None
146
- for section in grid.find_all("section", class_="border-t pt-40 mt-40"):
147
- h2 = section.find("h2", class_="t3 mb-0")
148
- if h2 and "utrustning" in h2.get_text(strip=True).lower():
149
- equipment_section = section
150
- break
151
-
152
- if not equipment_section:
153
- return
154
-
155
- equipment_list = equipment_section.find("ul")
156
- if equipment_list:
157
- equipment_items: list[str] = [
158
- li.get_text(strip=True) for li in equipment_list.find_all("li")
159
- ]
160
- if equipment_items:
161
- data["equipment"] = equipment_items
162
-
163
- def _extract_seller_type(self, soup: BeautifulSoup, data: dict[str, Any]) -> None:
164
- seller_type = (
165
- "dealer"
166
- if soup.find("div", class_=lambda x: x and "dealer" in str(x).lower())
167
- else "private"
168
- )
169
- data["seller_type"] = seller_type
170
-
171
- def _extract_ad_id(self, soup: BeautifulSoup, data: dict[str, Any]) -> None:
172
- ad_info_divs = soup.find_all(
173
- "div", class_="text-m flex md:flex-row flex-col md:gap-x-56 gap-y-16"
174
- )
175
- for div in ad_info_divs:
176
- ad_id_labels = div.find_all("p", class_="s-text-subtle mb-0")
177
- for ad_id_label in ad_id_labels:
178
- if "Annons-ID" in ad_id_label.get_text(strip=True):
179
- ad_id_elem = ad_id_label.find_next_sibling("p")
180
- if ad_id_elem:
181
- data["ad_id"] = ad_id_elem.get_text(strip=True)
182
- break
@@ -1,105 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
- from typing import Any
5
-
6
- import httpx
7
- from httpx import Response
8
-
9
- from .ad_parser import CarAd, RecommerceAd
10
- from .constants import (
11
- HEADERS,
12
- SITE_URL,
13
- CarColor,
14
- CarModel,
15
- CarSortOrder,
16
- CarTransmission,
17
- Category,
18
- Location,
19
- SortOrder,
20
- SubCategory,
21
- )
22
-
23
-
24
- @dataclass(frozen=True)
25
- class QueryParam:
26
- name: str
27
- value: str | int
28
-
29
-
30
- def _request(
31
- *,
32
- url: str,
33
- params: list[QueryParam],
34
- ) -> Response:
35
- response = httpx.get(
36
- url,
37
- headers=HEADERS,
38
- params=[(param.name, param.value) for param in params],
39
- )
40
- response.raise_for_status()
41
- return response
42
-
43
-
44
- @dataclass(frozen=True)
45
- class BlocketAPI:
46
- def search(
47
- self,
48
- query: str,
49
- *,
50
- sort_order: SortOrder = SortOrder.RELEVANCE,
51
- locations: list[Location] = [],
52
- category: Category | None = None,
53
- sub_category: SubCategory | None = None,
54
- ) -> Any:
55
- if category and sub_category:
56
- raise AssertionError("Cannot specify both category and sub_categories")
57
-
58
- url = f"{SITE_URL}/recommerce-search-page/api/search/SEARCH_ID_BAP_COMMON"
59
- params = [
60
- QueryParam("q", query),
61
- QueryParam("sort", sort_order.value),
62
- *[QueryParam("location", location.value) for location in locations],
63
- *([QueryParam("category", category.value)] if category else []),
64
- *([QueryParam("sub_category", sub_category.value)] if sub_category else []),
65
- ]
66
-
67
- return _request(url=url, params=params).json()
68
-
69
- def search_car(
70
- self,
71
- query: str | None = None,
72
- *,
73
- sort_order: CarSortOrder = CarSortOrder.RELEVANCE,
74
- locations: list[Location] = [],
75
- models: list[CarModel] = [],
76
- price_from: int | None = None,
77
- price_to: int | None = None,
78
- year_from: int | None = None,
79
- year_to: int | None = None,
80
- milage_from: int | None = None,
81
- milage_to: int | None = None,
82
- colors: list[CarColor] = [],
83
- transmissions: list[CarTransmission] = [],
84
- ) -> Any:
85
- url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_CAR_USED"
86
- params = [
87
- *([QueryParam("q", query)] if query else []),
88
- QueryParam("sort", sort_order.value),
89
- *[QueryParam("location", location.value) for location in locations],
90
- *[QueryParam("make", model.value) for model in models],
91
- *([QueryParam("price_from", price_from)] if price_from else []),
92
- *([QueryParam("price_to", price_to)] if price_to else []),
93
- *([QueryParam("year_from", year_from)] if year_from else []),
94
- *([QueryParam("year_to", year_to)] if year_to else []),
95
- *([QueryParam("milage_from", milage_from)] if milage_from else []),
96
- *([QueryParam("milage_to", milage_to)] if milage_to else []),
97
- *[QueryParam("exterior_colour", color.value) for color in colors],
98
- *[QueryParam("transmission", t.value) for t in transmissions],
99
- ]
100
-
101
- return _request(url=url, params=params).json()
102
-
103
- def get_ad(self, ad: RecommerceAd | CarAd) -> dict:
104
- response = _request(url=ad.url, params=[])
105
- return ad.parse(response)
File without changes
File without changes