fix: publishing contact fields and download stability (#771)

## ℹ️ 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.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens
2026-01-19 15:39:11 +01:00
committed by GitHub
parent 6ef6aea3a8
commit 15f35ba3ee
5 changed files with 126 additions and 57 deletions

View File

@@ -276,7 +276,7 @@ ad_defaults:
price_type: NEGOTIABLE # one of: FIXED, NEGOTIABLE, GIVE_AWAY, NOT_APPLICABLE price_type: NEGOTIABLE # one of: FIXED, NEGOTIABLE, GIVE_AWAY, NOT_APPLICABLE
shipping_type: SHIPPING # one of: PICKUP, SHIPPING, 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 # 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: contact:
name: "" name: ""
street: "" street: ""
@@ -406,7 +406,7 @@ special_attributes:
# haus_mieten.zimmer_d: value # Zimmer # haus_mieten.zimmer_d: value # Zimmer
shipping_type: # one of: PICKUP, SHIPPING, NOT_APPLICABLE (default: SHIPPING) 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 # specify shipping options / packages
# it is possible to select multiple packages, but only from one size (S, M, L)! # 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 # - DHL_31,5
# - Hermes_L # - Hermes_L
shipping_options: [] 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 # list of wildcard patterns to select images
# if relative paths are specified, then they are relative to this ad configuration file # if relative paths are specified, then they are relative to this ad configuration file

View File

@@ -304,6 +304,22 @@
"default": null, "default": null,
"title": "Zipcode" "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": { "phone": {
"anyOf": [ "anyOf": [
{ {

View File

@@ -15,7 +15,7 @@ from wcmatch import glob
from . import extract, resources from . import extract, resources
from ._version import __version__ 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 .model.config_model import Config
from .update_checker import UpdateChecker from .update_checker import UpdateChecker
from .utils import dicts, error_handlers, loggers, misc from .utils import dicts, error_handlers, loggers, misc
@@ -1153,56 +1153,7 @@ class KleinanzeigenBot(WebScrapingMixin):
description = self.__get_description(ad_cfg, with_affixes = True) description = self.__get_description(ad_cfg, with_affixes = True)
await self.web_execute("document.querySelector('#pstad-descrptn').value = `" + description.replace("`", "'") + "`") await self.web_execute("document.querySelector('#pstad-descrptn').value = `" + description.replace("`", "'") + "`")
############################# await self.__set_contact_fields(ad_cfg.contact)
# 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)
if mode == AdUpdateStrategy.MODIFY: if mode == AdUpdateStrategy.MODIFY:
############################# #############################
@@ -1297,6 +1248,89 @@ class KleinanzeigenBot(WebScrapingMixin):
dicts.save_dict(ad_file, ad_cfg_orig) 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: async def update_ads(self, ad_cfgs:list[tuple[str, Ad, dict[str, Any]]]) -> None:
""" """
Updates a list of ads. Updates a list of ads.
@@ -1675,8 +1709,14 @@ class KleinanzeigenBot(WebScrapingMixin):
saved_ad_ids = [] saved_ad_ids = []
ads = self.load_ads(ignore_inactive = False, exclude_ads_with_id = False) # do not skip because of existing IDs ads = self.load_ads(ignore_inactive = False, exclude_ads_with_id = False) # do not skip because of existing IDs
for ad in ads: for ad in ads:
ad_id = int(ad[2]["id"]) saved_ad_id = ad[1].id
saved_ad_ids.append(ad_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 # 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} ad_id_by_url = {url:ad_extractor.extract_ad_id_from_ad_url(url) for url in own_ad_urls}

View File

@@ -66,6 +66,11 @@ class ContactDefaults(ContextualModel):
name:str | None = None name:str | None = None
street:str | None = None street:str | None = None
zipcode:int | 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 phone:str | None = None

View File

@@ -138,7 +138,6 @@ kleinanzeigen_bot/__init__.py:
" -> SUCCESS: ad published with ID %s": " -> ERFOLG: Anzeige mit ID %s veröffentlicht" " -> 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" " -> SUCCESS: ad updated with ID %s": " -> ERFOLG: Anzeige mit ID %s aktualisiert"
" -> effective ad meta:": " -> effektive Anzeigen-Metadaten:" " -> 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..." "Press a key to continue...": "Eine Taste drücken, um fortzufahren..."
update_ads: 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 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" "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: __upload_images:
" -> found %s": "-> %s gefunden" " -> found %s": "-> %s gefunden"
"image": "Bild" "image": "Bild"