feat: +fl.ru

This commit is contained in:
2026-07-14 15:04:31 +07:00
parent 8b0e1ffffa
commit a0f1f1ade8
6 changed files with 97 additions and 10 deletions

View File

@@ -3,6 +3,7 @@ TELEGRAM_CHANNEL_ID=@your_channel_or_chat_id
TELEGRAM_PROXY_URL= TELEGRAM_PROXY_URL=
SCHEDULER_INTERVAL_MINUTES=5 SCHEDULER_INTERVAL_MINUTES=5
KWORK_CATEGORY_IDS=41 KWORK_CATEGORY_IDS=41
FL_RSS_URL=https://www.fl.ru/rss/?category=5
KEYWORDS_INCLUDE=python,parser,api KEYWORDS_INCLUDE=python,parser,api
KEYWORDS_EXCLUDE=wordpress,figma KEYWORDS_EXCLUDE=wordpress,figma
MIN_PRICE_AMOUNT= MIN_PRICE_AMOUNT=

View File

@@ -1,12 +1,13 @@
# my_parser # my_parser
Парсер офферов с `kwork.ru` с отправкой в Telegram и возможностью добавлять новые источники. Парсер офферов с `kwork.ru` и `fl.ru` с отправкой в Telegram и возможностью добавлять новые источники.
## Что умеет ## Что умеет
- запускается каждые `X` минут через `APScheduler` - запускается каждые `X` минут через `APScheduler`
- читает настройки из `.env` - читает настройки из `.env`
- забирает проекты `kwork.ru` по списку категорий `fc` - забирает проекты `kwork.ru` по списку категорий `fc`
- забирает проекты `fl.ru` из RSS-ленты
- фильтрует офферы по ключевым словам - фильтрует офферы по ключевым словам
- фильтрует офферы по минимальной сумме - фильтрует офферы по минимальной сумме
- не отправляет уже отправленные офферы повторно - не отправляет уже отправленные офферы повторно
@@ -29,6 +30,8 @@ python main.py
Дополнительно можно задать `MIN_PRICE_AMOUNT`, чтобы пропускать только офферы с бюджетом не ниже указанного значения в рублях. Дополнительно можно задать `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`. Для Telegram можно указать `TELEGRAM_PROXY_URL`, например `socks5://user:pass@host:port` или `http://host:port`.
## Docker ## Docker
@@ -57,6 +60,7 @@ docker run --rm --env-file .env -v "$(pwd)/data:/app/data" my-parser
- `app/config.py` - загрузка настроек - `app/config.py` - загрузка настроек
- `app/sources/base.py` - базовый интерфейс источника - `app/sources/base.py` - базовый интерфейс источника
- `app/sources/kwork.py` - реализация источника `kwork` - `app/sources/kwork.py` - реализация источника `kwork`
- `app/sources/fl.py` - реализация RSS-источника `fl.ru`
- `app/service.py` - получение, фильтрация и отправка офферов - `app/service.py` - получение, фильтрация и отправка офферов
- `app/telegram.py` - публикация сообщений в Telegram - `app/telegram.py` - публикация сообщений в Telegram
- `app/state.py` - хранение уже отправленных офферов - `app/state.py` - хранение уже отправленных офферов
@@ -66,5 +70,3 @@ docker run --rm --env-file .env -v "$(pwd)/data:/app/data" my-parser
1. Создать новый класс в `app/sources/`, унаследованный от `OfferSource`. 1. Создать новый класс в `app/sources/`, унаследованный от `OfferSource`.
2. Реализовать в нем `fetch_offers()` с возвратом списка `Offer`. 2. Реализовать в нем `fetch_offers()` с возвратом списка `Offer`.
3. Подключить источник в `main.py`. 3. Подключить источник в `main.py`.
Для `fl.ru` можно будет добавить отдельный класс по той же схеме без изменений основного пайплайна.

View File

@@ -14,6 +14,7 @@ class Settings:
telegram_proxy_url: str | None telegram_proxy_url: str | None
scheduler_interval_minutes: int scheduler_interval_minutes: int
kwork_category_ids: list[int] kwork_category_ids: list[int]
fl_rss_url: str | None
keywords_include: list[str] keywords_include: list[str]
keywords_exclude: list[str] keywords_exclude: list[str]
min_price_amount: int | None min_price_amount: int | None
@@ -33,6 +34,7 @@ def load_settings() -> Settings:
telegram_proxy_url=_parse_optional_text(os.getenv("TELEGRAM_PROXY_URL", "")), telegram_proxy_url=_parse_optional_text(os.getenv("TELEGRAM_PROXY_URL", "")),
scheduler_interval_minutes=int(os.getenv("SCHEDULER_INTERVAL_MINUTES", "5")), scheduler_interval_minutes=int(os.getenv("SCHEDULER_INTERVAL_MINUTES", "5")),
kwork_category_ids=_parse_int_list(os.getenv("KWORK_CATEGORY_IDS", "41")), 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_include=_parse_text_list(os.getenv("KEYWORDS_INCLUDE", "")),
keywords_exclude=_parse_text_list(os.getenv("KEYWORDS_EXCLUDE", "")), keywords_exclude=_parse_text_list(os.getenv("KEYWORDS_EXCLUDE", "")),
min_price_amount=_parse_optional_int(os.getenv("MIN_PRICE_AMOUNT", "")), min_price_amount=_parse_optional_int(os.getenv("MIN_PRICE_AMOUNT", "")),

View File

@@ -1,4 +1,5 @@
from app.sources.base import OfferSource from app.sources.base import OfferSource
from app.sources.fl import FlSource
from app.sources.kwork import KworkSource from app.sources.kwork import KworkSource
__all__ = ["OfferSource", "KworkSource"] __all__ = ["OfferSource", "FlSource", "KworkSource"]

72
app/sources/fl.py Normal file
View File

@@ -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]

17
main.py
View File

@@ -10,7 +10,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import load_settings from app.config import load_settings
from app.service import OfferProcessingService from app.service import OfferProcessingService
from app.sources import KworkSource from app.sources import FlSource, KworkSource
from app.state import SentOffersStore from app.state import SentOffersStore
from app.telegram import TelegramPublisher from app.telegram import TelegramPublisher
@@ -63,13 +63,22 @@ async def main() -> None:
publisher = TelegramPublisher(bot=bot, channel_id=settings.telegram_channel_id) publisher = TelegramPublisher(bot=bot, channel_id=settings.telegram_channel_id)
store = SentOffersStore(settings.state_file) store = SentOffersStore(settings.state_file)
service = OfferProcessingService( sources = [
sources=[
KworkSource( KworkSource(
category_ids=settings.kwork_category_ids, category_ids=settings.kwork_category_ids,
timeout_seconds=settings.requests_timeout_seconds, 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, publisher=publisher,
sent_offers_store=store, sent_offers_store=store,
include_keywords=settings.keywords_include, include_keywords=settings.keywords_include,