mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 10:31:50 +01:00
fix: address codeql notes and warnings (#740)
This commit is contained in:
@@ -18,7 +18,7 @@ class FormatterRule(Protocol):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def apply(self, tree:ast.AST, lines:List[str], path:Path) -> List[str]:
|
def apply(self, tree:ast.AST, lines:List[str], path:Path) -> List[str]:
|
||||||
...
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class NoSpaceAfterColonInTypeAnnotationRule(FormatterRule):
|
class NoSpaceAfterColonInTypeAnnotationRule(FormatterRule):
|
||||||
|
|||||||
@@ -746,6 +746,7 @@ class KleinanzeigenBot(WebScrapingMixin):
|
|||||||
|
|
||||||
await ainput(_("Press a key to continue..."))
|
await ainput(_("Press a key to continue..."))
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# No captcha detected within timeout.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def login(self) -> None:
|
async def login(self) -> None:
|
||||||
@@ -796,6 +797,7 @@ class KleinanzeigenBot(WebScrapingMixin):
|
|||||||
LOG.warning("############################################")
|
LOG.warning("############################################")
|
||||||
await ainput("Press ENTER when done...")
|
await ainput("Press ENTER when done...")
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# No SMS verification prompt detected.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -807,6 +809,7 @@ class KleinanzeigenBot(WebScrapingMixin):
|
|||||||
"//div[@id='ConsentManagementPage']//*//button//*[contains(., 'Alle ablehnen und fortfahren')]",
|
"//div[@id='ConsentManagementPage']//*//button//*[contains(., 'Alle ablehnen und fortfahren')]",
|
||||||
timeout = gdpr_timeout)
|
timeout = gdpr_timeout)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# GDPR banner not shown within timeout.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def is_logged_in(self) -> bool:
|
async def is_logged_in(self) -> bool:
|
||||||
@@ -994,6 +997,7 @@ class KleinanzeigenBot(WebScrapingMixin):
|
|||||||
try:
|
try:
|
||||||
await self.web_select(By.CSS_SELECTOR, "select#price-type-react, select#micro-frontend-price-type, select#priceType", price_type)
|
await self.web_select(By.CSS_SELECTOR, "select#price-type-react, select#micro-frontend-price-type, select#priceType", price_type)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# Price type selector not present on this page variant.
|
||||||
pass
|
pass
|
||||||
if ad_cfg.price:
|
if ad_cfg.price:
|
||||||
if mode == AdUpdateStrategy.MODIFY:
|
if mode == AdUpdateStrategy.MODIFY:
|
||||||
@@ -1112,6 +1116,7 @@ class KleinanzeigenBot(WebScrapingMixin):
|
|||||||
if not ad_cfg.images and await self.web_check(By.XPATH, image_hint_xpath, Is.DISPLAYED):
|
if not ad_cfg.images and await self.web_check(By.XPATH, image_hint_xpath, Is.DISPLAYED):
|
||||||
await self.web_click(By.XPATH, image_hint_xpath)
|
await self.web_click(By.XPATH, image_hint_xpath)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# Image hint not shown; continue publish flow.
|
||||||
pass # nosec
|
pass # nosec
|
||||||
|
|
||||||
#############################
|
#############################
|
||||||
@@ -1127,6 +1132,7 @@ class KleinanzeigenBot(WebScrapingMixin):
|
|||||||
await self.web_scroll_page_down()
|
await self.web_scroll_page_down()
|
||||||
await ainput(_("Press a key to continue..."))
|
await ainput(_("Press a key to continue..."))
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# Payment form not present.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
confirmation_timeout = self._timeout("publishing_confirmation")
|
confirmation_timeout = self._timeout("publishing_confirmation")
|
||||||
@@ -1234,6 +1240,7 @@ class KleinanzeigenBot(WebScrapingMixin):
|
|||||||
if await self.web_text(By.ID, "postad-category-path"):
|
if await self.web_text(By.ID, "postad-category-path"):
|
||||||
is_category_auto_selected = True
|
is_category_auto_selected = True
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# Category auto-selection indicator not available within timeout.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if category:
|
if category:
|
||||||
@@ -1267,6 +1274,7 @@ class KleinanzeigenBot(WebScrapingMixin):
|
|||||||
if not await self.web_check(By.XPATH, select_container_xpath, Is.DISPLAYED):
|
if not await self.web_check(By.XPATH, select_container_xpath, Is.DISPLAYED):
|
||||||
await (await self.web_find(By.XPATH, select_container_xpath)).apply("elem => elem.singleNodeValue.style.display = 'block'")
|
await (await self.web_find(By.XPATH, select_container_xpath)).apply("elem => elem.singleNodeValue.style.display = 'block'")
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# Skip visibility adjustment when container cannot be located in time.
|
||||||
pass # nosec
|
pass # nosec
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1341,6 +1349,7 @@ class KleinanzeigenBot(WebScrapingMixin):
|
|||||||
await self.web_click(By.XPATH,
|
await self.web_click(By.XPATH,
|
||||||
'//dialog//button[contains(., "Andere Versandmethoden")]')
|
'//dialog//button[contains(., "Andere Versandmethoden")]')
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# Dialog option not present; already on the individual shipping page.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1350,6 +1359,7 @@ class KleinanzeigenBot(WebScrapingMixin):
|
|||||||
'//input[contains(@placeholder, "Versandkosten (optional)")]',
|
'//input[contains(@placeholder, "Versandkosten (optional)")]',
|
||||||
timeout = short_timeout)
|
timeout = short_timeout)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# Input not visible yet; click the individual shipping option.
|
||||||
await self.web_click(By.XPATH, '//*[contains(@id, "INDIVIDUAL") and contains(@data-testid, "Individueller Versand")]')
|
await self.web_click(By.XPATH, '//*[contains(@id, "INDIVIDUAL") and contains(@data-testid, "Individueller Versand")]')
|
||||||
|
|
||||||
if ad_cfg.shipping_costs is not None:
|
if ad_cfg.shipping_costs is not None:
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ class AdExtractor(WebScrapingMixin):
|
|||||||
|
|
||||||
# Save the ad configuration file (offload to executor to avoid blocking the event loop)
|
# Save the ad configuration file (offload to executor to avoid blocking the event loop)
|
||||||
ad_file_path = str(Path(final_dir) / f"ad_{ad_id}.yaml")
|
ad_file_path = str(Path(final_dir) / f"ad_{ad_id}.yaml")
|
||||||
header_string = "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json"
|
header_string = (
|
||||||
|
"# yaml-language-server: $schema="
|
||||||
|
"https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json"
|
||||||
|
)
|
||||||
await asyncio.get_running_loop().run_in_executor(
|
await asyncio.get_running_loop().run_in_executor(
|
||||||
None,
|
None,
|
||||||
lambda: dicts.save_dict(ad_file_path, ad_cfg.model_dump(), header = header_string)
|
lambda: dicts.save_dict(ad_file_path, ad_cfg.model_dump(), header = header_string)
|
||||||
@@ -141,9 +144,6 @@ class AdExtractor(WebScrapingMixin):
|
|||||||
:return: the ad ID, a (ten-digit) integer number
|
:return: the ad ID, a (ten-digit) integer number
|
||||||
"""
|
"""
|
||||||
|
|
||||||
num_part = url.rsplit("/", maxsplit = 1)[-1] # suffix
|
|
||||||
id_part = num_part.split("-", maxsplit = 1)[0]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
path = url.split("?", maxsplit = 1)[0] # Remove query string if present
|
path = url.split("?", maxsplit = 1)[0] # Remove query string if present
|
||||||
last_segment = path.rstrip("/").rsplit("/", maxsplit = 1)[-1] # Get last path component
|
last_segment = path.rstrip("/").rsplit("/", maxsplit = 1)[-1] # Get last path component
|
||||||
@@ -165,7 +165,7 @@ class AdExtractor(WebScrapingMixin):
|
|||||||
|
|
||||||
# Try to find the main ad list container first
|
# Try to find the main ad list container first
|
||||||
try:
|
try:
|
||||||
ad_list_container = await self.web_find(By.ID, "my-manageitems-adlist")
|
_ = await self.web_find(By.ID, "my-manageitems-adlist")
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
LOG.warning("Ad list container #my-manageitems-adlist not found. Maybe no ads present?")
|
LOG.warning("Ad list container #my-manageitems-adlist not found. Maybe no ads present?")
|
||||||
return []
|
return []
|
||||||
@@ -290,6 +290,7 @@ class AdExtractor(WebScrapingMixin):
|
|||||||
await self.web_click(By.CLASS_NAME, "mfp-close")
|
await self.web_click(By.CLASS_NAME, "mfp-close")
|
||||||
await self.web_sleep()
|
await self.web_sleep()
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
# Popup did not appear within timeout.
|
||||||
pass
|
pass
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,11 @@
|
|||||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||||
import copy, logging, os, re, sys # isort: skip
|
import copy, logging, os, re, sys # isort: skip
|
||||||
from gettext import gettext as _
|
from gettext import gettext as _
|
||||||
from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING, Logger
|
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from typing import Any, Final # @UnusedImport
|
from typing import Any, Final # @UnusedImport
|
||||||
|
|
||||||
import colorama
|
import colorama
|
||||||
|
|
||||||
from . import i18n, reflect
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Logger",
|
"Logger",
|
||||||
"LogFileHandle",
|
"LogFileHandle",
|
||||||
@@ -26,7 +23,14 @@ __all__ = [
|
|||||||
"is_debug"
|
"is_debug"
|
||||||
]
|
]
|
||||||
|
|
||||||
LOG_ROOT:Final[logging.Logger] = logging.getLogger()
|
CRITICAL = logging.CRITICAL
|
||||||
|
DEBUG = logging.DEBUG
|
||||||
|
ERROR = logging.ERROR
|
||||||
|
INFO = logging.INFO
|
||||||
|
WARNING = logging.WARNING
|
||||||
|
Logger = logging.Logger
|
||||||
|
|
||||||
|
LOG_ROOT:Final[Logger] = logging.getLogger()
|
||||||
|
|
||||||
|
|
||||||
class _MaxLevelFilter(logging.Filter):
|
class _MaxLevelFilter(logging.Filter):
|
||||||
@@ -141,7 +145,7 @@ def configure_console_logging() -> None:
|
|||||||
class LogFileHandle:
|
class LogFileHandle:
|
||||||
"""Encapsulates a log file handler with close and status methods."""
|
"""Encapsulates a log file handler with close and status methods."""
|
||||||
|
|
||||||
def __init__(self, file_path:str, handler:RotatingFileHandler, logger:logging.Logger) -> None:
|
def __init__(self, file_path:str, handler:RotatingFileHandler, logger:Logger) -> None:
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self._handler:RotatingFileHandler | None = handler
|
self._handler:RotatingFileHandler | None = handler
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
@@ -183,15 +187,16 @@ def flush_all_handlers() -> None:
|
|||||||
handler.flush()
|
handler.flush()
|
||||||
|
|
||||||
|
|
||||||
def get_logger(name:str | None = None) -> logging.Logger:
|
def get_logger(name:str | None = None) -> Logger:
|
||||||
"""
|
"""
|
||||||
Returns a localized logger
|
Returns a localized logger
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class TranslatingLogger(logging.Logger):
|
class TranslatingLogger(Logger):
|
||||||
|
|
||||||
def _log(self, level:int, msg:object, *args:Any, **kwargs:Any) -> None:
|
def _log(self, level:int, msg:object, *args:Any, **kwargs:Any) -> None:
|
||||||
if level != DEBUG: # debug messages should not be translated
|
if level != DEBUG: # debug messages should not be translated
|
||||||
|
from . import i18n, reflect # noqa: PLC0415 # avoid cyclic import at module load
|
||||||
msg = i18n.translate(msg, reflect.get_caller(2))
|
msg = i18n.translate(msg, reflect.get_caller(2))
|
||||||
super()._log(level, msg, *args, **kwargs)
|
super()._log(level, msg, *args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ class WebScrapingMixin:
|
|||||||
if is_relevant_browser:
|
if is_relevant_browser:
|
||||||
browser_processes.append(proc.info)
|
browser_processes.append(proc.info)
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
# Process ended or is not accessible; skip it.
|
||||||
pass
|
pass
|
||||||
except (psutil.Error, PermissionError) as exc:
|
except (psutil.Error, PermissionError) as exc:
|
||||||
LOG.warning("(warn) Unable to inspect browser processes: %s", exc)
|
LOG.warning("(warn) Unable to inspect browser processes: %s", exc)
|
||||||
@@ -518,6 +519,7 @@ class WebScrapingMixin:
|
|||||||
self.page = None # pyright: ignore[reportAttributeAccessIssue]
|
self.page = None # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
def get_compatible_browser(self) -> str:
|
def get_compatible_browser(self) -> str:
|
||||||
|
browser_paths:list[str | None] = []
|
||||||
match platform.system():
|
match platform.system():
|
||||||
case "Linux":
|
case "Linux":
|
||||||
browser_paths = [
|
browser_paths = [
|
||||||
|
|||||||
@@ -1076,29 +1076,31 @@ class TestAdExtractorDownload:
|
|||||||
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
||||||
extractor.page = page_mock
|
extractor.page = page_mock
|
||||||
|
|
||||||
with patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
|
with (
|
||||||
|
patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
|
||||||
"Test Title", # Title extraction
|
"Test Title", # Title extraction
|
||||||
"Test Title", # Second title call for full extraction
|
"Test Title", # Second title call for full extraction
|
||||||
"Description text", # Description
|
"Description text", # Description
|
||||||
"03.02.2025" # Creation date
|
"03.02.2025" # Creation date
|
||||||
]), \
|
]),
|
||||||
patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
|
patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
|
||||||
"universalAnalyticsOpts": {
|
"universalAnalyticsOpts": {
|
||||||
"dimensions": {
|
"dimensions": {
|
||||||
"dimension92": "",
|
"dimension92": "",
|
||||||
"dimension108": ""
|
"dimension108": ""
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}), \
|
}
|
||||||
patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"), \
|
}),
|
||||||
patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}), \
|
patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"),
|
||||||
patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")), \
|
patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}),
|
||||||
patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)), \
|
patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")),
|
||||||
patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False), \
|
patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)),
|
||||||
patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []), \
|
patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False),
|
||||||
patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
|
patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []),
|
||||||
name = "Test", zipcode = "12345", location = "Berlin"
|
patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
|
||||||
)):
|
name = "Test", zipcode = "12345", location = "Berlin"
|
||||||
|
)),
|
||||||
|
):
|
||||||
|
|
||||||
ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
|
ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
|
||||||
base_dir, 12345
|
base_dir, 12345
|
||||||
@@ -1133,29 +1135,31 @@ class TestAdExtractorDownload:
|
|||||||
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
||||||
extractor.page = page_mock
|
extractor.page = page_mock
|
||||||
|
|
||||||
with patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
|
with (
|
||||||
|
patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
|
||||||
"Test Title", # Title extraction
|
"Test Title", # Title extraction
|
||||||
"Test Title", # Second title call for full extraction
|
"Test Title", # Second title call for full extraction
|
||||||
"Description text", # Description
|
"Description text", # Description
|
||||||
"03.02.2025" # Creation date
|
"03.02.2025" # Creation date
|
||||||
]), \
|
]),
|
||||||
patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
|
patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
|
||||||
"universalAnalyticsOpts": {
|
"universalAnalyticsOpts": {
|
||||||
"dimensions": {
|
"dimensions": {
|
||||||
"dimension92": "",
|
"dimension92": "",
|
||||||
"dimension108": ""
|
"dimension108": ""
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}), \
|
}
|
||||||
patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"), \
|
}),
|
||||||
patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}), \
|
patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"),
|
||||||
patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")), \
|
patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}),
|
||||||
patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)), \
|
patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")),
|
||||||
patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False), \
|
patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)),
|
||||||
patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []), \
|
patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False),
|
||||||
patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
|
patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []),
|
||||||
name = "Test", zipcode = "12345", location = "Berlin"
|
patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
|
||||||
)):
|
name = "Test", zipcode = "12345", location = "Berlin"
|
||||||
|
)),
|
||||||
|
):
|
||||||
|
|
||||||
ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
|
ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
|
||||||
base_dir, 12345
|
base_dir, 12345
|
||||||
@@ -1192,29 +1196,31 @@ class TestAdExtractorDownload:
|
|||||||
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
page_mock.url = "https://www.kleinanzeigen.de/s-anzeige/test/12345"
|
||||||
extractor.page = page_mock
|
extractor.page = page_mock
|
||||||
|
|
||||||
with patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
|
with (
|
||||||
|
patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
|
||||||
"Test Title", # Title extraction
|
"Test Title", # Title extraction
|
||||||
"Test Title", # Second title call for full extraction
|
"Test Title", # Second title call for full extraction
|
||||||
"Description text", # Description
|
"Description text", # Description
|
||||||
"03.02.2025" # Creation date
|
"03.02.2025" # Creation date
|
||||||
]), \
|
]),
|
||||||
patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
|
patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
|
||||||
"universalAnalyticsOpts": {
|
"universalAnalyticsOpts": {
|
||||||
"dimensions": {
|
"dimensions": {
|
||||||
"dimension92": "",
|
"dimension92": "",
|
||||||
"dimension108": ""
|
"dimension108": ""
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}), \
|
}
|
||||||
patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"), \
|
}),
|
||||||
patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}), \
|
patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"),
|
||||||
patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")), \
|
patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}),
|
||||||
patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)), \
|
patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")),
|
||||||
patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False), \
|
patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)),
|
||||||
patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []), \
|
patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False),
|
||||||
patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
|
patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []),
|
||||||
name = "Test", zipcode = "12345", location = "Berlin"
|
patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
|
||||||
)):
|
name = "Test", zipcode = "12345", location = "Berlin"
|
||||||
|
)),
|
||||||
|
):
|
||||||
|
|
||||||
ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
|
ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
|
||||||
base_dir, 12345
|
base_dir, 12345
|
||||||
@@ -1246,29 +1252,31 @@ class TestAdExtractorDownload:
|
|||||||
base_dir = tmp_path / "downloaded-ads"
|
base_dir = tmp_path / "downloaded-ads"
|
||||||
base_dir.mkdir()
|
base_dir.mkdir()
|
||||||
|
|
||||||
with patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
|
with (
|
||||||
|
patch.object(extractor, "web_text", new_callable = AsyncMock, side_effect = [
|
||||||
title_with_umlauts, # Title extraction
|
title_with_umlauts, # Title extraction
|
||||||
title_with_umlauts, # Second title call for full extraction
|
title_with_umlauts, # Second title call for full extraction
|
||||||
"Description text", # Description
|
"Description text", # Description
|
||||||
"03.02.2025" # Creation date
|
"03.02.2025" # Creation date
|
||||||
]), \
|
]),
|
||||||
patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
|
patch.object(extractor, "web_execute", new_callable = AsyncMock, return_value = {
|
||||||
"universalAnalyticsOpts": {
|
"universalAnalyticsOpts": {
|
||||||
"dimensions": {
|
"dimensions": {
|
||||||
"dimension92": "",
|
"dimension92": "",
|
||||||
"dimension108": ""
|
"dimension108": ""
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}), \
|
}
|
||||||
patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"), \
|
}),
|
||||||
patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}), \
|
patch.object(extractor, "_extract_category_from_ad_page", new_callable = AsyncMock, return_value = "160"),
|
||||||
patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")), \
|
patch.object(extractor, "_extract_special_attributes_from_ad_page", new_callable = AsyncMock, return_value = {}),
|
||||||
patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)), \
|
patch.object(extractor, "_extract_pricing_info_from_ad_page", new_callable = AsyncMock, return_value = (None, "NOT_APPLICABLE")),
|
||||||
patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False), \
|
patch.object(extractor, "_extract_shipping_info_from_ad_page", new_callable = AsyncMock, return_value = ("NOT_APPLICABLE", None, None)),
|
||||||
patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []), \
|
patch.object(extractor, "_extract_sell_directly_from_ad_page", new_callable = AsyncMock, return_value = False),
|
||||||
patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
|
patch.object(extractor, "_download_images_from_ad_page", new_callable = AsyncMock, return_value = []),
|
||||||
name = "Test", zipcode = "12345", location = "Berlin"
|
patch.object(extractor, "_extract_contact_from_ad_page", new_callable = AsyncMock, return_value = ContactPartial(
|
||||||
)):
|
name = "Test", zipcode = "12345", location = "Berlin"
|
||||||
|
)),
|
||||||
|
):
|
||||||
|
|
||||||
ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
|
ad_cfg, result_dir = await extractor._extract_ad_page_info_with_directory_handling(
|
||||||
base_dir, 12345
|
base_dir, 12345
|
||||||
@@ -1285,7 +1293,10 @@ class TestAdExtractorDownload:
|
|||||||
|
|
||||||
from kleinanzeigen_bot.utils import dicts # noqa: PLC0415
|
from kleinanzeigen_bot.utils import dicts # noqa: PLC0415
|
||||||
|
|
||||||
header_string = "# yaml-language-server: $schema=https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json"
|
header_string = (
|
||||||
|
"# yaml-language-server: $schema="
|
||||||
|
"https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/refs/heads/main/schemas/ad.schema.json"
|
||||||
|
)
|
||||||
|
|
||||||
# save_dict normalizes path to NFC, matching the NFC directory name
|
# save_dict normalizes path to NFC, matching the NFC directory name
|
||||||
dicts.save_dict(str(ad_file_path), ad_cfg.model_dump(), header = header_string)
|
dicts.save_dict(str(ad_file_path), ad_cfg.model_dump(), header = header_string)
|
||||||
|
|||||||
@@ -1582,6 +1582,14 @@ def test_file_logger_writes_message(tmp_path:Path, caplog:pytest.LogCaptureFixtu
|
|||||||
assert "Logger test log message" in contents
|
assert "Logger test log message" in contents
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_price_reduction_persistence(count:int | None) -> dict[str, Any]:
|
||||||
|
"""Return a dict with price_reduction_count only when count is positive (count -> dict[str, Any])."""
|
||||||
|
ad_cfg_orig:dict[str, Any] = {}
|
||||||
|
if count is not None and count > 0:
|
||||||
|
ad_cfg_orig["price_reduction_count"] = count
|
||||||
|
return ad_cfg_orig
|
||||||
|
|
||||||
|
|
||||||
class TestPriceReductionPersistence:
|
class TestPriceReductionPersistence:
|
||||||
"""Tests for price_reduction_count persistence logic."""
|
"""Tests for price_reduction_count persistence logic."""
|
||||||
|
|
||||||
@@ -1589,12 +1597,8 @@ class TestPriceReductionPersistence:
|
|||||||
def test_persistence_logic_saves_when_count_positive(self) -> None:
|
def test_persistence_logic_saves_when_count_positive(self) -> None:
|
||||||
"""Test the conditional logic that decides whether to persist price_reduction_count."""
|
"""Test the conditional logic that decides whether to persist price_reduction_count."""
|
||||||
# Simulate the logic from publish_ad lines 1076-1079
|
# Simulate the logic from publish_ad lines 1076-1079
|
||||||
ad_cfg_orig:dict[str, Any] = {}
|
|
||||||
|
|
||||||
# Test case 1: price_reduction_count = 3 (should persist)
|
# Test case 1: price_reduction_count = 3 (should persist)
|
||||||
price_reduction_count = 3
|
ad_cfg_orig = _apply_price_reduction_persistence(3)
|
||||||
if price_reduction_count is not None and price_reduction_count > 0:
|
|
||||||
ad_cfg_orig["price_reduction_count"] = price_reduction_count
|
|
||||||
|
|
||||||
assert "price_reduction_count" in ad_cfg_orig
|
assert "price_reduction_count" in ad_cfg_orig
|
||||||
assert ad_cfg_orig["price_reduction_count"] == 3
|
assert ad_cfg_orig["price_reduction_count"] == 3
|
||||||
@@ -1602,23 +1606,15 @@ class TestPriceReductionPersistence:
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_persistence_logic_skips_when_count_zero(self) -> None:
|
def test_persistence_logic_skips_when_count_zero(self) -> None:
|
||||||
"""Test that price_reduction_count == 0 does not get persisted."""
|
"""Test that price_reduction_count == 0 does not get persisted."""
|
||||||
ad_cfg_orig:dict[str, Any] = {}
|
|
||||||
|
|
||||||
# Test case 2: price_reduction_count = 0 (should NOT persist)
|
# Test case 2: price_reduction_count = 0 (should NOT persist)
|
||||||
price_reduction_count = 0
|
ad_cfg_orig = _apply_price_reduction_persistence(0)
|
||||||
if price_reduction_count is not None and price_reduction_count > 0:
|
|
||||||
ad_cfg_orig["price_reduction_count"] = price_reduction_count
|
|
||||||
|
|
||||||
assert "price_reduction_count" not in ad_cfg_orig
|
assert "price_reduction_count" not in ad_cfg_orig
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_persistence_logic_skips_when_count_none(self) -> None:
|
def test_persistence_logic_skips_when_count_none(self) -> None:
|
||||||
"""Test that price_reduction_count == None does not get persisted."""
|
"""Test that price_reduction_count == None does not get persisted."""
|
||||||
ad_cfg_orig:dict[str, Any] = {}
|
|
||||||
|
|
||||||
# Test case 3: price_reduction_count = None (should NOT persist)
|
# Test case 3: price_reduction_count = None (should NOT persist)
|
||||||
price_reduction_count = None
|
ad_cfg_orig = _apply_price_reduction_persistence(None)
|
||||||
if price_reduction_count is not None and price_reduction_count > 0:
|
|
||||||
ad_cfg_orig["price_reduction_count"] = price_reduction_count
|
|
||||||
|
|
||||||
assert "price_reduction_count" not in ad_cfg_orig
|
assert "price_reduction_count" not in ad_cfg_orig
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from kleinanzeigen_bot.utils.pydantics import ContextualValidationError
|
|||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class _ApplyAutoPriceReduction(Protocol):
|
class _ApplyAutoPriceReduction(Protocol):
|
||||||
def __call__(self, ad_cfg:SimpleNamespace, ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None:
|
def __call__(self, ad_cfg:SimpleNamespace, ad_cfg_orig:dict[str, Any], ad_file_relative:str) -> None:
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ConfigProtocol(Protocol):
|
|||||||
user_data_dir:str | None
|
user_data_dir:str | None
|
||||||
|
|
||||||
def add_extension(self, ext:str) -> None:
|
def add_extension(self, ext:str) -> None:
|
||||||
...
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _nodriver_start_mock() -> Mock:
|
def _nodriver_start_mock() -> Mock:
|
||||||
|
|||||||
Reference in New Issue
Block a user