Skip to content

Commit 36ca178

Browse files
authored
feat: upgrade nodriver from 0.39 to 0.47 (#635)
## ℹ️ Description Upgrade nodriver dependency from pinned version 0.39.0 to latest 0.47.0 to resolve browser startup issues and JavaScript evaluation problems that affected versions 0.40-0.44. - Link to the related issue(s): Resolves nodriver compatibility issues - This upgrade addresses browser startup problems and window.BelenConf evaluation failures that were blocking the use of newer nodriver versions. ## 📋 Changes Summary - Updated nodriver dependency from pinned 0.39.0 to >=0.47.0 in pyproject.toml - Fixed RemoteObject handling in web_execute method for nodriver 0.47 compatibility - Added comprehensive BelenConf test fixture with real production data structure - Added integration test to validate window.BelenConf evaluation works correctly - Added German translation for new error message - Replaced real user data with privacy-safe dummy data in test fixtures ### 🔧 Type Safety Improvements **Added explicit `str()` conversions to resolve type inference issues:** The comprehensive BelenConf test fixture contains deeply nested data structures that caused pyright's type checker to infer complex dictionary types throughout the codebase. To ensure type safety and prevent runtime errors, I added explicit `str()` conversions in key locations: - **CSRF tokens**: `str(csrf_token)` - Ensures CSRF tokens are treated as strings - **Special attributes**: `str(special_attribute_value)` - Converts special attribute values to strings - **DOM attributes**: `str(special_attr_elem.attrs.id)` - Ensures element IDs are strings - **URL handling**: `str(current_img_url)` and `str(href_attributes)` - Converts URLs and href attributes to strings - **Price values**: `str(ad_cfg.price)` - Ensures price values are strings These conversions are defensive programming measures that ensure backward compatibility and prevent type-related runtime errors, even if the underlying data structures change in the future. ### ⚙️ Type of Change - [x] ✨ New feature (adds new functionality without breaking existing usage) - [ ] 🐞 Bug fix (non-breaking change which fixes an issue) - [ ] 💥 Breaking change (changes that might break existing user setups, scripts, or configurations) ## ✅ Checklist Before requesting a review, confirm the following: - [x] I have reviewed my changes to ensure they meet the project's standards. - [x] I have tested my changes and ensured that all tests pass (`pdm run test`). - [x] I have formatted the code (`pdm run format`). - [x] I have verified that linting passes (`pdm run lint`). - [x] I have updated documentation where necessary. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent a2745c0 commit 36ca178

File tree

12 files changed

+526
-21
lines changed

12 files changed

+526
-21
lines changed

pdm.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ dependencies = [
3838
"certifi",
3939
"colorama",
4040
"jaraco.text", # required by pkg_resources during runtime
41-
"nodriver==0.39.0", # 0.40-0.44 have issues starting browsers and evaluating self.web_execute("window.BelenConf") fails
41+
"nodriver>=0.47.0", # Updated from 0.39.0 - 0.40-0.44 had issues starting browsers and evaluating self.web_execute("window.BelenConf") fails
4242
"pydantic>=2.0.0",
4343
"ruamel.yaml",
4444
"psutil",
@@ -366,6 +366,10 @@ data_file = ".temp/coverage.sqlite"
366366
branch = true # track branch coverage
367367
relative_files = true
368368

369+
[tool.coverage.report]
370+
precision = 2
371+
show_missing = true
372+
skip_covered = false
369373

370374
#####################
371375
# yamlfix

src/kleinanzeigen_bot/__init__.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -690,13 +690,13 @@ async def delete_ad(self, ad_cfg:Ad, published_ads:list[dict[str, Any]], *, dele
690690
await self.web_request(
691691
url = f"{self.root_url}/m-anzeigen-loeschen.json?ids={published_ad_id}",
692692
method = "POST",
693-
headers = {"x-csrf-token": csrf_token}
693+
headers = {"x-csrf-token": str(csrf_token)}
694694
)
695695
elif ad_cfg.id:
696696
await self.web_request(
697697
url = f"{self.root_url}/m-anzeigen-loeschen.json?ids={ad_cfg.id}",
698698
method = "POST",
699-
headers = {"x-csrf-token": csrf_token},
699+
headers = {"x-csrf-token": str(csrf_token)},
700700
valid_response_codes = [200, 404]
701701
)
702702

@@ -1048,12 +1048,14 @@ async def __set_special_attributes(self, ad_cfg:Ad) -> None:
10481048

10491049
LOG.debug("Found %i special attributes", len(ad_cfg.special_attributes))
10501050
for special_attribute_key, special_attribute_value in ad_cfg.special_attributes.items():
1051+
# Ensure special_attribute_value is treated as a string
1052+
special_attribute_value_str = str(special_attribute_value)
10511053

10521054
if special_attribute_key == "condition_s":
1053-
await self.__set_condition(special_attribute_value)
1055+
await self.__set_condition(special_attribute_value_str)
10541056
continue
10551057

1056-
LOG.debug("Setting special attribute [%s] to [%s]...", special_attribute_key, special_attribute_value)
1058+
LOG.debug("Setting special attribute [%s] to [%s]...", special_attribute_key, special_attribute_value_str)
10571059
try:
10581060
# if the <select> element exists but is inside an invisible container, make the container visible
10591061
select_container_xpath = f"//div[@class='l-row' and descendant::select[@id='{special_attribute_key}']]"
@@ -1070,20 +1072,20 @@ async def __set_special_attributes(self, ad_cfg:Ad) -> None:
10701072
raise TimeoutError(f"Failed to set special attribute [{special_attribute_key}] (not found)") from ex
10711073

10721074
try:
1073-
elem_id = special_attr_elem.attrs.id
1075+
elem_id:str = str(special_attr_elem.attrs.id)
10741076
if special_attr_elem.local_name == "select":
10751077
LOG.debug("Attribute field '%s' seems to be a select...", special_attribute_key)
1076-
await self.web_select(By.ID, elem_id, special_attribute_value)
1078+
await self.web_select(By.ID, elem_id, special_attribute_value_str)
10771079
elif special_attr_elem.attrs.type == "checkbox":
10781080
LOG.debug("Attribute field '%s' seems to be a checkbox...", special_attribute_key)
10791081
await self.web_click(By.ID, elem_id)
10801082
else:
10811083
LOG.debug("Attribute field '%s' seems to be a text input...", special_attribute_key)
1082-
await self.web_input(By.ID, elem_id, special_attribute_value)
1084+
await self.web_input(By.ID, elem_id, special_attribute_value_str)
10831085
except TimeoutError as ex:
10841086
LOG.debug("Attribute field '%s' is not of kind radio button.", special_attribute_key)
10851087
raise TimeoutError(f"Failed to set special attribute [{special_attribute_key}]") from ex
1086-
LOG.debug("Successfully set attribute field [%s] to [%s]...", special_attribute_key, special_attribute_value)
1088+
LOG.debug("Successfully set attribute field [%s] to [%s]...", special_attribute_key, special_attribute_value_str)
10871089

10881090
async def __set_shipping(self, ad_cfg:Ad, mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None:
10891091
if ad_cfg.shipping_type == "PICKUP":

src/kleinanzeigen_bot/extract.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ async def _download_images_from_ad_page(self, directory:str, ad_id:int) -> list[
8585
if current_img_url is None:
8686
continue
8787

88-
with urllib_request.urlopen(current_img_url) as response: # noqa: S310 Audit URL open for permitted schemes.
88+
with urllib_request.urlopen(str(current_img_url)) as response: # noqa: S310 Audit URL open for permitted schemes.
8989
content_type = response.info().get_content_type()
9090
file_ending = mimetypes.guess_extension(content_type)
9191
img_path = f"{directory}/{img_fn_prefix}{img_nr}{file_ending}"
@@ -185,8 +185,8 @@ async def extract_own_ads_urls(self) -> list[str]:
185185

186186
# Extract references using the CORRECTED selector
187187
try:
188-
page_refs = [
189-
(await self.web_find(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent = li)).attrs["href"]
188+
page_refs:list[str] = [
189+
str((await self.web_find(By.CSS_SELECTOR, "div h3 a.text-onSurface", parent = li)).attrs["href"])
190190
for li in list_items
191191
]
192192
refs.extend(page_refs)
@@ -405,13 +405,15 @@ async def _extract_category_from_ad_page(self) -> str:
405405
category_line = await self.web_find(By.ID, "vap-brdcrmb")
406406
category_first_part = await self.web_find(By.CSS_SELECTOR, "a:nth-of-type(2)", parent = category_line)
407407
category_second_part = await self.web_find(By.CSS_SELECTOR, "a:nth-of-type(3)", parent = category_line)
408-
cat_num_first = category_first_part.attrs["href"].rsplit("/", maxsplit = 1)[-1][1:]
409-
cat_num_second = category_second_part.attrs["href"].rsplit("/", maxsplit = 1)[-1][1:]
408+
href_first:str = str(category_first_part.attrs["href"])
409+
href_second:str = str(category_second_part.attrs["href"])
410+
cat_num_first = href_first.rsplit("/", maxsplit = 1)[-1][1:]
411+
cat_num_second = href_second.rsplit("/", maxsplit = 1)[-1][1:]
410412
category:str = cat_num_first + "/" + cat_num_second
411413

412414
return category
413415

414-
async def _extract_special_attributes_from_ad_page(self, belen_conf:dict[str, Any]) -> dict[str, Any]:
416+
async def _extract_special_attributes_from_ad_page(self, belen_conf:dict[str, Any]) -> dict[str, str]:
415417
"""
416418
Extracts the special attributes from an ad page.
417419
If no items are available then special_attributes is empty

src/kleinanzeigen_bot/resources/translations.de.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,9 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py:
391391
"4. Check browser binary permissions: %s": "4. Überprüfen Sie die Browser-Binärdatei-Berechtigungen: %s"
392392
"4. Check if any antivirus or security software is blocking the connection": "4. Überprüfen Sie, ob Antiviren- oder Sicherheitssoftware die Verbindung blockiert"
393393

394+
web_execute:
395+
"Failed to convert RemoteObject to dict: %s": "Fehler beim Konvertieren von RemoteObject zu dict: %s"
396+
394397
web_check:
395398
"Unsupported attribute: %s": "Nicht unterstütztes Attribut: %s"
396399

src/kleinanzeigen_bot/utils/web_scraping_mixin.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,24 @@ async def web_execute(self, jscode:str) -> Any:
562562
LOG.debug("web_execute(`%s`) = `%s`", jscode, result)
563563
self.__class__.web_execute._prev_jscode = jscode # type: ignore[attr-defined] # noqa: SLF001 Private member accessed
564564

565+
# Handle nodriver 0.47+ RemoteObject behavior
566+
# If result is a RemoteObject with deep_serialized_value, convert it to a dict
567+
if hasattr(result, "deep_serialized_value"):
568+
deep_serialized = getattr(result, "deep_serialized_value", None)
569+
if deep_serialized is not None:
570+
try:
571+
# Convert the deep_serialized_value to a regular dict
572+
serialized_data = getattr(deep_serialized, "value", None)
573+
if serialized_data is not None:
574+
if isinstance(serialized_data, list):
575+
# Convert list of [key, value] pairs to dict
576+
return dict(serialized_data)
577+
return serialized_data
578+
except (AttributeError, TypeError, ValueError) as e:
579+
LOG.warning("Failed to convert RemoteObject to dict: %s", e)
580+
# Return the original result if conversion fails
581+
return result
582+
565583
return result
566584

567585
async def web_find(self, selector_type:By, selector_value:str, *, parent:Element | None = None, timeout:int | float = 5) -> Element:

tests/conftest.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
22
# SPDX-License-Identifier: AGPL-3.0-or-later
33
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
4+
import json
45
import os
6+
from pathlib import Path
57
from typing import Any, Final, cast
68
from unittest.mock import MagicMock
79

@@ -179,6 +181,22 @@ def mock_web_text_responses() -> list[str]:
179181
]
180182

181183

184+
@pytest.fixture
185+
def belen_conf_sample() -> dict[str, Any]:
186+
"""Provides sample BelenConf data for testing JavaScript evaluation.
187+
188+
This fixture loads the BelenConf sample data from the fixtures directory,
189+
allowing tests to validate window.BelenConf evaluation without accessing
190+
kleinanzeigen.de directly.
191+
"""
192+
fixtures_dir = Path(__file__).parent / "fixtures"
193+
belen_conf_path = fixtures_dir / "belen_conf_sample.json"
194+
195+
with open(belen_conf_path, "r", encoding = "utf-8") as f:
196+
data = json.load(f)
197+
return cast(dict[str, Any], data)
198+
199+
182200
@pytest.fixture(autouse = True)
183201
def silence_nodriver_logs() -> None:
184202
loggers.get_logger("nodriver").setLevel(loggers.WARNING)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
{
2+
"jsBaseUrl": "https://static.kleinanzeigen.de/static/js",
3+
"isBrowse": "false",
4+
"isProd": true,
5+
"initTime": 1704067200000,
6+
"universalAnalyticsOpts": {
7+
"account": "UA-24356365-9",
8+
"domain": "kleinanzeigen.de",
9+
"userId": "dummy_user_id_1234567890abcdef12",
10+
"dimensions": {
11+
"dimension1": "MyAds",
12+
"dimension2": "",
13+
"dimension3": "",
14+
"dimension6": "",
15+
"dimension7": "",
16+
"dimension8": "",
17+
"dimension9": "",
18+
"dimension10": "",
19+
"dimension11": "",
20+
"dimension12": "",
21+
"dimension13": "",
22+
"dimension15": "de_DE",
23+
"dimension20": "dummy_user_id_1234567890abcdefgh",
24+
"dimension21": "dummy_encrypted_token_abcdef1234567890/1234567890abcdefgh+ijkl=lmnopqrstuvwxyz01234567==",
25+
"dimension23": "true",
26+
"dimension24": "private",
27+
"dimension25": "0031_A|0042_A|0021_A|0030_A|0006_B|0028_A|0029_B|0007_C|0037_B|0026_B|0004_A|0005_A|0002_B|0036_B|0058_A|0003_B|0011_R|0022_B|0044_B|0012_B|0023_A|60_A|0008_B",
28+
"dimension28": "distribution_test-c;yo_s-A;liberty-experimental-DEFAULT;liberty-experimental-2-DEFAULT;Lib_E;",
29+
"dimension50": "(NULL)",
30+
"dimension53": "",
31+
"dimension90": "",
32+
"dimension91": "",
33+
"dimension94": "",
34+
"dimension95": "",
35+
"dimension96": "",
36+
"dimension97": "",
37+
"dimension121": "registered",
38+
"dimension125": "distribution_test-c",
39+
"dimension128": "yo_s-A",
40+
"dimension130": "liberty-experimental-DEFAULT",
41+
"dimension131": "liberty-experimental-2-DEFAULT",
42+
"dimension135": "Lib_E",
43+
"dimension136": "PRIVATE"
44+
},
45+
"extraDimensions": {
46+
"dimension73": "1"
47+
},
48+
"sendPageView": true
49+
},
50+
"tnsPhoneVerificationBundleUrl": "https://www.kleinanzeigen.de/bffstatic/tns-phone-verification-web/tns-phone-verification-web-bundle.js",
51+
"labs": {
52+
"activeExperiments": {
53+
"BLN-25381-ka-offboarding": "B",
54+
"BLN-23248_BuyNow_SB": "B",
55+
"BLN-22726_buyer_banner": "B",
56+
"BLN-25958-greensunday": "A",
57+
"EKTP-2111-page-extraction": "B",
58+
"KARE-1015-Cont-Highlights": "B",
59+
"FLPRO-130-churn-reason": "B",
60+
"EKMO-100_reorder_postad": "B",
61+
"BLN-27366_mortgage_sim": "A",
62+
"KLUE-274-financing": "B",
63+
"lws-aws-traffic": "B",
64+
"SPEX-1052-ads-feedback": "B",
65+
"BLN-24652_category_alert": "B",
66+
"FLPRO-753-motors-fee": "B",
67+
"BLN-21783_testingtime": "B",
68+
"EBAYKAD-2252_group-assign": "A",
69+
"liberty-experiment-style": "A",
70+
"PRO-leads-feedback": "A",
71+
"SPEX-1077-adfree-sub": "D",
72+
"BLN-26740_enable_drafts": "B",
73+
"ka-follower-network": "B",
74+
"EKPAY-3287-counter-offer": "B",
75+
"PLC-189_plc-migration": "A",
76+
"EKMO-271_mweb": "A",
77+
"audex-libertyjs-update": "A",
78+
"performance-test-desktop": "B",
79+
"BLN-26541-radius_feature": "A",
80+
"EKPAY-3409-hermes-heavy": "A",
81+
"SPEX-1077-adfree-sub-tech": "B",
82+
"EKMO-243_MyAdsC2b_ABC": "C",
83+
"Pro-Business-Hub": "A",
84+
"fp_pla_desktop": "A",
85+
"SPEX-1250_prebid_gpid": "B",
86+
"prebid-update": "A",
87+
"EKPAY-4088-negotiation": "B",
88+
"desktop_payment_badge_SRP": "R",
89+
"BLN-23401_buyNow_in_chat": "B",
90+
"BLN-18532_highlight": "B",
91+
"cmp-equal-choice": "B",
92+
"BLN-27207_checkout_page": "B",
93+
"I2I-homepage-trendsetter": "A",
94+
"ignite_web_better_session": "C",
95+
"EBAYKAD-3536_floor_ai": "B",
96+
"ignite_improve_session": "C",
97+
"EKPAY-3214-NudgeBanner": "A",
98+
"BLN-24684-enc-brndg-data": "A",
99+
"BLN-25794-watchlist-feed": "B",
100+
"PRPL-252_ces_postad": "A",
101+
"BLN-25659-car-financing": "B",
102+
"EKPAY-3370_klarna_hide": "A",
103+
"AUDEX-519_pb_ortb_cfg": "B",
104+
"BLN-26398_stepstone_link": "B",
105+
"BLN-25450_Initial_message": "A",
106+
"cmp-leg-int": "B",
107+
"audex-awr-update": "A",
108+
"BLN-25216-new-user-badges": "B",
109+
"KAD-333_dominant_category": "B",
110+
"EKPAY-4460-kyc-entrypoint": "A",
111+
"BLN-27350_plc_rollback": "B",
112+
"BLN-25556_INIT_MSG_V2": "B",
113+
"KARE-1294_private_label": "B",
114+
"SPEX-1529_adnami-script": "A",
115+
"DESKTOP-promo-switch": "A",
116+
"EKPAY-3478-buyer-dispute": "A",
117+
"FLPRO-693-ad-duplication": "B",
118+
"BLN-27554_lds_kaos_test": "B",
119+
"BLN-26961": "C",
120+
"BIPHONE-9700_buy_now": "B",
121+
"EKPAY-3336-interstial_grp": "A",
122+
"BLN-27261_smava_provider": "A",
123+
"10149_desktop_offboarding": "B",
124+
"SPEX-1504-confiant": "A",
125+
"PLC-104_plc-login": "B"
126+
}
127+
}
128+
}

0 commit comments

Comments
 (0)