feat: ai letter + disabled fl

This commit is contained in:
2026-07-17 12:21:53 +07:00
parent b906825134
commit 3fe870024d
13 changed files with 1610 additions and 24 deletions

View File

@@ -9,3 +9,8 @@ KEYWORDS_EXCLUDE=wordpress,figma
MIN_PRICE_AMOUNT=
REQUESTS_TIMEOUT_SECONDS=20
STATE_FILE=data/sent_offers.json
PUBLISHED_OFFERS_FILE=data/published_offers.json
OPENAI_API_BASE_URL=https://api.openai.com/v1
OPENAI_API_KEY=your_api_key
OPENAI_MODEL=gpt-4o-mini
OPENAI_SYSTEM_PROMPT_FILE=/app/prompts/offer_reply_system_prompt.txt

80
app/ai.py Normal file
View File

@@ -0,0 +1,80 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import requests
from app.models import Offer
class OfferReplyGenerator:
def __init__(
self,
api_base_url: str,
api_key: str,
model: str,
system_prompt_file: Path,
timeout_seconds: int,
) -> None:
self._api_base_url = api_base_url.rstrip("/")
self._api_key = api_key
self._model = model
self._system_prompt = system_prompt_file.read_text(encoding="utf-8").strip()
self._timeout_seconds = timeout_seconds
async def generate_reply(self, offer: Offer) -> str:
return await asyncio.to_thread(self._generate_reply_sync, offer)
def _generate_reply_sync(self, offer: Offer) -> str:
response = requests.post(
f"{self._api_base_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
json={
"model": self._model,
"messages": [
{"role": "system", "content": self._system_prompt},
{"role": "user", "content": _build_offer_prompt(offer)},
],
},
timeout=self._timeout_seconds,
)
response.raise_for_status()
payload = response.json()
choices = payload.get("choices") or []
if not choices:
raise ValueError("OpenAI-compatible API returned no choices")
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str) and content.strip():
return content.strip()
if isinstance(content, list):
text_parts = [item.get("text", "") for item in content if isinstance(item, dict)]
text = "\n".join(part.strip() for part in text_parts if part and part.strip()).strip()
if text:
return text
raise ValueError("OpenAI-compatible API returned empty content")
def _build_offer_prompt(offer: Offer) -> str:
seller_line = f"Исполнитель: @{offer.seller_username}\n" if offer.seller_username else ""
price_amount_line = f"Числовой бюджет: {offer.price_amount}\n" if offer.price_amount is not None else ""
return (
f"Подготовь сообщение для отклика на этот оффер.\n"
f"Источник: {offer.source}\n"
f"ID: {offer.external_id}\n"
f"Заголовок: {offer.title}\n"
f"Категория: {offer.category_id}\n"
f"Бюджет: {offer.price}\n"
f"{price_amount_line}"
f"Опубликовано: {offer.published_at}\n"
f"{seller_line}"
f"Описание:\n{offer.description}\n\n"
f"Ссылка: {offer.url}"
)

View File

@@ -20,6 +20,11 @@ class Settings:
min_price_amount: int | None
requests_timeout_seconds: int
state_file: Path
published_offers_file: Path
openai_api_base_url: str
openai_api_key: str
openai_model: str
openai_system_prompt_file: Path
def load_settings() -> Settings:
@@ -40,6 +45,11 @@ def load_settings() -> Settings:
min_price_amount=_parse_optional_int(os.getenv("MIN_PRICE_AMOUNT", "")),
requests_timeout_seconds=int(os.getenv("REQUESTS_TIMEOUT_SECONDS", "20")),
state_file=Path(os.getenv("STATE_FILE", "data/sent_offers.json")),
published_offers_file=Path(os.getenv("PUBLISHED_OFFERS_FILE", "data/published_offers.json")),
openai_api_base_url=_require_env("OPENAI_API_BASE_URL"),
openai_api_key=_require_env("OPENAI_API_KEY"),
openai_model=_require_env("OPENAI_MODEL"),
openai_system_prompt_file=Path(_require_env("OPENAI_SYSTEM_PROMPT_FILE")),
)

View File

@@ -19,3 +19,32 @@ class Offer:
@property
def dedup_key(self) -> str:
return f"{self.source}:{self.external_id}"
def to_dict(self) -> dict[str, str | int | None]:
return {
"source": self.source,
"external_id": self.external_id,
"title": self.title,
"description": self.description,
"price": self.price,
"price_amount": self.price_amount,
"url": self.url,
"category_id": self.category_id,
"published_at": self.published_at,
"seller_username": self.seller_username,
}
@classmethod
def from_dict(cls, data: dict[str, str | int | None]) -> "Offer":
return cls(
source=str(data["source"]),
external_id=str(data["external_id"]),
title=str(data["title"]),
description=str(data["description"]),
price=str(data["price"]),
price_amount=int(data["price_amount"]) if data["price_amount"] is not None else None,
url=str(data["url"]),
category_id=int(data["category_id"]),
published_at=str(data["published_at"]),
seller_username=str(data["seller_username"]) if data["seller_username"] is not None else None,
)

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
from app.filters import get_offer_skip_reason
from app.state import SentOffersStore
from app.state import PublishedOffersStore, SentOffersStore
from app.telegram import TelegramPublisher
from app.sources.base import OfferSource
@@ -17,6 +17,7 @@ class OfferProcessingService:
sources: list[OfferSource],
publisher: TelegramPublisher,
sent_offers_store: SentOffersStore,
published_offers_store: PublishedOffersStore,
include_keywords: list[str],
exclude_keywords: list[str],
min_price_amount: int | None,
@@ -24,6 +25,7 @@ class OfferProcessingService:
self._sources = sources
self._publisher = publisher
self._sent_offers_store = sent_offers_store
self._published_offers_store = published_offers_store
self._include_keywords = include_keywords
self._exclude_keywords = exclude_keywords
self._min_price_amount = min_price_amount
@@ -66,12 +68,13 @@ class OfferProcessingService:
continue
try:
await self._publisher.publish_offer(offer)
message = await self._publisher.publish_offer(offer)
except Exception:
logger.exception("Failed to publish offer %s", offer.dedup_key)
continue
self._sent_offers_store.add(offer.dedup_key)
self._published_offers_store.add(message.chat.id, message.message_id, offer)
published_count += 1
logger.info("Published offer %s", offer.dedup_key)

View File

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

View File

@@ -3,6 +3,8 @@ from __future__ import annotations
import json
from pathlib import Path
from app.models import Offer
class SentOffersStore:
def __init__(self, file_path: Path) -> None:
@@ -29,3 +31,44 @@ class SentOffersStore:
def _persist(self) -> None:
with self._file_path.open("w", encoding="utf-8") as file:
json.dump(sorted(self._sent_keys), file, ensure_ascii=False, indent=2)
class PublishedOffersStore:
def __init__(self, file_path: Path) -> None:
self._file_path = file_path
self._file_path.parent.mkdir(parents=True, exist_ok=True)
self._offers_by_message_key = self._load()
def add(self, chat_id: int, message_id: int, offer: Offer) -> None:
self._offers_by_message_key[self._message_key(chat_id, message_id)] = offer.to_dict()
self._persist()
def get(self, chat_id: int, message_id: int) -> Offer | None:
data = self._offers_by_message_key.get(self._message_key(chat_id, message_id))
if data is None:
return None
return Offer.from_dict(data)
def remove(self, chat_id: int, message_id: int) -> None:
removed = self._offers_by_message_key.pop(self._message_key(chat_id, message_id), None)
if removed is not None:
self._persist()
def _load(self) -> dict[str, dict[str, str | int | None]]:
if not self._file_path.exists():
return {}
with self._file_path.open("r", encoding="utf-8") as file:
data = json.load(file)
if not isinstance(data, dict):
return {}
return data
def _persist(self) -> None:
with self._file_path.open("w", encoding="utf-8") as file:
json.dump(self._offers_by_message_key, file, ensure_ascii=False, indent=2)
@staticmethod
def _message_key(chat_id: int, message_id: int) -> str:
return f"{chat_id}:{message_id}"

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
from app.models import Offer
@@ -11,12 +11,12 @@ class TelegramPublisher:
self._bot = bot
self._channel_id = channel_id
async def publish_offer(self, offer: Offer) -> None:
await self._bot.send_message(
async def publish_offer(self, offer: Offer) -> Message:
return await self._bot.send_message(
chat_id=self._channel_id,
text=_format_offer_message(offer),
disable_web_page_preview=True,
reply_markup=_build_delete_markup(),
reply_markup=_build_offer_markup(),
)
@@ -39,9 +39,10 @@ def _format_offer_message(offer: Offer) -> str:
)
def _build_delete_markup() -> InlineKeyboardMarkup:
def _build_offer_markup() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text="❌", callback_data="delete_post")]
[InlineKeyboardButton(text="Принять", callback_data="accept_offer")],
[InlineKeyboardButton(text="❌", callback_data="delete_post")],
]
)

1226
data/published_offers.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,56 @@
{}
[
"fl:5513258",
"fl:5513359",
"fl:5513369",
"fl:5513372",
"fl:5513401",
"fl:5513486",
"fl:5513539",
"fl:5513551",
"fl:5513570",
"fl:5513582",
"fl:5513591",
"fl:5513636",
"fl:5513646",
"fl:5513659",
"fl:5513674",
"fl:5513681",
"fl:5513694",
"fl:5513716",
"fl:5513732",
"fl:5513738",
"fl:5513743",
"fl:5513763",
"fl:5513784",
"fl:5513823",
"fl:5513833",
"fl:5513842",
"fl:5513871",
"fl:5513872",
"fl:5513894",
"fl:5513897",
"fl:5513913",
"fl:5513964",
"fl:5513973",
"fl:5513981",
"fl:5514010",
"fl:5514016",
"fl:5514018",
"fl:5514025",
"fl:5514041",
"fl:5514084",
"fl:5514093",
"fl:5514096",
"fl:5514112",
"fl:5514122",
"fl:5514130",
"kwork:3218016",
"kwork:3218028",
"kwork:3218180",
"kwork:3218440",
"kwork:3218756",
"kwork:3218763",
"kwork:3218781",
"kwork:3218812",
"kwork:3218824"
]

9
docker-compose.yml Normal file
View File

@@ -0,0 +1,9 @@
services:
parser:
build: .
container_name: my-parser
restart: unless-stopped
env_file: .env
volumes:
- ./data:/app/data
- ./prompts:/app/prompts:ro

103
main.py
View File

@@ -4,14 +4,16 @@ import asyncio
import logging
from aiogram import Bot, Dispatcher, Router
from aiogram.types import CallbackQuery
from aiogram.types import CallbackQuery, Message
from aiogram.client.session.aiohttp import AiohttpSession
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.ai import OfferReplyGenerator
from app.config import load_settings
from app.service import OfferProcessingService
from app.sources import FlSource, KworkSource
from app.state import SentOffersStore
from app.sources import KworkSource
# from app.sources import FlSource
from app.state import PublishedOffersStore, SentOffersStore
from app.telegram import TelegramPublisher
@@ -22,10 +24,75 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
def _build_direct_message_text(message: Message, offer_url: str, reply_text: str) -> str:
message_link = _build_message_link(message)
if message_link:
return f"Пост: {message_link}\nОффер: {offer_url}\n\n{reply_text}"
return f"Оффер: {offer_url}\n\n{reply_text}"
def build_dispatcher() -> Dispatcher:
def _build_message_link(message: Message) -> str | None:
if message.chat.username:
return f"https://t.me/{message.chat.username}/{message.message_id}"
chat_id = str(message.chat.id)
if not chat_id.startswith("-100"):
return None
internal_chat_id = chat_id[4:]
if not internal_chat_id:
return None
return f"https://t.me/c/{internal_chat_id}/{message.message_id}"
def build_dispatcher(
reply_generator: OfferReplyGenerator,
published_offers_store: PublishedOffersStore,
) -> Dispatcher:
router = Router()
@router.callback_query(lambda callback: callback.data == "accept_offer")
async def accept_offer_callback(callback: CallbackQuery, bot: Bot) -> None:
if callback.message is None:
await callback.answer()
return
offer = published_offers_store.get(callback.message.chat.id, callback.message.message_id)
if offer is None:
await callback.answer("Не удалось найти оффер", show_alert=True)
return
await callback.answer("Готовлю сообщение")
try:
reply_text = await reply_generator.generate_reply(offer)
await bot.send_message(
chat_id=callback.from_user.id,
text=_build_direct_message_text(callback.message, offer.url, reply_text),
disable_web_page_preview=True,
)
logger.info(
"Generated DM for user_id=%s from chat_id=%s message_id=%s",
callback.from_user.id,
callback.message.chat.id,
callback.message.message_id,
)
except Exception:
logger.exception(
"Failed to generate or send DM for user_id=%s chat_id=%s message_id=%s",
callback.from_user.id,
callback.message.chat.id,
callback.message.message_id,
)
try:
await bot.send_message(
chat_id=callback.from_user.id,
text="Не удалось подготовить сообщение. Убедитесь, что вы начали диалог с ботом, и попробуйте снова.",
)
except Exception:
logger.exception("Failed to send fallback DM to user_id=%s", callback.from_user.id)
@router.callback_query(lambda callback: callback.data == "delete_post")
async def delete_post_callback(callback: CallbackQuery) -> None:
if callback.message is None:
@@ -34,6 +101,7 @@ def build_dispatcher() -> Dispatcher:
try:
await callback.message.delete()
published_offers_store.remove(callback.message.chat.id, callback.message.message_id)
logger.info(
"Deleted Telegram message chat_id=%s message_id=%s by user_id=%s",
callback.message.chat.id,
@@ -56,12 +124,20 @@ def build_dispatcher() -> Dispatcher:
async def main() -> None:
settings = load_settings()
dispatcher = build_dispatcher()
session = AiohttpSession(proxy=settings.telegram_proxy_url) if settings.telegram_proxy_url else None
bot = Bot(token=settings.telegram_bot_token, session=session)
publisher = TelegramPublisher(bot=bot, channel_id=settings.telegram_channel_id)
store = SentOffersStore(settings.state_file)
published_offers_store = PublishedOffersStore(settings.published_offers_file)
reply_generator = OfferReplyGenerator(
api_base_url=settings.openai_api_base_url,
api_key=settings.openai_api_key,
model=settings.openai_model,
system_prompt_file=settings.openai_system_prompt_file,
timeout_seconds=settings.requests_timeout_seconds,
)
dispatcher = build_dispatcher(reply_generator, published_offers_store)
sources = [
KworkSource(
@@ -69,18 +145,19 @@ async def main() -> None:
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,
)
)
# 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,
published_offers_store=published_offers_store,
include_keywords=settings.keywords_include,
exclude_keywords=settings.keywords_exclude,
min_price_amount=settings.min_price_amount,
@@ -100,4 +177,4 @@ async def main() -> None:
if __name__ == "__main__":
asyncio.run(main())
asyncio.run(main())

View File

@@ -0,0 +1,47 @@
## Роль и личность
Ты -- помощник фрилансера, отвечающий за бюрократическую работу
Твоя миссия: помогать обрабатывать заявки и офферы
## Ключевые принципы
1. В ответ ты указываешь **ТОЛЬКО** текст. Никакой markdown разметки, чистый текст.
2. Говори ВСЕГДА от первого лица. Ты и есть фрилансер, и от твоих слов зависит то, возьмут ли его на этот оффер.
3. Структурируем каждый ответ.
4. Мы не бегаем за заказчиками. Ты уверен в себе и своих возможностях, спокоен.
5. Ответ не более 2000 символов. Минимум 150.
6. Ты **НЕ ДАЁШЬ** сторонних контактов -- это против правил площадки.
7. Не используй стандартные клише вроде "не только, но и".
## Структура
Твой отклик должен следовать структуре:
1. Быстрый старт.
Начинаем сразу с сути -- кто мы такие, почему наша кандидатура, опыт и прочее. Максимум 1-2 предложения чтобы зацепить глаз и уверить в том, что мы -- подходящий компетентный кандидат.
2. Основная часть.
Наша задача -- ответить на главную боль заказчика. Подмечай в оффере слабости и безжалостно проходись по ним.
Плохая база от прошлого заказа? Перепроектируем с фокусом на долгосрочное использование.
Список до 6 пунктов по тому, какой стек будем использовать
3. Заключение
Важно сделать call to action -- `готов обсудить детали`, уточни какую-то важную мысль. Максимум 3 предложения.
## Данные о назначеном фрилансере.
Тебе предоставлена карточка работника.
```
**Специализация:** Backend / DevOps Engineer (Python)
**Формат работы:** Удаленно (Фриланс / Part-time)
**Контакты:** https://t.me/agonyecho | https://hexdev.lol | agonyecho@proton.me
## Профессиональный профиль
Backend-разработчик со специализацией на Python-автоматизации, веб-парсинге и управлении инфраструктурой. Имею глубокий практический опыт работы с Linux-системами (Arch Linux как основная ОС) и контейнеризацией (Docker/Compose). Сфокусирован на создании отказоустойчивой серверной логики, проектировании баз данных и развертывании приложений в production-ready окружении. Достигаю высокой автономности в работе — от написания кода до его деплоя на VPS.
## Технический стек
- Языки программирования: Python (requests, BeautifulSoup4, lxml, aiogram, pytest, sqlalchemy orm)
- Инфраструктура и DevOps: Docker, Docker Compose, Linux CLI/Bash, Caddy, Nginx (reverse proxy, SSL)
- Базы данных: PostgreSQL (проектирование схем, базовый CRUD, оптимизация)
- Инструменты контроля: Git, Gitea
```