Skip to content
13 changes: 10 additions & 3 deletions src/kleinanzeigen_bot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1059,9 +1059,13 @@ async def __set_special_attributes(self, ad_cfg:Ad) -> None:
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(f"Failed to set special attribute by either ID or Name [{special_attribute_key}] (not found)") from ex

try:
elem_id:str = str(special_attr_elem.attrs.id)
Expand All @@ -1071,6 +1075,9 @@ async def __set_special_attributes(self, ad_cfg:Ad) -> None:
elif special_attr_elem.attrs.type == "checkbox":
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)
await self.web_input(By.ID, elem_id, special_attribute_value_str)
Expand Down
7 changes: 7 additions & 0 deletions src/kleinanzeigen_bot/resources/translations.de.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ kleinanzeigen_bot/__init__.py:
"Attribute field '%s' is not of kind radio button.": "Attributfeld '%s' ist kein Radiobutton."
"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..."
Expand Down Expand Up @@ -401,8 +402,14 @@ kleinanzeigen_bot/utils/web_scraping_mixin.py:
web_find:
"Unsupported selector type: %s": "Nicht unterstützter Selektor-Typ: %s"

web_select_combobox:
"Combobox input field does not have 'aria-controls' attribute to locate dropdown options.": "Das Eingabefeld der Combobox hat kein 'aria-controls'-Attribut, um Dropdown-Optionen zu finden."
"Cannot locate combobox dropdown options.": "Kann Combobox-Dropdown-Optionen nicht finden."
"No <li> options found in combobox dropdown with id '%s'.": "Keine <li>-Optionen im Combobox-Dropdown mit der ID '%s' gefunden."

web_find_all:
"Unsupported selector type: %s": "Nicht unterstützter Selektor-Typ: %s"

close_browser_session:
"Closing Browser session...": "Schließe Browser-Sitzung..."

Expand Down
95 changes: 87 additions & 8 deletions src/kleinanzeigen_bot/utils/web_scraping_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,22 +848,101 @@ async def web_select(self, selector_type:By, selector_value:str, selected_value:
timeout_error_message = f"No clickable HTML element with selector: {selector_type}='{selector_value}' found"
)
elem = await self.web_find(selector_type, selector_value)

js_value = json.dumps(selected_value) # safe escaping for JS
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;
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 with value {selected_value} not found.");

throw new Error("Option not found by value or displayed text: " + wanted);
}}
""")
await self.web_sleep()
return elem

async def web_select_combobox(self, selector_type:By, selector_value:str, selected_value:Any, timeout:int | float = 5) -> Element:
"""
Selects an <li> Option of a <input/> HTML Combobox element by visible text.

