Remove standalone PF artifacts
This commit is contained in:
155
README.md
155
README.md
@@ -1,143 +1,40 @@
|
|||||||
# DLD Monitor
|
# monitoring-pf
|
||||||
|
|
||||||
Внутренний инструмент для агентства недвижимости в Дубае: мониторит цены объявлений конкурентов на **PropertyFinder.ae** и **Bayut.com** по DLD Permit Number, шлёт уведомления в Telegram при:
|
Сервис мониторинга объявлений PropertyFinder/Bayut по DLD Permit Number для
|
||||||
|
портала. Он хранит проекты, конкурирующие объявления и историю цен, а UI
|
||||||
|
публикуется через portal по `/monitoring-pf`.
|
||||||
|
|
||||||
- 📈📉 изменении цены конкурента,
|
## Назначение
|
||||||
- ❌ удалении объявления (404 / withdrawn),
|
|
||||||
- 🆕 появлении нового объявления с тем же permit (новый брокер выставил ту же квартиру).
|
|
||||||
|
|
||||||
## Архитектура
|
- отслеживать изменение цены конкурента;
|
||||||
|
- фиксировать удаление/withdrawn объявлений;
|
||||||
|
- находить новые объявления с тем же DLD Permit Number;
|
||||||
|
- уведомлять ответственных сотрудников через Telegram.
|
||||||
|
|
||||||
```
|
## Развёртывание
|
||||||
┌──────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
||||||
│ Web UI │ │ Scheduler │ │ Telegram Bot │
|
Сервис рассчитан на запуск внутри портала/k8s. Манифесты лежат в `k8s/`.
|
||||||
│ (FastAPI) │ │ (APScheduler) │ │ (polling) │
|
Перед применением заполните секреты в `k8s/secrets.yaml`.
|
||||||
│ add project │ │ every N hours │ │ /start /check │
|
|
||||||
└──────┬───────┘ └────────┬─────────┘ └────────┬────────┘
|
```bash
|
||||||
│ │ │
|
kubectl apply -k k8s
|
||||||
└──────────────┬──────┴───────────────────────┘
|
|
||||||
▼
|
|
||||||
┌────────────────┐
|
|
||||||
│ monitor │
|
|
||||||
│ service │ ← скрапит PF и Bayut, пишет в SQLite
|
|
||||||
└────────────────┘
|
|
||||||
│
|
|
||||||
▼ уведомления в TG конкретному employee
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Локальный запуск (Windows)
|
Standalone-скрипты локального Windows-запуска и compose-обвязка удалены, чтобы
|
||||||
|
проект не дублировал инфраструктуру портала.
|
||||||
### 1. Создать виртуальное окружение и поставить зависимости
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python -m venv .venv
|
|
||||||
.\.venv\Scripts\Activate.ps1
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Получить токен Telegram-бота
|
|
||||||
|
|
||||||
Уже есть? Отлично. Если нет:
|
|
||||||
1. Откройте Telegram, найдите [@BotFather](https://t.me/BotFather).
|
|
||||||
2. Отправьте `/newbot`, придумайте имя.
|
|
||||||
3. Скопируйте токен вида `123456:ABC-DEF…`.
|
|
||||||
|
|
||||||
### 3. Создать `.env`
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
Copy-Item .env.example .env
|
|
||||||
notepad .env
|
|
||||||
```
|
|
||||||
|
|
||||||
В `.env` вставьте:
|
|
||||||
|
|
||||||
```
|
|
||||||
TG_BOT_TOKEN=ваш_токен_от_botfather
|
|
||||||
SCRAPE_INTERVAL_HOURS=4
|
|
||||||
ADMIN_CHAT_ID= # опционально — куда слать системные ошибки
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Запустить три процесса (в **трёх** разных окнах PowerShell)
|
|
||||||
|
|
||||||
**Окно 1 — веб-интерфейс:**
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\Activate.ps1
|
|
||||||
python run_web.py
|
|
||||||
```
|
|
||||||
Откройте http://127.0.0.1:8000
|
|
||||||
|
|
||||||
**Окно 2 — Telegram-бот:**
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\Activate.ps1
|
|
||||||
python -m app.bot
|
|
||||||
```
|
|
||||||
|
|
||||||
**Окно 3 — фоновый сканер:**
|
|
||||||
```powershell
|
|
||||||
.\.venv\Scripts\Activate.ps1
|
|
||||||
python -m app.scheduler
|
|
||||||
```
|
|
||||||
|
|
||||||
## Первое использование
|
|
||||||
|
|
||||||
1. Откройте бота в Telegram и отправьте `/start` — он зарегистрирует ваш `chat_id`.
|
|
||||||
2. В веб-UI перейдите в **Сотрудники** — убедитесь, что вы там есть с ✓ TG.
|
|
||||||
3. Нажмите **+ Новый проект**, заполните:
|
|
||||||
- Название (например: «Marina Pinnacle 1502, 2BR»)
|
|
||||||
- DLD Permit Number (Trakheesi)
|
|
||||||
- Тип сделки (продажа/аренда)
|
|
||||||
- Владелец = вы
|
|
||||||
4. На странице проекта нажмите **Проверить сейчас** — система найдёт все объявления конкурентов с этим permit на PF и Bayut.
|
|
||||||
5. Дальше фоновый сканер сам будет проверять каждые `SCRAPE_INTERVAL_HOURS` часов и слать уведомления в Telegram.
|
|
||||||
|
|
||||||
## Команды бота
|
|
||||||
|
|
||||||
- `/start` — подключить себя как сотрудника (запоминает chat_id)
|
|
||||||
- `/list` — список ваших проектов
|
|
||||||
- `/check` — запустить проверку всех ваших проектов сейчас
|
|
||||||
- `/whoami` — показать свой chat_id
|
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
```
|
```text
|
||||||
app/
|
app/
|
||||||
├── config.py настройки из .env
|
├── config.py настройки окружения
|
||||||
├── db.py SQLAlchemy engine + session
|
├── db.py SQLAlchemy engine/session
|
||||||
├── models.py Employee, Project, CompetitorListing, PriceHistory
|
├── models.py Employee, Project, CompetitorListing, PriceHistory
|
||||||
├── web.py FastAPI роуты и UI
|
├── web.py FastAPI роуты и UI
|
||||||
├── bot.py Telegram-бот
|
├── bot.py Telegram-бот
|
||||||
├── scheduler.py APScheduler фоновый сканер
|
├── scheduler.py фоновый сканер
|
||||||
├── scrapers/
|
├── scrapers/ PropertyFinder/Bayut парсеры
|
||||||
│ ├── base.py httpx + парсинг __NEXT_DATA__
|
├── services/ бизнес-логика и уведомления
|
||||||
│ ├── propertyfinder.py
|
└── templates/ Jinja2 UI
|
||||||
│ └── bayut.py
|
k8s/ манифесты для портала
|
||||||
├── services/
|
|
||||||
│ ├── monitor.py детект изменений, основная бизнес-логика
|
|
||||||
│ └── notifier.py отправка в TG
|
|
||||||
└── templates/ Jinja2 (Bootstrap 5)
|
|
||||||
data/
|
|
||||||
└── monitor.db SQLite (создаётся автоматически)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Перенос на сервер
|
|
||||||
|
|
||||||
Когда придёт время — нужно:
|
|
||||||
1. Поставить Python 3.11+ на сервер (Linux).
|
|
||||||
2. Скопировать репозиторий, `pip install -r requirements.txt`.
|
|
||||||
3. Создать `.env`.
|
|
||||||
4. Поставить три процесса под `systemd`:
|
|
||||||
- `dld-monitor-web.service` → `python run_web.py`
|
|
||||||
- `dld-monitor-bot.service` → `python -m app.bot`
|
|
||||||
- `dld-monitor-scheduler.service` → `python -m app.scheduler`
|
|
||||||
5. Поставить nginx + TLS перед веб-портом (8000).
|
|
||||||
|
|
||||||
## Возможные проблемы скрапинга
|
|
||||||
|
|
||||||
PF/Bayut могут начать блокировать запросы при частых обращениях. Признаки:
|
|
||||||
- В логах сканера видны `Blocked by site (403/429)`.
|
|
||||||
- Поиск возвращает 0 объявлений, хотя они должны быть.
|
|
||||||
|
|
||||||
Что делать:
|
|
||||||
1. Уменьшите `SCRAPE_INTERVAL_HOURS` (реже = меньше риска).
|
|
||||||
2. Если не помогает — добавьте Playwright (headless-браузер). Заготовка: `pip install playwright && playwright install chromium`, затем заменить `fetch_html` на запуск через Playwright.
|
|
||||||
3. Опционально — прокси.
|
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
# Журнал сессии (2026-05-25)
|
|
||||||
|
|
||||||
## Что построили
|
|
||||||
|
|
||||||
Внутренний мониторинг цен конкурентов на propertyfinder.ae и bayut.com для HOME LIGA REAL ESTATE.
|
|
||||||
|
|
||||||
**Стек:** FastAPI + Jinja2 (Bootstrap 5) + SQLAlchemy/SQLite + APScheduler + python-telegram-bot + httpx/BS4.
|
|
||||||
|
|
||||||
**Три процесса** (каждый — свой .bat-лаунчер):
|
|
||||||
- `run_web.bat` — веб-UI на http://127.0.0.1:8000
|
|
||||||
- `run_bot.bat` — Telegram-бот (polling)
|
|
||||||
- `run_scheduler.bat` — фоновый сканер каждые `SCRAPE_INTERVAL_HOURS` часов
|
|
||||||
|
|
||||||
## Эволюция архитектуры
|
|
||||||
|
|
||||||
### Первая попытка (отвергнута)
|
|
||||||
**Идея:** сотрудник вводит DLD Permit Number своего объекта → система автоматически ищет «то же permit» на PF и Bayut → находит объявления конкурентов.
|
|
||||||
|
|
||||||
**Почему не сработало:**
|
|
||||||
1. В Дубае **каждый брокер получает свой permit на свою публикацию**. Два брокера, рекламирующих одну квартиру = два разных permit. Permit не идентифицирует физический объект — он идентифицирует конкретное объявление конкретного брокера.
|
|
||||||
2. PF на странице объявления показывает permit **картинкой** через сервис верификации, не plain text (anti-scraping).
|
|
||||||
3. PF search `?q=<permit>` — free-text по названию/описанию, не структурированный фильтр.
|
|
||||||
|
|
||||||
### Финальная архитектура
|
|
||||||
**Manual URL list + опциональные подсказки:**
|
|
||||||
1. Сотрудник создаёт проект (название, тип сделки, владелец, опц. building/bedrooms/sqft).
|
|
||||||
2. На странице проекта **вручную вставляет URL** объявления конкурента → система делает single-page fetch, парсит `__NEXT_DATA__`, добавляет в трекинг.
|
|
||||||
3. Если указано здание — кнопка «🔍 Подобрать похожие» ищет на PF/Bayut по `building + bedrooms` и предлагает кандидатов с кнопкой «+ Отслеживать».
|
|
||||||
4. Каждые 4 часа фоновый сканер делает refetch каждого отслеживаемого URL → детектит:
|
|
||||||
- 📈📉 изменение цены
|
|
||||||
- ❌ удаление (URL отдаёт 404)
|
|
||||||
- ♻️ возвращение из удалённого статуса
|
|
||||||
5. Уведомления — в Telegram личкой владельцу проекта.
|
|
||||||
|
|
||||||
## Модель данных
|
|
||||||
|
|
||||||
- `Employee` — name, tg_chat_id (опц.), tg_username
|
|
||||||
- `Project` — title, deal_type, owner_id, our_price, building, bedrooms, size_sqft, our_url, dld_permit (все после `owner` — опционально)
|
|
||||||
- `CompetitorListing` — source (PF|Bayut), external_id, url, current_price, status (active|removed), agent_name, agency_name, first_seen, last_seen
|
|
||||||
- `PriceHistory` — listing_id, price, recorded_at
|
|
||||||
|
|
||||||
## Как подключиться сотруднику
|
|
||||||
|
|
||||||
1. В TG найти бота → отправить `/start`.
|
|
||||||
2. Отправить `/whoami` → бот пришлёт chat_id.
|
|
||||||
3. В вебе http://127.0.0.1:8000/employees → найти/создать запись → вставить chat_id → Сохранить.
|
|
||||||
|
|
||||||
## Известные ограничения
|
|
||||||
|
|
||||||
- **PF/Bayut могут блокировать** при частых запросах (видно как `Blocked by site (403/429)` в логах). Решение — увеличить интервал; если уже не помогает — добавить Playwright fallback.
|
|
||||||
- **Подсказки эвристические**: ищем по совпадению building name в title + bedrooms-фильтр. Могут попасть «другие квартиры в том же здании» — поэтому добавление в трекинг через ручное подтверждение.
|
|
||||||
- **Permit как plain text** на PF не отдаётся. Если когда-нибудь понадобится — нужен OCR на verification-image.
|
|
||||||
|
|
||||||
## Что в .env
|
|
||||||
|
|
||||||
```
|
|
||||||
TG_BOT_TOKEN=<токен от @BotFather>
|
|
||||||
SCRAPE_INTERVAL_HOURS=4
|
|
||||||
ADMIN_CHAT_ID= # опц.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
DLD Permit Number/
|
|
||||||
├── run_web.bat, run_bot.bat, run_scheduler.bat
|
|
||||||
├── run_web.py
|
|
||||||
├── requirements.txt
|
|
||||||
├── .env (не в git — содержит токен)
|
|
||||||
├── data/monitor.db (создаётся автоматически)
|
|
||||||
├── app/
|
|
||||||
│ ├── config.py ← settings + резолвит относительные SQLite-пути в абсолютные
|
|
||||||
│ ├── db.py, models.py
|
|
||||||
│ ├── web.py ← FastAPI: CRUD проектов, /listings add/delete, /suggest
|
|
||||||
│ ├── bot.py ← /start, /whoami, /list, /check
|
|
||||||
│ ├── scheduler.py
|
|
||||||
│ ├── scrapers/{base,propertyfinder,bayut}.py
|
|
||||||
│ ├── services/{monitor,notifier}.py
|
|
||||||
│ └── templates/ ← projects_list, project_form, project_detail, suggest, employees, base
|
|
||||||
```
|
|
||||||
|
|
||||||
## Что протестировать в первую очередь
|
|
||||||
|
|
||||||
1. Удалить старый `data/monitor.db` (схема изменилась).
|
|
||||||
2. Запустить три .bat файла.
|
|
||||||
3. `/start` боту → `/whoami` → chat_id → вписать в Сотрудники.
|
|
||||||
4. Создать тестовый проект (Aykon City Tower B, 2BR).
|
|
||||||
5. Вставить URL реального объявления конкурента → проверить, что добавилось с ценой/брокером.
|
|
||||||
6. Жмякнуть «Подобрать похожие» → посмотреть кандидатов.
|
|
||||||
7. Жмякнуть «Проверить сейчас» → если цена не менялась, изменений не будет (это нормально); для теста алертов можно поменять цену в БД руками или подождать реального изменения.
|
|
||||||
89
diagnose.py
89
diagnose.py
@@ -1,89 +0,0 @@
|
|||||||
r"""Одноразовая диагностика: проверяет всю цепочку мониторинга.
|
|
||||||
Запуск: & "<...>\.venv\Scripts\python.exe" diagnose.py
|
|
||||||
Только чтение БД + проверка токена TG + живой re-fetch. Сообщения НЕ шлёт."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.db import SessionLocal
|
|
||||||
from app.models import CompetitorListing, Employee, PriceHistory, Project
|
|
||||||
from app.services.monitor import check_project
|
|
||||||
|
|
||||||
|
|
||||||
def line(c: str = "-") -> None:
|
|
||||||
print(c * 60)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
# --- 1. Содержимое БД ---
|
|
||||||
line("=")
|
|
||||||
print("1) ДАННЫЕ В БАЗЕ")
|
|
||||||
line("=")
|
|
||||||
projects = db.query(Project).all()
|
|
||||||
if not projects:
|
|
||||||
print(" ! В базе нет ни одного проекта.")
|
|
||||||
return
|
|
||||||
for p in projects:
|
|
||||||
owner = p.owner
|
|
||||||
chat = owner.tg_chat_id if owner else None
|
|
||||||
chat_str = chat if chat else "НЕ ЗАДАН (уведомления не дойдут!)"
|
|
||||||
print(f"\n Проект #{p.id}: {p.title}")
|
|
||||||
print(f" тип: {p.deal_type.value} | наша цена: {p.our_price}")
|
|
||||||
print(f" владелец: {owner.name if owner else '—'} | tg_chat_id: {chat_str}")
|
|
||||||
print(f" последняя проверка: {p.last_checked_at or 'ещё не было'}")
|
|
||||||
listings = p.listings
|
|
||||||
print(f" отслеживаемых объявлений конкурентов: {len(listings)}")
|
|
||||||
for l in listings:
|
|
||||||
hist = db.query(PriceHistory).filter(PriceHistory.listing_id == l.id).count()
|
|
||||||
price = f"{l.current_price:,.0f} {l.currency}".replace(",", " ") if l.current_price else "БЕЗ ЦЕНЫ"
|
|
||||||
print(f" - [{l.source.value}] {l.status.value} | {price} | история: {hist} зап.")
|
|
||||||
print(f" {(l.title or 'без названия')[:70]}")
|
|
||||||
print(f" брокер: {l.agency_name or '—'} | {l.url}")
|
|
||||||
|
|
||||||
# --- 2. Telegram токен ---
|
|
||||||
line("=")
|
|
||||||
print("2) TELEGRAM")
|
|
||||||
line("=")
|
|
||||||
if not settings.tg_bot_token:
|
|
||||||
print(" ! TG_BOT_TOKEN не задан в .env")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
r = httpx.get(
|
|
||||||
f"https://api.telegram.org/bot{settings.tg_bot_token}/getMe",
|
|
||||||
timeout=15.0,
|
|
||||||
)
|
|
||||||
if r.status_code == 200 and r.json().get("ok"):
|
|
||||||
bot = r.json()["result"]
|
|
||||||
print(f" OK токен валиден: @{bot.get('username')} ({bot.get('first_name')})")
|
|
||||||
else:
|
|
||||||
print(f" ! getMe вернул {r.status_code}: {r.text}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ! Ошибка связи с Telegram: {e}")
|
|
||||||
print(f" ADMIN_CHAT_ID: {settings.admin_chat_id or 'не задан'}")
|
|
||||||
|
|
||||||
# --- 3. Живой re-fetch (то, что делает сканер раз в 4 ч) ---
|
|
||||||
line("=")
|
|
||||||
print("3) ЖИВАЯ ПЕРЕПРОВЕРКА (re-fetch всех объявлений)")
|
|
||||||
line("=")
|
|
||||||
for p in projects:
|
|
||||||
print(f"\n Проект #{p.id}: {p.title} — проверяю {len(p.listings)} объявл...")
|
|
||||||
changes = check_project(db, p)
|
|
||||||
if changes:
|
|
||||||
print(f" обнаружено изменений: {len(changes)} (в реальном прогоне ушли бы в TG):")
|
|
||||||
for c in changes:
|
|
||||||
print(" " + c.replace("\n", " | ").replace("<b>", "").replace("</b>", ""))
|
|
||||||
else:
|
|
||||||
print(" изменений нет — все объявления успешно перезагружены, цены прежние.")
|
|
||||||
line("=")
|
|
||||||
print("Готово. Если у объявлений есть цены и статус active, а токен валиден — цепочка рабочая.")
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
services:
|
|
||||||
web:
|
|
||||||
build: .
|
|
||||||
image: dld-permit-monitor:latest
|
|
||||||
container_name: dld-web
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
WEB_HOST: 0.0.0.0
|
|
||||||
WEB_PORT: 8000
|
|
||||||
ports:
|
|
||||||
- "8080:8000"
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
command: ["python", "run_web.py"]
|
|
||||||
|
|
||||||
bot:
|
|
||||||
image: dld-permit-monitor:latest
|
|
||||||
depends_on:
|
|
||||||
- web
|
|
||||||
container_name: dld-bot
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: .env
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
command: ["python", "-m", "app.bot"]
|
|
||||||
|
|
||||||
scheduler:
|
|
||||||
image: dld-permit-monitor:latest
|
|
||||||
depends_on:
|
|
||||||
- web
|
|
||||||
container_name: dld-scheduler
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: .env
|
|
||||||
volumes:
|
|
||||||
- ./data:/app/data
|
|
||||||
command: ["python", "-m", "app.scheduler"]
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
@echo off
|
|
||||||
cd /d "%~dp0"
|
|
||||||
".venv\Scripts\python.exe" -m app.bot
|
|
||||||
pause
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
@echo off
|
|
||||||
cd /d "%~dp0"
|
|
||||||
".venv\Scripts\python.exe" -m app.scheduler
|
|
||||||
pause
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
@echo off
|
|
||||||
cd /d "%~dp0"
|
|
||||||
".venv\Scripts\python.exe" run_web.py
|
|
||||||
pause
|
|
||||||
Reference in New Issue
Block a user