---
name: VakHunter — обзор проекта
description: Telegram-бот агрегатор IT-вакансий и фриланса на mac mini под launchd; ~20 парсеров (бот маркетит «20 площадок»), тарифы Trial 0₽/Standard 149₽/Pro 490₽ (D32), прод-стек, точки входа и где смотреть логи
type: project
originSessionId: 805ead91-dfa3-49d2-a5e6-01a980e715fd
---
VakHunter — async Telegram-бот-агрегатор IT-вакансий и фриланс-заказов. Прод на mac mini.

- **Путь:** `~/Services/VakHunter` (на mac mini, SSH `cebers_mini@Cebers-mini.local` — mDNS, IP плавает)
- **launchd unit:** `local.vakhunter`. Перезапуск: `launchctl kickstart -k gui/$(id -u)/local.vakhunter`
- **Entrypoint:** `bot/main_ptb.py` (PTB v22, async). Запускается через `mac/run_vakhunter_macos.sh`
- **Стек:** Python 3.9, python-telegram-bot>=22.0, APScheduler, SQLite
- **БД:** `vakhunter.db` (один файл, инициализация и миграции inline в `database/db.py`)
- **State:** `user_states.pkl` — PersistentTTLCache для UI-мастеров (dirty-only flush каждые 60с)
- **Логи:** `~/Services/shared/logs/vakhunter.error.log` (всё через stderr); `vakhunter.log` остаётся пустым
- **Бэкапы перед патчами:** `~/Services/shared/backups/vakhunter_YYYYMMDD_HHMMSS/`
- **Прокси:** Telegram-трафик через xray `http://127.0.0.1:2081`. SELECTIVE_PROXY_* флаги в `.env`. Парсеры в основном идут direct
- **AGENT_HANDOFF.md** в корне — главная точка входа для будущих агентов (архитектура, потоки данных, как чинить)
- **Текущая ветка:** `devin/1777709351-search-and-tracking-fixes` (на 2026-05-18)

**20 зарегистрированных парсеров** в `parsers/` (PARSERS dict в bot/scheduler.py). ⚠️ **AvitoServices ОТКЛЮЧЁН 2026-06-20 (57f30ab, D29):** раздел Avito `predlozheniya_uslug` отдаёт ИСПОЛНИТЕЛЕЙ (рекламу мастеров), а не заказы заказчиков; снят из PARSERS и всех тарифов, файл оставлен dead-code. Заказы дают kwork/fl/profi/freelancehunt/weblancer/workzilla. Базовые: HH, HHKZ/BY/UZ (hh_intl), FL, Kwork, Avito (Работа), HabrCareer, Superjob, Rabota, Trudvsem, Profi, TelegramChannels. **+6 от 2026-06-10 (ec553e2):** GorodRabot+Zarplata (все профессии РФ, рублёвые ЗП — ядро «бот для всех специальностей»), RemoteOK+WWR (en-удалёнка, **через xray-прокси** — прямой IP троттлится), getmatch+GeekJob (IT) + **Freelancehunt (d5b8394, фриланс RU/UA 🛒, офиц. API v2 без токена через прокси)**. Weblancer отложен (Next.js SPA без публичного API, статика пустая). Все async через httpx (BaseParser, таймаут 30s, fallback proxy→direct для GET). **Источник доходит до юзера только если он в `allowed_sources` тарифа** (db.py UPDATE-стейтменты, авторитет, перезаписывают БД на старте) И зарегистрирован в PARSERS. Грабли новых: Zarplata — параметр `text=` не `query=` + server-state `HH-Lux-InitialState` + area=113; getmatch `/api/offers` не фильтрует текстом → клиентский фильтр; GeekJob низкий выход (JS-поиск); profi жив но малоурожаен (маркетплейс услуг). Новый парсер: проверять прямым вызовом `search()`, не только smoke.

**Фикс резюме 2026-06-10 (b0d0f66):** при приёмке «всё ли работает» поймал баг — загрузка PDF-резюме обновляла профиль в БД, но падало подтверждение `UnboundLocalError: InlineKeyboardButton referenced before assignment`. Корень: два локальных `from telegram import …` в admin-ветках `handle_text` делали имена локальными на всю функцию (Obsidian D12). Убраны. Live: Java-резюме → профиль Java/Новосибирск, 0 traceback. KF1/KF2/KF4 повторно 8/8. **Резюме-пробы дают ложный ❌ — вердикт по БД (`users.specialty`), не по тексту баббла.**