:param timeout: timeout in seconds
:raises TimeoutError: if element could not be found within time
:raises UnexpectedTagNameException: if element is not a <select> element
"""
input_field = await self.web_find(selector_type, selector_value, timeout = timeout)
await input_field.clear_input()
await input_field.send_keys(str(selected_value))
await self.web_sleep()

# From the Inputfield, get the attribute "aria-controls" which POINTS to the Dropdown ul #id:
dropdown_id = input_field.attrs.get("aria-controls")
if not dropdown_id:
LOG.error(_("Combobox input field does not have 'aria-controls' attribute to locate dropdown options."))
raise TimeoutError(_("Cannot locate combobox dropdown options."))

dropdown_elem = await self.web_find(By.ID, dropdown_id)
js_value = json.dumps(selected_value) # safe escaping for JS

# This selects the correct <li> by visible text inside the dropdown. It includes normalization, i.e. trimming
# leading/trailing spaces and collapsing multiple spaces to single spaces for matching. It is done case-insensitive.
ok = await dropdown_elem.apply(f"""
function (element) {{
const selected = String({js_value});
const normalize = s => (s ?? '').replace(/\\s+/g, ' ').trim().toLowerCase();
// Normalize whitespace and convert to lowercase for comparison

// Get all <li> elements inside the dropdown
const items = element.querySelectorAll(':scope > li[role="option"], :scope > li');

for (const li of items) {{
// The visible label is typically inside the last <span>
const labelEl = li.querySelector(':scope > span:last-of-type');
const label = normalize(labelEl ? labelEl.textContent : li.textContent);

// Compare normalized lowercase values
if (label === normalize(selected)) {{
// Scroll to make sure the element is visible
try {{
li.scrollIntoView({{block: 'nearest'}});
}} catch (e) {{}}

// Click the matched element
li.click();
return true;
}}
}}

// Return false if no matching item was found
return false;
}}
""")
if not ok:
LOG.error(_("No <li> options found in combobox dropdown with id '%s'."), dropdown_id)
raise TimeoutError(_("Cannot locate combobox dropdown options."))

await self.web_sleep()
return dropdown_elem

async def _validate_chrome_version_configuration(self) -> None:
"""
Validate Chrome version configuration for Chrome 136+ security requirements.
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/test_web_scraping_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,62 @@ async def test_web_input_clear_failure(self, web_scraper:WebScrapingMixin, mock_
with pytest.raises(Exception, match = "Cannot clear input"):
await web_scraper.web_input(By.ID, "test-id", "test text")

@pytest.mark.asyncio
async def test_web_select_combobox_missing_dropdown_options(self, web_scraper: WebScrapingMixin) -> None:
"""Test combobox selection when aria-controls attribute is missing."""
input_field = AsyncMock(spec=Element)
input_field.attrs = {}
input_field.clear_input = AsyncMock()
input_field.send_keys = AsyncMock()
web_scraper.web_find = AsyncMock(return_value=input_field)

with pytest.raises(TimeoutError, match="Cannot locate combobox dropdown options."):
await web_scraper.web_select_combobox(By.ID, "combo-id", "Option", timeout=0.1)

input_field.clear_input.assert_awaited_once()
input_field.send_keys.assert_awaited_once_with("Option")

@pytest.mark.asyncio
async def test_web_select_combobox_selects_matching_option(self, web_scraper: WebScrapingMixin) -> None:
"""Test combobox selection matches a visible <li> option."""
input_field = AsyncMock(spec=Element)
input_field.attrs = {"aria-controls": "dropdown-id"}
input_field.clear_input = AsyncMock()
input_field.send_keys = AsyncMock()

dropdown_elem = AsyncMock(spec=Element)
dropdown_elem.apply = AsyncMock(return_value=True)

web_scraper.web_find = AsyncMock(side_effect=[input_field, dropdown_elem])
web_scraper.web_sleep = AsyncMock()

result = await web_scraper.web_select_combobox(By.ID, "combo-id", "Visible Label")

assert result is dropdown_elem
input_field.clear_input.assert_awaited_once()
input_field.send_keys.assert_awaited_once_with("Visible Label")
dropdown_elem.apply.assert_awaited_once()
assert web_scraper.web_sleep.await_count == 2

@pytest.mark.asyncio
async def test_web_select_combobox_no_matching_option_raises(self, web_scraper: WebScrapingMixin) -> None:
"""Test combobox selection raises when no <li> matches the entered text."""
input_field = AsyncMock(spec=Element)
input_field.attrs = {"aria-controls": "dropdown-id"}
input_field.clear_input = AsyncMock()
input_field.send_keys = AsyncMock()

dropdown_elem = AsyncMock(spec=Element)
dropdown_elem.apply = AsyncMock(return_value=False)

web_scraper.web_find = AsyncMock(side_effect=[input_field, dropdown_elem])
web_scraper.web_sleep = AsyncMock()

with pytest.raises(TimeoutError, match="Cannot locate combobox dropdown options."):
await web_scraper.web_select_combobox(By.ID, "combo-id", "Missing Label")

dropdown_elem.apply.assert_awaited_once()

@pytest.mark.asyncio
async def test_web_open_timeout(self, web_scraper:WebScrapingMixin, mock_browser:AsyncMock) -> None:
"""Test page load timeout in web_open."""
Expand Down