From 15f35ba3ee1c392f0152e9f73561b269cd7b3b05 Mon Sep 17 00:00:00 2001 From: Jens <1742418+1cu@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:39:11 +0100 Subject: [PATCH] fix: publishing contact fields and download stability (#771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ℹ️ Description - Link to the related issue(s): Issue #761 - Describe the motivation and context for this change. - This PR bundles several small fixes identified during recent testing, covering issue #761 and related publishing/download edge cases. ## 📋 Changes Summary - Avoid crashes in `download --ads=new` when existing local ads lack an ID; skip those files for the “already downloaded” set and log a clear reason. - Harden publishing contact fields: clear ZIP before typing; tolerate missing phone field; handle missing street/name/ZIP/location gracefully with warnings instead of aborting. - Improve location selection by matching full option text or the district suffix after ` - `. - Preserve `contact.location` in defaults (config model + regenerated schema with example). ### ⚙️ 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) - [ ] ✨ 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. - [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. ## Summary by CodeRabbit ## Release Notes * **New Features** * Added optional location field to contact configuration for specifying city/locality details in listings. * Enhanced contact field validation with improved error handling and fallback mechanisms. * **Bug Fixes** * Ad download process now gracefully handles unpublished or manually created ads instead of failing. * **Documentation** * Clarified shipping type requirements and cost configuration guidance in README. ✏️ Tip: You can customize this high-level summary in your review settings. --- README.md | 6 +- schemas/config.schema.json | 16 ++ src/kleinanzeigen_bot/__init__.py | 146 +++++++++++------- src/kleinanzeigen_bot/model/config_model.py | 5 + .../resources/translations.de.yaml | 10 +- 5 files changed, 126 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 7ce862c..335b38a 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ ad_defaults: price_type: NEGOTIABLE # one of: FIXED, NEGOTIABLE, GIVE_AWAY, NOT_APPLICABLE shipping_type: SHIPPING # one of: PICKUP, SHIPPING, NOT_APPLICABLE # NOTE: shipping_costs and shipping_options must be configured per-ad, not as defaults - sell_directly: false # requires shipping_options to take effect + sell_directly: false # requires shipping_type SHIPPING to take effect contact: name: "" street: "" @@ -406,7 +406,7 @@ special_attributes: # haus_mieten.zimmer_d: value # Zimmer shipping_type: # one of: PICKUP, SHIPPING, NOT_APPLICABLE (default: SHIPPING) -shipping_costs: # e.g. 2.95 +shipping_costs: # e.g. 2.95 (for individual postage, keep shipping_type SHIPPING and leave shipping_options empty) # specify shipping options / packages # it is possible to select multiple packages, but only from one size (S, M, L)! @@ -423,7 +423,7 @@ shipping_costs: # e.g. 2.95 # - DHL_31,5 # - Hermes_L shipping_options: [] -sell_directly: # true or false, requires shipping_options to take effect (default: false) +sell_directly: # true or false, requires shipping_type SHIPPING to take effect (default: false) # list of wildcard patterns to select images # if relative paths are specified, then they are relative to this ad configuration file diff --git a/schemas/config.schema.json b/schemas/config.schema.json index aabd924..08eb71f 100644 --- a/schemas/config.schema.json +++ b/schemas/config.schema.json @@ -304,6 +304,22 @@ "default": null, "title": "Zipcode" }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "city or locality of the listing (can include multiple districts)", + "examples": [ + "Sample Town - District One" + ], + "title": "Location" + }, "phone": { "anyOf": [ { diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 52758cf..6a99d9f 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -15,7 +15,7 @@ from wcmatch import glob from . import extract, resources from ._version import __version__ -from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, calculate_auto_price +from .model.ad_model import MAX_DESCRIPTION_LENGTH, Ad, AdPartial, Contact, calculate_auto_price from .model.config_model import Config from .update_checker import UpdateChecker from .utils import dicts, error_handlers, loggers, misc @@ -1153,56 +1153,7 @@ class KleinanzeigenBot(WebScrapingMixin): description = self.__get_description(ad_cfg, with_affixes = True) await self.web_execute("document.querySelector('#pstad-descrptn').value = `" + description.replace("`", "'") + "`") - ############################# - # set contact zipcode - ############################# - if ad_cfg.contact.zipcode: - await self.web_input(By.ID, "pstad-zip", ad_cfg.contact.zipcode) - # Set city if location is specified - if ad_cfg.contact.location: - try: - await self.web_sleep(1) # Wait for city dropdown to populate - options = await self.web_find_all(By.CSS_SELECTOR, "#pstad-citychsr option") - - for option in options: - if option.text == ad_cfg.contact.location: - await self.web_select(By.ID, "pstad-citychsr", option.attrs.value) - break - except TimeoutError: - LOG.debug("Could not set city from location") - - ############################# - # set contact street - ############################# - if ad_cfg.contact.street: - try: - if await self.web_check(By.ID, "pstad-street", Is.DISABLED): - await self.web_click(By.ID, "addressVisibility") - await self.web_sleep() - except TimeoutError: - # ignore - pass - await self.web_input(By.ID, "pstad-street", ad_cfg.contact.street) - - ############################# - # set contact name - ############################# - if ad_cfg.contact.name and not await self.web_check(By.ID, "postad-contactname", Is.READONLY): - await self.web_input(By.ID, "postad-contactname", ad_cfg.contact.name) - - ############################# - # set contact phone - ############################# - if ad_cfg.contact.phone: - if await self.web_check(By.ID, "postad-phonenumber", Is.DISPLAYED): - try: - if await self.web_check(By.ID, "postad-phonenumber", Is.DISABLED): - await self.web_click(By.ID, "phoneNumberVisibility") - await self.web_sleep() - except TimeoutError: - # ignore - pass - await self.web_input(By.ID, "postad-phonenumber", ad_cfg.contact.phone) + await self.__set_contact_fields(ad_cfg.contact) if mode == AdUpdateStrategy.MODIFY: ############################# @@ -1297,6 +1248,89 @@ class KleinanzeigenBot(WebScrapingMixin): dicts.save_dict(ad_file, ad_cfg_orig) + async def __set_contact_fields(self, contact:Contact) -> None: + ############################# + # set contact zipcode + ############################# + if contact.zipcode: + zipcode_set = True + try: + zip_field = await self.web_find(By.ID, "pstad-zip") + if zip_field is None: + raise TimeoutError("ZIP input not found") + await zip_field.clear_input() + except TimeoutError: + # fall back to standard input below + pass + try: + await self.web_input(By.ID, "pstad-zip", contact.zipcode) + except TimeoutError: + LOG.warning(_("Could not set contact zipcode: %s"), contact.zipcode) + zipcode_set = False + # Set city if location is specified + if contact.location and zipcode_set: + try: + options = await self.web_find_all(By.CSS_SELECTOR, "#pstad-citychsr option") + + found = False + for option in options: + opt_text = option.text.strip() + target = contact.location.strip() + if opt_text == target: + await self.web_select(By.ID, "pstad-citychsr", option.attrs.value) + found = True + break + if " - " in opt_text and opt_text.split(" - ", 1)[1] == target: + await self.web_select(By.ID, "pstad-citychsr", option.attrs.value) + found = True + break + if not found: + LOG.warning(_("No city dropdown option matched location: %s"), contact.location) + except TimeoutError: + LOG.warning(_("Could not set contact location: %s"), contact.location) + + ############################# + # set contact street + ############################# + if contact.street: + try: + if await self.web_check(By.ID, "pstad-street", Is.DISABLED): + await self.web_click(By.ID, "addressVisibility") + await self.web_sleep() + await self.web_input(By.ID, "pstad-street", contact.street) + except TimeoutError: + LOG.warning(_("Could not set contact street.")) + + ############################# + # set contact name + ############################# + if contact.name: + try: + if not await self.web_check(By.ID, "postad-contactname", Is.READONLY): + await self.web_input(By.ID, "postad-contactname", contact.name) + except TimeoutError: + LOG.warning(_("Could not set contact name.")) + + ############################# + # set contact phone + ############################# + if contact.phone: + try: + if await self.web_check(By.ID, "postad-phonenumber", Is.DISPLAYED): + try: + if await self.web_check(By.ID, "postad-phonenumber", Is.DISABLED): + await self.web_click(By.ID, "phoneNumberVisibility") + await self.web_sleep() + except TimeoutError: + # ignore + pass + await self.web_input(By.ID, "postad-phonenumber", contact.phone) + except TimeoutError: + LOG.warning( + _("Phone number field not present on page. This is expected for many private accounts; " + "commercial accounts may still support phone numbers.") + ) + async def update_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None: """ Updates a list of ads. @@ -1675,8 +1709,14 @@ class KleinanzeigenBot(WebScrapingMixin): saved_ad_ids = [] ads = self.load_ads(ignore_inactive = False, exclude_ads_with_id = False) # do not skip because of existing IDs for ad in ads: - ad_id = int(ad[2]["id"]) - saved_ad_ids.append(ad_id) + saved_ad_id = ad[1].id + if saved_ad_id is None: + LOG.debug( + "Skipping saved ad without id (likely unpublished or manually created): %s", + ad[0] + ) + continue + saved_ad_ids.append(int(saved_ad_id)) # determine ad IDs from links ad_id_by_url = {url:ad_extractor.extract_ad_id_from_ad_url(url) for url in own_ad_urls} diff --git a/src/kleinanzeigen_bot/model/config_model.py b/src/kleinanzeigen_bot/model/config_model.py index e1ae71c..181be94 100644 --- a/src/kleinanzeigen_bot/model/config_model.py +++ b/src/kleinanzeigen_bot/model/config_model.py @@ -66,6 +66,11 @@ class ContactDefaults(ContextualModel): name:str | None = None street:str | None = None zipcode:int | str | None = None + location:str | None = Field( + default = None, + description = "city or locality of the listing (can include multiple districts)", + examples = ["Sample Town - District One"] + ) phone:str | None = None diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index 6358085..3b200f0 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -138,7 +138,6 @@ kleinanzeigen_bot/__init__.py: " -> SUCCESS: ad published with ID %s": " -> ERFOLG: Anzeige mit ID %s veröffentlicht" " -> SUCCESS: ad updated with ID %s": " -> ERFOLG: Anzeige mit ID %s aktualisiert" " -> effective ad meta:": " -> effektive Anzeigen-Metadaten:" - "Could not set city from location": "Stadt konnte nicht aus dem Standort gesetzt werden" "Press a key to continue...": "Eine Taste drücken, um fortzufahren..." update_ads: @@ -152,6 +151,15 @@ kleinanzeigen_bot/__init__.py: "Unable to open condition dialog and select condition [%s]": "Zustandsdialog konnte nicht geöffnet und Zustand [%s] nicht ausgewählt werden" "Unable to select condition [%s]": "Zustand [%s] konnte nicht ausgewählt werden" + __set_contact_fields: + "Could not set contact street.": "Kontaktstraße konnte nicht gesetzt werden." + "Could not set contact name.": "Kontaktname konnte nicht gesetzt werden." + "Could not set contact location: %s": "Kontaktort konnte nicht gesetzt werden: %s" + "Could not set contact zipcode: %s": "Kontakt-PLZ konnte nicht gesetzt werden: %s" + "No city dropdown option matched location: %s": "Kein Eintrag im Orts-Dropdown passte zum Ort: %s" + ? "Phone number field not present on page. This is expected for many private accounts; commercial accounts may still support phone numbers." + : "Telefonnummernfeld auf der Seite nicht vorhanden. Dies ist bei vielen privaten Konten zu erwarten; gewerbliche Konten unterstützen Telefonnummern möglicherweise weiterhin." + __upload_images: " -> found %s": "-> %s gefunden" "image": "Bild"