blocket-api 0.3.92__tar.gz → 0.3.93__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.93
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.6
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,26 @@ 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
+
94
+ # get ad details
95
+ from blocket_api import CarAd, RecommerceAd, BoatAd
85
96
 
86
97
  api.get_ad(RecommerceAd(12345678))
87
98
  api.get_ad(CarAd(12345678))
99
+ api.get_ad(BoatAd(12345678))
88
100
  ```
89
101
 
90
102
  ## 📝 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,26 @@ 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
+
72
+ # get ad details
73
+ from blocket_api import CarAd, RecommerceAd, BoatAd
63
74
 
64
75
  api.get_ad(RecommerceAd(12345678))
65
76
  api.get_ad(CarAd(12345678))
77
+ api.get_ad(BoatAd(12345678))
66
78
  ```
67
79
 
68
80
  ## 📝 Notes
@@ -1,7 +1,9 @@
1
+ from .ad_parser import BoatAd as BoatAd
1
2
  from .ad_parser import CarAd as CarAd
2
3
  from .ad_parser import RecommerceAd as RecommerceAd
3
4
  from .blocket import BlocketAPI as BlocketAPI
4
5
  from .blocket import Location as Location
6
+ from .constants import BoatType as BoatType
5
7
  from .constants import CarColor as CarColor
6
8
  from .constants import CarModel as CarModel
7
9
  from .constants import CarSortOrder as CarSortOrder
@@ -0,0 +1,184 @@
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)
@@ -0,0 +1,141 @@
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, 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
+ SortOrder,
21
+ SubCategory,
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class QueryParam:
27
+ name: str
28
+ value: str | int
29
+
30
+
31
+ def _request(*, url: str, params: list[QueryParam]) -> Response:
32
+ response = httpx.get(
33
+ url,
34
+ headers=HEADERS,
35
+ params=[(param.name, param.value) for param in params],
36
+ )
37
+ response.raise_for_status()
38
+ return response
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class BlocketAPI:
43
+ def search(
44
+ self,
45
+ query: str,
46
+ *,
47
+ sort_order: SortOrder = SortOrder.RELEVANCE,
48
+ locations: list[Location] = [],
49
+ category: Category | None = None,
50
+ sub_category: SubCategory | None = None,
51
+ ) -> Any:
52
+ if category and sub_category:
53
+ raise AssertionError("Cannot specify both category and sub_categories")
54
+
55
+ url = f"{SITE_URL}/recommerce-search-page/api/search/SEARCH_ID_BAP_COMMON"
56
+
57
+ param_dict = {
58
+ "q": query,
59
+ "sort": sort_order.value,
60
+ "category": category.value if category else None,
61
+ "sub_category": sub_category.value if sub_category else None,
62
+ }
63
+
64
+ params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None]
65
+
66
+ params.extend(QueryParam("location", loc.value) for loc in locations)
67
+
68
+ return _request(url=url, params=params).json()
69
+
70
+ def search_car(
71
+ self,
72
+ query: str | None = None,
73
+ *,
74
+ sort_order: CarSortOrder = CarSortOrder.RELEVANCE,
75
+ locations: list[Location] = [],
76
+ models: list[CarModel] = [],
77
+ price_from: int | None = None,
78
+ price_to: int | None = None,
79
+ year_from: int | None = None,
80
+ year_to: int | None = None,
81
+ milage_from: int | None = None,
82
+ milage_to: int | None = None,
83
+ colors: list[CarColor] = [],
84
+ transmissions: list[CarTransmission] = [],
85
+ ) -> Any:
86
+ url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_CAR_USED"
87
+
88
+ param_dict = {
89
+ "q": query,
90
+ "sort": sort_order.value,
91
+ "price_from": price_from,
92
+ "price_to": price_to,
93
+ "year_from": year_from,
94
+ "year_to": year_to,
95
+ "milage_from": milage_from,
96
+ "milage_to": milage_to,
97
+ }
98
+
99
+ params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None]
100
+
101
+ # Multi-value params
102
+ params.extend(QueryParam("location", loc.value) for loc in locations)
103
+ params.extend(QueryParam("make", model.value) for model in models)
104
+ params.extend(QueryParam("exterior_colour", color.value) for color in colors)
105
+ params.extend(QueryParam("transmission", t.value) for t in transmissions)
106
+
107
+ return _request(url=url, params=params).json()
108
+
109
+ def search_boat(
110
+ self,
111
+ query: str | None = None,
112
+ *,
113
+ sort_order: CarSortOrder = CarSortOrder.RELEVANCE,
114
+ types: list[BoatType] = [],
115
+ locations: list[Location] = [],
116
+ price_from: int | None = None,
117
+ price_to: int | None = None,
118
+ length_from: int | None = None,
119
+ length_to: int | None = None,
120
+ ) -> Any:
121
+ url = f"{SITE_URL}/mobility/search/api/search/SEARCH_ID_BOAT_USED"
122
+
123
+ param_dict = {
124
+ "q": query,
125
+ "sort": sort_order.value,
126
+ "price_from": price_from,
127
+ "price_to": price_to,
128
+ "length_feet_from": length_from,
129
+ "length_feet_to": length_to,
130
+ }
131
+
132
+ params = [QueryParam(k, v) for k, v in param_dict.items() if v is not None]
133
+
134
+ params.extend(QueryParam("class", t.value) for t in types)
135
+ params.extend(QueryParam("location", loc.value) for loc in locations)
136
+
137
+ return _request(url=url, params=params).json()
138
+
139
+ def get_ad(self, ad: RecommerceAd | CarAd | BoatAd) -> dict:
140
+ response = _request(url=ad.url, params=[])
141
+ 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,22 @@ class CarSortOrder(StrEnum):
29
29
  YEAR_ASC = "YEAR_ASC"