**Киллер-фичи 2026-06-10 (11af8ed):** зарплатный бейдж (salary_stats.py, медиана 30д, только 💼, n≥5), «✍️ Сопроводительное» из детальной карточки (cl_vac_), «🎯 Мои отклики» в Профиле (воронка ожила), резюме→профиль починено. КРИТИЧНО: интерактивный LLM на `fast_model` llama-3.1-8b (~6s) — 70b таймаутил 120s и морозил всю очередь апдейтов (concurrent_updates НЕ включён — гонки user_states, см. Obsidian D10). add_vacancy коэрсит не-строки (фикс «binding parameter 5» → карточки без кнопок).

**Аудит 2026-06-10 (коммиты 4f9a2ca, b126540):** pytest писал в прод-БД (Database() default = относительный путь) — 87 тест-юзеров банили kwork своими подписками; фикс `tests/conftest.py` (подмена db_path на tmp), прод вычищен (95→8 юзеров, бэкап pre-testclean). Telegram city-фильтр ослаблен (нет города в посте ≠ mismatch, синонимы мск/спб). Полные доки теперь в Obsidian `Projects/VakHunter/` (5 файлов). Сессия 06-04..08: 8 коммитов UX (💼/🛒, Подробнее-трекинг, L1 learning, меню 5→3, анти-спам opt-in, подписка из профиля одним тапом).

**Честность тарифов 2026-06-11 (d8e0494):** убраны «фантомные» источники из `allowed_sources` (freelanceru/youdo/workzilla/weblancer — рекламировались без парсера, `PARSERS.get`→None). Счётчики площадок исправлены на реальные: Trial 8 / Basic 6 / Standard 15 / Pro 17 / Premium 18 (= len(allowed_sources)). profi.ru подтверждён подключённым. **Инвариант (Obsidian D13): `set(allowed_sources) ⊆ set(PARSERS)`, число в features == len(allowed_sources)** — при добавлении парсера сначала PARSERS потом тариф; при отказе не оставлять ключ в тарифе.

**Зарплатный фильтр давал пустую выдачу (2026-06-16, 7f4be1d):** жалоба «подписка из профиля ничего не находит». Корень — наивный парсинг ЗП в `filter_vacancies` (`re.findall(r'\d+')+max`) разбивал «150 000 ₽» по пробелу-разделителю тысяч → читал как 150 → `<salary_min` → резал ВСЕ подходящие вакансии. Бил по всем юзерам с заданной `desired_salary` (она наследуется в `salary_min` подписки через `resolve_subscription_with_user`). Фикс: переиспользован корректный `parse_salary_rub` (salary_stats.py). Политика по «нет ₽-суммы» → мягкая (показываем «не указана»/валютные). **Аудит-сессия 2026-06-16 (развитие бота, ultracode).** Workflow-аудит упал на session-limit → анализ вручную по зеркалу `/tmp/vh_src`. Шипнуто: **watchdog 2-слойный** (D18, 74c71cc — + детект зависшего polling через getWebhookInfo.pending_update_count, бот дважды падал из-за слепоты к polling); **«✅ Подходит вам»** в карточке (ab7d86e — `local_scoring.match_reasons`, прозрачность релевантности, детерминированно). **Согласованные развилки (юзер):** realtime — ТОЛЬКО Pro/Premium + opt-in; онбординг — ОДИН тихий nudge с opt-out. **Сделано (D19, 2a98865):** realtime «🔥 Сильный матч» — job `realtime_scan` 600с, gating tariff 3,4 + opt-in + тихие часы + cooldown 20мин/8 в день, свежесть по `vacancies.created_at` (НЕ парсеров — там время скрейпа), score≥0.7, тумблер в настройках уведомлений + апселл, killswitch `REALTIME_ALERTS_ENABLED`. Грабля: чистка тест-данных одним autocommit-коннектом (иначе `database is locked`). **Дорожная карта аудита ЗАВЕРШЕНА (2026-06-16, коммиты до 6b71c72):** #2 онбординг-nudge (один opt-out, D21); #3 /market + кнопка в профиле (`market_overview`, честно); #4 cross-source дедуп (`is_fingerprint_sent`); #5 дайджест «N новых с прошлой проверки»; #6 единый экран подписок (D20, manage делегирует); #7 сопроводительное — оставлено отдельным сообщением (намеренно, edit=регресс), полиш закрытия; #8 валютные ЗП `approx_rub` «≈ N ₽» (D22). pytest 125, живой TG-прогон пройден. **НЕ берём (честно):** отклик-в-1-клик на hh/Avito (нужен офиц. OAuth), новые РФ-источники за платным API/антиботом. Все Decisions D18-D22 в Obsidian. **НЕ берём (честно, нужен внешний ресурс):** отклик-в-один-клик на hh/Avito (нужен офиц. OAuth/API), новые РФ-источники за платным API/тяжёлым антиботом.

**Аудит «к продаже» + новые тарифы (2026-06-19, коммиты 18c23a6..bbe9378):** юзер решил готовить бота к продаже. **Тарифы — монотонная лестница 4 шт** (D24): Trial 0₽/6площ/6ч, Standard 299₽/12/3ч, Pro 890₽/18/1ч+realtime, Premium 1890₽/21/30мин; Basic убран; UPDATE-блок db.py теперь задаёт ВСЕ колонки (цены из SEED не применялись). **Цены дублируются в `bot/payment_service.TARIFF_PRICES` — править ОБА места**, стережёт `tests/test_payment_prices.py`. **Workflow-аудит** (8 направлений → состязательная проверка; 3 направления handlers/router/payment падали на safety-флаге Opus → handlers/router добиты доаудитом с не-cyber формулировкой, payment проверен вручную). **Исправлено 15 багов** (3 коммита), главное: КРИТ payment_service слал старые цены 500/1500/3000; `/search` отдавал платные источники по истёкшему тарифу → теперь истёкший = Trial-доступ в `check_tariff_limits` (D25); онбординг без parse_mode=HTML (сырые теги); ЗП-фильтры теряли «150 000» (→parse_salary_rub); admin «История 30д» недостижима (порядок префиксов); фильтры поиска затирались при вводе ЗП. **Ложная тревога:** `check_tariff_expiry` (ежечасный спам+инвойсы) — закомментирован, не запускается. **Отложенный low-backlog** (не блокирует продажу) — в Obsidian Launch.md. **payment_service ещё НЕ подключён к live-кнопкам оплаты** (P0). Грабли аудита: workflow-агенты дают ЛИДЫ, не истину — каждую находку проверять самому (топ-1 был false positive из-за незамеченной проводки).

**Актуализация текстов + юр-доки Platega (2026-06-19, 629701a, D26):** жалоба — тексты бота отстали (inline-help врал ценами 500/1500/3000; площадки «10+/18/14»). Исправлено: inline `handle_help` → цены 299/890/1890 + актуальные площадки/интервалы; число площадок везде едино **21**; `bot/legal.py` (новый, единый источник URL юр-доков + `legal_buttons()`). **Провайдер оплаты выбран — Platega** (СБП НСПК 8.5% / крипта 5%, без юрлица): требует постоянный доступ к Политике+Соглашению кнопками → `legal_buttons()` (bot/legal.py, URL на telegra.ph) в: **главном inline-меню** (2c668ec), **нижней reply-клавиатуре** через текст-кнопку `📄 Документы`→inline-ссылки (1379b5d, URL в reply-кб нельзя), /help, экран `compare_tariffs`. **Команду /legal завёл, но удалил (ae699fd) — button-first.** документы — **свои** VakHunter-страницы на telegra.ph (28cc6a5, заменили шаблоны Platega; без ФИО, оператор=«Администрация сервиса»; см. [[vakhunter_legal_docs]] с URL+токеном). TODO: ИНН при оформлении самозанятости + 54-ФЗ чек (Platega серый). URL = telegra.ph-шаблоны Platega (обезличенные — кастомизировать под оператора/152-ФЗ перед приёмом денег, менять только legal.py). Живой TG-probe тест-юзером 8688355181 = 11/11 PASS (скриншоты сверены). **Отдельный баг (зафиксирован, НЕ фикшен):** organic-юзеру `tariff_expires` не ставится в `add_user` → Trial бессрочен, хотя пишем «7 дней» (P0/монетизация).

**Гейт подписки на @CodoCeh (2026-06-19, f1889a2, как в zemlya-bot):** `bot/channel_gate.py` + TypeHandler group=-100 + ApplicationHandlerStop. Не подписан на канал КодоЦех (@CodoCeh, ведёт CebersPost) → бот не пускает, экран «Перейти в канал / Я подписался». Админы+callback bypass; getChatMember+TTL-кеш; fail-open. Конфиг CHANNEL_REQUIRED_* (дефолт @CodoCeh, пусто=выкл). @VakHunter_bot должен быть в канале (уже есть). Тест-юзер 8688355181 подписан, чтобы e2e-скилл не упирался в гейт. Контакт поддержки = @cebers_dev (не @cebers).

**2-й обяз. канал + промо-чистка + фикс рефералки (2026-06-20, 4c9c3fe):** гейт теперь на СПИСОК каналов `config.required_channels` (подписка нужна на ВСЕ) — `bot/channel_gate.py` рефакторнут (`missing_channels`, кеш по (user,chat), fail-open по каждому). Добавлен **@vakhunterInfo** (env `CHANNEL2_*`, peer 4449121097), бот уже админ → энфорсится. ⚠️ **тест-юзер e2e 8688355181 теперь гейтится — подписать на @vakhunterInfo для skill-прогонов.** Промокоды: публичные NewVak/CebersVak убраны (help-текст + seed db.py + деактивированы в прод is_active=0) — раздача только через админ-панель (своя промо-система = zemlya, порт не нужен). Рефералка была сломана гейтом (перехватывал /start ref_ → бонус не шёл): фикс — гейт стэшит referrer (`_pending_ref`), `subscribed_check` применяет после подписки; логика в `_grant_referral` (общая для start+subscribed_check), бонус +7д пригласившему теперь независимо от доставки уведомления. `apply_referral_purchase_bonus` — мёртвый код (milestone-бонусы не подключены), основной join-бонус работает. **Аудит навигации (2026-06-20, 4590ea9):** убраны мёртвая кнопка main_menu, осиротевшее меню more_menu (favorites/quick_earnings «Назад» уводили туда), бэки Кабинета → реальный родитель, краши `del user_states`→`.pop`, фантом-тариф Basic в expired-push, юр-кнопки на экран оплаты; PDF-матрица проверена живьём (Obsidian D27/D28). **⏳ Ждёт юзера:** Platega (заменить Robokassa+ЮKassa — нужны creds X-MerchantId/X-Secret + доки вебхука; см. [[vakhunter_legal_docs]] D28); CebersPost → @vakhunterInfo (пуш новостей «с тэгами» — нужна спека).

**Параллельная обработка юзеров (2026-06-19, 741f5cd, D10 пересмотрен):** жалоба «во время выгрузки блокируются все остальные». Корень — PTB обрабатывал апдейты последовательно (`concurrent_updates` был выключен), тяжёлый живой поиск/«пример тарифа» одного юзера (10-22с в обработчике) морозил бот для всех. Включён **`concurrent_updates(32)`** в Application.builder (main_ptb). Безопасно: `user_states`=PersistentTTLCache под RLock; разные юзеры=разные ключи; PTB-таски на одном loop. Потолок 32 (не unlimited) — ограничить веер httpx через xray-прокси (SPOF). Проверено живьём: tariff_example >30с + параллельный /help за 1.0с. Watch: SQLite «database is locked» под реальной нагрузкой (коннект-на-операцию+WAL обычно ок).

**Живой пример «разница тарифов» (2026-06-17, dd7c96e, D23):** кнопка «🔎 Показать на примере» на «💳 Мой тариф» и «📊 Сравнить тарифы» (роут `tariff_example`, обработчик `handle_tariff_example`, pure-логика `bot/tariff_example.py` + 8 тестов). По запросу юзера (профиль→последний поиск→дефолт «менеджер») живьём (`asyncio.gather`, `search_profile_sources`, timeout 22с) тянет РЕАЛЬНЫЕ вакансии: «Сейчас» vs «На <апгрейд>» (надёжные `RELIABLE_DEMO_SOURCES`=superjob/getmatch/kwork/freelancehunt/remoteok/geekjob, до 3, БЕЗ antibot-Avito/profi) + честный текст (скорость 6ч→1ч→30мин, 🔥 алерты Pro/Premium, подписки 1→10→∞). Premium→ветка «уже максимум». Живой TG-прогон 15/15 (IT + «повар»). **Сопутствующий прод-фикс (a8d276a, D23):** superjob/getmatch/zarplata/gorodrabot/remoteok/geekjob/freelancehunt отдают `city=None` → `matches_vacancy_location` резал их в НОЛЬ для ЛЮБОГО юзера с городом (широта старших тарифов не доходила в реальной выдаче). Внедрён общий принцип «нет распознанной локации = pass» (как telegram, D8): `location_matches_city` режет только если назван ДРУГОЙ известный город (`_mentions_any_known_city`), иначе проходит. Регрессии нет. Живьём: city=Москва superjob 0→5, getmatch 0→4. Детали — Obsidian Changelog 2026-06-17 + D23.

**Инвариант Obsidian D15: парсить ЗП ТОЛЬКО через `parse_salary_rub`, не плодить наивные re.findall+max.** Проверено: подписка #94 0→50, граничный тест 7/7. **Продолжение (a05d644, D16):** `resolve_subscription_with_user` больше НЕ превращает профильную `desired_salary` в жёсткий `salary_min` (разная оплата full-time/part-time → порог прятал part-time гиги); теперь это мягкий скор (`local_score_vacancy`, relevance-режим). Жёсткий ЗП-фильтр — только явный `salary_min/max` в самой подписке. Принцип: унаследованное из профиля = мягкое, явно заданное = жёсткое.

**Рецидивный сбой «бот не отвечает, но процесс жив» (2026-06-16):** polling-цикл Telegram зависает после серии сетевых сбоев через xray-прокси (`RemoteProtocolError`/`ConnectError` в логе как «Transient polling network error (auto-retrying)»), и НЕ восстанавливается сам, хотя `.request()`/`.get_updates_request()` имеют read_timeout=120s (outbound_proxy.build_telegram_request). **Watchdog слеп к этому**: проверяет только свежесть тика `Checking subscriptions` (поток APScheduler, крутится независимо в том же loop) — scheduler тикает → watchdog считает бота живым, пока Telegram-общение мертво (было 16ч). Лечение: `launchctl kickstart -k gui/$(id -u)/local.vakhunter`. Диагностика: процесс жив + scheduler тикает + `/start` молчит ⇒ polling-wedge. Проверка токена/webhook (через прокси): `venv/bin/python -c "load_dotenv; httpx.get(api.telegram.org/bot$BOT_TOKEN/getMe, proxy=2081)"` — токен валиден (getMe 200), webhook пуст. ⚠️ извлекать токен через python-dotenv, не shell-cut (теряет хвост). **Надёжный сигнал для авто-детекта: `getWebhookInfo.pending_update_count` растёт = polling не потребляет апдейты** (не конфликтует с polling, в отличие от getUpdates). Кандидат-фикс watchdog — ещё не внедрён.

