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 │
|
||||
│ (FastAPI) │ │ (APScheduler) │ │ (polling) │
|
||||
│ add project │ │ every N hours │ │ /start /check │
|
||||
└──────┬───────┘ └────────┬─────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
└──────────────┬──────┴───────────────────────┘
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ monitor │
|
||||
│ service │ ← скрапит PF и Bayut, пишет в SQLite
|
||||
└────────────────┘
|
||||
│
|
||||
▼ уведомления в TG конкретному employee
|
||||
## Развёртывание
|
||||
|
||||
Сервис рассчитан на запуск внутри портала/k8s. Манифесты лежат в `k8s/`.
|
||||
Перед применением заполните секреты в `k8s/secrets.yaml`.
|
||||
|
||||
```bash
|
||||
kubectl apply -k k8s
|
||||
```
|
||||
|
||||
## Локальный запуск (Windows)
|
||||
|
||||
### 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
|
||||
Standalone-скрипты локального Windows-запуска и compose-обвязка удалены, чтобы
|
||||
проект не дублировал инфраструктуру портала.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
```text
|
||||
app/
|
||||
├── config.py настройки из .env
|
||||
├── db.py SQLAlchemy engine + session
|
||||
├── config.py настройки окружения
|
||||
├── db.py SQLAlchemy engine/session
|
||||
├── models.py Employee, Project, CompetitorListing, PriceHistory
|
||||
├── web.py FastAPI роуты и UI
|
||||
├── bot.py Telegram-бот
|
||||
├── scheduler.py APScheduler фоновый сканер
|
||||
├── scrapers/
|
||||
│ ├── base.py httpx + парсинг __NEXT_DATA__
|
||||
│ ├── propertyfinder.py
|
||||
│ └── bayut.py
|
||||
├── services/
|
||||
│ ├── monitor.py детект изменений, основная бизнес-логика
|
||||
│ └── notifier.py отправка в TG
|
||||
└── templates/ Jinja2 (Bootstrap 5)
|
||||
data/
|
||||
└── monitor.db SQLite (создаётся автоматически)
|
||||
├── scheduler.py фоновый сканер
|
||||
├── scrapers/ PropertyFinder/Bayut парсеры
|
||||
├── services/ бизнес-логика и уведомления
|
||||
└── templates/ Jinja2 UI
|
||||
k8s/ манифесты для портала
|
||||
```
|
||||
|
||||
## Перенос на сервер
|
||||
|
||||
Когда придёт время — нужно:
|
||||
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