From 5f68c0989915d1e143ad31b6a14659d8360d7f55 Mon Sep 17 00:00:00 2001 From: Bjoern147 <43368639+bjspi@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:03:31 +0100 Subject: [PATCH] feat: Improved WebSelect Handling: Added Combobox Support, Enhanced Element Detection, and Smarter Option Matching (#679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ℹ️ Description Added Webselect-Function for Input/Dropdown Combobox PR for issue/missing feature #677 # Fixes / Enhancements Finding Special Attributes Elements can fail because they are currently only selected using the name="..." attributes of the HTML elements. If it fails, ALSO fallback-handle selecting special attribute HTML elements by ID instead / additionally. (For example the "brands" Input/Combobox for Mens Shoes... When trying to select a Value in a Option Matching Logic This improves UX and test robustness — users no longer need to know the exact underlying value, as matching also works with the visible label shown in the browser. 🧩 Result These updates make dropdown and combobox interactions more intuitive, resilient, and user-friendly across diverse HTML structures. ### ⚙️ Type of Change Select the type(s) of change(s) included in this pull request: - [x] 🐞 Bug fix (non-breaking change which fixes an issue) - [x] ✨ New feature (adds new functionality without breaking existing usage) - [ ] 💥 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. - [ ] 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. ## Summary by CodeRabbit * **Bug Fixes** * Field lookup now falls back to locating by ID when name lookup times out. * Option selection uses a two-pass match (value then displayed text); JS-path failures now surface as timeouts. * Error and log messages localized and clarified. * **New Features** * Support for combobox-style inputs: type into the input, open dropdown, and select by visible text (handles special characters). * **Tests** * Added tests for combobox selection, missing dropdowns, no-match errors, value-path selection, and special-character handling. --------- Co-authored-by: Jens <1742418+1cu@users.noreply.github.com> Co-authored-by: Claude --- src/kleinanzeigen_bot/__init__.py | 23 ++-- .../resources/translations.de.yaml | 12 +- .../utils/web_scraping_mixin.py | 113 ++++++++++++++++-- tests/unit/test_web_scraping_mixin.py | 113 ++++++++++++++++++ 4 files changed, 239 insertions(+), 22 deletions(-) diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 63452de..e912f6e 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -1068,24 +1068,31 @@ class KleinanzeigenBot(WebScrapingMixin): try: # finding element by name cause id are composed sometimes eg. autos.marke_s+autos.model_s for Modell by cars special_attr_elem = await self.web_find(By.XPATH, f"//*[contains(@name, '{special_attribute_key}')]") - except TimeoutError as ex: - LOG.debug("Attribute field '%s' could not be found.", special_attribute_key) - raise TimeoutError(f"Failed to set special attribute [{special_attribute_key}] (not found)") from ex + except TimeoutError: + # Trying to find element by ID instead cause sometimes there is NO name attribute... + try: + special_attr_elem = await self.web_find(By.ID, special_attribute_key) + except TimeoutError as ex: + LOG.debug(_("Attribute field '%s' could not be found."), special_attribute_key) + raise TimeoutError(_("Failed to set attribute '%s'") % special_attribute_key) from ex try: elem_id:str = str(special_attr_elem.attrs.id) if special_attr_elem.local_name == "select": - LOG.debug("Attribute field '%s' seems to be a select...", special_attribute_key) + LOG.debug(_("Attribute field '%s' seems to be a select..."), special_attribute_key) await self.web_select(By.ID, elem_id, special_attribute_value_str) elif special_attr_elem.attrs.type == "checkbox": - LOG.debug("Attribute field '%s' seems to be a checkbox...", special_attribute_key) + LOG.debug(_("Attribute field '%s' seems to be a checkbox..."), special_attribute_key) await self.web_click(By.ID, elem_id) + elif special_attr_elem.attrs.type == "text" and special_attr_elem.attrs.get("role") == "combobox": + LOG.debug(_("Attribute field '%s' seems to be a Combobox (i.e. text input with filtering dropdown)..."), special_attribute_key) + await self.web_select_combobox(By.ID, elem_id, special_attribute_value_str) else: - LOG.debug("Attribute field '%s' seems to be a text input...", special_attribute_key) + LOG.debug(_("Attribute field '%s' seems to be a text input..."), special_attribute_key) await self.web_input(By.ID, elem_id, special_attribute_value_str) except TimeoutError as ex: - LOG.debug("Attribute field '%s' is not of kind radio button.", special_attribute_key) - raise TimeoutError(f"Failed to set special attribute [{special_attribute_key}]") from ex + LOG.debug(_("Failed to set attribute field '%s' via known input types."), special_attribute_key) + raise TimeoutError(_("Failed to set attribute '%s'") % special_attribute_key) from ex LOG.debug("Successfully set attribute field [%s] to [%s]...", special_attribute_key, special_attribute_value_str) async def __set_shipping(self, ad_cfg:Ad, mode:AdUpdateStrategy = AdUpdateStrategy.REPLACE) -> None: diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index c1f7f35..ece3787 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -121,10 +121,12 @@ kleinanzeigen_bot/__init__.py: "Setting special attribute [%s] to [%s]...": "Setze spezielles Attribut [%s] auf [%s]..." "Successfully set attribute field [%s] to [%s]...": "Attributfeld [%s] erfolgreich auf [%s] gesetzt..." "Attribute field '%s' could not be found.": "Attributfeld '%s' konnte nicht gefunden werden." + "Failed to set attribute '%s'": "Fehler beim Setzen des Attributs '%s'" "Attribute field '%s' seems to be a select...": "Attributfeld '%s' scheint ein Auswahlfeld zu sein..." - "Attribute field '%s' is not of kind radio button.": "Attributfeld '%s' ist kein Radiobutton." + "Failed to set attribute field '%s' via known input types.": "Fehler beim Setzen des Attributfelds '%s' über bekannte Eingabetypen." "Attribute field '%s' seems to be a checkbox...": "Attributfeld '%s' scheint eine Checkbox zu sein..." "Attribute field '%s' seems to be a text input...": "Attributfeld '%s' scheint ein Texteingabefeld zu sein..." + "Attribute field '%s' seems to be a Combobox (i.e. text input with filtering dropdown)...": "Attributfeld '%s' scheint eine Combobox zu sein (d.h. Texteingabefeld mit Dropdown-Filter)..." download_ads: "Scanning your ad overview...": "Scanne Anzeigenübersicht..." @@ -403,6 +405,14 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py: web_check: "Unsupported attribute: %s": "Nicht unterstütztes Attribut: %s" + web_select: + "Option not found by value or displayed text: %s": "Option nicht gefunden nach Wert oder angezeigtem Text: %s" + + web_select_combobox: + "Combobox input field does not have 'aria-controls' attribute.": "Das Eingabefeld der Combobox hat kein 'aria-controls'-Attribut." + "Combobox missing aria-controls attribute": "Combobox fehlt aria-controls Attribut" + "No matching option found in combobox: '%s'": "Keine passende Option in Combobox gefunden: '%s'" + close_browser_session: "Closing Browser session...": "Schließe Browser-Sitzung..." diff --git a/src/kleinanzeigen_bot/utils/web_scraping_mixin.py b/src/kleinanzeigen_bot/utils/web_scraping_mixin.py index dae5be1..56eb528 100644 --- a/src/kleinanzeigen_bot/utils/web_scraping_mixin.py +++ b/src/kleinanzeigen_bot/utils/web_scraping_mixin.py @@ -969,23 +969,110 @@ class WebScrapingMixin: lambda: self.web_check(selector_type, selector_value, Is.CLICKABLE), timeout = timeout, timeout_error_message = f"No clickable HTML element with selector: {selector_type}='{selector_value}' found" ) - elem = await self.web_find(selector_type, selector_value) - await elem.apply(f""" - function (element) {{ - for(let i=0; i < element.options.length; i++) - {{ - if(element.options[i].value == "{selected_value}") {{ - element.selectedIndex = i; - element.dispatchEvent(new Event('change', {{ bubbles: true }})); - break; + elem = await self.web_find(selector_type, selector_value, timeout = timeout) + + js_value = json.dumps(selected_value) # safe escaping for JS + try: + await elem.apply(f""" + function (element) {{ + const wanted = String({js_value}); + + // 1) Try by value + for (let i = 0; i < element.options.length; i++) {{ + if (element.options[i].value === wanted) {{ + element.selectedIndex = i; + element.dispatchEvent(new Event('change', {{ bubbles: true }})); + return; + }} + }} + + // 2) Fallback by displayed text (trimmed) + const needle = wanted.trim(); + for (let i = 0; i < element.options.length; i++) {{ + const opt = element.options[i]; + const shown = (opt.label ?? opt.text ?? opt.textContent ?? '').trim(); + if (shown === needle) {{ + element.selectedIndex = i; + element.dispatchEvent(new Event('change', {{ bubbles: true }})); + return; + }} + }} + + throw new Error("Option not found by value or displayed text: " + wanted); }} - }} - throw new Error("Option with value {selected_value} not found."); - }} - """) + """) + except Exception as ex: + # Normalize selection failures to TimeoutError + raise TimeoutError(_("Option not found by value or displayed text: %s") % selected_value) from ex await self.web_sleep() return elem + async def web_select_combobox(self, selector_type:By, selector_value:str, selected_value:str | int, timeout:int | float | None = None) -> Element: + """ + Selects an option from a text-input combobox by typing the given value to + filter the dropdown and clicking the first
  • whose visible text matches. + Returns the dropdown