From a0f1f1ade84d32dcebe4780747860b36e264d05b Mon Sep 17 00:00:00 2001 From: hexdev Date: Tue, 14 Jul 2026 15:04:31 +0700 Subject: [PATCH] feat: +fl.ru --- .env.example | 1 + README.md | 8 +++-- app/config.py | 2 ++ app/sources/__init__.py | 3 +- app/sources/fl.py | 72 +++++++++++++++++++++++++++++++++++++++++ main.py | 21 ++++++++---- 6 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 app/sources/fl.py diff --git a/.env.example b/.env.example index bb3256d..4dc95f7 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ TELEGRAM_CHANNEL_ID=@your_channel_or_chat_id TELEGRAM_PROXY_URL= SCHEDULER_INTERVAL_MINUTES=5 KWORK_CATEGORY_IDS=41 +FL_RSS_URL=https://www.fl.ru/rss/?category=5 KEYWORDS_INCLUDE=python,parser,api KEYWORDS_EXCLUDE=wordpress,figma MIN_PRICE_AMOUNT= diff --git a/README.md b/README.md index c5ca736..bde93bf 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # my_parser -Парсер офферов с `kwork.ru` с отправкой в Telegram и возможностью добавлять новые источники. +Парсер офферов с `kwork.ru` и `fl.ru` с отправкой в Telegram и возможностью добавлять новые источники. ## Что умеет - запускается каждые `X` минут через `APScheduler` - читает настройки из `.env` - забирает проекты `kwork.ru` по списку категорий `fc` +- забирает проекты `fl.ru` из RSS-ленты - фильтрует офферы по ключевым словам - фильтрует офферы по минимальной сумме - не отправляет уже отправленные офферы повторно @@ -29,6 +30,8 @@ python main.py Дополнительно можно задать `MIN_PRICE_AMOUNT`, чтобы пропускать только офферы с бюджетом не ниже указанного значения в рублях. +RSS `fl.ru` настраивается через `FL_RSS_URL`. По умолчанию используется `https://www.fl.ru/rss/?category=5`. + Для Telegram можно указать `TELEGRAM_PROXY_URL`, например `socks5://user:pass@host:port` или `http://host:port`. ## Docker @@ -57,6 +60,7 @@ docker run --rm --env-file .env -v "$(pwd)/data:/app/data" my-parser - `app/config.py` - загрузка настроек - `app/sources/base.py` - базовый интерфейс источника - `app/sources/kwork.py` - реализация источника `kwork` +- `app/sources/fl.py` - реализация RSS-источника `fl.ru` - `app/service.py` - получение, фильтрация и отправка офферов - `app/telegram.py` - публикация сообщений в Telegram - `app/state.py` - хранение уже отправленных офферов @@ -66,5 +70,3 @@ docker run --rm --env-file .env -v "$(pwd)/data:/app/data" my-parser 1. Создать новый класс в `app/sources/`, унаследованный от `OfferSource`. 2. Реализовать в нем `fetch_offers()` с возвратом списка `Offer`. 3. Подключить источник в `main.py`. - -Для `fl.ru` можно будет добавить отдельный класс по той же схеме без изменений основного пайплайна. diff --git a/app/config.py b/app/config.py index 23d149e..a998c9a 100644 --- a/app/config.py +++ b/app/config.py @@ -14,6 +14,7 @@ class Settings: telegram_proxy_url: str | None scheduler_interval_minutes: int kwork_category_ids: list[int] + fl_rss_url: str | None keywords_include: list[str] keywords_exclude: list[str] min_price_amount: int | None @@ -33,6 +34,7 @@ def load_settings() -> Settings: telegram_proxy_url=_parse_optional_text(os.getenv("TELEGRAM_PROXY_URL", "")), scheduler_interval_minutes=int(os.getenv("SCHEDULER_INTERVAL_MINUTES", "5")), kwork_category_ids=_parse_int_list(os.getenv("KWORK_CATEGORY_IDS", "41")), + fl_rss_url=_parse_optional_text(os.getenv("FL_RSS_URL", "https://www.fl.ru/rss/?category=5")), keywords_include=_parse_text_list(os.getenv("KEYWORDS_INCLUDE", "")), keywords_exclude=_parse_text_list(os.getenv("KEYWORDS_EXCLUDE", "")), min_price_amount=_parse_optional_int(os.getenv("MIN_PRICE_AMOUNT", "")), diff --git a/app/sources/__init__.py b/app/sources/__init__.py index 55d9453..9af86cd 100644 --- a/app/sources/__init__.py +++ b/app/sources/__init__.py @@ -1,4 +1,5 @@ from app.sources.base import OfferSource +from app.sources.fl import FlSource from app.sources.kwork import KworkSource -__all__ = ["OfferSource", "KworkSource"] +__all__ = ["OfferSource", "FlSource", "KworkSource"] diff --git a/app/sources/fl.py b/app/sources/fl.py new file mode 100644 index 0000000..a997e68 --- /dev/null +++ b/app/sources/fl.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import html +from typing import Final +from urllib.parse import urlparse +from xml.etree import ElementTree + +import requests + +from app.models import Offer +from app.sources.base import OfferSource + + +class FlSource(OfferSource): + source_name = "fl" + _default_category_id: Final[int] = 5 + + def __init__(self, rss_url: str, timeout_seconds: int) -> None: + self._rss_url = rss_url + self._timeout_seconds = timeout_seconds + self._session = requests.Session() + self._session.headers.update( + { + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" + ) + } + ) + + def fetch_offers(self) -> list[Offer]: + response = self._session.get(self._rss_url, timeout=self._timeout_seconds) + response.raise_for_status() + + root = ElementTree.fromstring(response.content) + offers: list[Offer] = [] + + for item in root.findall("./channel/item"): + url = _get_item_text(item, "link") + external_id = _extract_external_id(url) + if external_id is None: + continue + + offers.append( + Offer( + source=self.source_name, + external_id=external_id, + title=_get_item_text(item, "title", "Без названия"), + description=_get_item_text(item, "description"), + price="Не указан", + price_amount=None, + url=url, + category_id=self._default_category_id, + published_at=_get_item_text(item, "pubDate"), + ) + ) + + return offers + + +def _get_item_text(item: ElementTree.Element, tag_name: str, default: str = "") -> str: + element = item.find(tag_name) + if element is None or element.text is None: + return default + return html.unescape(element.text.strip()) + + +def _extract_external_id(url: str) -> str | None: + path_parts = [part for part in urlparse(url).path.split("/") if part] + if len(path_parts) < 2 or path_parts[0] != "projects": + return None + return path_parts[1] diff --git a/main.py b/main.py index 1679dec..4122420 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from app.config import load_settings from app.service import OfferProcessingService -from app.sources import KworkSource +from app.sources import FlSource, KworkSource from app.state import SentOffersStore from app.telegram import TelegramPublisher @@ -63,13 +63,22 @@ async def main() -> None: publisher = TelegramPublisher(bot=bot, channel_id=settings.telegram_channel_id) store = SentOffersStore(settings.state_file) - service = OfferProcessingService( - sources=[ - KworkSource( - category_ids=settings.kwork_category_ids, + sources = [ + KworkSource( + category_ids=settings.kwork_category_ids, + timeout_seconds=settings.requests_timeout_seconds, + ) + ] + if settings.fl_rss_url: + sources.append( + FlSource( + rss_url=settings.fl_rss_url, timeout_seconds=settings.requests_timeout_seconds, ) - ], + ) + + service = OfferProcessingService( + sources=sources, publisher=publisher, sent_offers_store=store, include_keywords=settings.keywords_include,