feat: +fl.ru
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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` можно будет добавить отдельный класс по той же схеме без изменений основного пайплайна.
|
|
||||||
|
|||||||
@@ -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", "")),
|
||||||
|
|||||||
@@ -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
72
app/sources/fl.py
Normal 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]
|
||||||
21
main.py
21
main.py
@@ -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,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if settings.fl_rss_url:
|
||||||
|
sources.append(
|
||||||
|
FlSource(
|
||||||
|
rss_url=settings.fl_rss_url,
|
||||||
timeout_seconds=settings.requests_timeout_seconds,
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user