**Сессия 2026-06-20/21 (Platega-only + AI-коуч + avito off):** (1) **Платежи = только Platega** (ca14218) — Robokassa+ЮKassa удалены; заглушка до боевых кредов (D28 обновлён). Юр-документы **возвращены к шаблонам Platega 1:1** (e1349d3) — их модерация отклонила кастомные; байт-в-байт копии на нашем telegra.ph (новые URL: Politika-konfidencialnosti-06-20-26, Polzovatelskoe-soglashenie-06-20-19), см. [[vakhunter_legal_docs]]. (2) **AI-коуч допилен** (29718f2): `career_ai`→экран выбора «Разобрать вакансию»/«Общий вопрос»; пикер избранного(⭐)+откликов(✅) с 🗑; `career_ai_vac_<id>` грузит вакансию в контекст→Q&A по ней; продолжение диалога. Живой e2e PASS (ответ коуча реально по вакансии). Удаление из избранного — было и есть (`handle_favorites` 🗑 + Очистить всё), продублировано в пикере. (3) **avito_services ОТКЛЮЧЁН** (57f30ab, D29) — отдавал исполнителей; тарифы пересчитаны Trial 6/Standard 12/Pro 17/Premium 20; pytest 141; backend-поиск не падает на отсутствии парсера. **CebersPost→@vakhunterInfo подключён** (6502e24) — паттерн в [[ceberspost_connect_product_channel]]. **⏳ Ждёт юзера:** боевые креды Platega (X-MerchantId/X-Secret) + доки вебхука/подписи; @cebersLog_bot админом @vakhunterInfo.

**Веб-приложение ВакХантера достигло ПОЛНОГО паритета с ботом (2026-06-25, в монорепо [[kodoceh-web-overview]]):** фронт `apps/vakhunter` (Next.js, :20412) поверх `web/webapp.py` (aiohttp, :20452, проксируется `/api/vh/*`). Вход: Telegram-deeplink + **email magic-link с регистрацией** (новый веб-юзер = `user_id` в ОТРИЦАТЕЛЬНОМ диапазоне, чтобы не путать с TG-id; сразу пробный Pro 7д) + OAuth-каркас Яндекс/Mail (env-gated). Вкладки: Лента, **Подписки** (CRUD saved searches, лимит по тарифу), **Помощник** (карьерный AI поверх `bot.ai_assistant`/NIM: письма fast-8b, рекомендации/Q&A/сравнение 70b, резюме→профиль), Избранное, Отклики, **Профиль** (редактирование карьерного профиля+стоп-слова+настройки уведомлений, достижения, промокод, тариф+оплата Platega, вход по почте, web-push). **Алерты веб-юзерам идут через web-push**, НЕ Telegram: `bot/web_push_bridge.py` + хук в `scheduler._send_vacancies` (если `user_id<0`). Онбординг-модалка после входа (`@cbr/ui Onboarding`, «больше не показывать»). Вся серверная логика — на mini; правки бэка веба = тот же venv `~/Services/VakHunter/venv`, рестарт `launchctl kickstart -k local.vakhunter-web`; фронт — `npm run build --workspace apps/vakhunter` (Node 22) + рестарт по порту 20412. **При правке `bot/scheduler.py`/`ai_assistant.py` нужен рестарт самого бота** (`local.vakhunter`) — это live, import-check venv-ом ДО.

**Web-юзеры НЕ в `users` (by-design, важно при чистке БД):** новый веб-юзер = uid в ОТРИЦАТЕЛЬНОМ диапазоне, живёт в `web_email_identity`/`web_oauth_identity`/`web_login`, но в таблице `users` его НЕТ (min user_id прод = 889518). Поэтому `notification_settings.user_id` FK→users даёт «сироту» на живого web-юзера — это НОРМА, не чинить удалением. Мусор в БД чистить только с проверкой `web_*`. (2026-07-02, `bak-pre-fkclean`: снесены 266 legacy-payments `tariff_id=5`/robokassa + 3 тест-uid notification_settings; uid=−1 сохранён как живой web.)

**Архивация вакансий (2026-07-02, `a793e25`):** `vacancies.last_seen` (освежается при повторной встрече в скрейпе, IGNORE-ветка `add_vacancy`) + `cleanup_stale_vacancies(ttl=45д, env VACANCY_TTL_DAYS)` раз в сутки из scheduler-тика. Гарды навсегда: избранное/воронка/applied/заметки/коллекции; зависимые строки (sent_vacancies/переводы/скоры/action-лог) удаляются вместе (FK off на время операции). Первый прогон: 1848→1400.

**Why:** прод-сервис, поэтому правки делать с бэкапом и через рестарт launchd (не убивать процесс напрямую).
**How to apply:** перед фиксом — `cp` в backups, после коммита — `launchctl kickstart -k` чтобы подхватился новый код. Проверять `tail vakhunter.error.log` на чистый "Application started" без traceback.
