diff --git a/README.md b/README.md index 69d3a09..03705a2 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ **kleinanzeigen-bot** is a console based application to ease publishing of ads to [kleinanzeigen.de](https://kleinanzeigen.de). It is the spiritual successor to [Second-Hand-Friends/ebayKleinanzeigen](https://github.com/Second-Hand-Friends/ebayKleinanzeigen) with the following advantages: +- multi-language support (DE/EN) - supports Microsoft Edge browser (Chromium based) -- does not require selenium and chromedrivers +- does not require Selenium and chromedrivers - better captcha handling - config: - use YAML or JSON for config files diff --git a/pyproject.toml b/pyproject.toml index a2aec24..6ad41c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -200,6 +200,7 @@ disable= [ "broad-except", "consider-using-assignment-expr", "docstring-first-line-empty", + "global-statement", "missing-docstring", "multiple-imports", "multiple-statements", @@ -215,11 +216,13 @@ notes = [ "FIXME", "XXX", "TODO" ] # list of note tags to take in consideration [tool.pylint.design] # https://pylint.pycqa.org/en/latest/user_guide/configuration/all-options.html#design-checker # https://pylint.pycqa.org/en/latest/user_guide/checkers/features.html#design-checker-messages +max-args = 6 # maximum number of arguments for function / method (R0913) max-attributes = 15 # maximum number of instance attributes for a class (R0902) max-branches = 40 # maximum number of branch for function / method body (R0912) max-locals = 30 # maximum number of local variables for function / method body (R0914) -max-returns = 10 # maximum number of return / yield for function / method body (R0911) +max-returns = 15 # maximum number of return / yield for function / method body (R0911) max-statements = 150 # maximum number of statements in function / method body (R0915) +max-positional-arguments = 6 # maximum number of positional arguments for function / method (R0917) max-public-methods = 30 # maximum number of public methods for a class (R0904) diff --git a/src/kleinanzeigen_bot/__init__.py b/src/kleinanzeigen_bot/__init__.py index 14a6830..dfc9151 100644 --- a/src/kleinanzeigen_bot/__init__.py +++ b/src/kleinanzeigen_bot/__init__.py @@ -9,6 +9,7 @@ import urllib.parse as urllib_parse import urllib.request as urllib_request from collections.abc import Iterable from datetime import datetime +from gettext import gettext as _ from logging.handlers import RotatingFileHandler from typing import Any, Final @@ -17,14 +18,15 @@ from ruamel.yaml import YAML from wcmatch import glob from . import utils, resources, extract -from .utils import abspath, ainput, apply_defaults, ensure, is_frozen, pluralize, safe_get, parse_datetime +from .i18n import Locale, get_current_locale, set_current_locale, get_translating_logger, pluralize +from .utils import abspath, ainput, apply_defaults, ensure, is_frozen, safe_get, parse_datetime from .web_scraping_mixin import By, Element, Page, Is, WebScrapingMixin from ._version import __version__ # W0406: possibly a bug, see https://github.com/PyCQA/pylint/issues/3933 LOG_ROOT:Final[logging.Logger] = logging.getLogger() -LOG:Final[logging.Logger] = logging.getLogger("kleinanzeigen_bot") +LOG:Final[logging.Logger] = get_translating_logger(__name__) LOG.setLevel(logging.INFO) colorama.init() @@ -130,7 +132,40 @@ class KleinanzeigenBot(WebScrapingMixin): else: exe = "python -m kleinanzeigen_bot" - print(textwrap.dedent(f"""\ + if get_current_locale().language == "de": + print(textwrap.dedent(f"""\ + Verwendung: {colorama.Fore.LIGHTMAGENTA_EX}{exe} BEFEHL [OPTIONEN]{colorama.Style.RESET_ALL} + + Befehle: + publish - (erneutes) Veröffentlichen von Anzeigen + verify - Überprüft der Konfigurationsdateien + delete - Löscht Anzeigen + download - Lädt eine oder mehrere Anzeigen herunter + -- + help - Zeigt diese Hilfe an (Standardbefehl) + version - Zeigt die Version der Anwendung an + + Optionen: + --ads=all|due|new| (publish) - Gibt an, welche Anzeigen (erneut) veröffentlicht werden sollen (STANDARD: due) + Mögliche Werte: + * all: Veröffentlicht alle Anzeigen erneut, ignoriert republication_interval + * due: Veröffentlicht alle neuen Anzeigen und erneut entsprechend dem republication_interval + * new: Veröffentlicht nur neue Anzeigen (d.h. Anzeigen ohne ID in der Konfigurationsdatei) + * : Gibt eine oder mehrere Anzeigen-IDs an, die veröffentlicht werden sollen, z. B. "--ads=1,2,3", ignoriert republication_interval + --ads=all|new| (download) - Gibt an, welche Anzeigen heruntergeladen werden sollen (STANDARD: new) + Mögliche Werte: + * all: Lädt alle Anzeigen aus Ihrem Profil herunter + * new: Lädt Anzeigen aus Ihrem Profil herunter, die lokal noch nicht gespeichert sind + * : Gibt eine oder mehrere Anzeigen-IDs zum Herunterladen an, z. B. "--ads=1,2,3" + --force - Alias für '--ads=all' + --keep-old - Verhindert das Löschen alter Anzeigen bei erneuter Veröffentlichung + --config= - Pfad zur YAML- oder JSON-Konfigurationsdatei (STANDARD: ./config.yaml) + --lang=en|de - Anzeigesprache (STANDARD: Systemsprache, wenn unterstützt, sonst Englisch) + --logfile= - Pfad zur Protokolldatei (STANDARD: ./kleinanzeigen-bot.log) + -v, --verbose - Aktiviert detaillierte Ausgabe – nur nützlich zur Fehlerbehebung + """)) + else: + print(textwrap.dedent(f"""\ Usage: {colorama.Fore.LIGHTMAGENTA_EX}{exe} COMMAND [OPTIONS]{colorama.Style.RESET_ALL} Commands: @@ -158,8 +193,9 @@ class KleinanzeigenBot(WebScrapingMixin): --keep-old - don't delete old ads on republication --config= - path to the config YAML or JSON file (DEFAULT: ./config.yaml) --logfile= - path to the logfile (DEFAULT: ./kleinanzeigen-bot.log) + --lang=en|de - Displaylanguage (STANDARD: Systemlangauge if supported, otherwise English) -v, --verbose - enables verbose output - only useful when troubleshooting issues - """)) + """)) def parse_args(self, args:list[str]) -> None: try: @@ -170,11 +206,12 @@ class KleinanzeigenBot(WebScrapingMixin): "help", "keep-old", "logfile=", + "lang=", "verbose" ]) except getopt.error as ex: LOG.error(ex.msg) - LOG.error("Use --help to display available options") + LOG.error("Use --help to display available options.") sys.exit(2) for option, value in options: @@ -195,6 +232,8 @@ class KleinanzeigenBot(WebScrapingMixin): self.ads_selector = "all" case "--keep-old": self.keep_old_ads = True + case "--lang": + set_current_locale(Locale.of(value)) case "-v" | "--verbose": LOG.setLevel(logging.DEBUG) logging.getLogger("nodriver").setLevel(logging.INFO) @@ -244,7 +283,7 @@ class KleinanzeigenBot(WebScrapingMixin): if re.compile(r'\d+[,\d+]*').search(self.ads_selector): ids = [int(n) for n in self.ads_selector.split(',')] use_specific_ads = True - LOG.info('Start fetch task for the ad(s) with the id(s):') + LOG.info('Start fetch task for the ad(s) with id(s):') LOG.info(' | '.join([str(id_) for id_ in ids])) ad_fields = utils.load_dict_from_module(resources, "ad_fields.yaml") @@ -261,7 +300,7 @@ class KleinanzeigenBot(WebScrapingMixin): continue if use_specific_ads: - if not ad_cfg["id"] in ids: + if ad_cfg["id"] not in ids: LOG.info(" -> SKIPPED: ad [%s] is not in list of given ids.", ad_file) continue else: @@ -362,7 +401,7 @@ class KleinanzeigenBot(WebScrapingMixin): def load_config(self) -> None: config_defaults = utils.load_dict_from_module(resources, "config_defaults.yaml") - config = utils.load_dict_if_exists(self.config_file_path, "config") + config = utils.load_dict_if_exists(self.config_file_path, _("config")) if config is None: LOG.warning("Config file %s does not exist. Creating it with default values...", self.config_file_path) @@ -427,7 +466,7 @@ class KleinanzeigenBot(WebScrapingMixin): try: await self.web_find(By.TEXT, "Wir haben dir gerade einen 6-stelligen Code für die Telefonnummer", timeout = 4) LOG.warning("############################################") - LOG.warning("# Device verification message detected. Please handle it.") + LOG.warning("# Device verification message detected. Please follow the instruction displayed in the Browser.") LOG.warning("############################################") await ainput("Press ENTER when done...") except TimeoutError: @@ -460,7 +499,7 @@ class KleinanzeigenBot(WebScrapingMixin): await self.web_sleep() LOG.info("############################################") - LOG.info("DONE: Deleting %s", pluralize("ad", count)) + LOG.info("DONE: Deleted %s", pluralize("ad", count)) LOG.info("############################################") async def delete_ad(self, ad_cfg: dict[str, Any], delete_old_ads_by_title: bool) -> bool: @@ -469,8 +508,7 @@ class KleinanzeigenBot(WebScrapingMixin): await self.web_open(f"{self.root_url}/m-meine-anzeigen.html") csrf_token_elem = await self.web_find(By.CSS_SELECTOR, "meta[name=_csrf]") csrf_token = csrf_token_elem.attrs["content"] - if csrf_token is None: - raise AssertionError("Expected CSRF Token not found in HTML content!") + ensure(csrf_token is not None, "Expected CSRF Token not found in HTML content!") if delete_old_ads_by_title: published_ads = json.loads((await self.web_request(f"{self.root_url}/m-meine-anzeigen-verwalten.json?sort=DEFAULT"))["content"])["ads"] @@ -666,7 +704,7 @@ class KleinanzeigenBot(WebScrapingMixin): LOG.warning("# Captcha present! Please solve the captcha.") LOG.warning("############################################") await self.web_scroll_page_down() - input("Press a key to continue...") + input(_("Press a key to continue...")) except TimeoutError: pass @@ -722,7 +760,7 @@ class KleinanzeigenBot(WebScrapingMixin): # Click continue button await self.web_click(By.XPATH, '//*[contains(@class, "ModalDialog--Actions")]//button[.//*[text()[contains(.,"Bestätigen")]]]') except TimeoutError as ex: - raise TimeoutError("Unable to close condition dialog") from ex + raise TimeoutError(_("Unable to close condition dialog!")) from ex async def __set_category(self, category: str | None, ad_file:str) -> None: # click on something to trigger automatic category detection @@ -867,10 +905,10 @@ class KleinanzeigenBot(WebScrapingMixin): if self.ads_selector in {'all', 'new'}: # explore ads overview for these two modes LOG.info('Scanning your ad overview...') own_ad_urls = await ad_extractor.extract_own_ads_urls() - LOG.info('%d ads were found!', len(own_ad_urls)) + LOG.info('%s found.', pluralize("ad", len(own_ad_urls))) if self.ads_selector == 'all': # download all of your adds - LOG.info('Start fetch task for all your ads!') + LOG.info('Starting download of all ads...') success_count = 0 # call download function for each ad page @@ -892,7 +930,7 @@ class KleinanzeigenBot(WebScrapingMixin): # 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} - LOG.info('Start fetch task for your unsaved ads!') + LOG.info("Starting download of not yet downloaded ads...") new_count = 0 for ad_url, ad_id in ad_id_by_url.items(): # check if ad with ID already saved @@ -903,11 +941,11 @@ class KleinanzeigenBot(WebScrapingMixin): if await ad_extractor.naviagte_to_ad_page(ad_url): await ad_extractor.download_ad(ad_id) new_count += 1 - LOG.info('%d new ad(s) were downloaded from your profile.', new_count) + LOG.info('%s were downloaded from your profile.', pluralize("new ad", new_count)) elif re.compile(r'\d+[,\d+]*').search(self.ads_selector): # download ad(s) with specific id(s) ids = [int(n) for n in self.ads_selector.split(',')] - LOG.info('Start fetch task for the ad(s) with the id(s):') + LOG.info('Starting download of ad(s) with the id(s):') LOG.info(' | '.join([str(ad_id) for ad_id in ids])) for ad_id in ids: # call download routine for every id @@ -932,7 +970,7 @@ def main(args:list[str]) -> None: |_|\_\_|\___|_|_| |_|\__,_|_| |_/___\___|_|\__, |\___|_| |_| |_.__/ \___/ \__| |___/ https://github.com/Second-Hand-Friends/kleinanzeigen-bot - """), flush = True) + """)[1:], flush = True) # [1:] removes the first empty blank line utils.configure_console_logging() diff --git a/src/kleinanzeigen_bot/extract.py b/src/kleinanzeigen_bot/extract.py index 9c567ae..4d0a618 100644 --- a/src/kleinanzeigen_bot/extract.py +++ b/src/kleinanzeigen_bot/extract.py @@ -9,10 +9,15 @@ import mimetypes from datetime import datetime from typing import Any, Final +from .i18n import get_translating_logger, pluralize from .utils import is_integer, parse_decimal, save_dict from .web_scraping_mixin import Browser, By, Element, Is, WebScrapingMixin -LOG:Final[logging.Logger] = logging.getLogger("kleinanzeigen_bot.AdExtractor") +__all__ = [ + "AdExtractor", +] + +LOG:Final[logging.Logger] = get_translating_logger(__name__) class AdExtractor(WebScrapingMixin): @@ -42,7 +47,7 @@ class AdExtractor(WebScrapingMixin): new_base_dir = os.path.join(relative_directory, f'ad_{ad_id}') if os.path.exists(new_base_dir): - LOG.info('Deleting current folder of ad...') + LOG.info('Deleting current folder of ad %s...', ad_id) shutil.rmtree(new_base_dir) os.mkdir(new_base_dir) LOG.info('New directory for ad created at %s.', new_base_dir) @@ -68,7 +73,7 @@ class AdExtractor(WebScrapingMixin): image_box = await self.web_find(By.CLASS_NAME, 'galleryimage-large') n_images = len(await self.web_find_all(By.CSS_SELECTOR, '.galleryimage-element[data-ix]', parent = image_box)) - LOG.info('Found %d images.', n_images) + LOG.info('Found %s.', pluralize("image", n_images)) img_element:Element = await self.web_find(By.CSS_SELECTOR, 'div:nth-child(1) > img', parent = image_box) img_fn_prefix = 'ad_' + str(ad_id) + '__img' @@ -97,13 +102,13 @@ class AdExtractor(WebScrapingMixin): new_div = await self.web_find(By.CSS_SELECTOR, f'div.galleryimage-element:nth-child({img_nr + 1})') img_element = await self.web_find(By.TAG_NAME, 'img', parent = new_div) except TimeoutError: - LOG.error('NEXT button in image gallery somehow missing, abort image fetching.') + LOG.error('NEXT button in image gallery somehow missing, aborting image fetching.') break img_nr += 1 - LOG.info('Downloaded %d image(s).', dl_counter) + LOG.info('Downloaded %s.', pluralize("image", dl_counter)) except TimeoutError: # some ads do not require images - LOG.warning('No image area found. Continue without downloading images.') + LOG.warning('No image area found. Continuing without downloading images.') return img_paths @@ -153,10 +158,10 @@ class AdExtractor(WebScrapingMixin): parent = await self.web_find(By.CSS_SELECTOR, 'div:nth-of-type(1)', parent = pagination))) if n_buttons > 1: multi_page = True - LOG.info('It seems like you have many ads!') + LOG.info('It looks like you have many ads!') else: multi_page = False - LOG.info('It seems like all your ads fit on one overview page.') + LOG.info('It looks like all your ads fit on one overview page.') refs:list[str] = [] while True: # loop reference extraction until no more forward page @@ -208,7 +213,7 @@ class AdExtractor(WebScrapingMixin): # close (warning) popup, if given try: await self.web_find(By.ID, 'vap-ovrly-secure') - LOG.warning('A popup appeared.') + LOG.warning('A popup appeared!') await self.web_click(By.CLASS_NAME, 'mfp-close') await self.web_sleep() except TimeoutError: diff --git a/src/kleinanzeigen_bot/i18n.py b/src/kleinanzeigen_bot/i18n.py new file mode 100644 index 0000000..7543986 --- /dev/null +++ b/src/kleinanzeigen_bot/i18n.py @@ -0,0 +1,206 @@ +""" +SPDX-FileCopyrightText: © Sebastian Thomschke and contributors +SPDX-License-Identifier: AGPL-3.0-or-later +SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +""" +import ctypes, gettext, inspect, locale, logging, os, sys +from collections.abc import Sized +from typing import Any, Final, NamedTuple + +from . import resources, utils # pylint: disable=cyclic-import + +__all__ = [ + "Locale", + "get_translating_logger", +] + +LOG_ROOT:Final[logging.Logger] = logging.getLogger() +LOG:Final[logging.Logger] = logging.getLogger(__name__) + + +class Locale(NamedTuple): + + language:str # Language code (e.g., "en", "de") + region:str | None = None # Region code (e.g., "US", "DE") + encoding:str = "UTF-8" # Encoding format (e.g., "UTF-8") + + def __str__(self) -> str: + """ + >>> str(Locale("en", "US", "UTF-8")) + 'en_US.UTF-8' + >>> str(Locale("en", "US")) + 'en_US.UTF-8' + >>> str(Locale("en")) + 'en.UTF-8' + >>> str(Locale("de", None, "UTF-8")) + 'de.UTF-8' + """ + region_part = f"_{self.region}" if self.region else "" + encoding_part = f".{self.encoding}" if self.encoding else "" + return f"{self.language}{region_part}{encoding_part}" + + @staticmethod + def of(locale_string: str) -> 'Locale': + """ + >>> Locale.of("en_US.UTF-8") + Locale(language='en', region='US', encoding='UTF-8') + >>> Locale.of("de.UTF-8") + Locale(language='de', region=None, encoding='UTF-8') + >>> Locale.of("de_DE") + Locale(language='de', region='DE', encoding='UTF-8') + >>> Locale.of("en") + Locale(language='en', region=None, encoding='UTF-8') + >>> Locale.of("en.UTF-8") + Locale(language='en', region=None, encoding='UTF-8') + """ + parts = locale_string.split(".") + language_and_region = parts[0] + encoding = parts[1].upper() if len(parts) > 1 else "UTF-8" + + parts = language_and_region.split("_") + language = parts[0] + region = parts[1].upper() if len(parts) > 1 else None + + return Locale(language = language, region = region, encoding = encoding) + + +def _detect_locale() -> Locale: + """ + Detects the system language, returning a tuple of (language, region, encoding). + - On macOS/Linux, it uses the LANG environment variable. + - On Windows, it uses the Windows API via ctypes to get the default UI language. + + Returns: + (language, region, encoding): e.g. ("en", "US", "UTF-8") + """ + lang = os.environ.get("LANG", None) + + if not lang and os.name == "nt": # Windows + try: + lang = locale.windows_locale.get(ctypes.windll.kernel32.GetUserDefaultUILanguage(), "en_US") # type: ignore[attr-defined,unused-ignore] # mypy + except Exception: + LOG.warning("Error detecting language on Windows", exc_info = True) + + return Locale.of(lang) if lang else Locale("en", "US", "UTF-8") + + +_CURRENT_LOCALE: Locale = _detect_locale() +_TRANSLATIONS: dict[str, Any] | None = None + + +def translate(text:object, caller: inspect.FrameInfo | None) -> str: + text = str(text) + if not caller: + return text + + global _TRANSLATIONS + if _TRANSLATIONS is None: + try: + _TRANSLATIONS = utils.load_dict_from_module(resources, f"translations.{_CURRENT_LOCALE[0]}.yaml") + except FileNotFoundError: + _TRANSLATIONS = {} + + if not _TRANSLATIONS: + return text + + module_name = caller.frame.f_globals.get('__name__') # pylint: disable=redefined-outer-name + file_basename = os.path.splitext(os.path.basename(caller.filename))[0] + if module_name and module_name.endswith(f".{file_basename}"): + module_name = module_name[:-(len(file_basename) + 1)] + file_key = f"{file_basename}.py" if module_name == file_basename else f"{module_name}/{file_basename}.py" + translation = utils.safe_get(_TRANSLATIONS, + file_key, + caller.function, + text + ) + return translation if translation else text + + +_original_gettext = gettext.gettext +gettext.gettext = lambda message: translate(_original_gettext(message), utils.get_caller()) +for module_name, module in sys.modules.items(): + if module is None or module_name in sys.builtin_module_names: + continue + if hasattr(module, '_') and getattr(module, '_') is _original_gettext: + setattr(module, '_', gettext.gettext) + if hasattr(module, 'gettext') and getattr(module, 'gettext') is _original_gettext: + setattr(module, 'gettext', gettext.gettext) + + +def get_translating_logger(name: str | None = None) -> logging.Logger: + + class TranslatingLogger(logging.Logger): + + def _log(self, level: int, msg: object, *args: Any, **kwargs: Any) -> None: + if level != logging.DEBUG: # debug messages should not be translated + msg = translate(msg, utils.get_caller(2)) + super()._log(level, msg, *args, **kwargs) + + logging.setLoggerClass(TranslatingLogger) + return logging.getLogger(name) + + +def get_current_locale() -> Locale: + return _CURRENT_LOCALE + + +def set_current_locale(new_locale:Locale) -> None: + global _CURRENT_LOCALE, _TRANSLATIONS + if new_locale.language != _CURRENT_LOCALE.language: + _TRANSLATIONS = None + _CURRENT_LOCALE = new_locale + + +def pluralize(noun:str, count:int | Sized, prefix_with_count:bool = True) -> str: + """ + >>> pluralize("field", 1) + '1 field' + >>> pluralize("field", 2) + '2 fields' + >>> pluralize("field", 2, prefix_with_count = False) + 'fields' + """ + noun = translate(noun, utils.get_caller()) + + if isinstance(count, Sized): + count = len(count) + + prefix = f"{count} " if prefix_with_count else "" + + if count == 1: + return f"{prefix}{noun}" + + # German + if _CURRENT_LOCALE.language == "de": + # Special cases + irregular_plurals = { + "Attribute": "Attribute", + "Bild": "Bilder", + "Feld": "Felder", + } + if noun in irregular_plurals: + return f"{prefix}{irregular_plurals[noun]}" + for singular_suffix, plural_suffix in irregular_plurals.items(): + if noun.lower().endswith(singular_suffix): + pluralized = noun[:-len(singular_suffix)] + plural_suffix.lower() + return f"{prefix}{pluralized}" + + # Very simplified German rules + if noun.endswith("ei"): + return f"{prefix}{noun}en" # Datei -> Dateien + if noun.endswith("e"): + return f"{prefix}{noun}n" # Blume -> Blumen + if noun.endswith(("el", "er", "en")): + return f"{prefix}{noun}" # Keller -> Keller + if noun[-1] in "aeiou": + return f"{prefix}{noun}s" # Auto -> Autos + return f"{prefix}{noun}e" # Hund -> Hunde + + # English + if len(noun) < 2: + return f"{prefix}{noun}s" + if noun.endswith(('s', 'sh', 'ch', 'x', 'z')): + return f"{prefix}{noun}es" + if noun.endswith('y') and noun[-2].lower() not in "aeiou": + return f"{prefix}{noun[:-1]}ies" + return f"{prefix}{noun}s" diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml new file mode 100644 index 0000000..e48a67a --- /dev/null +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -0,0 +1,201 @@ +################################################# +getopt.py: +################################################# + do_longs: + "option --%s requires argument": "Option --%s benötigt ein Argument" + "option --%s must not have an argument": "Option --%s darf kein Argument haben" + long_has_args: + "option --%s not recognized": "Option --%s unbekannt" + "option --%s not a unique prefix": "Option --%s ist kein eindeutiger Prefix" + do_shorts: + "option -%s requires argument": "Option -%s benötigt ein Argument" + short_has_arg: + "option -%s not recognized": "Option -%s unbekannt" + + +################################################# +kleinanzeigen_bot/__init__.py: +################################################# + run: + "DONE: No configuration errors found.": "FERTIG: Keine Konfigurationsfehler gefunden." + 'You provided no ads selector. Defaulting to "due".': 'Es wurden keine Anzeigen-Selektor angegeben. Es wird "due" verwendet.' + "DONE: No new/outdated ads found.": "FERTIG: Keine neuen/veralteten Anzeigen gefunden." + "DONE: No ads to delete found.": "FERTIG: Keine zu löschnenden Anzeigen gefunden." + 'You provided no ads selector. Defaulting to "new".': 'Es wurden keine Anzeigen-Selektor angegeben. Es wird "new" verwendet.' + "Unknown command: %s" : "Unbekannter Befehl: %s" + + show_help: + "Usage:": "Verwendung:" + "COMMAND [OPTIONS]" : "BEFEHL [OPTIONEN]" + "Commands:": "Befehle" + + parse_args: + "Use --help to display available options.": "Mit --help können die verfügbaren Optionen angezeigt werden." + "More than one command given: %s": "Mehr als ein Befehl angegeben: %s" + + configure_file_logging: + "Logging to [%s]...": "Protokollierung in [%s]..." + "App version: %s": "App Version: %s" + "Python version: %s": "Python Version: %s" + + load_ads: + "Searching for ad config files...": "Suche nach Anzeigendateien..." + " -> found %s": "-> %s gefunden" + "ad config file": "Anzeigendatei" + "Start fetch task for the ad(s) with id(s):": "Starte Abrufaufgabe für die Anzeige(n) mit ID(s):" + " -> SKIPPED: inactive ad [%s]": " -> ÜBERSPRUNGEN: inaktive Anzeige [%s]" + " -> SKIPPED: ad [%s] is not in list of given ids.": " -> ÜBERSPRUNGEN: Anzeige [%s] ist nicht in der Liste der angegebenen IDs." + " -> SKIPPED: ad [%s] is not new. already has an id assigned.": + " -> ÜBERSPRUNGEN: Anzeige [%s] ist nicht neu. Eine ID wurde bereits zugewiesen." + " -> SKIPPED: ad [%s] was last published %d days ago. republication is only required every %s days": + " -> ÜBERSPRUNGEN: Anzeige [%s] wurde zuletzt vor %d Tagen veröffentlicht. Eine erneute Veröffentlichung ist nur alle %s Tage erforderlich." + "Loaded %s": "%s geladen" + "ad": "Anzeige" + + load_config: + " -> found %s": "-> %s gefunden" + "category": "Kategorie" + "config": "Konfiguration" + + login: + "Checking if already logged in...": "Überprüfe, ob bereits eingeloggt..." + "Already logged in as [%s]. Skipping login.": "Bereits eingeloggt als [%s]. Überspringe Anmeldung." + "Opening login page...": "Öffne Anmeldeseite..." + "Captcha present! Please solve the captcha.": "Captcha vorhanden! Bitte lösen Sie das Captcha." + + handle_after_login_logic: + "# Device verification message detected. Please follow the instruction displayed in the Browser.": + "# Nachricht zur Geräteverifizierung erkannt. Bitte den Anweisungen im Browser folgen." + "Press ENTER when done...": "EINGABETASTE drücken, wenn erledigt..." + "Handling GDPR disclaimer...": "Verarbeite DSGVO-Hinweis..." + + delete_ads: + "Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..." + "DONE: Deleted %s": "FERTIG: %s gelöscht" + "ad": "Anzeige" + + delete_ad: + "Deleting ad '%s' if already present...": "Lösche Anzeige '%s', falls bereits vorhanden..." + "Expected CSRF Token not found in HTML content!": "Erwartetes CSRF-Token wurde im HTML-Inhalt nicht gefunden!" + + publish_ads: + "Processing %s/%s: '%s' from [%s]...": "Verarbeite %s/%s: '%s' von [%s]..." + "DONE: (Re-)published %s": "FERTIG: %s (erneut) veröffentlicht" + "ad": "Anzeige" + + publish_ad: + "Publishing ad '%s'...": "Veröffentliche Anzeige '%s'..." + "Failed to set shipping attribute for type '%s'!": "Fehler beim setzen des Versandattributs für den Typ '%s'!" + "# Captcha present! Please solve the captcha.": "# Captcha vorhanden! Bitte lösen Sie das Captcha." + "Press a key to continue...": "Eine Taste drücken, um fortzufahren..." + " -> SUCCESS: ad published with ID %s": " -> ERFOLG: Anzeige mit ID %s veröffentlicht" + + __set_condition: + "Unable to close condition dialog!": "Kann den Dialog für Artikelzustand nicht schließen!" + + __upload_images: + " -> found %s": "-> %s gefunden" + "image": "Bild" + " -> uploading image [%s]": " -> Lade Bild [%s] hoch" + + download_ads: + "Scanning your ad overview...": "Scanne Anzeigenübersicht..." + '%s found!': '%s gefunden.' + "ad": "Anzeige" + "Starting download of all ads...": "Starte den Download aller Anzeigen..." + '%d of %d ads were downloaded from your profile.': '%d von %d Anzeigen wurden aus Ihrem Profil heruntergeladen.' + "Starting download of not yet downloaded ads...": "Starte den Download noch nicht heruntergeladener Anzeigen..." + 'The ad with id %d has already been saved.': 'Die Anzeige mit der ID %d wurde bereits gespeichert.' + '%s were downloaded from your profile.': '%s wurden aus Ihrem Profil heruntergeladen.' + "new ad": "neue Anzeige" + 'Starting download of ad(s) with the id(s):': 'Starte Download der Anzeige(n) mit den ID(s):' + 'Downloaded ad with id %d': 'Anzeige mit der ID %d heruntergeladen' + 'The page with the id %d does not exist!': 'Die Seite mit der ID %d existiert nicht!' + + +################################################# +kleinanzeigen_bot/extract.py: +################################################# + download_ad: + "Created ads directory at ./%s.": "Verzeichnis für Anzeigen erstellt unter ./%s." + "Deleting current folder of ad %s...": "Lösche aktuellen Ordner der Anzeige %s..." + "New directory for ad created at %s.": "Neues Verzeichnis für Anzeige erstellt unter %s." + + _download_images_from_ad_page: + "Found %s.": "%s gefunden." + "NEXT button in image gallery is missing, aborting image fetching.": + "NEXT-Schaltfläche in der Bildergalerie fehlt, Bildabruf abgebrochen." + "Downloaded %s.": "%s heruntergeladen." + "No image area found. Continue without downloading images.": + "Kein Bildbereich gefunden. Fahre fort ohne Bilder herunterzuladen." + + extract_ad_id_from_ad_url: + "The ad ID could not be extracted from the given URL %s": + "Die Anzeigen-ID konnte nicht aus der angegebenen URL extrahiert werden: %s" + + extract_own_ads_urls: + "There are currently no ads on your profile!": "Derzeit gibt es keine Anzeigen auf deinem Profil!" + "It looks like you have many ads!": "Es scheint viele Anzeigen zu geben!" + "It looks like all your ads fit on one overview page.": "Alle Anzeigen scheinen auf eine Übersichtsseite zu passen." + "Last ad overview page explored.": "Letzte Anzeigenübersichtsseite gesichtet." + + naviagte_to_ad_page: + "There is no ad under the given ID.": "Es gibt keine Anzeige unter der angegebenen ID." + "A popup appeared!": "Ein Popup ist erschienen!" + + _extract_ad_page_info: + 'Extracting information from ad with title \"%s\"': 'Extrahiere Informationen aus der Anzeige mit dem Titel "%s"' + + _extract_contact_from_ad_page: + 'No street given in the contact.': 'Keine Straße in den Kontaktdaten angegeben.' + +################################################# +kleinanzeigen_bot/utils.py: +################################################# + format: + "ERROR": "FEHLER" + "WARNING": "WARNUNG" + "CRITICAL": "KRITISCH" + + load_dict_if_exists: + "Loading %s[%s]...": "Lade %s[%s]..." + " from ": " aus " + 'Unsupported file type. The file name "%s" must end with *.json, *.yaml, or *.yml': + 'Nicht unterstützter Dateityp. Der Dateiname "%s" muss mit *.json, *.yaml oder *.yml enden.' + + save_dict: + "Saving [%s]...": "Speichere [%s]..." + + on_sigint: + "Aborted on user request.": "Auf Benutzerwunsch abgebrochen." + + +################################################# +kleinanzeigen_bot/web_scraping_mixin.py: +################################################# + create_browser_session: + "Creating Browser session...": "Erstelle Browsersitzung..." + " -> Browser binary location: %s": " -> Speicherort der Browser-Binärdatei: %s" + "Using existing browser process at %s:%s": "Verwende bestehenden Browser-Prozess unter %s:%s" + "New Browser session is %s": "Neue Browsersitzung ist %s" + " -> Browser profile name: %s": " -> Browser-Profilname: %s" + " -> Custom Browser argument: %s": " -> Benutzerdefiniertes Browser-Argument: %s" + " -> Browser user data dir: %s": " -> Benutzerdatenverzeichnis des Browsers: %s" + " -> Setting chrome prefs [%s]...": " -> Setze Chrome-Einstellungen [%s]..." + " -> Adding Browser extension: [%s]": " -> Füge Browser-Erweiterung hinzu: [%s]" + + get_compatible_browser: + "Installed browser for OS %s could not be detected": "Installierter Browser für OS %s konnte nicht erkannt werden" + "Installed browser could not be detected": "Installierter Browser konnte nicht erkannt werden" + + web_check: + "Unsupported attribute: %s": "Nicht unterstütztes Attribut: %s" + + web_find: + "Unsupported selector type: %s": "Nicht unterstützter Selektortyp: %s" + + web_find_all: + "Unsupported selector type: %s": "Nicht unterstützter Selektortyp: %s" + + web_sleep: + " ... pausing for %d ms ...": " ... pausiere für %d ms ..." diff --git a/src/kleinanzeigen_bot/utils.py b/src/kleinanzeigen_bot/utils.py index 2b64b6a..ccd5cdd 100644 --- a/src/kleinanzeigen_bot/utils.py +++ b/src/kleinanzeigen_bot/utils.py @@ -3,18 +3,20 @@ SPDX-FileCopyrightText: © Sebastian Thomschke and contributors SPDX-License-Identifier: AGPL-3.0-or-later SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ """ -import asyncio, copy, decimal, json, logging, os, re, socket, sys, traceback, time +import asyncio, copy, decimal, inspect, json, logging, os, re, socket, sys, traceback, time from importlib.resources import read_text as get_resource_as_string -from collections.abc import Callable, Sized +from collections.abc import Callable from datetime import datetime +from gettext import gettext as _ from types import FrameType, ModuleType, TracebackType from typing import Any, Final, TypeVar import coloredlogs from ruamel.yaml import YAML +from .i18n import get_translating_logger LOG_ROOT:Final[logging.Logger] = logging.getLogger() -LOG:Final[logging.Logger] = logging.getLogger("kleinanzeigen_bot.utils") +LOG:Final[logging.Logger] = get_translating_logger(__name__) # https://mypy.readthedocs.io/en/stable/generics.html#generic-functions T = TypeVar('T') @@ -45,7 +47,7 @@ def ensure(condition:Any | bool | Callable[[], bool], error_message:str, timeout if not isinstance(condition, Callable): # type: ignore[arg-type] # https://github.com/python/mypy/issues/6864 if condition: return - raise AssertionError(error_message) + raise AssertionError(_(error_message)) if timeout < 0: raise AssertionError("[timeout] must be >= 0") @@ -56,10 +58,21 @@ def ensure(condition:Any | bool | Callable[[], bool], error_message:str, timeout while not condition(): # type: ignore[operator] elapsed = time.time() - start_at if elapsed >= timeout: - raise AssertionError(error_message) + raise AssertionError(_(error_message)) time.sleep(poll_requency) +def get_caller(depth: int = 1) -> inspect.FrameInfo | None: + stack = inspect.stack() + try: + for frame in stack[depth + 1:]: + if frame.function and frame.function != "": + return frame + return None + finally: + del stack # Clean up the stack to avoid reference cycles + + def is_frozen() -> bool: """ >>> is_frozen() @@ -140,17 +153,30 @@ def safe_get(a_map:dict[Any, Any], *keys:str) -> Any: def configure_console_logging() -> None: + + class LevelTranslatingFormatter(coloredlogs.ColoredFormatter): # type: ignore + + def format(self, record:logging.LogRecord) -> str: + msg:str = super().format(record) + if record.levelno > logging.DEBUG: + levelname = _(record.levelname) + if levelname != record.levelname: + msg = msg.replace(record.levelname, levelname, 1) + return msg + + formatter = LevelTranslatingFormatter("[%(levelname)s] %(message)s") + stdout_log = logging.StreamHandler(sys.stderr) stdout_log.setLevel(logging.DEBUG) - stdout_log.setFormatter(coloredlogs.ColoredFormatter("[%(levelname)s] %(message)s")) - stdout_log.addFilter(type("", (logging.Filter,), { # pyright: ignore + stdout_log.addFilter(type("", (logging.Filter,), { "filter": lambda rec: rec.levelno <= logging.INFO })) + stdout_log.setFormatter(formatter) LOG_ROOT.addHandler(stdout_log) stderr_log = logging.StreamHandler(sys.stderr) stderr_log.setLevel(logging.WARNING) - stderr_log.setFormatter(coloredlogs.ColoredFormatter("[%(levelname)s] %(message)s")) + stderr_log.setFormatter(formatter) LOG_ROOT.addHandler(stderr_log) @@ -175,29 +201,6 @@ def on_sigint(_sig:int, _frame:FrameType | None) -> None: sys.exit(0) -def pluralize(noun:str, count:int | Sized, prefix_with_count:bool = True) -> str: - """ - >>> pluralize("field", 1) - '1 field' - >>> pluralize("field", 2) - '2 fields' - >>> pluralize("field", 2, prefix_with_count = False) - 'fields' - """ - if isinstance(count, Sized): - count = len(count) - - prefix = f"{count} " if prefix_with_count else "" - - if count == 1: - return f"{prefix}{noun}" - if noun.endswith('s') or noun.endswith('sh') or noun.endswith('ch') or noun.endswith('x') or noun.endswith('z'): - return f"{prefix}{noun}es" - if noun.endswith('y'): - return f"{prefix}{noun[:-1]}ies" - return f"{prefix}{noun}s" - - def load_dict(filepath:str, content_label:str = "") -> dict[str, Any]: """ :raises FileNotFoundError @@ -209,12 +212,12 @@ def load_dict(filepath:str, content_label:str = "") -> dict[str, Any]: def load_dict_if_exists(filepath:str, content_label:str = "") -> dict[str, Any] | None: - filepath = os.path.abspath(filepath) - LOG.info("Loading %s[%s]...", content_label and content_label + " from " or "", filepath) + abs_filepath = os.path.abspath(filepath) + LOG.info("Loading %s[%s]...", content_label and content_label + _(" from ") or "", abs_filepath) - _, file_ext = os.path.splitext(filepath) - if file_ext not in [".json", ".yaml", ".yml"]: - raise ValueError(f'Unsupported file type. The file name "{filepath}" must end with *.json, *.yaml, or *.yml') + __, file_ext = os.path.splitext(filepath) + if file_ext not in (".json", ".yaml", ".yml"): + raise ValueError(_('Unsupported file type. The filename "%s" must end with *.json, *.yaml, or *.yml') % filepath) if not os.path.exists(filepath): return None @@ -229,9 +232,9 @@ def load_dict_from_module(module:ModuleType, filename:str, content_label:str = " """ LOG.debug("Loading %s[%s.%s]...", content_label and content_label + " from " or "", module.__name__, filename) - _, file_ext = os.path.splitext(filename) + __, file_ext = os.path.splitext(filename) if file_ext not in (".json", ".yaml", ".yml"): - raise ValueError(f'Unsupported file type. The file name "{filename}" must end with *.json, *.yaml, or *.yml') + raise ValueError(f'Unsupported file type. The filename "{filename}" must end with *.json, *.yaml, or *.yml') content = get_resource_as_string(module, filename) # pylint: disable=deprecated-method return json.loads(content) if filename.endswith(".json") else YAML().load(content) # type: ignore[no-any-return] # mypy diff --git a/src/kleinanzeigen_bot/web_scraping_mixin.py b/src/kleinanzeigen_bot/web_scraping_mixin.py index 550fee2..6d16ed2 100644 --- a/src/kleinanzeigen_bot/web_scraping_mixin.py +++ b/src/kleinanzeigen_bot/web_scraping_mixin.py @@ -5,6 +5,7 @@ SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanze """ import asyncio, enum, inspect, json, logging, os, platform, secrets, shutil, time from collections.abc import Callable, Coroutine, Iterable +from gettext import gettext as _ from typing import cast, Any, Final try: @@ -18,12 +19,9 @@ from nodriver.core.config import Config from nodriver.core.element import Element from nodriver.core.tab import Tab as Page +from .i18n import get_translating_logger from .utils import ensure, is_port_open, T - -LOG:Final[logging.Logger] = logging.getLogger("kleinanzeigen_bot.selenium_mixin") - - __all__ = [ "Browser", "BrowserConfig", @@ -31,9 +29,11 @@ __all__ = [ "Element", "Page", "Is", - "WebScrapingMixin" + "WebScrapingMixin", ] +LOG:Final[logging.Logger] = get_translating_logger(__name__) + class By(enum.Enum): ID = enum.auto() @@ -77,7 +77,7 @@ class WebScrapingMixin: ensure(os.path.exists(self.browser_config.binary_location), f"Specified browser binary [{self.browser_config.binary_location}] does not exist.") else: self.browser_config.binary_location = self.get_compatible_browser() - LOG.info(" -> Chrome binary location: %s", self.browser_config.binary_location) + LOG.info(" -> Browser binary location: %s", self.browser_config.binary_location) ######################################################## # check if an existing browser instance shall be used... @@ -92,9 +92,9 @@ class WebScrapingMixin: if remote_port > 0: LOG.info("Using existing browser process at %s:%s", remote_host, remote_port) - if not is_port_open(remote_host, remote_port): - raise AssertionError(f"Browser process not reachable at {remote_host}:{remote_port}. " - + f"Start the browser with --remote-debugging-port={remote_port} or remove this port from your config.yaml") + ensure(is_port_open(remote_host, remote_port), + f"Browser process not reachable at {remote_host}:{remote_port}. " + + f"Start the browser with --remote-debugging-port={remote_port} or remove this port from your config.yaml") cfg = Config( browser_executable_path = self.browser_config.binary_location # actually not necessary but nodriver fails without ) @@ -140,7 +140,7 @@ class WebScrapingMixin: browser_args.append(f"--profile-directory={self.browser_config.profile_name}") for browser_arg in self.browser_config.arguments: - LOG.info(" -> Custom Chrome argument: %s", browser_arg) + LOG.info(" -> Custom Browser argument: %s", browser_arg) browser_args.append(browser_arg) if not LOG.isEnabledFor(logging.DEBUG): @@ -163,8 +163,8 @@ class WebScrapingMixin: os.makedirs(profile_dir, exist_ok = True) prefs_file = os.path.join(profile_dir, "Preferences") if not os.path.exists(prefs_file): - LOG.info("-> Setting chrome prefs [%s]...", prefs_file) - with open(prefs_file, "w", encoding='UTF-8') as fd: + LOG.info(" -> Setting chrome prefs [%s]...", prefs_file) + with open(prefs_file, "w", encoding = 'UTF-8') as fd: json.dump({ "credentials_enable_service": False, "enable_do_not_track": True, @@ -195,7 +195,7 @@ class WebScrapingMixin: # load extensions for crx_extension in self.browser_config.extensions: - LOG.info(" -> Adding extension: [%s]", crx_extension) + LOG.info(" -> Adding Browser extension: [%s]", crx_extension) ensure(os.path.exists(crx_extension), f"Configured extension-file [{crx_extension}] does not exist.") cfg.add_extension(crx_extension) @@ -250,15 +250,15 @@ class WebScrapingMixin: ] case _ as os_name: - raise AssertionError(f"Installed browser for OS [{os_name}] could not be detected") + raise AssertionError(_("Installed browser for OS %s could not be detected") % os_name) for browser_path in browser_paths: if browser_path and os.path.isfile(browser_path): return browser_path - raise AssertionError("Installed browser could not be detected") + raise AssertionError(_("Installed browser could not be detected")) - async def web_await(self, condition: Callable[[], T | Never | Coroutine[Any,Any,T | Never]], *, + async def web_await(self, condition: Callable[[], T | Never | Coroutine[Any, Any, T | Never]], *, timeout:int | float = 5, timeout_error_message: str = "") -> T: """ Blocks/waits until the given condition is met. @@ -307,6 +307,7 @@ class WebScrapingMixin: && element.offsetHeight > 0 } """)) + elem:Element = await self.web_find(selector_type, selector_value, timeout = timeout) match attr: @@ -329,7 +330,7 @@ class WebScrapingMixin: return false } """)) - raise AssertionError(f"Unsupported attribute: {attr}") + raise AssertionError(_("Unsupported attribute: %s") % attr) async def web_click(self, selector_type:By, selector_value:str, *, timeout:int | float = 5) -> Element: """ @@ -380,21 +381,19 @@ class WebScrapingMixin: timeout = timeout, timeout_error_message = f"No HTML element found using CSS selector '{selector_value}' within {timeout} seconds.") case By.TEXT: - if parent: - raise AssertionError(f"Specifying a parent element currently not supported with selector type: {selector_type}") + ensure(not parent, f"Specifying a parent element currently not supported with selector type: {selector_type}") return await self.web_await( lambda: self.page.find_element_by_text(selector_value, True), timeout = timeout, timeout_error_message = f"No HTML element found containing text '{selector_value}' within {timeout} seconds.") case By.XPATH: - if parent: - raise AssertionError(f"Specifying a parent element currently not supported with selector type: {selector_type}") + ensure(not parent, f"Specifying a parent element currently not supported with selector type: {selector_type}") return await self.web_await( lambda: self.page.find_element_by_text(selector_value, True), timeout = timeout, timeout_error_message = f"No HTML element found using XPath '{selector_value}' within {timeout} seconds.") - raise AssertionError(f"Unsupported selector type: {selector_type}") + raise AssertionError(_("Unsupported selector type: %s") % selector_type) async def web_find_all(self, selector_type:By, selector_value:str, *, parent:Element = None, timeout:int | float = 5) -> list[Element]: """ @@ -420,21 +419,19 @@ class WebScrapingMixin: timeout = timeout, timeout_error_message = f"No HTML elements found of tag <{selector_value}> within {timeout} seconds.") case By.TEXT: - if parent: - raise AssertionError(f"Specifying a parent element currently not supported with selector type: {selector_type}") + ensure(not parent, f"Specifying a parent element currently not supported with selector type: {selector_type}") return await self.web_await( lambda: self.page.find_elements_by_text(selector_value), timeout = timeout, timeout_error_message = f"No HTML elements found containing text '{selector_value}' within {timeout} seconds.") case By.XPATH: - if parent: - raise AssertionError(f"Specifying a parent element currently not supported with selector type: {selector_type}") + ensure(not parent, f"Specifying a parent element currently not supported with selector type: {selector_type}") return await self.web_await( lambda: self.page.find_elements_by_text(selector_value), timeout = timeout, timeout_error_message = f"No HTML elements found using XPath '{selector_value}' within {timeout} seconds.") - raise AssertionError(f"Unsupported selector type: {selector_type}") + raise AssertionError(_("Unsupported selector type: %s") % selector_type) async def web_input(self, selector_type:By, selector_value:str, text:str | int, *, timeout:int | float = 5) -> Element: """ @@ -503,7 +500,7 @@ class WebScrapingMixin: content: responseText }} }})) - """, await_promise=True)) + """, await_promise = True)) if isinstance(valid_response_codes, int): valid_response_codes = [valid_response_codes] ensure( diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..7f4e041 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,58 @@ +""" +SPDX-FileCopyrightText: © Sebastian Thomschke and contributors +SPDX-License-Identifier: AGPL-3.0-or-later +SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ +""" +import pytest +from _pytest.monkeypatch import MonkeyPatch # pylint: disable=import-private-name +from kleinanzeigen_bot import i18n + + +@pytest.mark.parametrize("lang, expected", [ + (None, ("en", "US", "UTF-8")), # Test with no LANG variable (should default to ("en", "US", "UTF-8")) + ("fr", ("fr", None, "UTF-8")), # Test with just a language code + ("fr_CA", ("fr", "CA", "UTF-8")), # Test with language + region, no encoding + ("pt_BR.iso8859-1", ("pt", "BR", "ISO8859-1")), # Test with language + region + encoding +]) +def test_detect_locale(monkeypatch: MonkeyPatch, lang: str | None, expected: i18n.Locale) -> None: + """ + Pytest test case to verify detect_system_language() behavior under various LANG values. + """ + # Clear or set the LANG environment variable as needed. + if lang is None: + monkeypatch.delenv("LANG", raising = False) + else: + monkeypatch.setenv("LANG", lang) + + # Call the function and compare the result to the expected output. + result = i18n._detect_locale() # pylint: disable=protected-access + assert result == expected, f"For LANG={lang}, expected {expected} but got {result}" + + +@pytest.mark.parametrize("lang, noun, count, prefix_with_count, expected", [ + ("en", "field", 1, True, "1 field"), + ("en", "field", 2, True, "2 fields"), + ("en", "field", 2, False, "fields"), + ("en", "attribute", 2, False, "attributes"), + ("en", "bus", 2, False, "buses"), + ("en", "city", 2, False, "cities"), + ("de", "Feld", 1, True, "1 Feld"), + ("de", "Feld", 2, True, "2 Felder"), + ("de", "Feld", 2, False, "Felder"), + ("de", "Anzeige", 2, False, "Anzeigen"), + ("de", "Attribute", 2, False, "Attribute"), + ("de", "Bild", 2, False, "Bilder"), + ("de", "Datei", 2, False, "Dateien"), + ("de", "Kategorie", 2, False, "Kategorien") +]) +def test_pluralize( + lang:str, + noun:str, + count:int, + prefix_with_count:bool, + expected: str +) -> None: + i18n.set_current_locale(i18n.Locale(lang, "US", "UTF_8")) + + result = i18n.pluralize(noun, count, prefix_with_count) + assert result == expected, f"For LANG={lang}, expected {expected} but got {result}" diff --git a/tests/test_web_scraping_mixin.py b/tests/test_web_scraping_mixin.py index a572be9..809ed26 100644 --- a/tests/test_web_scraping_mixin.py +++ b/tests/test_web_scraping_mixin.py @@ -8,12 +8,13 @@ from typing import cast import nodriver, pytest -from kleinanzeigen_bot.web_scraping_mixin import WebScrapingMixin from kleinanzeigen_bot.utils import ensure +from kleinanzeigen_bot.i18n import get_translating_logger +from kleinanzeigen_bot.web_scraping_mixin import WebScrapingMixin if os.environ.get("CI"): - logging.getLogger("kleinanzeigen_bot").setLevel(logging.DEBUG) - logging.getLogger("nodriver").setLevel(logging.DEBUG) + get_translating_logger("kleinanzeigen_bot").setLevel(logging.DEBUG) + get_translating_logger("nodriver").setLevel(logging.DEBUG) async def atest_init() -> None: