From 0a2406e093febddd6afeb1d010df4fc2ecdbaf79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:01 +0300 Subject: [PATCH 01/21] types: add stubs for PyMySQL and PyYAML --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 261a4db..5f6b8dd 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,8 @@ pandas = "^2.2.0" [tool.poetry.group.dev.dependencies] ruff = "^0.7.4" mypy = "^1.13.0" +types-PyMySQL = "^1.1.0.20240524" +types-PyYAML = "^6.0.12.20240917" [tool.poetry.group.legacy] optional = true From 0af84f897126260ecab6638e6981cc73e727c173 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:05 +0300 Subject: [PATCH 02/21] types: narrow Redis voice session payload --- app/voice/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/voice/session.py b/app/voice/session.py index 45a385e..36ae8bc 100755 --- a/app/voice/session.py +++ b/app/voice/session.py @@ -2,7 +2,7 @@ import json from datetime import datetime, timezone -from typing import Any +from typing import Any, cast class VoiceSessionStore: @@ -66,7 +66,7 @@ def get_session(self, call_sid: str) -> dict[str, Any] | None: return None if isinstance(raw, bytes): raw = raw.decode("utf-8") - return json.loads(raw) + return cast(dict[str, Any], json.loads(raw)) def save_session(self, state: dict[str, Any]) -> None: state["updated_at"] = self._now() From 876654de84c87c91f7afd329aca0f6feb44bd648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:07 +0300 Subject: [PATCH 03/21] types: annotate integration test entrypoint --- integrations/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/__main__.py b/integrations/__main__.py index 062a391..deb6289 100755 --- a/integrations/__main__.py +++ b/integrations/__main__.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) -async def test_integrations(): +async def test_integrations() -> None: """Test all configured integrations.""" settings = get_settings() From b4fad921abf1f0563202629bc240974942cafd3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:10 +0300 Subject: [PATCH 04/21] types: handle gather BaseException results --- integrations/webhooks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integrations/webhooks.py b/integrations/webhooks.py index d08ce84..646cc0c 100755 --- a/integrations/webhooks.py +++ b/integrations/webhooks.py @@ -64,7 +64,7 @@ async def send_new_lead(self, listing: ScrapedListing) -> List[bool]: success_results = [] for i, result in enumerate(results): - if isinstance(result, Exception): + if isinstance(result, BaseException): logger.error(f"Error sending to webhook {self.config.urls[i]}: {result}") success_results.append(False) else: @@ -107,7 +107,7 @@ async def send_batch_leads(self, listings: List[ScrapedListing]) -> List[bool]: success_results = [] for i, result in enumerate(results): - if isinstance(result, Exception): + if isinstance(result, BaseException): logger.error(f"Error sending to webhook {self.config.urls[i]}: {result}") success_results.append(False) else: From ed28e1019e8a87ff4684e1387f34019ff6e777e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:15 +0300 Subject: [PATCH 05/21] types: normalize AmoCRM lead id to int --- integrations/amocrm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/amocrm.py b/integrations/amocrm.py index 8d69ee0..2f4ecc6 100755 --- a/integrations/amocrm.py +++ b/integrations/amocrm.py @@ -139,7 +139,7 @@ async def create_lead(self, listing: ScrapedListing) -> Optional[int]: if response.status_code in [200, 201]: result = response.json() if "id" in result["add"][0]: - lead_id = result["add"][0]["id"] + lead_id = int(result["add"][0]["id"]) logger.info(f"Successfully created AmoCRM lead with ID: {lead_id}") return lead_id else: From d605b4cc56c6758e42e0674b42d8dc8d0b760376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:19 +0300 Subject: [PATCH 06/21] types: keep CRM result variables type-specific --- app/scraper_worker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/scraper_worker.py b/app/scraper_worker.py index 3135502..a638c48 100755 --- a/app/scraper_worker.py +++ b/app/scraper_worker.py @@ -67,11 +67,11 @@ async def send_to_integrations(listings: list[ScrapedListing]) -> None: bitrix24_integration = get_bitrix24_integration() if bitrix24_integration is not None: try: - successful_ids = await bitrix24_integration.send_batch_deals(listings) + successful_deal_ids = await bitrix24_integration.send_batch_deals(listings) logger.info( "Bitrix24 batch send completed", count=len(listings), - success_count=len(successful_ids), + success_count=len(successful_deal_ids), ) except Exception as exc: # noqa: BLE001 logger.warning("Bitrix24 integration failed", error=str(exc)) From 9792cb7d52bc222e35c3772f579b9b281d0db18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:32 +0300 Subject: [PATCH 07/21] types: make optional text-to-speech import explicit --- app/voice/prompts.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/voice/prompts.py b/app/voice/prompts.py index 70ae521..b4b756b 100755 --- a/app/voice/prompts.py +++ b/app/voice/prompts.py @@ -7,9 +7,11 @@ from app.voice.extractors import QUESTION_SEQUENCE try: - from google.cloud import texttospeech + from google.cloud import texttospeech as _texttospeech except ImportError: # pragma: no cover - optional dependency path - texttospeech = None + _texttospeech = None + +texttospeech: Any = _texttospeech SCRIPT_NAME = "bg_listing_v1" From b651809b866f713f2aa2046d4ce47353c0c19322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:35 +0300 Subject: [PATCH 08/21] types: narrow optional speech integration values --- app/voice/media.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/voice/media.py b/app/voice/media.py index ed5ebe0..8e1525b 100755 --- a/app/voice/media.py +++ b/app/voice/media.py @@ -6,14 +6,16 @@ import os import queue import threading -from typing import Any +from typing import Any, cast from app.core.logging import get_logger try: - from google.cloud import speech + from google.cloud import speech as _speech except ImportError: # pragma: no cover - optional dependency path - speech = None + _speech = None + +speech: Any = _speech logger = get_logger("voice.media") @@ -104,7 +106,7 @@ def request_generator() -> Any: break yield speech.StreamingRecognizeRequest(audio_content=chunk) - responses = client.streaming_recognize(streaming_config, request_generator()) + responses = client.streaming_recognize(config=streaming_config, requests=request_generator()) for response in responses: for result in response.results: if not result.alternatives: @@ -163,7 +165,7 @@ async def handle_media_message( ) if call_sid: manager.stop_stream(call_sid) - return call_sid + return cast(str | None, call_sid) await asyncio.sleep(0) return None From 9fa787da9ac8fa1ed509d7dbe1c81cf7437b4fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:37 +0300 Subject: [PATCH 09/21] types: narrow voice service dynamic returns --- app/voice/service.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/voice/service.py b/app/voice/service.py index 7af594a..15415a0 100755 --- a/app/voice/service.py +++ b/app/voice/service.py @@ -3,7 +3,7 @@ import csv from datetime import UTC, datetime, timezone from io import StringIO -from typing import Any +from typing import Any, cast from app.core.config import get_settings from app.core.logging import get_logger @@ -131,7 +131,7 @@ def validate_twilio_request(self, url: str, params: dict[str, str], signature: s logger.warning("Twilio request validation skipped because twilio is not installed.") return True validator = RequestValidator(self._settings.twilio_auth_token) - return validator.validate(url, params, signature) + return bool(validator.validate(url, params, signature)) async def start_listing_call( self, listing_ad_id: str, initiated_by: str = "api" @@ -202,7 +202,7 @@ async def start_listing_call( async def bootstrap_session(self, voice_call_id: int, call_sid: str) -> dict[str, Any]: existing = self._session_store.get_session(call_sid) if existing is not None: - return existing + return cast(dict[str, Any], existing) voice_call = await repository.get_voice_call(voice_call_id) if voice_call is None: @@ -241,7 +241,7 @@ async def bootstrap_session(self, voice_call_id: int, call_sid: str) -> dict[str session = self._session_store.get_session(call_sid) if session is None: raise RuntimeError("Voice session was not created.") - return session + return cast(dict[str, Any], session) async def persist_session_snapshot(self, call_sid: str, *, status: str | None = None) -> None: session = self._session_store.get_session(call_sid) From 17e604459005d8b1b29a53f73e18c0d7854d26a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:40 +0300 Subject: [PATCH 10/21] types: narrow Streamlit auth config --- app/ui/streamlit_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/ui/streamlit_app.py b/app/ui/streamlit_app.py index 3c02c3b..02aa80f 100755 --- a/app/ui/streamlit_app.py +++ b/app/ui/streamlit_app.py @@ -5,7 +5,7 @@ import sys from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, cast import pandas as pd import streamlit as st @@ -36,7 +36,7 @@ def load_auth_config() -> dict[str, Any]: with open(settings.streamlit_users_yaml_path, "r", encoding="utf-8") as file: config = yaml.load(file, Loader=SafeLoader) - return config + return cast(dict[str, Any], config) def render_login() -> tuple[bool, str | None]: From 5b30d46f0b5cc9530aff54177fd4c0212716aca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:55:50 +0300 Subject: [PATCH 11/21] types: make optional Sentry logging imports explicit --- app/core/logging.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/app/core/logging.py b/app/core/logging.py index e574089..e0bfab7 100755 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -3,23 +3,29 @@ import logging import sys from pathlib import Path +from typing import Any, cast import structlog try: - import sentry_sdk - from sentry_sdk.integrations.aiohttp import AioHttpIntegration - from sentry_sdk.integrations.httpx import HttpxIntegration - from sentry_sdk.integrations.logging import LoggingIntegration + import sentry_sdk as _sentry_sdk + from sentry_sdk.integrations.aiohttp import AioHttpIntegration as _AioHttpIntegration + from sentry_sdk.integrations.httpx import HttpxIntegration as _HttpxIntegration + from sentry_sdk.integrations.logging import LoggingIntegration as _LoggingIntegration SENTRY_AVAILABLE = True except ImportError: # pragma: no cover - optional dependency path - sentry_sdk = None - LoggingIntegration = None - AioHttpIntegration = None - HttpxIntegration = None + _sentry_sdk = None + _LoggingIntegration = None + _AioHttpIntegration = None + _HttpxIntegration = None SENTRY_AVAILABLE = False +sentry_sdk: Any = _sentry_sdk +LoggingIntegration: Any = _LoggingIntegration +AioHttpIntegration: Any = _AioHttpIntegration +HttpxIntegration: Any = _HttpxIntegration + def configure_logging( sentry_dsn: str = "", @@ -35,7 +41,7 @@ def configure_logging( if debug: level = logging.DEBUG - shared_processors = [ + shared_processors: list[structlog.types.Processor] = [ structlog.contextvars.merge_contextvars, structlog.stdlib.add_log_level, structlog.stdlib.add_logger_name, @@ -111,4 +117,4 @@ def capture_exception(exc: BaseException) -> None: def get_logger(name: str) -> structlog.stdlib.BoundLogger: - return structlog.get_logger(name) + return cast(structlog.stdlib.BoundLogger, structlog.get_logger(name)) From f71499151187c2be73b1d5c03de81dbfdbac7404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:56:54 +0300 Subject: [PATCH 12/21] types: normalize scraper attributes and async results --- app/services/async_scraper.py | 78 +++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/app/services/async_scraper.py b/app/services/async_scraper.py index 1defaa1..caeb189 100755 --- a/app/services/async_scraper.py +++ b/app/services/async_scraper.py @@ -5,7 +5,7 @@ import re from dataclasses import dataclass from datetime import date -from typing import Sequence +from typing import Any, Callable, Sequence from urllib.parse import parse_qs, urljoin, urlparse import httpx @@ -16,16 +16,21 @@ try: from utils import ( - extract_names, - extract_phone_numbers, - looks_like_person_name, - normalize_phone_number, + extract_names as _extract_names, + extract_phone_numbers as _extract_phone_numbers, + looks_like_person_name as _looks_like_person_name, + normalize_phone_number as _normalize_phone_number, ) except ImportError: # pragma: no cover - fallback path for isolated runtimes - extract_names = None - extract_phone_numbers = None - looks_like_person_name = None - normalize_phone_number = None + _extract_names = None + _extract_phone_numbers = None + _looks_like_person_name = None + _normalize_phone_number = None + +extract_names: Callable[[str], list[str]] | None = _extract_names +extract_phone_numbers: Callable[[str], list[str]] | None = _extract_phone_numbers +looks_like_person_name: Callable[[str], bool] | None = _looks_like_person_name +normalize_phone_number: Callable[[str], str] | None = _normalize_phone_number logger = get_logger("async_scraper") @@ -114,7 +119,7 @@ def __init__(self, site_config: SiteConfig, settings: Settings) -> None: ) self._proxy_index = 0 - def _get_client_kwargs(self) -> dict[str, object]: + def _get_client_kwargs(self) -> dict[str, Any]: headers = { "User-Agent": self._pick_user_agent(), "Accept": ( @@ -162,6 +167,17 @@ def _pick_proxy(self) -> str | None: return proxy return random.choice(pool) + @staticmethod + def _attr_str(tag: Tag | None, name: str, default: str = "") -> str: + if tag is None: + return default + value = tag.get(name) + if value is None: + return default + if isinstance(value, list): + return " ".join(str(item) for item in value) + return str(value) + async def scrape(self) -> list[ScrapedListing]: today = date.today().isoformat() async with httpx.AsyncClient(**self._get_client_kwargs()) as client: @@ -175,7 +191,7 @@ async def scrape(self) -> list[ScrapedListing]: listings: list[ScrapedListing] = [] seen_ids: set[str] = set() for result in page_results: - if isinstance(result, Exception): + if isinstance(result, BaseException): logger.exception( "Page scraping failed", site=self.site_config.name, @@ -298,7 +314,7 @@ def _parse_imoti_listing_page(self, html: str, base_url: str) -> list[ScrapedLis soup = BeautifulSoup(html, "html.parser") exact_cards = soup.select("article.product-classic") if exact_cards: - results: list[ScrapedListing] = [] + exact_results: list[ScrapedListing] = [] seen_ids: set[str] = set() for article in exact_cards: listing = self._parse_imoti_card_exact(article, base_url) @@ -306,8 +322,8 @@ def _parse_imoti_listing_page(self, html: str, base_url: str) -> list[ScrapedLis continue seen_ids.add(listing.ad_id) if self._passes_filters(listing): - results.append(listing) - return results + exact_results.append(listing) + return exact_results soup = BeautifulSoup(html, "lxml") links = soup.select(self.site_config.selectors.get("link", "a[href*='/наеми/']")) @@ -315,7 +331,7 @@ def _parse_imoti_listing_page(self, html: str, base_url: str) -> list[ScrapedLis seen_links: set[str] = set() for link_el in links: - link = self._normalize_link(base_url, link_el.get("href")) + link = self._normalize_link(base_url, self._attr_str(link_el, "href")) if not link or link in seen_links: continue seen_links.add(link) @@ -347,7 +363,7 @@ def _parse_imoti_card_exact(self, article: Tag, base_url: str) -> ScrapedListing if title_anchor is None: return None - link = self._normalize_link(base_url, title_anchor.get("href")) + link = self._normalize_link(base_url, self._attr_str(title_anchor, "href")) if not link: return None @@ -416,7 +432,7 @@ def _parse_alo_card(self, card: Tag, base_url: str) -> ScrapedListing | None: if link_el is None: return None - link = self._normalize_link(base_url, link_el.get("href")) + link = self._normalize_link(base_url, self._attr_str(link_el, "href")) if not link: return None @@ -430,7 +446,7 @@ def _parse_alo_card(self, card: Tag, base_url: str) -> ScrapedListing | None: title = self._title_from_url(link) image = card.select_one("a.avn_image img[alt], img[alt]") - image_alt = self._clean_text(image.get("alt", "") if image is not None else "") + image_alt = self._clean_text(self._attr_str(image, "alt")) card_text = self._clean_text(card.get_text(" ", strip=True)) combined_text = self._clean_text(f"{title} {image_alt} {card_text}") @@ -447,9 +463,7 @@ def _parse_alo_card(self, card: Tag, base_url: str) -> ScrapedListing | None: ad_id_match = re.search( r"adrows_(\d{4,12})", - " ".join( - card.get("id", []) if isinstance(card.get("id"), list) else [card.get("id", "")] - ), + self._attr_str(card, "id"), ) ad_id = ad_id_match.group(1) if ad_id_match else self._extract_ad_id(link) @@ -486,7 +500,7 @@ def _parse_generic_anchor_page(self, html: str, base_url: str) -> list[ScrapedLi seen_links: set[str] = set() for anchor in soup.select("a[href]"): - link = self._normalize_link(base_url, anchor.get("href")) + link = self._normalize_link(base_url, self._attr_str(anchor, "href")) if not link or link in seen_links or not self._link_looks_like_listing(link): continue @@ -518,7 +532,7 @@ def _parse_card(self, card: Tag, base_url: str) -> ScrapedListing | None: try: link_selector = self.site_config.selectors.get("link", "a[href]") link_el = card.select_one(link_selector) - link = self._normalize_link(base_url, link_el.get("href") if link_el else None) + link = self._normalize_link(base_url, self._attr_str(link_el, "href") if link_el else None) if not link: return None @@ -596,7 +610,7 @@ def _enrich_generic_detail(self, soup: BeautifulSoup, listing: ScrapedListing) - mailto_links = soup.select("a[href^='mailto:']") emails = [ - a.get("href", "").split(":", 1)[-1].split("?", 1)[0].strip() + self._attr_str(a, "href").split(":", 1)[-1].split("?", 1)[0].strip() for a in mailto_links if a.get("href") ] @@ -618,7 +632,7 @@ def _enrich_imoti_detail(self, soup: BeautifulSoup, listing: ScrapedListing) -> for block in soup.select("div.block-person-link"): icon = block.select_one("span.icon") - icon_classes = " ".join(icon.get("class", [])) if icon is not None else "" + icon_classes = self._attr_str(icon, "class") block_text = self._clean_text(block.get_text(" ", strip=True)) if "mdi-account" in icon_classes and block_text: @@ -629,7 +643,7 @@ def _enrich_imoti_detail(self, soup: BeautifulSoup, listing: ScrapedListing) -> if "mdi-phone" in icon_classes and not listing.phone: tel = block.select_one("a[href^='tel:']") - phone_source = tel.get("href", "") if tel is not None else block_text + phone_source = self._attr_str(tel, "href") if tel is not None else block_text phone = self._extract_phone_from_text(phone_source) if phone: listing.phone = phone @@ -805,7 +819,7 @@ def _extract_contact_email_from_detail_soup(self, soup: BeautifulSoup) -> str: for root in soup.select("div.block-person-link, div.block-info, div.block-agent"): mail = root.select_one("a[href^='mailto:']") if mail is not None: - email = mail.get("href", "").replace("mailto:", "").split("?", 1)[0].strip() + email = self._attr_str(mail, "href").replace("mailto:", "").split("?", 1)[0].strip() if email: return email match = EMAIL_RE.search(root.get_text(" ", strip=True)) @@ -814,7 +828,7 @@ def _extract_contact_email_from_detail_soup(self, soup: BeautifulSoup) -> str: mail = soup.select_one("a[href^='mailto:']") if mail is not None: - email = mail.get("href", "").replace("mailto:", "").split("?", 1)[0].strip() + email = self._attr_str(mail, "href").replace("mailto:", "").split("?", 1)[0].strip() if email: return email @@ -875,7 +889,7 @@ def _extract_phone_from_text(self, text: str) -> str: def _extract_phone_from_detail_soup(self, soup: BeautifulSoup) -> str: tel_link = soup.select_one("a[href^='tel:']") if tel_link is not None: - phone = self._extract_phone_from_text(tel_link.get("href", "")) + phone = self._extract_phone_from_text(self._attr_str(tel_link, "href")) if phone: return phone @@ -976,7 +990,7 @@ def _extract_image(self, card: Tag | None, base_url: str) -> str: image = card.select_one("img[src], img[data-src]") if image is None: return "" - return self._normalize_link(base_url, image.get("src") or image.get("data-src")) + return self._normalize_link(base_url, self._attr_str(image, "src") or self._attr_str(image, "data-src")) def _extract_seller_name(self, card: Tag | None) -> str: if card is None: @@ -1003,7 +1017,7 @@ def _guess_contact_name(self, text: str) -> str: def _extract_phone_from_imoti_card(self, article: Tag) -> str: tel_link = article.select_one('a[href^="tel:"]') if tel_link: - phone = self._extract_phone_from_text(tel_link.get("href", "")) + phone = self._extract_phone_from_text(self._attr_str(tel_link, "href")) if phone: return phone @@ -1079,7 +1093,7 @@ async def scrape_all_sites(self) -> list[ScrapedListing]: all_listings: list[ScrapedListing] = [] for index, result in enumerate(results): - if isinstance(result, Exception): + if isinstance(result, BaseException): logger.exception( "Site scrape failed", site=self.scrapers[index].site_config.name, From 3bb9fc1d4755fb508630fc7e688a042f99b7203b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:57:39 +0300 Subject: [PATCH 13/21] types: predeclare optional Sentry integrations --- app/core/logging.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/app/core/logging.py b/app/core/logging.py index e0bfab7..181d5fc 100755 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -7,25 +7,29 @@ import structlog +sentry_sdk: Any +LoggingIntegration: Any +AioHttpIntegration: Any +HttpxIntegration: Any + try: import sentry_sdk as _sentry_sdk from sentry_sdk.integrations.aiohttp import AioHttpIntegration as _AioHttpIntegration from sentry_sdk.integrations.httpx import HttpxIntegration as _HttpxIntegration from sentry_sdk.integrations.logging import LoggingIntegration as _LoggingIntegration + sentry_sdk = _sentry_sdk + LoggingIntegration = _LoggingIntegration + AioHttpIntegration = _AioHttpIntegration + HttpxIntegration = _HttpxIntegration SENTRY_AVAILABLE = True except ImportError: # pragma: no cover - optional dependency path - _sentry_sdk = None - _LoggingIntegration = None - _AioHttpIntegration = None - _HttpxIntegration = None + sentry_sdk = None + LoggingIntegration = None + AioHttpIntegration = None + HttpxIntegration = None SENTRY_AVAILABLE = False -sentry_sdk: Any = _sentry_sdk -LoggingIntegration: Any = _LoggingIntegration -AioHttpIntegration: Any = _AioHttpIntegration -HttpxIntegration: Any = _HttpxIntegration - def configure_logging( sentry_dsn: str = "", From 0e65a8a617aa646a6c6653978c35a8f5bf3d5194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:57:42 +0300 Subject: [PATCH 14/21] types: predeclare optional TTS module --- app/voice/prompts.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/voice/prompts.py b/app/voice/prompts.py index b4b756b..40d04f4 100755 --- a/app/voice/prompts.py +++ b/app/voice/prompts.py @@ -6,12 +6,13 @@ from app.voice.extractors import QUESTION_SEQUENCE +texttospeech: Any try: from google.cloud import texttospeech as _texttospeech -except ImportError: # pragma: no cover - optional dependency path - _texttospeech = None -texttospeech: Any = _texttospeech + texttospeech = _texttospeech +except ImportError: # pragma: no cover - optional dependency path + texttospeech = None SCRIPT_NAME = "bg_listing_v1" From 390a70c5522273008afd6ab99abb103eb9720a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:57:46 +0300 Subject: [PATCH 15/21] types: predeclare optional speech module --- app/voice/media.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/voice/media.py b/app/voice/media.py index 8e1525b..39a760e 100755 --- a/app/voice/media.py +++ b/app/voice/media.py @@ -10,12 +10,13 @@ from app.core.logging import get_logger +speech: Any try: from google.cloud import speech as _speech -except ImportError: # pragma: no cover - optional dependency path - _speech = None -speech: Any = _speech + speech = _speech +except ImportError: # pragma: no cover - optional dependency path + speech = None logger = get_logger("voice.media") From 8893cc219d644efd47f64f33bc2822fe3911411c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:57:49 +0300 Subject: [PATCH 16/21] types: predeclare optional scraper utilities --- app/services/async_scraper.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/services/async_scraper.py b/app/services/async_scraper.py index caeb189..a56937d 100755 --- a/app/services/async_scraper.py +++ b/app/services/async_scraper.py @@ -14,6 +14,11 @@ from app.core.config import Settings, SiteConfig from app.core.logging import get_logger +extract_names: Callable[[str], list[str]] | None +extract_phone_numbers: Callable[[str], list[str]] | None +looks_like_person_name: Callable[[str], bool] | None +normalize_phone_number: Callable[[str], str] | None + try: from utils import ( extract_names as _extract_names, @@ -21,16 +26,16 @@ looks_like_person_name as _looks_like_person_name, normalize_phone_number as _normalize_phone_number, ) + + extract_names = _extract_names + extract_phone_numbers = _extract_phone_numbers + looks_like_person_name = _looks_like_person_name + normalize_phone_number = _normalize_phone_number except ImportError: # pragma: no cover - fallback path for isolated runtimes - _extract_names = None - _extract_phone_numbers = None - _looks_like_person_name = None - _normalize_phone_number = None - -extract_names: Callable[[str], list[str]] | None = _extract_names -extract_phone_numbers: Callable[[str], list[str]] | None = _extract_phone_numbers -looks_like_person_name: Callable[[str], bool] | None = _looks_like_person_name -normalize_phone_number: Callable[[str], str] | None = _normalize_phone_number + extract_names = None + extract_phone_numbers = None + looks_like_person_name = None + normalize_phone_number = None logger = get_logger("async_scraper") From 3d7b42fd292f3f2716e1d49ada6fd4f8a6534db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:59:10 +0300 Subject: [PATCH 17/21] ci: capture Ruff formatting for typing changes --- .github/workflows/ci-cd.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index b80c4c4..4c1d7ef 100755 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -34,8 +34,16 @@ jobs: - name: Ruff run: poetry run ruff check app/ integrations/ utils.py - - name: Ruff format check - run: poetry run ruff format --check app/ integrations/ utils.py + - name: Ruff format preview + run: | + poetry run ruff format app/services/async_scraper.py app/voice/media.py + python - <<'PY' + import base64 + from pathlib import Path + for name in ("app/services/async_scraper.py", "app/voice/media.py"): + print(f"FORMATTED_FILE {name} {base64.b64encode(Path(name).read_bytes()).decode('ascii')}") + PY + exit 1 typing: runs-on: ubuntu-latest From 63d19e66152b5a8548735ccf65d7afb4d97ae042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:00:13 +0300 Subject: [PATCH 18/21] types: narrow media call sid returns --- app/voice/media.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/voice/media.py b/app/voice/media.py index 39a760e..e0817de 100755 --- a/app/voice/media.py +++ b/app/voice/media.py @@ -142,7 +142,7 @@ async def handle_media_message( ) manager.register_stream(call_sid, stream_sid) manager.ensure_stream(call_sid) - return call_sid + return cast(str, call_sid) if event == "media": media_payload = payload.get("media", {}) @@ -157,7 +157,7 @@ async def handle_media_message( encoded_audio = media_payload.get("payload", "") if encoded_audio: manager.push_audio(call_sid, base64.b64decode(encoded_audio)) - return call_sid + return cast(str, call_sid) if event == "stop": stop_payload = payload.get("stop", {}) From 537eeb13ea69a612764f4ac44299b5cda4e611a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:02:06 +0300 Subject: [PATCH 19/21] style: apply Ruff format to app/services/async_scraper.py --- app/services/async_scraper.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/services/async_scraper.py b/app/services/async_scraper.py index a56937d..bfd746f 100755 --- a/app/services/async_scraper.py +++ b/app/services/async_scraper.py @@ -537,7 +537,9 @@ def _parse_card(self, card: Tag, base_url: str) -> ScrapedListing | None: try: link_selector = self.site_config.selectors.get("link", "a[href]") link_el = card.select_one(link_selector) - link = self._normalize_link(base_url, self._attr_str(link_el, "href") if link_el else None) + link = self._normalize_link( + base_url, self._attr_str(link_el, "href") if link_el else None + ) if not link: return None @@ -995,7 +997,9 @@ def _extract_image(self, card: Tag | None, base_url: str) -> str: image = card.select_one("img[src], img[data-src]") if image is None: return "" - return self._normalize_link(base_url, self._attr_str(image, "src") or self._attr_str(image, "data-src")) + return self._normalize_link( + base_url, self._attr_str(image, "src") or self._attr_str(image, "data-src") + ) def _extract_seller_name(self, card: Tag | None) -> str: if card is None: From 3e0c88e87eea9473d4fc287ab1482205aff47c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:02:11 +0300 Subject: [PATCH 20/21] style: apply Ruff format to app/voice/media.py --- app/voice/media.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/voice/media.py b/app/voice/media.py index e0817de..4043163 100755 --- a/app/voice/media.py +++ b/app/voice/media.py @@ -107,7 +107,9 @@ def request_generator() -> Any: break yield speech.StreamingRecognizeRequest(audio_content=chunk) - responses = client.streaming_recognize(config=streaming_config, requests=request_generator()) + responses = client.streaming_recognize( + config=streaming_config, requests=request_generator() + ) for response in responses: for result in response.results: if not result.alternatives: From 83adb32b90d2c398da58dd703cd24395f16f7325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D0=B0=D0=B2=D0=BB=D0=BE?= <96915401+Rimus0cod@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:02:20 +0300 Subject: [PATCH 21/21] ci: make strict MyPy blocking again --- .github/workflows/ci-cd.yml | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 4c1d7ef..5da7c7f 100755 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -34,16 +34,8 @@ jobs: - name: Ruff run: poetry run ruff check app/ integrations/ utils.py - - name: Ruff format preview - run: | - poetry run ruff format app/services/async_scraper.py app/voice/media.py - python - <<'PY' - import base64 - from pathlib import Path - for name in ("app/services/async_scraper.py", "app/voice/media.py"): - print(f"FORMATTED_FILE {name} {base64.b64encode(Path(name).read_bytes()).decode('ascii')}") - PY - exit 1 + - name: Ruff format check + run: poetry run ruff format --check app/ integrations/ utils.py typing: runs-on: ubuntu-latest @@ -64,8 +56,7 @@ jobs: poetry config virtualenvs.create false poetry install --with dev - - name: MyPy (known typing debt) - continue-on-error: true + - name: MyPy run: poetry run mypy app/ integrations/ frontend: