From 488fbf62969df70d90969e2d53b2cbdab5c0ef4d 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:07:44 +0300 Subject: [PATCH 01/13] refactor: add scraper package --- app/scraper/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/scraper/__init__.py diff --git a/app/scraper/__init__.py b/app/scraper/__init__.py new file mode 100644 index 0000000..cce03c0 --- /dev/null +++ b/app/scraper/__init__.py @@ -0,0 +1,3 @@ +from app.scraper.models import ScrapedListing + +__all__ = ["ScrapedListing"] From cde5b1ffa1570f915649d814f69d0d23d69f987b 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:07:47 +0300 Subject: [PATCH 02/13] refactor: move scraped listing model to scraper package --- app/scraper/models.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 app/scraper/models.py diff --git a/app/scraper/models.py b/app/scraper/models.py new file mode 100644 index 0000000..4c0dceb --- /dev/null +++ b/app/scraper/models.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(slots=True) +class ScrapedListing: + ad_id: str + title: str + price: str + location: str + size: str + link: str + image_url: str = "" + source_site: str = "" + phone: str = "" + seller_name: str = "" + ad_type: str = "" + contact_name: str = "-" + contact_email: str = "-" + date_seen: str = "" From 52b23c485e3fbbb6cca221170d59a4f4d36eab7e 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:07:49 +0300 Subject: [PATCH 03/13] refactor: add scraper source package --- app/scraper/sources/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/scraper/sources/__init__.py diff --git a/app/scraper/sources/__init__.py b/app/scraper/sources/__init__.py new file mode 100644 index 0000000..27e2d5c --- /dev/null +++ b/app/scraper/sources/__init__.py @@ -0,0 +1,3 @@ +from app.scraper.sources.alo import AloSourceParser + +__all__ = ["AloSourceParser"] From c79df817d8431f3e1c36eeea7f506d0b9a08f60b 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:07:52 +0300 Subject: [PATCH 04/13] refactor: extract ALO source parser --- app/scraper/sources/alo.py | 205 +++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 app/scraper/sources/alo.py diff --git a/app/scraper/sources/alo.py b/app/scraper/sources/alo.py new file mode 100644 index 0000000..d09ed5f --- /dev/null +++ b/app/scraper/sources/alo.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import re +from typing import Protocol + +from bs4 import BeautifulSoup, Tag + +from app.scraper.models import ScrapedListing + + +class AloParserContext(Protocol): + @staticmethod + def _attr_str(tag: Tag | None, name: str, default: str = "") -> str: ... + + def _normalize_link(self, base_url: str, href: str | None) -> str: ... + + def _clean_text(self, value: str) -> str: ... + + def _title_from_url(self, link: str) -> str: ... + + def _is_alo_listing_candidate(self, title: str, combined_text: str) -> bool: ... + + def _extract_ad_id(self, link: str) -> str: ... + + def _extract_size(self, text: str) -> str: ... + + def _extract_image(self, card: Tag | None, base_url: str) -> str: ... + + def _passes_filters(self, listing: ScrapedListing) -> bool: ... + + def _detect_ad_type(self, seller_name: str) -> str: ... + + def _extract_phone_from_text(self, text: str) -> str: ... + + +class AloSourceParser: + def __init__(self, context: AloParserContext, source_site: str) -> None: + self._context = context + self._source_site = source_site + + def parse_listing_page(self, html: str, base_url: str) -> list[ScrapedListing]: + soup = BeautifulSoup(html, "lxml") + cards = soup.select("div.ad_block_normal") + results: list[ScrapedListing] = [] + seen_ids: set[str] = set() + + for card in cards: + listing = self._parse_card(card, base_url) + if listing is None or listing.ad_id in seen_ids: + continue + seen_ids.add(listing.ad_id) + if self._context._passes_filters(listing): + results.append(listing) + + return results + + def _parse_card(self, card: Tag, base_url: str) -> ScrapedListing | None: + context = self._context + link_el = card.select_one("a.avn_seo[href], a.avn_image[href], a[href]") + if link_el is None: + return None + + link = context._normalize_link(base_url, context._attr_str(link_el, "href")) + if not link: + return None + + title_el = card.select_one("a.avn_seo[href]") + title = context._clean_text( + title_el.get_text(" ", strip=True) + if title_el is not None + else link_el.get_text(" ", strip=True) + ) + if not title: + title = context._title_from_url(link) + + image = card.select_one("a.avn_image img[alt], img[alt]") + image_alt = context._clean_text(context._attr_str(image, "alt")) + card_text = context._clean_text(card.get_text(" ", strip=True)) + combined_text = context._clean_text(f"{title} {image_alt} {card_text}") + + if not context._is_alo_listing_candidate(title=title, combined_text=combined_text): + return None + + price_el = card.select_one(".avn_price") + price = context._clean_text( + price_el.get_text(" ", strip=True) if price_el is not None else "" + ) + + location_el = card.select_one(".avn_location") + location = context._clean_text( + location_el.get_text(" ", strip=True) if location_el is not None else "" + ) + + ad_id_match = re.search( + r"adrows_(\d{4,12})", + context._attr_str(card, "id"), + ) + ad_id = ad_id_match.group(1) if ad_id_match else context._extract_ad_id(link) + + return ScrapedListing( + ad_id=ad_id, + title=title, + price=price, + location=location, + size=context._extract_size(combined_text), + link=link, + image_url=context._extract_image(card, base_url), + source_site=self._source_site, + ) + + def enrich_detail(self, soup: BeautifulSoup, listing: ScrapedListing) -> None: + context = self._context + + title_el = soup.select_one("h1.large-headline, h1") + if title_el is not None: + title = context._clean_text(title_el.get_text(" ", strip=True)) + if title: + listing.title = title + + price_text = self._extract_detail_price(soup) + if price_text: + listing.price = price_text + + params = self._extract_params(soup) + if params.get("Местоположение"): + listing.location = params["Местоположение"] + if params.get("Квадратура"): + listing.size = params["Квадратура"].replace("\xa0", " ") + + seller_name = self._extract_seller_name(soup) + if seller_name: + listing.seller_name = seller_name + + contact_name = self._extract_contact_name(soup) + if contact_name: + listing.contact_name = contact_name + + visible_phone = self._extract_phone(soup) + if visible_phone: + listing.phone = visible_phone + + if soup.select_one(".contacts_wrapper_flex.has_agents"): + listing.ad_type = "agency" + elif listing.seller_name: + listing.ad_type = context._detect_ad_type(listing.seller_name) + else: + listing.ad_type = "private" + + def _extract_detail_price(self, soup: BeautifulSoup) -> str: + for cell in soup.select(".ads-params-price"): + text = self._context._clean_text(cell.get_text(" ", strip=True)) + if "€" in text or "лв" in text.lower(): + return text.split("Цената е около", 1)[0].strip() + return "" + + def _extract_params(self, soup: BeautifulSoup) -> dict[str, str]: + params: dict[str, str] = {} + for row in soup.select(".ads-params-row"): + title_el = row.select_one(".ads-param-title") + value_candidates = row.select(".ads-params-cell") + if title_el is None or len(value_candidates) < 2: + continue + title = self._context._clean_text(title_el.get_text(" ", strip=True)) + value = self._context._clean_text(value_candidates[-1].get_text(" ", strip=True)) + if title and value: + params[title] = value + return params + + def _extract_seller_name(self, soup: BeautifulSoup) -> str: + header = soup.select_one(".contacts.header") + if header is None: + return "" + + for candidate in header.stripped_strings: + value = self._context._clean_text(candidate) + if not value: + continue + if value in { + "Контакт с подателя", + "Контакт с подателя на обявата", + "Изпрати съобщение", + }: + continue + if value.endswith(".alo.bg"): + continue + if "Вход" in value and "Регистрация" in value: + continue + if "*" in value: + continue + return value + return "" + + def _extract_contact_name(self, soup: BeautifulSoup) -> str: + el = soup.select_one(".agent_div .contact_value, .contact_value") + return self._context._clean_text(el.get_text(" ", strip=True) if el is not None else "") + + def _extract_phone(self, soup: BeautifulSoup) -> str: + for span in soup.select(".contact_phone .ocd_span, .ocd_span"): + masked = self._context._clean_text(span.get_text(" ", strip=True)) + if "X" in masked.upper(): + continue + phone = self._context._extract_phone_from_text(masked) + if phone: + return phone + return "" From 91db10d9f321560936bbb984524fa437c04f3fd0 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:08:08 +0300 Subject: [PATCH 05/13] refactor: delegate ALO parsing to source module --- app/services/async_scraper.py | 187 +--------------------------------- 1 file changed, 5 insertions(+), 182 deletions(-) diff --git a/app/services/async_scraper.py b/app/services/async_scraper.py index bfd746f..c822b2f 100755 --- a/app/services/async_scraper.py +++ b/app/services/async_scraper.py @@ -3,7 +3,6 @@ import asyncio import random import re -from dataclasses import dataclass from datetime import date from typing import Any, Callable, Sequence from urllib.parse import parse_qs, urljoin, urlparse @@ -13,6 +12,8 @@ from app.core.config import Settings, SiteConfig from app.core.logging import get_logger +from app.scraper.models import ScrapedListing +from app.scraper.sources.alo import AloSourceParser extract_names: Callable[[str], list[str]] | None extract_phone_numbers: Callable[[str], list[str]] | None @@ -97,24 +98,6 @@ EMAIL_RE = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", flags=re.I) -@dataclass(slots=True) -class ScrapedListing: - ad_id: str - title: str - price: str - location: str - size: str - link: str - image_url: str = "" - source_site: str = "" - phone: str = "" - seller_name: str = "" - ad_type: str = "" - contact_name: str = "-" - contact_email: str = "-" - date_seen: str = "" - - class BaseScraper: def __init__(self, site_config: SiteConfig, settings: Settings) -> None: self.site_config = site_config @@ -123,6 +106,7 @@ def __init__(self, site_config: SiteConfig, settings: Settings) -> None: max(1, site_config.concurrency or settings.scrape_concurrency) ) self._proxy_index = 0 + self._alo_parser = AloSourceParser(self, site_config.name) def _get_client_kwargs(self) -> dict[str, Any]: headers = { @@ -307,7 +291,7 @@ def _build_page_url(self, page: int) -> str: def _parse_listing_page(self, html: str, base_url: str) -> list[ScrapedListing]: parsers = { "imoti.bg": self._parse_imoti_listing_page, - "alo.bg": self._parse_alo_listing_page, + "alo.bg": self._alo_parser.parse_listing_page, "dom.ria.com": self._parse_generic_anchor_page, "olx.ua": self._parse_generic_anchor_page, "lun.ua": self._parse_generic_anchor_page, @@ -416,73 +400,6 @@ def _parse_imoti_card_exact(self, article: Tag, base_url: str) -> ScrapedListing ad_type=self._detect_ad_type(seller_name), ) - def _parse_alo_listing_page(self, html: str, base_url: str) -> list[ScrapedListing]: - soup = BeautifulSoup(html, "lxml") - cards = soup.select("div.ad_block_normal") - results: list[ScrapedListing] = [] - seen_ids: set[str] = set() - - for card in cards: - listing = self._parse_alo_card(card, base_url) - if listing is None or listing.ad_id in seen_ids: - continue - seen_ids.add(listing.ad_id) - if self._passes_filters(listing): - results.append(listing) - - return results - - def _parse_alo_card(self, card: Tag, base_url: str) -> ScrapedListing | None: - link_el = card.select_one("a.avn_seo[href], a.avn_image[href], a[href]") - if link_el is None: - return None - - link = self._normalize_link(base_url, self._attr_str(link_el, "href")) - if not link: - return None - - title_el = card.select_one("a.avn_seo[href]") - title = self._clean_text( - title_el.get_text(" ", strip=True) - if title_el is not None - else link_el.get_text(" ", strip=True) - ) - if not title: - title = self._title_from_url(link) - - image = card.select_one("a.avn_image img[alt], img[alt]") - 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}") - - if not self._is_alo_listing_candidate(title=title, combined_text=combined_text): - return None - - price_el = card.select_one(".avn_price") - price = self._clean_text(price_el.get_text(" ", strip=True) if price_el is not None else "") - - location_el = card.select_one(".avn_location") - location = self._clean_text( - location_el.get_text(" ", strip=True) if location_el is not None else "" - ) - - ad_id_match = re.search( - r"adrows_(\d{4,12})", - self._attr_str(card, "id"), - ) - ad_id = ad_id_match.group(1) if ad_id_match else self._extract_ad_id(link) - - return ScrapedListing( - ad_id=ad_id, - title=title, - price=price, - location=location, - size=self._extract_size(combined_text), - link=link, - image_url=self._extract_image(card, base_url), - source_site=self.site_config.name, - ) - def _parse_generic_cards(self, html: str, base_url: str) -> list[ScrapedListing]: soup = BeautifulSoup(html, "lxml") cards = soup.select(self.site_config.selectors.get("card", "article, li, div")) @@ -602,7 +519,7 @@ async def _enrich_one( if self.site_config.name == "imoti.bg": self._enrich_imoti_detail(soup, listing) elif self.site_config.name == "alo.bg": - self._enrich_alo_detail(soup, listing) + self._alo_parser.enrich_detail(soup, listing) else: self._enrich_generic_detail(soup, listing) @@ -690,100 +607,6 @@ def _enrich_imoti_detail(self, soup: BeautifulSoup, listing: ScrapedListing) -> if listing.seller_name: listing.ad_type = self._detect_ad_type(listing.seller_name) - def _enrich_alo_detail(self, soup: BeautifulSoup, listing: ScrapedListing) -> None: - title_el = soup.select_one("h1.large-headline, h1") - if title_el is not None: - title = self._clean_text(title_el.get_text(" ", strip=True)) - if title: - listing.title = title - - price_text = self._extract_alo_detail_price(soup) - if price_text: - listing.price = price_text - - params = self._extract_alo_params(soup) - if params.get("Местоположение"): - listing.location = params["Местоположение"] - if params.get("Квадратура"): - listing.size = params["Квадратура"].replace("\xa0", " ") - - seller_name = self._extract_alo_seller_name(soup) - if seller_name: - listing.seller_name = seller_name - - contact_name = self._extract_alo_contact_name(soup) - if contact_name: - listing.contact_name = contact_name - - visible_phone = self._extract_alo_phone(soup) - if visible_phone: - listing.phone = visible_phone - - if soup.select_one(".contacts_wrapper_flex.has_agents"): - listing.ad_type = "agency" - elif listing.seller_name: - listing.ad_type = self._detect_ad_type(listing.seller_name) - else: - listing.ad_type = "private" - - def _extract_alo_detail_price(self, soup: BeautifulSoup) -> str: - for cell in soup.select(".ads-params-price"): - text = self._clean_text(cell.get_text(" ", strip=True)) - if "€" in text or "лв" in text.lower(): - return text.split("Цената е около", 1)[0].strip() - return "" - - def _extract_alo_params(self, soup: BeautifulSoup) -> dict[str, str]: - params: dict[str, str] = {} - for row in soup.select(".ads-params-row"): - title_el = row.select_one(".ads-param-title") - value_candidates = row.select(".ads-params-cell") - if title_el is None or len(value_candidates) < 2: - continue - title = self._clean_text(title_el.get_text(" ", strip=True)) - value = self._clean_text(value_candidates[-1].get_text(" ", strip=True)) - if title and value: - params[title] = value - return params - - def _extract_alo_seller_name(self, soup: BeautifulSoup) -> str: - header = soup.select_one(".contacts.header") - if header is None: - return "" - - for candidate in header.stripped_strings: - value = self._clean_text(candidate) - if not value: - continue - if value in { - "Контакт с подателя", - "Контакт с подателя на обявата", - "Изпрати съобщение", - }: - continue - if value.endswith(".alo.bg"): - continue - if "Вход" in value and "Регистрация" in value: - continue - if "*" in value: - continue - return value - return "" - - def _extract_alo_contact_name(self, soup: BeautifulSoup) -> str: - el = soup.select_one(".agent_div .contact_value, .contact_value") - return self._clean_text(el.get_text(" ", strip=True) if el is not None else "") - - def _extract_alo_phone(self, soup: BeautifulSoup) -> str: - for span in soup.select(".contact_phone .ocd_span, .ocd_span"): - masked = self._clean_text(span.get_text(" ", strip=True)) - if "X" in masked.upper(): - continue - phone = self._extract_phone_from_text(masked) - if phone: - return phone - return "" - def _looks_like_real_seller_name(self, value: str) -> bool: normalized = self._clean_text(value).lower() if not normalized: From fb70de9a85ce1c36792164b2c2e82efe3414b7f2 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:08:42 +0300 Subject: [PATCH 06/13] test: cover extracted ALO source parser --- tests/test_alo_source_parser.py | 131 ++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/test_alo_source_parser.py diff --git a/tests/test_alo_source_parser.py b/tests/test_alo_source_parser.py new file mode 100644 index 0000000..69a0a19 --- /dev/null +++ b/tests/test_alo_source_parser.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import unittest + +from bs4 import BeautifulSoup + +from app.core.config import Settings, SiteConfig +from app.services.async_scraper import BaseScraper + + +class AloSourceParserTests(unittest.TestCase): + def setUp(self) -> None: + self.site = SiteConfig( + name="alo.bg", + base_url="https://www.alo.bg/obiavi/imoti-naemi/?page={page}", + max_pages=1, + allowed_domains=["alo.bg", "www.alo.bg"], + ) + self.settings = Settings( + sites=[self.site], + city_filter=None, + scrape_detail_pages=False, + _env_file=None, + ) + self.scraper = BaseScraper(self.site, self.settings) + + def test_listing_page_is_delegated_to_alo_parser(self) -> None: + html = """ + +
+ + Двустаен апартамент под наем + + + квартира под наем 65 м² + +
700 €
+
София, Център
+
+ +
+ + Двустаен апартамент под наем + +
700 €
+
София, Център
+
+ +
+ + Транспортни услуги + +
100 €
+
София
+
+ + """ + + listings = self.scraper._parse_listing_page( + html, + base_url="https://www.alo.bg/obiavi/imoti-naemi/", + ) + + self.assertEqual(len(listings), 1) + listing = listings[0] + self.assertEqual(listing.ad_id, "123456") + self.assertEqual(listing.title, "Двустаен апартамент под наем") + self.assertEqual(listing.price, "700 €") + self.assertEqual(listing.location, "София, Център") + self.assertEqual(listing.size, "65 м²") + self.assertEqual(listing.source_site, "alo.bg") + self.assertEqual( + listing.link, + "https://www.alo.bg/obiava/123456/dvustaen-apartament-pod-naem", + ) + + def test_detail_enrichment_stays_source_specific(self) -> None: + listing = self.scraper._parse_listing_page( + """ +
+ + Апартамент под наем + +
650 €
+
София
+
+ """, + base_url="https://www.alo.bg/", + )[0] + + soup = BeautifulSoup( + """ + +

Обновен апартамент под наем

+
720 € Цената е около 1400 лв
+
+
Местоположение
+
label
+
София, Лозенец
+
+
+
Квадратура
+
label
+
72 м²
+
+
+ Контакт с подателя + Example Agency +
+
Иван Иванов
+
+359 888 123 456
+
+ + """, + "lxml", + ) + + self.scraper._alo_parser.enrich_detail(soup, listing) + + self.assertEqual(listing.title, "Обновен апартамент под наем") + self.assertEqual(listing.price, "720 €") + self.assertEqual(listing.location, "София, Лозенец") + self.assertEqual(listing.size, "72 м²") + self.assertEqual(listing.seller_name, "Example Agency") + self.assertEqual(listing.contact_name, "Иван Иванов") + self.assertTrue(listing.phone) + self.assertEqual(listing.ad_type, "agency") + + +if __name__ == "__main__": + unittest.main() From 6616ac1824ff395995c35d279d45df548aee9649 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:08:51 +0300 Subject: [PATCH 07/13] ci: validate extracted ALO parser --- .github/workflows/ci-cd.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 5da7c7f..ebbe7bc 100755 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -32,10 +32,13 @@ jobs: poetry install --with dev - name: Ruff - run: poetry run ruff check app/ integrations/ utils.py + run: poetry run ruff check app/ integrations/ utils.py tests/test_alo_source_parser.py - name: Ruff format check - run: poetry run ruff format --check app/ integrations/ utils.py + run: poetry run ruff format --check app/ integrations/ utils.py tests/test_alo_source_parser.py + + - name: ALO source parser regression test + run: poetry run python -m unittest discover -s tests -p "test_alo_source_parser.py" typing: runs-on: ubuntu-latest From f1c05d6a585ac91c0ea56c646ce99c6fff36f12a 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:10:05 +0300 Subject: [PATCH 08/13] refactor: import ScrapedListing from scraper models --- integrations/webhooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/webhooks.py b/integrations/webhooks.py index 646cc0c..7f4a1e2 100755 --- a/integrations/webhooks.py +++ b/integrations/webhooks.py @@ -5,7 +5,7 @@ import httpx from app.core.config import WebhookConfig -from app.services.async_scraper import ScrapedListing +from app.scraper.models import ScrapedListing logger = logging.getLogger(__name__) From c62a2099d79703f077fc1818b5e7abfa5c8030fe 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:10:08 +0300 Subject: [PATCH 09/13] refactor: import ScrapedListing from scraper models --- integrations/bitrix24.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/bitrix24.py b/integrations/bitrix24.py index f4f9cc7..a08fcd8 100755 --- a/integrations/bitrix24.py +++ b/integrations/bitrix24.py @@ -4,7 +4,7 @@ import httpx from app.core.config import Bitrix24Config, get_settings -from app.services.async_scraper import ScrapedListing +from app.scraper.models import ScrapedListing logger = logging.getLogger(__name__) From 8318d5ba452c2fe892dc414f8a9463ae34704c02 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:10:11 +0300 Subject: [PATCH 10/13] refactor: import ScrapedListing from scraper models --- integrations/amocrm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/amocrm.py b/integrations/amocrm.py index 2f4ecc6..b84fd98 100755 --- a/integrations/amocrm.py +++ b/integrations/amocrm.py @@ -5,7 +5,7 @@ import httpx from app.core.config import AmoCrmConfig -from app.services.async_scraper import ScrapedListing +from app.scraper.models import ScrapedListing logger = logging.getLogger(__name__) From ee92a3998be2b5c767ad28b4a869ce873e261b88 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:10:14 +0300 Subject: [PATCH 11/13] refactor: import ScrapedListing from scraper models --- app/services/repository.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/services/repository.py b/app/services/repository.py index 752a7b6..411bbc5 100755 --- a/app/services/repository.py +++ b/app/services/repository.py @@ -6,7 +6,8 @@ from typing import Any from app.db.mysql import mysql_pool -from app.services.async_scraper import ScrapedListing, to_listing_rows +from app.scraper.models import ScrapedListing +from app.services.async_scraper import to_listing_rows logger = logging.getLogger(__name__) _MISSING = object() From 634c6cdcb3d097a4286acdde0334e7a21e47777e 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:10:16 +0300 Subject: [PATCH 12/13] refactor: import ScrapedListing from scraper models --- app/scraper_worker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/scraper_worker.py b/app/scraper_worker.py index a638c48..d36976c 100755 --- a/app/scraper_worker.py +++ b/app/scraper_worker.py @@ -10,7 +10,8 @@ from app.core.config import Settings, get_settings from app.core.logging import capture_exception, configure_logging, get_logger from app.db.mysql import init_schema -from app.services.async_scraper import MultiSiteScraper, ScrapedListing +from app.scraper.models import ScrapedListing +from app.services.async_scraper import MultiSiteScraper from app.services.repository import upsert_leads from integrations.amocrm import get_amocrm_integration from integrations.bitrix24 import get_bitrix24_integration From 3de79096b339224213ce15244a7cd6aeb33d9c9a 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:10:19 +0300 Subject: [PATCH 13/13] refactor: import ScrapedListing from scraper models --- dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard.py b/dashboard.py index ebeab38..49ce94b 100755 --- a/dashboard.py +++ b/dashboard.py @@ -13,7 +13,7 @@ from PIL import Image, ImageDraw, ImageFont from app.db.mysql import mysql_pool -from app.services.async_scraper import ScrapedListing +from app.scraper.models import ScrapedListing logger = logging.getLogger(__name__)