30
30
 
31
31
 
32
+ class BoatSortOrder(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
+ # boat specific
40
+ LENGTH_DESC = "LENGTH_DESC"
41
+ LENGTH_ASC = "LENGTH_ASC"
42
+ SPEED_DESC = "SPEED_DESC"
43
+ SPEED_ASC = "SPEED_ASC"
44
+ YEAR_DESC = "YEAR_DESC"
45
+ YEAR_ASC = "YEAR_ASC"
46
+
47
+
32
48
  class Location(StrEnum):
33
49
  BLEKINGE = "0.300010"
34
50
  DALARNA = "0.300020"
@@ -348,3 +364,20 @@ class CarColor(IntEnum):
348
364
  class CarTransmission(IntEnum):
349
365
  AUTOMATIC = 2
350
366
  MANUAL = 1
367
+
368
+
369
+ class BoatType(IntEnum):
370
+ BOWRIDER = 7961
371
+ DAYCRUISER = 2184
372
+ FISKEBAT_ARBETSBAT = 6921
373
+ HYTTBAT = 7962
374
+ KABINBAT = 6923
375
+ POWERBOAT = 7960
376
+ RIB = 7343
377
+ SEGELBAT_MOTORSEGLARE = 2188
378
+ SMABAT_GUMMIBAT = 2186
379
+ SNIPA = 6922
380
+ STYRPULPETBAT = 3827
381
+ VATTENSKOTER = 2190
382
+ YACHT = 7959
383
+ ANNAT = 2258
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: blocket_api
3
- Version: 0.3.92
3
+ Version: 0.3.93
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.6
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,26 @@ 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
+
94
+ # get ad details
95
+ from blocket_api import CarAd, RecommerceAd, BoatAd
85
96
 
86
97
  api.get_ad(RecommerceAd(12345678))
87
98
  api.get_ad(CarAd(12345678))
99
+ api.get_ad(BoatAd(12345678))
88
100
  ```
89
101
 
90
102
  ## 📝 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.6
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "blocket_api"
3
- version = "0.3.92"
3
+ version = "0.3.93"
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.6",
21
21
  ]
22
22
 
23
23
 
@@ -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