├── ad ├── __init__.py ├── adapters │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_provider.py │ │ └── test_repository.py │ ├── utils.py │ ├── presenter.py │ ├── repository.py │ └── provider.py ├── core │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── strategies.py │ ├── usecases │ │ ├── __init__.py │ │ ├── create_full_ad.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── test_get_ads.py │ │ │ └── test_ads_sender.py │ │ ├── create_detail_ad.py │ │ ├── get_ads.py │ │ ├── ads_sender.py │ │ └── create_base_ads.py │ ├── adapters │ │ ├── __init__.py │ │ ├── provider.py │ │ └── repository.py │ ├── errors.py │ └── entities.py ├── logger.py ├── upload_ads.py ├── telegram_sender.py └── implementations.py ├── requirements-test.txt ├── nginx ├── Dockerfile └── conf ├── docs ├── diagrams │ ├── olx-icon.png │ ├── rss-icon.jpeg │ ├── olx-parser-architecture.png │ └── olx-diagram.py └── screenshots │ └── screenshot-1.png ├── entrypoint.sh ├── environment.ini ├── configuration.json ├── Dockerfile ├── unpined-deps.txt ├── docker-compose.yml ├── .pre-commit-config.yaml ├── requirements.txt ├── app.py ├── .gitignore ├── templates └── description.html ├── README.md └── LICENSE /ad/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ad/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ad/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ad/core/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ad/core/usecases/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ad/adapters/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ad/core/usecases/create_full_ad.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ad/core/usecases/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | ipdb 2 | deal 3 | hypothesis 4 | -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.13-alpine 2 | COPY conf /etc/nginx/conf.d/default.conf 3 | -------------------------------------------------------------------------------- /docs/diagrams/olx-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lerdem/olx-parser/HEAD/docs/diagrams/olx-icon.png -------------------------------------------------------------------------------- /docs/diagrams/rss-icon.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lerdem/olx-parser/HEAD/docs/diagrams/rss-icon.jpeg -------------------------------------------------------------------------------- /docs/screenshots/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lerdem/olx-parser/HEAD/docs/screenshots/screenshot-1.png -------------------------------------------------------------------------------- /docs/diagrams/olx-parser-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lerdem/olx-parser/HEAD/docs/diagrams/olx-parser-architecture.png -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | python -m ad.adapters.repository 3 | python -m ad.upload_ads & 4 | gunicorn --threads 4 --bind 0.0.0.0:8000 app:app 5 | -------------------------------------------------------------------------------- /nginx/conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | location / { 5 | proxy_pass http://app:8000; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ad/core/adapters/__init__.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class Presenter(ABC): 5 | @abstractmethod 6 | def present(self, ads): 7 | pass 8 | -------------------------------------------------------------------------------- /environment.ini: -------------------------------------------------------------------------------- 1 | [secrets] 2 | TELEGRAM_BOT_TOKEN=Replace-with-your-token 3 | CHAT_ID=Replace-with-your-chat_id-numbers 4 | 5 | [general] 6 | # Replace-with-your-public-IP 7 | IP=127.0.0.1 8 | -------------------------------------------------------------------------------- /ad/core/errors.py: -------------------------------------------------------------------------------- 1 | class EntityError(Exception): 2 | pass 3 | 4 | 5 | class UseCaseError(EntityError): 6 | pass 7 | 8 | 9 | class AdapterError(EntityError): 10 | pass 11 | -------------------------------------------------------------------------------- /configuration.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "search_url": "https://www.olx.ua/nedvizhimost/kvartiry/dolgosrochnaya-arenda-kvartir/lvov/?search%5Bprivate_business%5D=private", 4 | "tag": "arenda-lvov" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-alpine 2 | RUN mkdir /app 3 | WORKDIR /app 4 | ADD requirements.txt /app 5 | RUN pip install -U pip 6 | RUN pip install -r requirements.txt 7 | #ADD requirements-test.txt /app 8 | #RUN pip install -r requirements-test.txt 9 | COPY . /app 10 | -------------------------------------------------------------------------------- /ad/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger('upload-logger') 4 | handler = logging.FileHandler('cron-upload_ads-logs.txt') 5 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 6 | handler.setFormatter(formatter) 7 | logger.addHandler(handler) 8 | logger.setLevel(logging.DEBUG) 9 | -------------------------------------------------------------------------------- /unpined-deps.txt: -------------------------------------------------------------------------------- 1 | Flask~=2.0.2 2 | pydantic~=1.8.2 3 | lxml~=4.6.4 4 | requests~=2.26.0 5 | rfeed~=1.1.1 6 | pytz 7 | gunicorn 8 | punq 9 | python-telegram-bot~=11.1.0 10 | premailer~=3.10.0 11 | 12 | # to fix flask dependency 13 | # https://stackoverflow.com/questions/77213053/why-did-flask-start-failing-with-importerror-cannot-import-name-url-quote-fr 14 | Werkzeug==2.2.2 15 | -------------------------------------------------------------------------------- /ad/adapters/utils.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from pathlib import Path 3 | 4 | BASE_DIR = ( 5 | Path(__file__).resolve(strict=True).parent.parent.parent 6 | ) # project root dir = olx-parser-rss 7 | 8 | 9 | def get_config() -> configparser.ConfigParser: 10 | config_file = BASE_DIR.joinpath('environment.ini') 11 | config = configparser.ConfigParser() 12 | with open(config_file) as raw_config_file: 13 | config.read_file(raw_config_file) 14 | return config 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | build: 5 | context: . 6 | container_name: olx-server 7 | volumes: 8 | - .:/app 9 | environment: 10 | - REQUESTS_CA_BUNDLE=/usr/local/lib/python3.9/site-packages/certifi/cacert.pem 11 | command: /bin/sh entrypoint.sh 12 | proxy: 13 | build: 14 | context: nginx 15 | container_name: olx-nginx 16 | restart: always 17 | ports: 18 | - 12345:80 19 | depends_on: 20 | - app 21 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - id: double-quote-string-fixer 9 | - repo: https://github.com/psf/black 10 | rev: 19.3b0 11 | hooks: 12 | - id: black 13 | args: [--skip-string-normalization] 14 | #- repo: https://github.com/Lucas-C/pre-commit-hooks-java 15 | # sha: 1.3.9 16 | # hooks: 17 | # - id: validate-html 18 | -------------------------------------------------------------------------------- /docs/diagrams/olx-diagram.py: -------------------------------------------------------------------------------- 1 | from diagrams import Cluster, Diagram 2 | from diagrams.custom import Custom 3 | from diagrams.onprem.compute import Server 4 | 5 | 6 | with Diagram('Архитектура приложения', show=False, filename='olx-parser-architecture'): 7 | with Cluster('Backend'): 8 | web = Server('Веб приложение') 9 | cron = Server('Загрузчик данных из ОЛХ') 10 | olx_server = Custom('Олх сайт', 'olx-icon.png') 11 | 12 | backend = [web, cron, olx_server] 13 | 14 | rss_client = Custom('RSS frontend', 'rss-icon.jpeg') 15 | 16 | rss_client >> web >> cron >> olx_server 17 | -------------------------------------------------------------------------------- /ad/core/adapters/provider.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List, Tuple 3 | 4 | 5 | class CreateAdsProvider(ABC): 6 | @abstractmethod 7 | def get_raw(self, start_url) -> List[Tuple]: # or raises AdapterError 8 | pass 9 | 10 | 11 | class DetailedAdProvider(ABC): 12 | @abstractmethod 13 | def get_raw( 14 | self, external_url 15 | ) -> Tuple[List, str, str, str]: # or raises AdapterError 16 | pass 17 | 18 | 19 | class PhoneProvider(ABC): 20 | @abstractmethod 21 | def get_raw(self, external_id) -> str: # or raises AdapterError 22 | pass 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.0.3 2 | pydantic==1.8.2 3 | lxml==4.6.5 4 | requests==2.26.0 5 | rfeed==1.1.1 6 | pytz==2023.3.post1 7 | gunicorn==21.2.0 8 | punq==0.7.0 9 | python-telegram-bot==11.1.0 10 | premailer~=3.10.0 11 | 12 | # to fix flask dependency 13 | # https://stackoverflow.com/questions/77213053/why-did-flask-start-failing-with-importerror-cannot-import-name-url-quote-fr 14 | Werkzeug==2.2.2 15 | ## The following requirements were added by pip freeze: 16 | certifi==2023.11.17 17 | cffi==1.16.0 18 | charset-normalizer==2.0.12 19 | click==8.1.7 20 | cryptography==42.0.0 21 | future==0.18.3 22 | idna==3.6 23 | itsdangerous==2.1.2 24 | Jinja2==3.1.3 25 | MarkupSafe==2.1.4 26 | packaging==23.2 27 | pycparser==2.21 28 | typing_extensions==4.9.0 29 | urllib3==1.26.18 30 | -------------------------------------------------------------------------------- /ad/core/usecases/create_detail_ad.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from ad.core.adapters.provider import DetailedAdProvider 4 | from ad.core.adapters.repository import DetailedAdRepo 5 | from ad.core.entities import DetailedAd 6 | 7 | 8 | @dataclass 9 | class CreateDetailedAdUseCase: 10 | _repository: DetailedAdRepo 11 | _provider: DetailedAdProvider 12 | 13 | def __call__(self, ad_id: str) -> None: 14 | base_ad = self._repository.get_base_ad_by_id(ad_id) 15 | raw = self._provider.get_raw(base_ad.url) 16 | detailed_ad = DetailedAd( 17 | image_urls=raw[0], 18 | external_id=raw[1], 19 | description=raw[2], 20 | name=raw[3], 21 | **base_ad.dict() 22 | ) 23 | 24 | self._repository.save(detailed_ad) 25 | -------------------------------------------------------------------------------- /ad/core/entities.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Union 3 | from pydantic import BaseModel, HttpUrl 4 | 5 | 6 | class BaseAd(BaseModel): 7 | id: str 8 | tag: str 9 | title: str 10 | parse_date: datetime 11 | url: HttpUrl 12 | 13 | 14 | class _DetailAd(BaseModel): 15 | description: str 16 | image_urls: List[HttpUrl] 17 | external_id: str 18 | name: str 19 | 20 | 21 | class Contact(BaseModel): 22 | # https://github.com/samuelcolvin/pydantic/issues/1551 23 | phone: str 24 | 25 | 26 | class DetailedAd(_DetailAd, BaseAd): 27 | pass 28 | 29 | 30 | class FullAd(Contact, DetailedAd): 31 | # def serialize_fields(self): 32 | pass 33 | 34 | 35 | class View(BaseModel): 36 | id: str 37 | 38 | 39 | BaseAds = List[BaseAd] 40 | DetailedAds = List[DetailedAd] 41 | AnyAds = Union[DetailedAds, FullAd] 42 | FullAds = List[FullAd] 43 | Views = List[View] 44 | -------------------------------------------------------------------------------- /ad/upload_ads.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from random import randint 3 | 4 | from ad.core.errors import UseCaseError 5 | from ad.implementations import ad_detail_uploader, ads_creator 6 | from ad.logger import logger 7 | 8 | 9 | def _upload_job(): 10 | logger.debug('uploader started') 11 | while True: 12 | time_to_wait = randint(45, 120) 13 | logger.debug(f'waiting before upload from olx {time_to_wait} seconds') 14 | sleep(time_to_wait) 15 | try: 16 | new_ad_ids = ads_creator() 17 | except UseCaseError as e: 18 | logger.error(e) 19 | else: 20 | logger.debug(f'Загружены ads: {new_ad_ids}') 21 | for _id in new_ad_ids: 22 | try: 23 | ad_detail_uploader(ad_id=_id) 24 | except Exception as e: 25 | logger.error(f'Error with {_id}, {e}') 26 | 27 | 28 | if __name__ == '__main__': 29 | _upload_job() 30 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, Response, request 2 | 3 | from ad.implementations import get_detail_ads, get_full_ads_debug 4 | 5 | app = Flask(__name__) 6 | 7 | 8 | @app.route('/detail-rss') 9 | def detail_rss(): 10 | data = get_detail_ads( 11 | tag=request.args.get('tag'), stop_words=request.args.getlist('sw') 12 | ) 13 | return Response(data, headers={'Content-Type': 'application/rss+xml'}) 14 | 15 | 16 | @app.route('/debug-template') 17 | def debug_template(): 18 | data = get_full_ads_debug( 19 | tag=request.args.get('tag'), stop_words=request.args.getlist('sw') 20 | ) 21 | return Response(data, headers={'Content-Type': 'application/rss+xml'}) 22 | 23 | 24 | @app.route('/debug-html') 25 | def debug_html(): 26 | from ad.adapters.presenter import _get_detail 27 | from ad.adapters.repository import GetDebugRepo 28 | 29 | data = _get_detail(GetDebugRepo().get_all()[0]) 30 | return Response(data, headers={'Content-Type': 'text/html; charset=UTF-8'}) 31 | 32 | 33 | if __name__ == '__main__': 34 | app.run(host='0.0.0.0', debug=False, port=8000) 35 | -------------------------------------------------------------------------------- /ad/adapters/tests/test_provider.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock 3 | 4 | from requests import Session, HTTPError, ConnectionError 5 | from requests.exceptions import ChunkedEncodingError 6 | 7 | from ad.adapters.provider import _get_olx_search_html_base 8 | from ad.core.errors import AdapterError 9 | 10 | 11 | class Test(unittest.TestCase): 12 | def test_error1(self): 13 | fake_session_respone = MagicMock( 14 | **{'raise_for_status.side_effect': HTTPError('lol', '123')}, 15 | autospec=Session 16 | ) # session object 17 | session = MagicMock( 18 | **{'get.return_value': fake_session_respone}, autospec=Session 19 | ) # session object 20 | with self.assertRaises(AdapterError): 21 | _get_olx_search_html_base('http://fake.com', session) 22 | 23 | def test_error2(self): 24 | session = MagicMock( 25 | **{'get.side_effect': ConnectionError('lol', '123')}, autospec=Session 26 | ) # session object 27 | with self.assertRaises(AdapterError): 28 | _get_olx_search_html_base('http://fake.com', session) 29 | 30 | def test_error3(self): 31 | session = MagicMock( 32 | **{'get.side_effect': ChunkedEncodingError('lol', '123')}, autospec=Session 33 | ) # session object 34 | with self.assertRaises(AdapterError): 35 | _get_olx_search_html_base('http://fake.com', session) 36 | -------------------------------------------------------------------------------- /ad/core/usecases/get_ads.py: -------------------------------------------------------------------------------- 1 | import re 2 | from dataclasses import dataclass 3 | from functools import partial 4 | from typing import List, Optional 5 | 6 | from ad.core.adapters import Presenter 7 | from ad.core.adapters.repository import GetDetailedAdRepo 8 | 9 | 10 | @dataclass 11 | class GetAdsUseCase: 12 | _repo: GetDetailedAdRepo 13 | _presenter: Presenter 14 | 15 | def execute(self, tag: Optional[str], stop_words: List[str]): 16 | ads = self._repo.get_by_tag(tag) if tag is not None else self._repo.get_all() 17 | if stop_words: 18 | ads = self._exclude_stop_words_from_ads(stop_words, ads) 19 | last_30_ads = sorted(ads, key=lambda x: x.parse_date, reverse=True)[:30] 20 | return self._presenter.present(last_30_ads) 21 | 22 | @staticmethod 23 | def _exclude_stop_words_from_ads(stop_words, ads): 24 | stop_word_ignore = partial(_stop_word_ignore, stop_words) 25 | ads = (ad for ad in ads if stop_word_ignore(ad.title)) 26 | ads = (ad for ad in ads if stop_word_ignore(ad.description)) 27 | return ads 28 | 29 | 30 | def _stop_word_ignore(stop_words: List[str], text: str): 31 | if not stop_words and not text: 32 | return True 33 | # слово или часть слова (слева) начиная с пробела, без учета регистра 34 | pattern = re.compile('|'.join(rf'\b{word}' for word in stop_words), re.IGNORECASE) 35 | # match - "bla" - True - False 36 | # no match - None - False - True 37 | return not bool(pattern.search(text)) 38 | -------------------------------------------------------------------------------- /ad/core/usecases/ads_sender.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional, Dict 3 | 4 | from ad.core.adapters import Presenter 5 | from ad.core.adapters.repository import GetDetailedAdRepo, ViewsRepo, Sender 6 | from ad.core.entities import View, DetailedAds 7 | from ad.core.errors import AdapterError, UseCaseError 8 | 9 | 10 | @dataclass 11 | class AdsSenderUseCase: 12 | _ads_repository: GetDetailedAdRepo 13 | _views_repository: ViewsRepo 14 | _sender: Sender 15 | _presenter: Presenter 16 | 17 | def execute(self, tag: Optional[str]) -> None: 18 | ads = ( 19 | self._ads_repository.get_by_tag(tag) 20 | if tag is not None 21 | else self._ads_repository.get_all() 22 | ) 23 | ads_ids = [ad.id for ad in ads] 24 | views = self._views_repository.get_views_by_ids(ads_ids) 25 | views_ids = [view.id for view in views] 26 | unsent_ads_ids = set(ads_ids) - set(views_ids) 27 | 28 | ads_d: Dict[str:DetailedAds] = {ad.id: ad for ad in ads} 29 | unsent_ads: DetailedAds = [ads_d[unsent_ads_id] for unsent_ads_id in unsent_ads_ids] 30 | 31 | messages = self._presenter.present(unsent_ads) 32 | for message, unsent_ad in zip(messages, unsent_ads): 33 | try: 34 | self._sender.send_message(message) 35 | except AdapterError as e: 36 | raise UseCaseError(f'Ошибка при отправке сообщения: {e}') 37 | self._views_repository.save_view(View(id=unsent_ad.id)) 38 | -------------------------------------------------------------------------------- /ad/telegram_sender.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | from random import randint 3 | 4 | import punq 5 | 6 | from ad.adapters.presenter import BaseAdTelegramPresenter 7 | from ad.adapters.repository import TelegramSender, ViewsRepoCsv, DetailedAdGetRepoCsv 8 | from ad.core.adapters import Presenter 9 | from ad.core.adapters.repository import ViewsRepo, Sender, GetDetailedAdRepo 10 | from ad.core.errors import UseCaseError 11 | from ad.core.usecases.ads_sender import AdsSenderUseCase 12 | from ad.logger import logger 13 | 14 | 15 | def _telegram_sender_job(): 16 | logger.debug('Telegram sender started') 17 | while True: 18 | time_to_wait = randint(45, 120) 19 | logger.debug(f'waiting before send to telegram {time_to_wait} seconds') 20 | sleep(time_to_wait) 21 | try: 22 | _ads_sender(tag=None) 23 | except UseCaseError as e: 24 | logger.error(e) 25 | else: 26 | logger.debug(f'Telegram sender отработал без ошибок') 27 | 28 | 29 | if __name__ == '__main__': 30 | container = punq.Container() 31 | container.register(GetDetailedAdRepo, DetailedAdGetRepoCsv) 32 | container.register(ViewsRepo, ViewsRepoCsv) 33 | container.register(Sender, TelegramSender) 34 | container.register(Presenter, BaseAdTelegramPresenter) 35 | container.register(AdsSenderUseCase) 36 | try: 37 | _ads_sender = container.resolve(AdsSenderUseCase) 38 | except UseCaseError as e: 39 | logger.error(e) 40 | else: 41 | _ads_sender = _ads_sender.execute 42 | _telegram_sender_job() 43 | -------------------------------------------------------------------------------- /ad/core/usecases/create_base_ads.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from dataclasses import dataclass 3 | from datetime import datetime 4 | from operator import add 5 | from typing import List 6 | from functools import reduce 7 | import pytz 8 | 9 | from ad.core.adapters.provider import CreateAdsProvider 10 | from ad.core.adapters.repository import CreateAdsRepo, CreateAdsConfig 11 | from ad.core.entities import BaseAd 12 | from ad.core.errors import AdapterError, UseCaseError 13 | 14 | 15 | @dataclass 16 | class CreateAdsUseCase: 17 | _repository: CreateAdsRepo 18 | _provider: CreateAdsProvider 19 | _configuration: CreateAdsConfig 20 | 21 | def __call__(self) -> List[str]: 22 | # TODO проблема, если 2 из 10 url падает, то дальше не идет загрузка. 23 | confs = self._configuration.get_configuration() 24 | return reduce( 25 | add, [self.__process_one(conf.search_url, conf.tag) for conf in confs], [] 26 | ) 27 | 28 | def __process_one(self, url: str, tag: str) -> List[str]: 29 | try: 30 | raw = self._provider.get_raw(start_url=url) 31 | except AdapterError as e: 32 | raise UseCaseError( 33 | f'Ошибка при получении "raw" данных: {e}.\nFor debug url={url}, tag={tag}' 34 | ) 35 | saved = self._repository.get_all() 36 | existed_urls = [ad.url for ad in saved] 37 | provider_ads = [ 38 | BaseAd( 39 | id=uuid.uuid4().hex, 40 | tag=tag, 41 | title=f'{item[0]} - {item[1]}', 42 | parse_date=datetime.now(pytz.utc), 43 | url=item[2], 44 | ) 45 | for item in raw 46 | ] 47 | new = [ad for ad in provider_ads if ad.url not in existed_urls] 48 | self._repository.save(new) 49 | return [i.id for i in new] 50 | -------------------------------------------------------------------------------- /ad/implementations.py: -------------------------------------------------------------------------------- 1 | import punq 2 | 3 | from ad.adapters.presenter import DetailedAdFeedPresenter 4 | from ad.adapters.provider import DetailedAdProviderOlx, CreateProviderOlx 5 | from ad.adapters.repository import ( 6 | DetailedAdGetRepoCsv, 7 | DetailedAdRepoCsv, 8 | CreateAdsRepoCsv, 9 | CreateAdsConfigJson, 10 | GetDebugRepo, 11 | ) 12 | from ad.core.adapters import Presenter 13 | from ad.core.adapters.provider import CreateAdsProvider, DetailedAdProvider 14 | from ad.core.adapters.repository import ( 15 | CreateAdsRepo, 16 | CreateAdsConfig, 17 | DetailedAdRepo, 18 | GetDetailedAdRepo, 19 | ) 20 | from ad.core.usecases.create_base_ads import CreateAdsUseCase 21 | from ad.core.usecases.create_detail_ad import CreateDetailedAdUseCase 22 | from ad.core.usecases.get_ads import GetAdsUseCase 23 | 24 | container = punq.Container() 25 | container.register(CreateAdsRepo, CreateAdsRepoCsv) 26 | container.register(CreateAdsProvider, CreateProviderOlx) 27 | container.register(CreateAdsConfig, CreateAdsConfigJson) 28 | container.register(CreateAdsUseCase) 29 | ads_creator = container.resolve(CreateAdsUseCase) 30 | 31 | container.register(DetailedAdRepo, DetailedAdRepoCsv) 32 | container.register(DetailedAdProvider, DetailedAdProviderOlx) 33 | container.register(CreateDetailedAdUseCase) 34 | ad_detail_uploader = container.resolve(CreateDetailedAdUseCase) 35 | 36 | container.register(GetDetailedAdRepo, DetailedAdGetRepoCsv) 37 | container.register(Presenter, DetailedAdFeedPresenter) 38 | container.register(GetAdsUseCase) 39 | _get_ads_usecase = container.resolve(GetAdsUseCase) 40 | get_detail_ads = _get_ads_usecase.execute 41 | 42 | container3 = punq.Container() 43 | container3.register(GetDetailedAdRepo, GetDebugRepo) 44 | container3.register(Presenter, DetailedAdFeedPresenter) 45 | container3.register(GetAdsUseCase) 46 | _get_ads_debug_usecase = container3.resolve(GetAdsUseCase) 47 | get_full_ads_debug = _get_ads_debug_usecase.execute 48 | -------------------------------------------------------------------------------- /ad/adapters/tests/test_repository.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from hypothesis import given 3 | 4 | 5 | from ad.adapters.repository import ( 6 | DetailedAdRepoCsv, 7 | _deserialize_detail, 8 | _serialize_detail, 9 | ) 10 | from ad.core.entities import DetailedAd 11 | from ad.core.tests.strategies import DetailedAdSt 12 | 13 | _ad = DetailedAd( 14 | id='bc516e2abb5445ae9d03128a7a911f8f', # dont show in template 15 | tag='arenda-dnepr', # dont show in template 16 | title='Сдам 2-х комнатную квартиру на длительный период - Днепр', 17 | publication_date='2021-11-04 12:58:45', # dont show in template 18 | parse_date='2021-11-04 12:58:45', 19 | url='https://www.olx.ua/d/obyavlenie/sdam-2-h-komnatnuyu-kvartiru-na-dlitelnyy-period-IDN7dzO.html', 20 | description='Сдам 2-х комнатную квартиру на длительный период для семейной пары в районе ' 21 | '97 школы' 22 | ' (Ул. Братьев Трофимовых 40), 6 этаж 9-и этажного дома, не угловая, теплая, есть лоджия, застеклена.', 23 | image_urls=[ 24 | 'https://ireland.apollo.olxcdn.com:443/v1/files/dodwyas1emy32-UA/image;s=4000x3000', 25 | 'https://ireland.apollo.olxcdn.com/v1/files/pxokmbrmwf9v2-UA/image;s=1104x1472', 26 | 'https://ireland.apollo.olxcdn.com/v1/files/ve9s1d20cn211-UA/image;s=1104x1472', 27 | 'https://ireland.apollo.olxcdn.com/v1/files/ralzthng8yp52-UA/image;s=1944x2592', 28 | 'https://ireland.apollo.olxcdn.com/v1/files/il2y84fnyo5w-UA/image;s=591x1280', 29 | ], 30 | external_id='725276749', 31 | name='Феликс', 32 | ) 33 | saved = [_ad] 34 | new_or_updated_ad = DetailedAd(**_ad.dict()) 35 | 36 | 37 | class TestStringMethods(unittest.TestCase): 38 | def test_save(self): 39 | repo = DetailedAdRepoCsv() 40 | res = list(repo._mix_existed_ads_and_one_new(saved, new_or_updated_ad)) 41 | self.assertListEqual(res, [new_or_updated_ad]) 42 | 43 | @given(DetailedAdSt) 44 | def test_detail_serialization(self, detailed_ad): 45 | new_ad = _deserialize_detail(_serialize_detail(detailed_ad)) 46 | self.assertEqual(new_ad, detailed_ad) 47 | 48 | 49 | if __name__ == '__main__': 50 | unittest.main() 51 | -------------------------------------------------------------------------------- /ad/core/adapters/repository.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import List, MutableSequence 3 | from pydantic import BaseModel, HttpUrl, root_validator 4 | 5 | from ad.core.entities import BaseAds, DetailedAd, BaseAd, Views, View, DetailedAds 6 | 7 | 8 | class CreateAdsRepo(ABC): 9 | @abstractmethod 10 | def save(self, base_ads: BaseAds) -> None: 11 | pass 12 | 13 | @abstractmethod 14 | def get_all(self) -> BaseAds: 15 | pass 16 | 17 | 18 | class DetailedAdRepo(ABC): 19 | @abstractmethod 20 | def save(self, detailed_ad: DetailedAd) -> None: 21 | pass 22 | 23 | @abstractmethod 24 | def get_base_ad_by_id(self, id: str) -> BaseAd: 25 | pass 26 | 27 | 28 | class GetDetailedAdRepo(ABC): 29 | @abstractmethod 30 | def get_all(self) -> DetailedAds: 31 | pass 32 | 33 | @abstractmethod 34 | def get_by_tag(self, tag: str) -> DetailedAds: 35 | pass 36 | 37 | 38 | class ViewsRepo(ABC): 39 | @abstractmethod 40 | def get_views_by_ids(self, ad_ids: List[str]) -> Views: 41 | pass 42 | 43 | @abstractmethod 44 | def save_view(self, view: View) -> None: # or raises AdapterError 45 | pass 46 | 47 | 48 | class Sender(ABC): 49 | @abstractmethod 50 | def send_message(self, msg: str) -> None: # or raises AdapterError 51 | pass 52 | 53 | 54 | class _ConfigurationItem(BaseModel): 55 | search_url: HttpUrl 56 | tag: str 57 | 58 | 59 | Configurations = List[_ConfigurationItem] 60 | 61 | 62 | class Configuration(BaseModel): 63 | __root__: Configurations 64 | 65 | @root_validator 66 | def check_tag_unique(cls, values): 67 | confs = values['__root__'] 68 | _is_unique_by([i.tag for i in confs], 'tags list') 69 | _is_unique_by([i.search_url for i in confs], 'urls list') 70 | return values 71 | 72 | 73 | def _is_unique_by(values: MutableSequence, values_name: str): 74 | if len(values) != len(set(values)): 75 | raise ValueError(f'{values_name} should be unique') 76 | 77 | 78 | class CreateAdsConfig(ABC): 79 | @abstractmethod 80 | def get_configuration(self) -> Configurations: 81 | pass 82 | -------------------------------------------------------------------------------- /ad/core/usecases/tests/test_get_ads.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import create_autospec 3 | 4 | import hypothesis.strategies as st 5 | from hypothesis import given, example, assume, settings, HealthCheck 6 | 7 | from ad.core.adapters import Presenter 8 | from ad.core.adapters.repository import GetDetailedAdRepo 9 | from ad.core.entities import BaseAds 10 | from ad.core.tests.strategies import DetailedAdSt 11 | from ad.core.usecases.get_ads import GetAdsUseCase, _stop_word_ignore 12 | 13 | 14 | class TestGetAdsUseCase(unittest.TestCase): 15 | def setUp(self) -> None: 16 | self.ads_repo = create_autospec(GetDetailedAdRepo) 17 | self.presenter = create_autospec(Presenter) 18 | 19 | @given(st.lists(DetailedAdSt, unique_by=lambda x: x.id, min_size=1, max_size=30)) 20 | def test_usecase_ok(self, return_repo): 21 | self.ads_repo.reset_mock() # hypothesis dont reset mocks 22 | self.presenter.reset_mock() # hypothesis dont reset mocks 23 | self.ads_repo.get_all.return_value = return_repo 24 | 25 | get_ads = GetAdsUseCase(_repo=self.ads_repo, _presenter=self.presenter) 26 | get_ads.execute(tag=None, stop_words=[]) 27 | presenter_call_arg: BaseAds = self.presenter.present.call_args_list[0][0][0] 28 | self.assertEqual(len(presenter_call_arg), len(return_repo)) 29 | 30 | @example( 31 | stop_words=['вул. Киснева', 'Образцова'], 32 | text='Сдам 2х комнатную квартиру по улице Тепличная', 33 | is_ignore=True 34 | ) 35 | @example( 36 | stop_words=['вул. Киснева', 'Образцова'], 37 | text='Сдам 2 кімнатну квартиру, у кінеці пр. Слобожанське, вул. Киснева 2', 38 | is_ignore=False, 39 | ) 40 | @example( 41 | stop_words=['Образцова', 'Левобережн',], 42 | text='Квартира на левобережном в аренду. Не рієлтор. - 7 000 грн.', 43 | is_ignore=False, 44 | ) 45 | @example( 46 | stop_words=['Образцова', 'левобережн', ], 47 | text='Сдам 2к Фестивальный , Левобережный 3 рядом Караван , Ашан, Березинка - 7 000 грн.', 48 | is_ignore=False, 49 | ) 50 | @example( 51 | stop_words=[], 52 | text='', 53 | is_ignore=True, 54 | ) 55 | @given( 56 | stop_words=st.just(['вул. Киснева', 'Образцова']), 57 | text=st.just('Сдам 2-ком. Квартиру. Калиновой и Образцова. 4/9 Эт.'), 58 | is_ignore=st.just(False), 59 | ) 60 | def test_stop_word_ignore(self, stop_words, text, is_ignore): 61 | res = _stop_word_ignore(stop_words, text) 62 | self.assertIs( 63 | res, is_ignore, msg='если есть совпадение, то возвращаеться False' 64 | ) 65 | -------------------------------------------------------------------------------- /ad/core/usecases/tests/test_ads_sender.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import create_autospec 3 | 4 | import hypothesis.strategies as st 5 | from hypothesis import given, example, assume, settings 6 | 7 | from ad.core.adapters import Presenter 8 | from ad.core.adapters.repository import GetDetailedAdRepo, ViewsRepo, Sender 9 | from ad.core.errors import AdapterError, UseCaseError 10 | from ad.core.tests.strategies import BaseAdSt 11 | from ad.core.usecases.ads_sender import AdsSenderUseCase 12 | 13 | 14 | class Test(unittest.TestCase): 15 | def setUp(self) -> None: 16 | self.ads_repo = create_autospec(GetDetailedAdRepo) 17 | self.view_repo = create_autospec(ViewsRepo) 18 | self.sender = create_autospec(Sender) 19 | self.presenter = create_autospec(Presenter) 20 | 21 | @given(st.lists(BaseAdSt, unique_by=lambda x: x.id, max_size=10)) 22 | @example(return_repo=[]) 23 | def test_usecase_ok(self, return_repo): 24 | self._reset_mocks() # hypothesis dont reset mocks 25 | self.ads_repo.get_all.return_value = return_repo 26 | view_repo = return_repo[:3] 27 | viewed_ads_count = len(view_repo) 28 | self.view_repo.get_views_by_ids.return_value = view_repo 29 | self.presenter.present.return_value = ['return_message'] * viewed_ads_count 30 | 31 | send_ads = AdsSenderUseCase( 32 | self.ads_repo, self.view_repo, self.sender, self.presenter 33 | ) 34 | send_ads.execute(None) 35 | 36 | self.presenter.present.assert_called_once() 37 | self.assertEqual( 38 | self.sender.send_message.call_count, self.view_repo.save_view.call_count 39 | ) 40 | self.assertGreaterEqual(viewed_ads_count, self.sender.send_message.call_count) 41 | 42 | def _reset_mocks(self): 43 | self.presenter.reset_mock() 44 | self.sender.reset_mock() 45 | self.view_repo.reset_mock() 46 | self.ads_repo.reset_mock() 47 | 48 | @given(BaseAdSt, BaseAdSt) 49 | @settings(max_examples=1) 50 | def test_usecase_error(self, _ad1, _ad2): 51 | assume(_ad1 != _ad2) 52 | self._reset_mocks() 53 | 54 | self.ads_repo.get_all.return_value = [_ad1, _ad2] 55 | self.view_repo.get_views_by_ids.return_value = [_ad1] 56 | self.presenter.present.return_value = ['msg1'] 57 | self.sender.send_message.side_effect = AdapterError 58 | 59 | send_ads = AdsSenderUseCase( 60 | self.ads_repo, self.view_repo, self.sender, self.presenter 61 | ) 62 | with self.assertRaises(UseCaseError): 63 | send_ads.execute(None) 64 | 65 | self.sender.send_message.assert_called_once_with('msg1') 66 | self.view_repo.save_view.assert_not_called() 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # Pycharm 141 | .idea/ 142 | 143 | *.csv 144 | 145 | cron-upload_ads-logs.txt 146 | *.pickle 147 | -------------------------------------------------------------------------------- /ad/adapters/presenter.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import ipaddress 3 | from typing import List 4 | 5 | from jinja2 import Environment, FileSystemLoader 6 | from premailer import transform 7 | from rfeed import Feed, Item, Guid 8 | 9 | from ad.adapters.utils import get_config 10 | from ad.core.adapters import Presenter 11 | from ad.core.entities import BaseAds, DetailedAds, FullAd, BaseAd 12 | from ad.core.errors import AdapterError 13 | 14 | _BASE_TEXT = 'RSS feed parsed from Olx' 15 | 16 | 17 | class DetailedAdFeedPresenter(Presenter): 18 | def __init__(self): 19 | self._port: int = 12345 20 | self._host_ip: str = self._get_host_ip() 21 | 22 | def present(self, ads: DetailedAds): 23 | items = [] 24 | for ad in ads: 25 | item = Item( 26 | guid=Guid(ad.id, isPermaLink=False), 27 | title=ad.title, 28 | link=ad.url, 29 | description=_get_detail(ad), 30 | author='lerdem', 31 | pubDate=ad.parse_date, 32 | ) 33 | items.append(item) 34 | 35 | description = f'{ads[0].tag}: {_BASE_TEXT}' if ads else _BASE_TEXT 36 | title = ads[0].tag if ads else _BASE_TEXT 37 | feed = Feed( 38 | title=title, 39 | link=f'http://{self._host_ip}:{self._port}/detail-rss', 40 | description=description, 41 | language='ru-Ru', 42 | lastBuildDate=datetime.datetime.now(), 43 | items=items, 44 | ) 45 | return feed.rss() 46 | 47 | @staticmethod 48 | def _get_host_ip() -> str: # or raises AdapterError 49 | config = get_config() 50 | maybe_ip = config.get('general', 'IP') 51 | try: 52 | # https://stackoverflow.com/questions/319279/how-to-validate-ip-address-in-python 53 | sure_ip = ipaddress.ip_address(maybe_ip) 54 | except ValueError as e: 55 | raise AdapterError(e) 56 | else: 57 | return str(sure_ip) 58 | 59 | 60 | class BaseAdTelegramPresenter(Presenter): 61 | def present(self, ads: BaseAds) -> List[str]: 62 | return [self._ad_to_html(ad) for ad in ads] 63 | 64 | @staticmethod 65 | def _ad_to_html(ad: BaseAd) -> str: 66 | # .encode('utf8').decode('utf8') to fix telegram cyrilic rendering issues 67 | return f'''{ad.title}'''.encode('utf8').decode('utf8') 68 | 69 | 70 | def _get_detail(ad: FullAd) -> str: 71 | file_loader = FileSystemLoader('templates') 72 | env = Environment(loader=file_loader) 73 | template = env.get_template('description.html') 74 | html = template.render(ad=ad) 75 | inline_html = transform(html) 76 | return inline_html 77 | 78 | 79 | if __name__ == '__main__': 80 | 81 | content = 'This is about page' 82 | 83 | file_loader = FileSystemLoader('templates') 84 | env = Environment(loader=file_loader) 85 | 86 | template = env.get_template('description.html') 87 | 88 | output = template.render(preview_url=content) 89 | print(output) 90 | -------------------------------------------------------------------------------- /ad/core/tests/strategies.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from itertools import cycle 3 | import hypothesis.strategies as st 4 | 5 | 6 | from ad.core.entities import BaseAd, DetailedAd 7 | 8 | _URLS = [ 9 | 'https://www.olx.ua/d/uk/obyavlenie/kvartira-v-arendu-brznka-karavan-klochko-lvoberezhniy-IDUeFir.html', 10 | 'https://www.olx.ua/d/uk/obyavlenie/sdam-2-h-kvartiru-levyy-bereg-kosiora-pravda-IDTX3ZS.html', 11 | 'https://www.olx.ua/d/uk/obyavlenie/sdam-2k-kvartiru-pr-mira-IDUdKIe.html', 12 | 'https://www.olx.ua/d/uk/obyavlenie/sdam-2-komnatnuyu-levoberezhnyy-3-karavan-IDUdZfi.html', 13 | 'https://www.olx.ua/d/uk/obyavlenie/kvartira-na-levoberezhnom-v-arendu-ne-rltor-IDSrbyI.html', 14 | 'https://www.olx.ua/d/uk/obyavlenie/sdam-2-kom-kvartiru-donetskoe-shosse-134-klochko-6-berezinka-karavan-IDUeW72.html', 15 | ] 16 | UrlSt = st.builds(lambda: next(cycle(_URLS))) 17 | IdSt = st.text(alphabet='qwertyui1234567889', min_size=10, max_size=10) 18 | TagSt = st.text(alphabet='qwertyuiop-asdfghjklzxcvbnm') 19 | TitleSt = st.text() 20 | ParseDateSt = st.datetimes(datetime.now()) 21 | 22 | BaseAdSt = st.builds( 23 | BaseAd, id=IdSt, tag=TagSt, title=TitleSt, parse_date=ParseDateSt, url=UrlSt 24 | ) 25 | 26 | _IMAGE_URLS = [ 27 | 'https://ireland.apollo.olxcdn.com:443/v1/files/j31k5csrm9vp2-UA/image;s=854x384', 28 | 'https://ireland.apollo.olxcdn.com:443/v1/files/jn6omphtsgad-UA/image;s=384x854', 29 | 'https://ireland.apollo.olxcdn.com:443/v1/files/zansrvj2uh673-UA/image;s=384x854', 30 | 'https://ireland.apollo.olxcdn.com:443/v1/files/k3uysaiiaevn-UA/image;s=854x384', 31 | 'https://ireland.apollo.olxcdn.com:443/v1/files/vd624onnfj521-UA/image;s=384x854', 32 | 'https://ireland.apollo.olxcdn.com:443/v1/files/t3h4zijo2xds-UA/image;s=854x384', 33 | 'https://ireland.apollo.olxcdn.com:443/v1/files/0usz5bi289vp2-UA/image;s=384x854', 34 | 'https://ireland.apollo.olxcdn.com:443/v1/files/jjdd7d35d8ca-UA/image;s=854x384', 35 | 'https://ireland.apollo.olxcdn.com:443/v1/files/flbsr10fq6uh3-UA/image;s=384x854', 36 | 'https://ireland.apollo.olxcdn.com:443/v1/files/t1shs5579wxd2-UA/image;s=384x854', 37 | 'https://ireland.apollo.olxcdn.com:443/v1/files/k69e39fw5b0s1-UA/image;s=854x384', 38 | 'https://ireland.apollo.olxcdn.com:443/v1/files/hknglo3p5c0a1-UA/image;s=384x854', 39 | 'https://ireland.apollo.olxcdn.com:443/v1/files/90abhy3rgbdx1-UA/image;s=1200x1600', 40 | 'https://ireland.apollo.olxcdn.com:443/v1/files/9nwf5zho7ur73-UA/image;s=1600x1200', 41 | 'https://ireland.apollo.olxcdn.com:443/v1/files/fkyfzffs04uc2-UA/image;s=1200x1600', 42 | 'https://ireland.apollo.olxcdn.com:443/v1/files/an642er892ys3-UA/image;s=1200x1600', 43 | 'https://ireland.apollo.olxcdn.com:443/v1/files/qk5iak4xq43n2-UA/image;s=1600x1200', 44 | 'https://ireland.apollo.olxcdn.com:443/v1/files/9pb0ssxp4zan3-UA/image;s=1200x1600', 45 | 'https://ireland.apollo.olxcdn.com:443/v1/files/2juha7qyu52g3-UA/image;s=1200x1600', 46 | 'https://ireland.apollo.olxcdn.com:443/v1/files/tf2wgvjespxm-UA/image;s=1200x1600', 47 | 'https://ireland.apollo.olxcdn.com:443/v1/files/qlbavct2iycx2-UA/image;s=1200x1600', 48 | 'https://ireland.apollo.olxcdn.com:443/v1/files/ejke0r2rnx553-UA/image;s=1200x1600', 49 | 'https://ireland.apollo.olxcdn.com:443/v1/files/qpy48jmoaqzc1-UA/image;s=1200x1600', 50 | 'https://ireland.apollo.olxcdn.com:443/v1/files/8dbgdbn6mems1-UA/image;s=1200x1600', 51 | ] 52 | 53 | ImageUrlsSt = st.lists( 54 | st.builds(lambda: next(cycle(_IMAGE_URLS))), min_size=1, max_size=4 55 | ) 56 | DetailedAdSt = st.builds( 57 | DetailedAd, 58 | id=IdSt, 59 | tag=TagSt, 60 | title=TitleSt, 61 | parse_date=ParseDateSt, 62 | url=UrlSt, 63 | description=TitleSt, 64 | image_urls=ImageUrlsSt, 65 | external_id=st.text(alphabet='1234567890', min_size=8, max_size=8), 66 | name=TitleSt, 67 | ) 68 | 69 | if __name__ == '__main__': 70 | ad = BaseAdSt.example() 71 | ad2 = DetailedAdSt.example() 72 | from pprint import pprint 73 | 74 | pprint(ad) 75 | pprint(ad2) 76 | -------------------------------------------------------------------------------- /ad/adapters/repository.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import csv 3 | import os 4 | from itertools import chain 5 | from typing import Dict, List 6 | from telegram import Bot 7 | from telegram.bot import InvalidToken 8 | 9 | from ad.adapters.utils import get_config, BASE_DIR 10 | from ad.core.adapters.repository import ( 11 | CreateAdsRepo, 12 | DetailedAdRepo, 13 | CreateAdsConfig, 14 | Configuration, 15 | Configurations, 16 | ViewsRepo, 17 | Sender, 18 | GetDetailedAdRepo, 19 | ) 20 | from ad.core.entities import ( 21 | BaseAds, 22 | BaseAd, 23 | FullAd, 24 | DetailedAd, 25 | DetailedAds, 26 | AnyAds, 27 | FullAds, 28 | Views, 29 | View, 30 | ) 31 | from ad.core.errors import AdapterError 32 | 33 | _BASE_FILE_NAME = BASE_DIR.joinpath('.base-ads.csv') 34 | _DETAIL_FILE_NAME = BASE_DIR.joinpath('.detail-ads.csv') 35 | _FULL_FILE_NAME = BASE_DIR.joinpath('.full-ads.csv') 36 | _VIEWS_FILE_NAME = BASE_DIR.joinpath('.ad-views.csv') 37 | 38 | _file_field_map = { 39 | _BASE_FILE_NAME: BaseAd.__fields__.keys(), 40 | _DETAIL_FILE_NAME: DetailedAd.__fields__.keys(), 41 | _FULL_FILE_NAME: FullAd.__fields__.keys(), 42 | _VIEWS_FILE_NAME: View.__fields__.keys(), 43 | } 44 | 45 | 46 | def _init_storage(file_name, fields): 47 | if not os.path.exists(file_name): 48 | with open(file_name, 'w', newline='') as csvfile: 49 | fieldnames = fields 50 | writer = csv.DictWriter(csvfile, fieldnames=fieldnames) 51 | writer.writeheader() 52 | 53 | 54 | def _migrate(): 55 | for file_name, fields in _file_field_map.items(): 56 | _init_storage(file_name, fields) 57 | 58 | 59 | class CreateAdsRepoCsv(CreateAdsRepo): 60 | def save(self, base_ads: BaseAds) -> None: 61 | with open(_BASE_FILE_NAME, 'a', newline='') as csvfile: 62 | fieldnames = BaseAd.__fields__.keys() 63 | writer = csv.DictWriter(csvfile, fieldnames=fieldnames) 64 | for ad in base_ads: 65 | writer.writerow(ad.dict()) 66 | 67 | def get_all(self) -> BaseAds: 68 | with open(_BASE_FILE_NAME) as csvfile: 69 | reader = csv.DictReader(csvfile) 70 | return [BaseAd(**row) for row in reader] 71 | 72 | 73 | class DetailedAdRepoCsv(DetailedAdRepo): 74 | def save(self, detailed_ad: DetailedAd) -> None: 75 | saved = self.get_all_detail() 76 | with open(_DETAIL_FILE_NAME, 'w', newline='') as csvfile: 77 | fieldnames = DetailedAd.__fields__.keys() 78 | writer = csv.DictWriter(csvfile, fieldnames=fieldnames) 79 | writer.writeheader() 80 | for ad in self._mix_existed_ads_and_one_new(saved, detailed_ad): 81 | writer.writerow(_serialize_detail(ad)) 82 | 83 | @staticmethod 84 | def _mix_existed_ads_and_one_new( 85 | existed_ads: DetailedAds, new_or_updated_ad: DetailedAd 86 | ): 87 | existed_ads_without_new = filter( 88 | lambda x: x.external_id != new_or_updated_ad.external_id, existed_ads 89 | ) 90 | for ad in chain(existed_ads_without_new, [new_or_updated_ad]): 91 | yield ad 92 | 93 | @staticmethod 94 | def get_all_detail() -> DetailedAds: 95 | with open(_DETAIL_FILE_NAME) as csvfile: 96 | reader = csv.DictReader(csvfile) 97 | return [_deserialize_detail(row) for row in reader] 98 | 99 | @staticmethod 100 | def get_all_base() -> BaseAds: 101 | return CreateAdsRepoCsv().get_all() 102 | 103 | def get_base_ad_by_id(self, id: str) -> BaseAd: 104 | try: 105 | return [x for x in self.get_all_base() if x.id == id][0] 106 | except IndexError: 107 | raise AdapterError(f'Не найдено объявление {id}') 108 | 109 | 110 | def _serialize_detail(ad: DetailedAd) -> Dict: 111 | data = ad.dict() 112 | urls = _serialize_urls(data.pop('image_urls')) 113 | data['image_urls'] = urls 114 | return data 115 | 116 | 117 | def _deserialize_detail(row: Dict) -> DetailedAd: 118 | raw = row.pop('image_urls') 119 | urls = _deserialize_urls(raw) 120 | row['image_urls'] = urls 121 | return DetailedAd(**row) 122 | 123 | 124 | def _serialize_urls(urls): 125 | return ','.join(urls) 126 | 127 | 128 | def _deserialize_urls(raw: str): 129 | if not raw: 130 | return [] 131 | return raw.split(',') 132 | 133 | 134 | class DetailedAdGetRepoCsv(GetDetailedAdRepo): 135 | def get_all(self) -> DetailedAds: 136 | return DetailedAdRepoCsv().get_all_detail() 137 | 138 | def get_by_tag(self, tag: str) -> DetailedAds: 139 | return _filter_by_tag(tag, self.get_all()) 140 | 141 | 142 | def _filter_by_tag(tag, items: AnyAds) -> AnyAds: 143 | return [ad for ad in items if ad.tag == tag] 144 | 145 | 146 | class CreateAdsConfigJson(CreateAdsConfig): 147 | def get_configuration(self) -> Configurations: 148 | return Configuration.parse_file('configuration.json').__root__ 149 | 150 | 151 | class GetDebugRepo(GetDetailedAdRepo): 152 | def get_all(self) -> FullAds: 153 | ad = FullAd( 154 | id='bc516e2abb5445ae9d03128a7a911f8f', # dont show in template 155 | tag='arenda-dnepr', # dont show in template 156 | title='Сдам 2-х комнатную квартиру на длительный период - Днепр', 157 | publication_date='2021-11-04 12:58:45', # dont show in template 158 | parse_date='2021-11-04 12:58:45', 159 | url='https://www.olx.ua/d/obyavlenie/sdam-2-h-komnatnuyu-kvartiru-na-dlitelnyy-period-IDN7dzO.html', 160 | description='Сдам 2-х комнатную квартиру на длительный период для семейной пары в районе ' 161 | '97 школы' 162 | ' (Ул. Братьев Трофимовых 40), 6 этаж 9-и этажного дома, не угловая, теплая, есть лоджия, застеклена.', 163 | image_urls=[ 164 | 'https://ireland.apollo.olxcdn.com:443/v1/files/dodwyas1emy32-UA/image;s=4000x3000', 165 | 'https://ireland.apollo.olxcdn.com/v1/files/pxokmbrmwf9v2-UA/image;s=1104x1472', 166 | 'https://ireland.apollo.olxcdn.com/v1/files/ve9s1d20cn211-UA/image;s=1104x1472', 167 | 'https://ireland.apollo.olxcdn.com/v1/files/ralzthng8yp52-UA/image;s=1944x2592', 168 | 'https://ireland.apollo.olxcdn.com/v1/files/il2y84fnyo5w-UA/image;s=591x1280', 169 | ], 170 | external_id='725276749', 171 | name='Феликс', 172 | phone='+380995437751', 173 | ) 174 | return [ad] 175 | 176 | def get_by_tag(self, tag: str) -> DetailedAds: 177 | return _filter_by_tag(tag, self.get_all()) 178 | 179 | 180 | class ViewsRepoCsv(ViewsRepo): 181 | def get_views_by_ids(self, ad_ids: List[str]) -> Views: 182 | # ad_ids = [0,1,2,3] views = [1,2] return [1,2] 183 | with open(_VIEWS_FILE_NAME) as csvfile: 184 | reader = csv.DictReader(csvfile) 185 | all_ad_views = [View(**row) for row in reader] 186 | all_ad_views_d = {view.id: view for view in all_ad_views} 187 | viewed_ads_ids = set(ad_ids).intersection(set(all_ad_views_d.keys())) 188 | return [ 189 | view 190 | for view_id, view in all_ad_views_d.items() 191 | if view_id in viewed_ads_ids 192 | ] 193 | 194 | def save_view(self, view: View) -> None: 195 | with open(_VIEWS_FILE_NAME, 'a', newline='') as csvfile: 196 | fieldnames = View.__fields__.keys() 197 | writer = csv.DictWriter(csvfile, fieldnames=fieldnames) 198 | writer.writerow(view.dict()) 199 | 200 | 201 | class TelegramSender(Sender): 202 | def __init__(self): 203 | _token = self._get_token() 204 | try: 205 | self._bot = Bot(token=_token) 206 | except InvalidToken: 207 | raise AdapterError('Нужен валидный телеграм токен, а не любые символы') 208 | self._chat_id = self._get_chat_id() 209 | 210 | def send_message(self, msg: str) -> None: 211 | self._bot.send_message(chat_id=self._chat_id, text=msg, parse_mode='HTML') 212 | 213 | @staticmethod 214 | def _get_token(): 215 | config = get_config() 216 | try: 217 | return config.get('secrets', 'TELEGRAM_BOT_TOKEN') 218 | except configparser.NoOptionError: 219 | raise AdapterError( 220 | '''Нет токена для телеграм бота. 221 | В файле environment.ini в [secrets] укажите: 222 | TELEGRAM_BOT_TOKEN=Replace-with-your-token''' 223 | ) 224 | 225 | @staticmethod 226 | def _get_chat_id() -> int: 227 | config = get_config() 228 | try: 229 | return config.getint('secrets', 'CHAT_ID') 230 | except ValueError: 231 | raise AdapterError('телеграм CHAT_ID должен состоять из цифр') 232 | 233 | 234 | if __name__ == '__main__': 235 | _migrate() 236 | -------------------------------------------------------------------------------- /templates/description.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |40 | Возможности: 41 |
(в начало)
90 | 91 | 92 | 93 | 94 | ## Начало 95 | 96 | 97 | Верхнеуровнево проект состорит из двух частей: 98 | 1. backend - состотит из веб приложения и процесса который загружает данные из ОЛХ объявлений 99 | 2. frontend - предполагает 2 вараинта использования: 100 | 1. любое приложение поддерживающие [RSS протокол](https://ru.wikipedia.org/wiki/RSS). 101 | Т.е. начиная [RSS клиентами](https://en.wikipedia.org/wiki/Comparison_of_feed_aggregators), заканчивая ботами в мессенжерах ([пример](https://github.com/BoKKeR/RSS-to-Telegram-Bot)) 102 | 2. отправка данных в телеграм бот. 103 | ![Диаграмма архитектуры приложения][architecture-diagram] 104 | 105 | ### Системные заввисимости 106 | 107 | 108 | Для установки backend необходимо иметь следующее ПО: 109 | - [git](https://git-scm.com/downloads) 110 | - [docker](https://docs.docker.com/engine/install/) 111 | - [docker-compose](https://docs.docker.com/compose/install/) 112 | - либо на уровне провайдера открыть порт номер 12345 либо с помощью [ufw](https://wiki.ubuntu.com/UncomplicatedFirewall) 113 | 114 | Работу с frontend рассмотрим на примере RSS клиента [QuiteRSS][frontend-example] 115 | 116 | ### Установка backend 117 | 118 | 119 | 1. Клонирование репозитория 120 | ```sh 121 | git clone https://github.com/lerdem/olx-parser.git 122 | ``` 123 | 2. Установка поисковых запросов для мониторинга в файле configuration.json ([пример конфигурации][configuration-json]) 124 | ```sh 125 | cd olx-parser/ && nano configuration.json 126 | ``` 127 | 3. Сборка и запуск backend 128 | ```sh 129 | docker-compose up -d --build 130 | ``` 131 | 132 |(в начало)
133 | 134 | 135 | 136 | 137 | ## Использование через приложение с RSS (frontend 1) 138 | 139 | 140 | Необходимо добавить feed в выбраный вами вариант RSS клиента. 141 | Для этого на примере QuiteRSS добавьте feed (через Ctrl+N) ссылку 142 | вида http://(в начало)
156 | 157 | 158 | ## Использование через Телеграм бота (frontend 2) 159 | 160 | 161 | 1. Настройка телеграм бота. В файл environment.ini установить актуальные 162 | TELEGRAM_BOT_TOKEN и CHAT_ID 163 | ```sh 164 | nano environment.ini 165 | ``` 166 | 2. Запустить телеграм бот. 167 | ```shell script 168 | docker exec -it olx-server python -m ad.telegram_sender & 169 | ``` 170 | 171 |(в начало)
172 | 173 | 174 | 175 | 176 | ## Планы доработок 177 | 178 | 179 | - [ ] Реклама в сообществах аренды жилья 180 | - [ ] Сеть каналов по регионам 181 | - [ ] Семантическое версионирование 182 | - [ ] Добавить скрипт по генерации changelog на базе коммитов 183 | - [ ] картинки в base64 (вопрос приватности т.к. загрузка идет с серверов олх) 184 | - [ ] размер картинок 185 | - [x] Добавить альтернативу RSS 186 | - [ ] Разное время парсинга для разных урлов 187 | - [ ] Главная страница с: 188 | - [ ] Конфигурацией настроек парсера. Объявлений (из url/form) 189 | - [ ] Списком возможных фидов 190 | - [ ] Списком вариантов деплоя проекта 191 | - [ ] Трансформация введенной урл в rss? 192 | - [ ] Сделать хранение csv опциональным 193 | - [ ] Разделять base и detail для экономии трафика 194 | - [ ] Поиск дубликатов фото объявлений и мошенников 195 | - [ ] Бан база по телефону и отзыву пользователей 196 | - [ ] Парсинг номеров телефонов 197 | - [ ] Поддержка [sentry](https://docs.sentry.io/platforms/python/) 198 | - [ ] Валидация тегов и 404 199 | 200 | See the [open issues](https://github.com/lerdem/olx-parser/issues) for a full list of proposed features (and known issues). 201 | 202 |(в начало)
203 | 204 | 205 | 206 | ## Причины создания проекта 207 | 208 | 209 | Причина написания проекта родилась после осознания состояния рынка недвижимости. 210 | До развала СССР рынка недвижимости не было, т.к. в СССР жилье было правом и гарантировалось конституцией, получали его не за деньги, а по распределению. 211 | Сейчас, в 21 веке, капитализм распространен по большинству стран, следовательно, вместо самореализации в жизни человек вынужден выбирать максимально денежную работу для выплаты ипотеки/аренды недвижимости. 212 | И попытка строительства в СССР прогрессивного экономического уклада **социализма** была призвана решить положение экономического принуждения человека. 213 | Все аспекты прогрессивности социализма можно увидеть только сравнивая с **капитализмом**. 214 | Капитализму как экономическому укладу свойственен рынок, посредством него происходит обмен товаров частных собственников. 215 | Вопрос появления рынка недвижимости, был вопросом времени, но второстепенным в "лихие 90-е". 216 | Первостепенным вопросом был, получение контроля на крупнейшими активами советского времени, т.е. **перевод собственности общественной на заводы/шахты/фабрики/земельные участки в собственность частную**. 217 | После этого передела, вдруг бандиты стали бизнесменами и начались "честные" рыночные отношения(в истории такой процесс называется первичным накоплением капитала). 218 | После уже появляются разнообразные рынки товаров и интересующий нас рынок недвижимости. 219 | 220 | Конкретно будет рассматриваться аренда жилья, но из дальнейшего изложения можно увидеть сходства с другими рынками. 221 | На этом рынке как и на любом другом есть **продавец** и **покупатель** у первого товар у второго деньги. 222 | У каждого участника свои требования, например продавец ищет кандидатов со "стабильной" работой и региональной пропиской, арендатор ищет вариант недалеко от метро и максимально дешево. 223 | Помимо требований бывает еще ряд проблем: мошенники, арестованное жилье, личностные черты характера участников сделки. 224 | И вот, чтобы упростить все эти моменты на рынке появляется **посредник - риелтор**, часть проблем по поиску жилья от берет на себя. 225 | Платит за его услуги зачастую покупатель. 226 | Продавец здесь имеет более выгодное положение по отношению к покупателю, т.к. он собственник недвижимости и без него сделки не будет. 227 | И вроде все логично, хочешь самостоятельно искать недвижимость - будет дешевле, дольше с поиском и согласованием, хочешь через риелтора - будет дороже, возможно быстрее с поиском и урегулирование берет на себя посредник. 228 | 229 | Что упускается из этой логичной "картины"? Факторы **монополизации рынка и интернет**. 230 | С развитием рынка менее конкурентных поглощают более конкурентные участники. 231 | Т.е. на место множества малых(или одиночных) риелторов, со своими базами недвижимости, приходят меньшее множество фирм предоставляющими риелторские услуги. 232 | И здесь риелтор уже просто наемный работник. Базы недвижимости становятся больше и в меньшем количестве рук. 233 | И это явление монополизации происходит постоянно, т.к. это свойство рынка. 234 | Теперь о другом факторе - интернет. 235 | **Интернет стал условием для появления новой формы отношений между продавцом и покупателем.** 236 | Стали появляться интернет магазины, доски объявлений(и ОЛХ который парсим в этом проекте). 237 | Теперь проблема поиска недвижимости была сведена к обустройству системы(сайта) с возможностью публикации информации со стороны собственника и инструментами поиска и фильтрации со стороны соискателя. 238 | И по началу появление таких сайтов упрощало взаимодействие людей при поиске недвижимости. 239 | Но не забываем **это рынок и монополисты свой денежный интерес не упустят**. 240 | Спустя время, доски объявлений станут платными, а объявления о недвижимости преимущественно будут от риелторских фирм. 241 | Даже в ситуации когда человек не из их базы решит сдать недвижимость, для этого он разместит объявление на сайтах объявлений, после чего фирмы убеждают человека о необходимости сделки через них. 242 | 243 | Итог, процесс монополизации рынка недвижимости в пользу риелторских фирм ставит в безвыходное положение соискателя. 244 | Он практически не может отказаться от услуг риелторов. 245 | Доски объявлений/сайты в своем рассвете приносящие пользу со временем стали орудием в руках монополистов. 246 | С течением развития рынка недвижимости суть риелторской услуги это монопольное владение информацией о продавцах и продажа ее покупателю. 247 | И не вся информация продается, а лишь информация про нужный объект недвижимости. 248 | Т.е. оплата идет за нечто (информацию) производство которого равно публикации поста в социальной сети. 249 | Интернет дает возможность обмениваться информацией бесплатно, но бизнесмены умудряются влезть в обмен и брать плату. 250 | Описанный пример показывает паразитическую сущность капитализма в 21 веке. 251 | 252 | Этот [проект](https://github.com/lerdem/olx-parser) как [авада-кедавра](https://dic.academic.ru/dic.nsf/ruwiki/152498) бессмертному, монопольное положение собственников риелторских фирм победить он не может. 253 | Проект может лишь увеличишь шанс сделать звонок собственнику недвижимости до звонка риелтора. 254 | 255 | Что нужно для победы над монополистами вообще? 256 | **Нужна смена экономического уклада, смена капитализма социализмом**. 257 | Любые попытки сопротивления антимонопольными законами или написания open source альтернатив, равно борьбе со следствиями. 258 | Учитесь, анализируйте, действуйте! 259 | 260 |(в начало)
261 | 262 | 263 | 264 | ## Лицензия 265 | 266 | 267 | Распространяется под лицензией GPL-3. [Детали](https://github.com/lerdem/olx-parser/blob/master/LICENSE). 268 | 269 |(в начало)
270 | 271 | 272 | 273 | 274 | ## Благодарности 275 | 276 | 277 | * [Best-README-Template](https://github.com/othneildrew/Best-README-Template) 278 | 279 |(в начало)
280 | 281 | 282 | 283 | 284 | 285 | [contributors-shield]: https://img.shields.io/github/contributors/lerdem/olx-parser.svg?style=for-the-badge 286 | [contributors-url]: https://github.com/lerdem/olx-parser/graphs/contributors 287 | [forks-shield]: https://img.shields.io/github/forks/lerdem/olx-parser.svg?style=for-the-badge 288 | [forks-url]: https://github.com/lerdem/olx-parser/network/members 289 | [stars-shield]: https://img.shields.io/github/stars/lerdem/olx-parser.svg?style=for-the-badge 290 | [stars-url]: https://github.com/lerdem/olx-parser/stargazers 291 | [issues-shield]: https://img.shields.io/github/issues/lerdem/olx-parser.svg?style=for-the-badge 292 | [issues-url]: https://github.com/lerdem/olx-parser/issues 293 | [license-shield]: https://img.shields.io/github/license/lerdem/olx-parser.svg?style=for-the-badge 294 | [license-url]: https://github.com/lerdem/olx-parser/blob/master/LICENSE.txt 295 | [frontend-example]: https://quiterss.org/en/download 296 | [screenshot-1]: docs/screenshots/screenshot-1.png 297 | [architecture-diagram]: docs/diagrams/olx-parser-architecture.png 298 | [configuration-json]: https://github.com/lerdem/olx-parser/blob/master/configuration.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc.