feat: ai letter + disabled fl
This commit is contained in:
@@ -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
80
app/ai.py
Normal 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}"
|
||||
)
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
43
app/state.py
43
app/state.py
@@ -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}"
|
||||
|
||||
@@ -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
1226
data/published_offers.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
9
docker-compose.yml
Normal 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
103
main.py
@@ -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())
|
||||
47
prompts/offer_reply_system_prompt.txt
Normal file
47
prompts/offer_reply_system_prompt.txt
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user