Lead Radar: исходный код

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuriandClaude Opus 5.5 committed 2026-10-25 23:11:04 +04:00
commit 9749da5ae7
201 files changed
+97685

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
# Приёмка по критериям ТЗ (раздел 9)
Состояние на 09.10.2026. «Автотест» — сценарий в `apps/api/src/*.int.test.ts`, проверяется командой
`pnpm test`. Автотесты работают с заглушкой Telegram, поэтому всё, что касается настоящего бота и
телефона, нужно один раз пройти руками на боевом стенде.
| № | Критерий | Автотест | Руками |
| - | -------- | -------- | ------ |
| 1 | Объявление под фильтр приходит в Telegram всем нужным агентам; время замерено | «первый проход наполняет базу…», «личный фильтр: уведомление получает только его владелец» | Не пройдено с настоящим ботом и привязанным агентом. Замер задержки один: 41 с (docs/PARSER.md); медиана за сутки — в разделе «Парсер» |
| 2 | Одновременное «Беру»: лид у одного, второй видит «Уже взят» | «при одновременном нажатии лид получает один агент…» | Проверить двумя телефонами |
| 3 | 24 часа без смены статуса: предупреждение, возврат в пул, уведомление о потере | «через 24 часа без смены статуса лид возвращается в пул…» | — |
| 4 | Чёрный список и порог N не создают лид в режиме «только собственники» | «„только собственники“: метка агентства, порог N и чёрный список…» | — |
| 5 | Агент не видит чужие лиды при выключенной видимости и видит при включённой | «видимость: агент не видит чужие лиды…» | — |
| 6 | Solo не видит аналитику и базу; в Team нельзя добавить участника сверх лимита (с 09.10.2026 лимиты: Team 10, Agency 35) | «…Solo базы и аналитики не видит», «в Team нельзя добавить одиннадцатого участника, даже одновременно» | — |
| 7 | Руководитель изменил воронку — канбан, аналитика и карточки показывают новые статусы | «руководитель добавляет, переименовывает, переставляет и удаляет промежуточные статусы» | Пройдено в браузере |
| 8 | Сделка без комиссии невозможна; счёт по «%» совпадает с ручным расчётом | «отказ требует причину, сделка — комиссию…», «счёт 1-го числа совпадает с ручным расчётом…» | Пройдено в браузере на демо-компании: (4200 + 1 540 000 / 385 + 2600) × 7 % = 756 $ |
| 9 | Смена тарифа применяется только со следующего месяца | «из кабинета применяется только со следующего месяца» | Пройдено в браузере |
| 10 | 16-й день просрочки: уведомления прекращаются, CRM на чтение; после оплаты доступ возвращается | «16-й день просрочки…», «…компания без доступа лидов не получает» | — |
| 11 | Суперадмин меняет дату окончания и отправляет сообщение выбранной компании | «просрочка: … суперадмин возвращает доступ», «сообщения в Telegram: всем, компании, руководителям, одному пользователю; журнал» | Отправка проверена в браузере; доставка в настоящий Telegram — нет: в тестовой базе у пользователей компаний Telegram не привязан |
| 12 | Снятое объявление получает отметку и дату; повторное приходит новым лидом со ссылкой на прошлый | «раз в сутки объект из CRM проверяется…», «снятое объявление опубликовано снова…», «тот же объект под новым номером…» | Блоки «Снято» и «Уже был в работе» в браузере не смотрели |
| 13 | Все экраны и сообщения бота на трёх языках | — | Экраны переключаются. Армянские тексты не вычитаны носителем языка |
## Общие требования
| Требование | Состояние |
| ---------- | --------- |
| Один бэкенд для веб-кабинета и Mini App, реляционная база, очередь уведомлений | Есть |
| Лимиты Telegram и повтор при ошибке | Есть, автотест «при ошибке отправки сообщение повторяется…» |
| Пароли в виде хеша, проверка роли и компании на сервере | Есть |
| HTTPS | Появится при выкладке: сертификат выпускает Nginx Proxy Manager |
| Ежедневная резервная копия | Настроена в боевой сборке, проверена локально |
| Chrome и Safari, Mini App на iOS и Android | Проверено только в Chrome, включая экран телефона. Safari и настоящий Telegram на iOS и Android — нет |
## Mini App
Проверено в Chrome на экране 390 × 844 с настоящими подписанными данными Telegram: вход без пароля,
мастер настройки, лента, карточка, «Беру», смена статуса, фильтры, подписка. В настоящем Telegram
не открывалось: Telegram открывает Mini App только по https-адресу, а он появится после выкладки.
+60
View File
@@ -0,0 +1,60 @@
# Биллинг
Как считаются счета и подписка. Код: `apps/api/src/billing/`, тесты: `apps/api/src/billing.int.test.ts`.
Все даты считаются по времени Еревана.
## Две модели
| | Фикс | % от комиссии |
| --- | --- | --- |
| Когда платят | вперёд, за следующий месяц | после, за прошлый календарный месяц |
| Когда выставляется счёт | за 7 дней до окончания подписки | 1-го числа |
| Срок оплаты | дата окончания подписки | 8-е число |
| Что даёт оплата | подписка продлевается на месяц | подписка действует до 8-го числа следующего месяца |
Месяц без сделок на модели «%»: счёт на 0 закрывается сам, подписка продлевается.
Минимального платежа нет — это открытый вопрос ТЗ.
Плата за установку (Team 500 $, Agency 750 $) начисляется один раз отдельным счётом. Её оплата
дату подписки не двигает; исключение — новая компания на модели «%», у которой первого месячного
счёта нет: её подписка стартует с оплаты установки.
## Комиссия в разных валютах
Комиссию по сделке агент вводит в любой валюте, счёт выставляется в долларах. Курсы задаёт
суперадмин: «Счета» → «Курсы валют». В счёте сохраняется курс на момент выставления и расшифровка
по сделкам — клиент видит её в разделе «Подписка».
## Смена тарифа
- Клиент меняет тариф и модель в разделе «Подписка». Изменение вступает в силу со следующего
расчётного месяца: на фиксе — на следующий день после окончания оплаченного периода, на «%» —
с 1-го числа. До этого в кабинете видно «Запланировано с такой-то даты», смену можно отменить.
- Если оплаченного периода нет (подписка ещё не начиналась или уже закончилась), тариф меняется сразу.
- Суперадмин меняет тариф в карточке компании сразу.
- Переход на тариф меньше, чем участников в компании, запрещён.
- При переходе с фикса на «%» сделки, закрытые до перехода, в счёт не попадают. При уходе с «%»
посреди месяца сделки с начала месяца закрываются отдельным счётом.
## Просрочка и напоминания
Напоминания уходят руководителю в бот не раньше 10:00: за 7, 3 и 1 день до окончания; после
окончания — каждый день с числом оставшихся дней; при ограничении доступа — один раз.
Через 15 дней (срок меняет суперадмин) уведомления о лидах останавливаются, CRM работает только
на чтение. Раздел «Подписка» открыт всегда. После отметки оплаты доступ возвращается сразу.
## Оплата
Сейчас работает ручной режим: суперадмин в разделе «Счета» нажимает «Отметить оплату».
Онлайн-оплата подключается без переделки биллинга. Нужно написать класс по контракту
`apps/api/src/billing/provider.ts` (создать платёж → ссылка на страницу оплаты; разобрать и
проверить уведомление провайдера) и передать его в `buildApp({ payments })` в `apps/api/src/main.ts`.
После этого у клиента в счёте появится кнопка «Оплатить», а провайдер будет присылать уведомления
на `/api/payments/webhook/<имя провайдера>`.
## Что не решено
- Провайдер эквайринга и валюта списания.
- Минимальный платёж на модели «%».
- Откуда брать курс: сейчас его вручную задаёт суперадмин.
+47
View File
@@ -0,0 +1,47 @@
# Выкладка на VPS
Боевой стенд — три контейнера на том же VPS, где сайт: база `leadradar-db`, сервис `leadradar-api`
(API, бот, парсер), кабинет `leadradar-web` (nginx), плюс `leadradar-backup` с ежедневной копией
базы. Наружу их публикует уже работающий Nginx Proxy Manager. Чужие контейнеры на VPS
(`brain-*`, `nepotolok*`, `yandp-site`) выкладка не трогает.
Локально сборка проверена 09.10.2026: образы собираются, миграции применяются, `/api/health`
отвечает, резервная копия создаётся. Первая выкладка на VPS — 09.10.2026: контейнеры работают,
бот `@yandpleadradar_bot` и парсер запущены. Домен в Nginx Proxy Manager нужно добавить вручную (шаг 5).
## Что нужно один раз
1. **Домен.** В Namecheap добавить A-запись поддомена на `188.116.21.175`.
2. **Боевой бот.** Создать бота в @BotFather, затем `/setdomain` → домен кабинета (нужно для
входа через Telegram в браузере). Кнопку меню с Mini App сервис ставит сам при запуске.
3. **Настройки на VPS.** Создать `/opt/leadradar/.env` по образцу
[`deploy/.env.production.example`](../deploy/.env.production.example): пароль базы, адрес
кабинета с `https://`, токен и имя бота, ключ Jina.
4. **Первая выкладка.** На Mac из папки `leadradar`: `./deploy.sh`.
5. **Адрес в Nginx Proxy Manager.** Proxy Host: домен → `leadradar-web`, порт 80, включить
Let's Encrypt и Force SSL. Панель NPM открывается через
`ssh -i ~/.ssh/yandp_vps -L 8181:127.0.0.1:81 root@188.116.21.175` → http://localhost:8181.
6. **Суперадмин.** На VPS:
`docker exec leadradar-api sh -c 'cd /app/apps/api && node --import tsx src/cli/create-superadmin.ts <email> "Имя"'`
— команда напечатает пароль.
## Обновление
На Mac: `./deploy.sh`. Скрипт прогоняет проверки и тесты, загружает исходники, пересобирает образы
на сервере и перезапускает контейнеры. Миграции базы применяются при старте `leadradar-api`.
## Резервные копии
`/opt/leadradar/backups/leadradar-ГГГГ-ММ-ДД.sql.gz`, хранятся 14 последних. Восстановление на VPS:
```bash
gunzip -c /opt/leadradar/backups/leadradar-2026-10-09.sql.gz | docker exec -i leadradar-db psql -U leadradar leadradar
```
Копии лежат на том же сервере, что и база: от поломки диска они не спасут. Вынос копий на другой
сервер или в облако не настроен.
## Что не настроено
- Почта: письма подтверждения email и сброса пароля пишутся в лог `leadradar-api`.
- Онлайн-оплата: см. [BILLING.md](BILLING.md).
+58
View File
@@ -0,0 +1,58 @@
# Промт: переезд Lead Radar на crm.yandp.tech
Вставить целиком в новую сессию Claude Code, открытую в папке `site/CRM/leadradar`.
---
Переносим Lead Radar (папка `site/CRM/leadradar`, прод — сервер 69.62.120.231, ssh-алиас `vps2`,
`/opt/leadradar`) со старого домена `listcrm.yandpconsulting.com` на новый `crm.yandp.tech`.
## Что должно получиться
1. **`crm.yandp.tech` — основной адрес CRM.** Все ссылки, которые генерирует сервис (письма,
приглашения, кнопки и уведомления бота, Mini App, возврат после оплаты, отчёты, ссылки клиентам),
ведут на `crm.yandp.tech`. Для всех компаний, включая AREV.
2. **`listcrm.yandpconsulting.com` — редирект 301 на `crm.yandp.tech` с тем же путём и параметрами.**
Старые ссылки клиентов, приглашения и закладки продолжают работать через переадресацию.
3. **`arev.yandp.tech` — второй адрес CRM, только для организации AREV (companies.id = 10):**
- ссылки клиентам AREV генерируются на `arev.yandp.tech` (уже сделано 19.10.2026 через
`settings.clientHost`, сохранить; у остальных компаний — `crm.yandp.tech`);
- сотрудники AREV и суперадмин могут войти и работать в CRM на этом домене;
- пользователь другой компании на `arev.yandp.tech` не попадает в кабинет: видит понятное сообщение
со ссылкой на `crm.yandp.tech`; регистрация новых компаний на этом домене закрыта.
## Что проверить в коде
- Ссылки строятся из `APP_URL` (`config.appUrl`) — найди все места поиском по `appUrl`. Сам переезд для
них — это смена `APP_URL` на `https://crm.yandp.tech` в `/opt/leadradar/.env`.
- Ограничение «только AREV» на `arev.yandp.tech` делай на сервере (по заголовку Host), не только во
фронте. Домен → компания задаётся одним местом в настройках, без хардкода по коду.
- **Вход через Telegram-виджет** у бота работает только на одном домене — пусть это будет
`crm.yandp.tech`. Дай мне команду `/setdomain` для BotFather. На `arev.yandp.tech` виджет
скрыть, вход по email должен работать.
- Сессии привязаны к домену: после переезда всем один раз придётся войти заново — это нормально.
- Тесты на пункты выше, `pnpm typecheck && pnpm test` зелёные.
## Инфраструктура (делаю я, ты даёшь точные шаги)
- DNS у Hostinger: `A crm` и `A arev` → 69.62.120.231 (проверь `dig`, прежде чем просить SSL).
- Nginx Proxy Manager (`ssh -L 8181:127.0.0.1:81 vps2` → http://localhost:8181): proxy host на
`leadradar-web:80` для обоих новых доменов, SSL Let's Encrypt, Force SSL, HTTP/2, Websockets.
- Редирект старого домена — скажи, как настроить его в NPM (Redirection Host или правка
существующего proxy host), и в какой момент.
## Порядок и безопасность
1. Сначала план: правки в коде, порядок выкладки, откат. Покажи его мне до правок.
2. Порядок: DNS и SSL на новых доменах → бэкап базы → выкладка кода → проверка на новых доменах →
смена `APP_URL` и перезапуск API → BotFather → редирект старого домена.
3. Бэкап: `ssh vps2 'mkdir -p /opt/leadradar/manual-backups && docker exec leadradar-db pg_dump -U leadradar
-d leadradar | gzip > /opt/leadradar/manual-backups/before-yandp-tech-$(date +%F).sql.gz'`.
4. Выкладка только так (по умолчанию скрипт смотрит на СТАРЫЙ сервер — там поднимется второй бот):
`VPS_HOST=root@69.62.120.231 SSH_KEY=~/.ssh/vps_claude ./deploy.sh`
5. После выкладки проверь: вход и кабинет на `crm.yandp.tech` и `arev.yandp.tech`, отказ чужому
пользователю на `arev`, ссылка клиента AREV и другой компании, старая ссылка через редирект,
кнопка бота, письмо приглашения. `docker logs leadradar-api` без ошибок.
6. Откат описать заранее: прошлый `APP_URL`, прошлая версия кода, бэкап базы.
Неясное — спрашивай до правок, а не во время выкладки.
+101
View File
@@ -0,0 +1,101 @@
# Парсер List.am
Модуль взят из прежней CRM (`apps/api/src/import` → `apps/api/src/parser`) и доработан.
Конвейер, который им пользуется, — `apps/api/src/ingest/service.ts`.
## Как это работает
1. **Все категории недвижимости** list.am читаются по кругу: квартиры, дома, коммерческая,
участки, гаражи — продажа и аренда (10 категорий). Напрямую на list.am сервис не ходит:
сайт закрыт Cloudflare, страницы читаются через публичный ридер Jina (`r.jina.ai`).
2. **Свежесть.** Список list.am отсортирован по «поднятию», а блок «Топ» ротируется, поэтому
незнакомое объявление ещё не значит новое. Возраст определяется по дате «Размещено» на
странице объявления. В базу попадают объявления, размещённые **не раньше чем 3 дня назад**
(`FRESH_DAYS`). Более старые запоминаются только по ID (`seen_listings`), чтобы не
перечитывать их при каждом «поднятии».
3. **Общая база.** Свежее объявление сохраняется в таблицу `listings` один раз, независимо от
того, есть ли под него фильтр у клиентов.
4. **Раздача.** Объявление из базы сопоставляется с фильтрами всех компаний с действующей
подпиской; где подошло — создаётся лид в пуле и уходят уведомления в Telegram.
Новый или изменённый фильтр сразу получает подходящие объявления из базы за последние
3 дня — в пул, без уведомлений.
5. **Первый проход** по категории наполняет базу без лидов: эти объявления висели на сайте
до подключения.
## Что собирается
ID, ссылка, тип сделки, тип объекта, область и район (или населённый пункт), адрес, цена и
валюта, комнаты, площадь, этаж и этажность, описание, обложка фото, метка «Агентство»,
дата размещения.
## Ключ ридера Jina (`JINA_API_KEY`)
Ключ оплачивается по объёму ответа (токенам), поэтому используется экономно:
| Что читается | Как | Объём |
| --- | --- | --- |
| Страница категории (10 штук за круг) | **без ключа**, бесплатно, не чаще ~20 запросов в минуту | ~22 000 токенов за страницу — с ключом это сожгло бы баланс за часы |
| Дата размещения незнакомого объявления | с ключом: HTML-вырезка подвала страницы | ~330 токенов |
| Детали свежего объявления | с ключом: HTML-вырезка блоков с адресом, характеристиками, описанием | 1 000–6 000 токенов |
Если ключ отклонён или баланс кончился, парсер на час переходит на режим без ключа и продолжает
работать. Без ключа совсем: дата известна с точностью до дня, детали подтягиваются примерно у
половины объявлений, круг длится 2–3 минуты.
## Замеры (08.10.2026, локальный стенд)
| Что | С ключом | Без ключа |
| --- | --- | --- |
| Проход парсера по 10 категориям | 31–35 с | 107–200 с |
| Полный круг (проход + пауза 5 с) | около 37 с | 2–3,5 мин |
| Ошибки чтения за проход | 0 | 0–2 |
| Первый проход (наполнение базы) | — | около 10 минут |
| Задержка «размещено → лид» | 41 с (один замер: размещено в 20:50:05, лид в 20:50:46) | не измеряется: нет времени размещения |
Объявление попадает в базу в течение одного круга после появления на первой странице категории,
то есть обычно быстрее минуты. Круг ограничен бесплатным чтением страниц категорий: 10 страниц
с паузой ~3 с. Замер задержки пока единичный — медиана за сутки видна суперадмину в разделе «Парсер».
Расход ключа на длинном отрезке не измерен: баланс в кабинете Jina обновляется с задержкой.
Оценка по объёму ответов — несколько миллионов токенов в сутки.
## Ограничения
| Что | Состояние |
| --- | --- |
| Телефон и имя автора | Не собираются: list.am отдаёт телефон отдельным запросом по нажатию «Позвонить». Решение заказчика: агент берёт номер по ссылке на объявление |
| Фото | Только обложка |
| Область вне Еревана | Определяется по названию населённого пункта в конце карточки и справочнику городов (`packages/shared/src/regions.ts`). Село, которого нет в справочнике, остаётся без области и не проходит фильтр по области |
| Площадь домов | Иногда берётся площадь участка вместо площади дома |
| Устойчивость | Своей защиты от блокировок нет: всё зависит от ридера. Если страница объявления не прочиталась, объявление не теряется — попытка повторится на следующем круге. При сбое всех категорий суперадмин получает алерт в Telegram (не чаще раза в час) |
## Наблюдения о list.am
- ID объявлений растут по порядку, около 4 000 в день. Это используется как дешёвый
предварительный отсев: ID, отстающий от последних больше чем на 20 000, заведомо старше 3 дней.
- У list.am две нумерации: старая (до ~24,3 млн, закончилась в сентябре 2026) и новая «995…».
## Запасной источник: фид (`LISTING_SOURCE=feed`)
`FeedListingSource` читает JSON-фид по `LISTING_FEED_URL` (официальный API или провайдер данных).
К конвейеру он пока не подключён — понадобится, если появится источник с телефонами.
Формат: `GET {url}?city=…&category=…&ownerOnly=…[&cursor=…]`, заголовок
`Authorization: Bearer {LISTING_FEED_TOKEN}`; ответ `{ "items": [...], "next": "курсор или null" }`.
Обязательны только `id` и `url`; остальные поля: `title`, `description`, `price`, `currency`,
`dealType`, `propertyType`, `region`, `district`, `address`, `rooms`, `floor`, `floorsTotal`,
`area`, `ownerName`, `phone`, `isAgency`, `publishedAt`, `photos`.
## Снятые и повторно опубликованные объявления
- Раз в 10 минут проверяется пачка из 20 объявлений, по которым есть лиды; каждое объявление —
раз в сутки, сначала те, что кто-то брал в работу. Запросы идут без ключа ридера.
- Признак снятия (замер 08.10.2026): у страницы нет подвала с номером объявления (ридер отвечает
422), а заголовок — «Данного объявления не существует». Если подвала нет, но и такого заголовка
нет, решение откладывается на час.
- Снятое объявление снова появилось в списке категории под тем же номером — оно становится
активным, и компании, у которой прошлый лид закрыт сделкой или отказом, приходит новый лид.
- Объект под новым номером связывается с прошлым лидом, если совпали адрес и телефон автора.
Телефон парсер не собирает, поэтому на практике работает запасное правило: совпали адрес,
тип, комнаты, площадь и этаж, а прошлое объявление снято. Возможна ложная связь для одинаковых
квартир в одном доме — она влияет только на блок «Уже был в работе».
- 08.10.2026 пробный баланс ключа Jina закончился (ответ 402): парсер сам работает без ключа.
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
<defs>
<radialGradient id="bg" cx="50%" cy="50%" r="70%">
<stop offset="0" stop-color="#0b1a33"/>
<stop offset="1" stop-color="#020617"/>
</radialGradient>
<radialGradient id="glow" cx="50%" cy="50%" r="50%">
<stop offset="0" stop-color="#34d399" stop-opacity=".45"/>
<stop offset="1" stop-color="#34d399" stop-opacity="0"/>
</radialGradient>
<linearGradient id="sweep" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#22d3ee" stop-opacity="0"/>
<stop offset="1" stop-color="#22d3ee" stop-opacity=".28"/>
</linearGradient>
</defs>
<rect width="640" height="640" fill="url(#bg)"/>
<!-- луч радара: от центра к пойманной точке -->
<path d="M320 320 L320 112 A208 208 0 0 1 467 173 Z" fill="url(#sweep)"/>
<g fill="none" stroke="#22d3ee" stroke-width="10">
<circle cx="320" cy="320" r="208" stroke-opacity=".3"/>
<circle cx="320" cy="320" r="140" stroke-opacity=".5"/>
<circle cx="320" cy="320" r="72" stroke-opacity=".8"/>
</g>
<circle cx="320" cy="320" r="22" fill="#22d3ee"/>
<circle cx="443" cy="197" r="70" fill="url(#glow)"/>
<circle cx="443" cy="197" r="34" fill="#34d399"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB