Add Telegram listener, scheduler, and extended parsers admin UI.
Introduce background scheduling and migrations for analytics ingest, expand parser management and map toolbar UX, and ignore local data/session files from version control. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
data/telegram.session
|
data/
|
||||||
data/*.session
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ flowchart LR
|
|||||||
| Центр | Контейнеры | Назначение |
|
| Центр | Контейнеры | Назначение |
|
||||||
|-------|------------|------------|
|
|-------|------------|------------|
|
||||||
| **ЦА** | `ca-db`, `ca-api`, `ca-frontend` | PostgreSQL, ingest API, карта, distribution API |
|
| **ЦА** | `ca-db`, `ca-api`, `ca-frontend` | PostgreSQL, ingest API, карта, distribution API |
|
||||||
| **ЦП** | `cp-workers` | Парсинг Telegram (`centers/parsing/workers`) |
|
| **ЦП** | `cp-workers` | Парсинг Telegram: real-time listener (Telethon) + batch-задания из Redis |
|
||||||
| **Общее** | `redis` | Очередь заданий ЦА → ЦП |
|
| **Общее** | `redis` | Очередь заданий ЦА → ЦП |
|
||||||
|
|
||||||
## Структура monorepo
|
## Структура monorepo
|
||||||
@@ -67,7 +67,7 @@ docker compose up --build
|
|||||||
| Раздел | Путь | Описание |
|
| Раздел | Путь | Описание |
|
||||||
|--------|------|----------|
|
|--------|------|----------|
|
||||||
| **Карта** | `/` | Интерактивная карта событий: навигация по датам (flatpickr), пресеты периода, фильтры региона/темы/источника, подложки Яндекс/OSM/Topo/ESRI, линейка, полноэкранный режим, центрирование по координатам и городам, поиск населённых пунктов (Nominatim). CRUD для ручных объектов (ПКМ). Поддерживает `?eventId=` |
|
| **Карта** | `/` | Интерактивная карта событий: навигация по датам (flatpickr), пресеты периода, фильтры региона/темы/источника, подложки Яндекс/OSM/Topo/ESRI, линейка, полноэкранный режим, центрирование по координатам и городам, поиск населённых пунктов (Nominatim). CRUD для ручных объектов (ПКМ). Поддерживает `?eventId=` |
|
||||||
| **Парсеры** | `/parsers` | Создание заданий Telegram-парсинга, таблица статусов с автообновлением (5 с), повтор failed-заданий |
|
| **Парсеры** | `/parsers` | Telegram-парсеры с периодическим запуском, настройка интервала, редактирование и удаление; дубликаты по `source_url` не записываются |
|
||||||
| **События** | `/events` | Фильтрация, пагинация, просмотр деталей, ссылка «На карте» для событий с координатами |
|
| **События** | `/events` | Фильтрация, пагинация, просмотр деталей, ссылка «На карте» для событий с координатами |
|
||||||
| **Аналитика** | `/analytics` | KPI-карточки, график динамики ingest за 30 дней, топ населённых пунктов и регионов |
|
| **Аналитика** | `/analytics` | KPI-карточки, график динамики ingest за 30 дней, топ населённых пунктов и регионов |
|
||||||
| **ПИ** | `/consumers` | CRUD подписчиков distribution API, ротация ключей, тест среза через `/api/v1/events` |
|
| **ПИ** | `/consumers` | CRUD подписчиков distribution API, ротация ключей, тест среза через `/api/v1/events` |
|
||||||
@@ -108,8 +108,10 @@ docker compose up --build
|
|||||||
|
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|-------|------|----------|
|
|-------|------|----------|
|
||||||
| POST | `/admin/jobs` | Создать задание парсинга (ставится в Redis) |
|
| POST | `/admin/jobs` | Создать парсер (сразу в очередь + периодический запуск) |
|
||||||
| GET | `/admin/jobs` | Список заданий |
|
| GET | `/admin/jobs` | Список парсеров |
|
||||||
|
| PATCH | `/admin/jobs/{id}` | Изменить канал/лимит, `interval_seconds`, `is_active` |
|
||||||
|
| DELETE | `/admin/jobs/{id}` | Удалить парсер |
|
||||||
| POST | `/admin/jobs/{id}/retry` | Повторить failed-задание |
|
| POST | `/admin/jobs/{id}/retry` | Повторить failed-задание |
|
||||||
| GET | `/admin/events` | События с фильтрами (`{ items, total }`) |
|
| GET | `/admin/events` | События с фильтрами (`{ items, total }`) |
|
||||||
| GET | `/admin/analytics/summary` | KPI-сводка |
|
| GET | `/admin/analytics/summary` | KPI-сводка |
|
||||||
@@ -148,14 +150,35 @@ curl http://localhost:8080/api/v1/events \
|
|||||||
-H 'Authorization: Bearer test-pi-api-key-change-me'
|
-H 'Authorization: Bearer test-pi-api-key-change-me'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Парсинг Telegram
|
||||||
|
|
||||||
|
`cp-workers` объединяет два режима в **одном процессе** (общая сессия `telegram.session`):
|
||||||
|
|
||||||
|
| Режим | Как работает |
|
||||||
|
|-------|----------------|
|
||||||
|
| **Listener (real-time)** | Telethon `NewMessage` / `Album` на активных парсерах (`is_active=true`); новый пост сразу уходит в ingest |
|
||||||
|
| **Batch (по расписанию)** | Планировщик в `ca-api` ставит задание в Redis; воркер забирает последние N постов (`iter_messages`) |
|
||||||
|
|
||||||
|
Дубликаты по `source_url` при ingest пропускаются. Listener не меняет `status` парсера (флаг `listener: true` в ingest).
|
||||||
|
|
||||||
|
Переменные `cp-workers`:
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|------------|--------------|----------|
|
||||||
|
| `TELEGRAM_LISTENER_ENABLED` | `true` | Включить real-time listener |
|
||||||
|
| `TELEGRAM_LISTENER_REFRESH_SECONDS` | `60` | Как часто обновлять список каналов из БД |
|
||||||
|
|
||||||
|
Internal API: `GET /internal/listener/subscriptions` — список активных каналов для listener.
|
||||||
|
|
||||||
## Поток данных
|
## Поток данных
|
||||||
|
|
||||||
1. Аналитик создаёт задание: `POST /admin/jobs`
|
1. Аналитик создаёт парсер: `POST /admin/jobs` (канал, лимит, интервал в секундах)
|
||||||
2. `ca-api` ставит задание в Redis (`cp:jobs`)
|
2. **Listener** сразу подписывается на канал и ingest-ит новые посты
|
||||||
3. `cp-workers` забирает задание, парсит Telegram через существующую сессию
|
3. **Планировщик** `ca-api` периодически ставит batch-задание в Redis (`cp:jobs`)
|
||||||
4. Результаты отправляются в `POST /internal/ingest`
|
4. `cp-workers` забирает batch-задание, парсит последние N постов
|
||||||
5. События с координатами автоматически появляются на карте как `MapObject`
|
5. Результаты → `POST /internal/ingest` (дубликаты по `source_url` пропускаются)
|
||||||
6. Внешние ПИ получают отфильтрованный срез через `/api/v1/events`
|
6. Новые события с координатами появляются на карте как `MapObject`
|
||||||
|
7. Внешние ПИ получают срез через `/api/v1/events`
|
||||||
|
|
||||||
## Миграция EventRecord → Event
|
## Миграция EventRecord → Event
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from .database import Base, engine, get_db
|
from .database import Base, engine, get_db
|
||||||
from .routers import admin, internal, map, objects, v1
|
from .routers import admin, internal, map, objects, v1
|
||||||
from .seed import seed_objects, seed_test_consumer
|
from .seed import seed_objects, seed_test_consumer
|
||||||
|
from .services.migrations import migrate_schema
|
||||||
|
from .services.scheduler import start_scheduler
|
||||||
from .storage import ensure_upload_dir
|
from .storage import ensure_upload_dir
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ from .storage import ensure_upload_dir
|
|||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
ensure_upload_dir()
|
ensure_upload_dir()
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
migrate_schema(engine)
|
||||||
|
scheduler_stop = start_scheduler()
|
||||||
db = next(get_db())
|
db = next(get_db())
|
||||||
try:
|
try:
|
||||||
seed_objects(db)
|
seed_objects(db)
|
||||||
@@ -20,6 +24,7 @@ async def lifespan(_: FastAPI):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
yield
|
yield
|
||||||
|
scheduler_stop.set()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="CA API (Analytics Center)", lifespan=lifespan)
|
app = FastAPI(title="CA API (Analytics Center)", lifespan=lifespan)
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ class ParseJob(Base):
|
|||||||
source_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
source_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
source_config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
source_config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
schedule: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
schedule: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
interval_seconds: Mapped[int] = mapped_column(Integer, default=3600, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(50), default="pending", index=True)
|
status: Mapped[str] = mapped_column(String(50), default="pending", index=True)
|
||||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ..schemas import (
|
|||||||
EventRead,
|
EventRead,
|
||||||
ParseJobCreate,
|
ParseJobCreate,
|
||||||
ParseJobRead,
|
ParseJobRead,
|
||||||
|
ParseJobUpdate,
|
||||||
TimelinePoint,
|
TimelinePoint,
|
||||||
TopItem,
|
TopItem,
|
||||||
)
|
)
|
||||||
@@ -42,6 +43,8 @@ def create_parse_job(payload: ParseJobCreate, db: Session = Depends(get_db)):
|
|||||||
source_type=payload.source_type,
|
source_type=payload.source_type,
|
||||||
source_config=payload.source_config,
|
source_config=payload.source_config,
|
||||||
schedule=payload.schedule,
|
schedule=payload.schedule,
|
||||||
|
interval_seconds=payload.interval_seconds,
|
||||||
|
is_active=payload.is_active,
|
||||||
status="queued",
|
status="queued",
|
||||||
)
|
)
|
||||||
db.add(job)
|
db.add(job)
|
||||||
@@ -70,6 +73,8 @@ def retry_parse_job(job_id: int, db: Session = Depends(get_db)):
|
|||||||
job = db.query(ParseJob).filter(ParseJob.id == job_id).first()
|
job = db.query(ParseJob).filter(ParseJob.id == job_id).first()
|
||||||
if not job:
|
if not job:
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
if job.status in ("queued", "running"):
|
||||||
|
raise HTTPException(status_code=409, detail="Job is already running or queued")
|
||||||
|
|
||||||
job.status = "queued"
|
job.status = "queued"
|
||||||
job.last_error = None
|
job.last_error = None
|
||||||
@@ -80,6 +85,40 @@ def retry_parse_job(job_id: int, db: Session = Depends(get_db)):
|
|||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/jobs/{job_id}", response_model=ParseJobRead)
|
||||||
|
def update_parse_job(
|
||||||
|
job_id: int,
|
||||||
|
payload: ParseJobUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
job = db.query(ParseJob).filter(ParseJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
|
if payload.source_config is not None:
|
||||||
|
job.source_config = payload.source_config
|
||||||
|
if payload.interval_seconds is not None:
|
||||||
|
job.interval_seconds = payload.interval_seconds
|
||||||
|
if payload.is_active is not None:
|
||||||
|
job.is_active = payload.is_active
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/jobs/{job_id}", status_code=204)
|
||||||
|
def delete_parse_job(job_id: int, db: Session = Depends(get_db)):
|
||||||
|
job = db.query(ParseJob).filter(ParseJob.id == job_id).first()
|
||||||
|
if not job:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
if job.status == "running":
|
||||||
|
raise HTTPException(status_code=409, detail="Cannot delete a running job")
|
||||||
|
|
||||||
|
db.delete(job)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/events", response_model=EventListResponse)
|
@router.get("/events", response_model=EventListResponse)
|
||||||
def list_events(
|
def list_events(
|
||||||
limit: int = Query(default=50, ge=1, le=1000),
|
limit: int = Query(default=50, ge=1, le=1000),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
|
|||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..deps import verify_internal_token
|
from ..deps import verify_internal_token
|
||||||
from ..models import ParseJob
|
from ..models import ParseJob
|
||||||
from ..schemas import IngestRequest, IngestResponse
|
from ..schemas import IngestRequest, IngestResponse, ListenerSubscription
|
||||||
from ..services.ingest import ingest_events
|
from ..services.ingest import ingest_events
|
||||||
|
|
||||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||||
@@ -18,10 +18,16 @@ def internal_ingest(
|
|||||||
_: None = Depends(verify_internal_token),
|
_: None = Depends(verify_internal_token),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
ingested, updated, map_synced = ingest_events(db, payload.events, payload.job_id)
|
ingested, updated, skipped, map_synced = ingest_events(
|
||||||
|
db,
|
||||||
|
payload.events,
|
||||||
|
payload.job_id,
|
||||||
|
touch_job=not payload.listener,
|
||||||
|
)
|
||||||
return IngestResponse(
|
return IngestResponse(
|
||||||
ingested=ingested,
|
ingested=ingested,
|
||||||
updated=updated,
|
updated=updated,
|
||||||
|
skipped=skipped,
|
||||||
map_objects_synced=map_synced,
|
map_objects_synced=map_synced,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,7 +45,31 @@ def update_job_status(
|
|||||||
raise HTTPException(status_code=404, detail="Job not found")
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
|
||||||
job.status = status
|
job.status = status
|
||||||
|
if status in ("completed", "failed"):
|
||||||
job.last_run_at = datetime.now(timezone.utc)
|
job.last_run_at = datetime.now(timezone.utc)
|
||||||
job.last_error = error
|
job.last_error = error
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"id": job.id, "status": job.status}
|
return {"id": job.id, "status": job.status}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/listener/subscriptions", response_model=list[ListenerSubscription])
|
||||||
|
def listener_subscriptions(
|
||||||
|
_: None = Depends(verify_internal_token),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
jobs = (
|
||||||
|
db.query(ParseJob)
|
||||||
|
.filter(
|
||||||
|
ParseJob.is_active.is_(True),
|
||||||
|
ParseJob.source_type == "telegram",
|
||||||
|
)
|
||||||
|
.order_by(ParseJob.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
result: list[ListenerSubscription] = []
|
||||||
|
for job in jobs:
|
||||||
|
channel = job.source_config.get("channel") if job.source_config else None
|
||||||
|
if not channel:
|
||||||
|
continue
|
||||||
|
result.append(ListenerSubscription(job_id=job.id, channel=str(channel)))
|
||||||
|
return result
|
||||||
|
|||||||
@@ -111,11 +111,18 @@ class IngestEventItem(BaseModel):
|
|||||||
class IngestRequest(BaseModel):
|
class IngestRequest(BaseModel):
|
||||||
job_id: int | None = None
|
job_id: int | None = None
|
||||||
events: list[IngestEventItem] = Field(default_factory=list)
|
events: list[IngestEventItem] = Field(default_factory=list)
|
||||||
|
listener: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ListenerSubscription(BaseModel):
|
||||||
|
job_id: int
|
||||||
|
channel: str
|
||||||
|
|
||||||
|
|
||||||
class IngestResponse(BaseModel):
|
class IngestResponse(BaseModel):
|
||||||
ingested: int
|
ingested: int
|
||||||
updated: int
|
updated: int
|
||||||
|
skipped: int = 0
|
||||||
map_objects_synced: int
|
map_objects_synced: int
|
||||||
|
|
||||||
|
|
||||||
@@ -123,6 +130,14 @@ class ParseJobCreate(BaseModel):
|
|||||||
source_type: str = "telegram"
|
source_type: str = "telegram"
|
||||||
source_config: dict[str, Any] = Field(default_factory=dict)
|
source_config: dict[str, Any] = Field(default_factory=dict)
|
||||||
schedule: str | None = None
|
schedule: str | None = None
|
||||||
|
interval_seconds: int = Field(default=3600, ge=60, le=604800)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ParseJobUpdate(BaseModel):
|
||||||
|
source_config: dict[str, Any] | None = None
|
||||||
|
interval_seconds: int | None = Field(default=None, ge=60, le=604800)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ParseJobRead(BaseModel):
|
class ParseJobRead(BaseModel):
|
||||||
@@ -132,6 +147,8 @@ class ParseJobRead(BaseModel):
|
|||||||
source_type: str
|
source_type: str
|
||||||
source_config: dict[str, Any]
|
source_config: dict[str, Any]
|
||||||
schedule: str | None
|
schedule: str | None
|
||||||
|
interval_seconds: int
|
||||||
|
is_active: bool
|
||||||
status: str
|
status: str
|
||||||
last_run_at: datetime | None
|
last_run_at: datetime | None
|
||||||
last_error: str | None
|
last_error: str | None
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ def ingest_events(
|
|||||||
db: Session,
|
db: Session,
|
||||||
items: list[IngestEventItem],
|
items: list[IngestEventItem],
|
||||||
job_id: int | None = None,
|
job_id: int | None = None,
|
||||||
) -> tuple[int, int, int]:
|
*,
|
||||||
|
touch_job: bool = True,
|
||||||
|
) -> tuple[int, int, int, int]:
|
||||||
ingested = 0
|
ingested = 0
|
||||||
updated = 0
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
map_synced = 0
|
map_synced = 0
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -22,21 +25,9 @@ def ingest_events(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
existing.source_type = item.source_type
|
skipped += 1
|
||||||
existing.raw_text = item.raw_text
|
continue
|
||||||
existing.title = item.title
|
|
||||||
existing.description = item.description
|
|
||||||
existing.locality = item.locality
|
|
||||||
existing.latitude = item.latitude
|
|
||||||
existing.longitude = item.longitude
|
|
||||||
existing.event_date = item.event_date
|
|
||||||
existing.region = item.region
|
|
||||||
existing.topic = item.topic
|
|
||||||
existing.tags = item.tags
|
|
||||||
existing.metadata_ = item.metadata
|
|
||||||
event = existing
|
|
||||||
updated += 1
|
|
||||||
else:
|
|
||||||
event = Event(
|
event = Event(
|
||||||
source_type=item.source_type,
|
source_type=item.source_type,
|
||||||
source_url=item.source_url,
|
source_url=item.source_url,
|
||||||
@@ -59,7 +50,7 @@ def ingest_events(
|
|||||||
if sync_event_to_map_object(db, event):
|
if sync_event_to_map_object(db, event):
|
||||||
map_synced += 1
|
map_synced += 1
|
||||||
|
|
||||||
if job_id is not None:
|
if touch_job and job_id is not None:
|
||||||
job = db.query(ParseJob).filter(ParseJob.id == job_id).first()
|
job = db.query(ParseJob).filter(ParseJob.id == job_id).first()
|
||||||
if job:
|
if job:
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -69,4 +60,4 @@ def ingest_events(
|
|||||||
job.last_error = None
|
job.last_error = None
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return ingested, updated, map_synced
|
return ingested, updated, skipped, map_synced
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_schema(engine: Engine) -> None:
|
||||||
|
"""Apply lightweight schema updates for existing deployments."""
|
||||||
|
inspector = inspect(engine)
|
||||||
|
if "parse_jobs" not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = {col["name"] for col in inspector.get_columns("parse_jobs")}
|
||||||
|
statements: list[str] = []
|
||||||
|
|
||||||
|
if "interval_seconds" not in columns:
|
||||||
|
statements.append(
|
||||||
|
"ALTER TABLE parse_jobs ADD COLUMN interval_seconds INTEGER NOT NULL DEFAULT 3600"
|
||||||
|
)
|
||||||
|
if "is_active" not in columns:
|
||||||
|
statements.append(
|
||||||
|
"ALTER TABLE parse_jobs ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not statements:
|
||||||
|
return
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
for stmt in statements:
|
||||||
|
conn.execute(text(stmt))
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ..database import SessionLocal
|
||||||
|
from ..models import ParseJob
|
||||||
|
from .jobs import enqueue_job
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TICK_SECONDS = 30
|
||||||
|
RECURRING_STATUSES = ("completed", "failed")
|
||||||
|
|
||||||
|
|
||||||
|
def run_scheduler_tick() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
jobs = (
|
||||||
|
db.query(ParseJob)
|
||||||
|
.filter(
|
||||||
|
ParseJob.is_active.is_(True),
|
||||||
|
ParseJob.interval_seconds > 0,
|
||||||
|
ParseJob.status.in_(RECURRING_STATUSES),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for job in jobs:
|
||||||
|
if job.last_run_at is None:
|
||||||
|
continue
|
||||||
|
elapsed = (now - job.last_run_at).total_seconds()
|
||||||
|
if elapsed < job.interval_seconds:
|
||||||
|
continue
|
||||||
|
|
||||||
|
job.status = "queued"
|
||||||
|
job.last_error = None
|
||||||
|
db.commit()
|
||||||
|
enqueue_job(job.id, job.source_type, job.source_config)
|
||||||
|
logger.info("Re-queued recurring job %s (interval %ss)", job.id, job.interval_seconds)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scheduler tick failed")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduler_loop(stop_event: threading.Event) -> None:
|
||||||
|
while not stop_event.wait(TICK_SECONDS):
|
||||||
|
run_scheduler_tick()
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler() -> threading.Event:
|
||||||
|
stop_event = threading.Event()
|
||||||
|
thread = threading.Thread(target=_scheduler_loop, args=(stop_event,), daemon=True)
|
||||||
|
thread.start()
|
||||||
|
logger.info("Parse job scheduler started (tick every %ss)", TICK_SECONDS)
|
||||||
|
return stop_event
|
||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
EventRecord,
|
EventRecord,
|
||||||
ParseJob,
|
ParseJob,
|
||||||
ParseJobCreate,
|
ParseJobCreate,
|
||||||
|
ParseJobUpdate,
|
||||||
TimelinePoint,
|
TimelinePoint,
|
||||||
TopItem,
|
TopItem,
|
||||||
} from "../types/admin";
|
} from "../types/admin";
|
||||||
@@ -44,6 +45,22 @@ export function retryJob(jobId: number): Promise<ParseJob> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateJob(jobId: number, payload: ParseJobUpdate): Promise<ParseJob> {
|
||||||
|
return request<ParseJob>(
|
||||||
|
`/jobs/${jobId}`,
|
||||||
|
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||||
|
ADMIN_BASE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteJob(jobId: number): Promise<void> {
|
||||||
|
return request<void>(
|
||||||
|
`/jobs/${jobId}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
ADMIN_BASE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchEvents(filters: EventFilters = {}): Promise<EventListResponse> {
|
export function fetchEvents(filters: EventFilters = {}): Promise<EventListResponse> {
|
||||||
return request<EventListResponse>(
|
return request<EventListResponse>(
|
||||||
`/events${buildQuery(filters as Record<string, string | number | undefined>)}`,
|
`/events${buildQuery(filters as Record<string, string | number | undefined>)}`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { CITY_PRESETS } from "../../config/cities";
|
import { CITY_PRESETS } from "../../config/cities";
|
||||||
import type { LeafletMapApi } from "../../composables/useLeafletMap";
|
import type { LeafletMapApi } from "../../composables/useLeafletMap";
|
||||||
import { parseCoordsInput } from "../../composables/usePlaceSearch";
|
import { parseCoordsInput } from "../../composables/usePlaceSearch";
|
||||||
@@ -16,13 +16,13 @@ function formatCoords(lat: number, lng: number): string {
|
|||||||
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText(text: string) {
|
watch(
|
||||||
try {
|
() => props.centerCoords,
|
||||||
await navigator.clipboard.writeText(text);
|
(coords) => {
|
||||||
} catch {
|
coordsInput.value = formatCoords(coords.lat, coords.lng);
|
||||||
// ignore
|
},
|
||||||
}
|
{ immediate: true, deep: true },
|
||||||
}
|
);
|
||||||
|
|
||||||
function centerOnInput() {
|
function centerOnInput() {
|
||||||
const parsed = parseCoordsInput(coordsInput.value);
|
const parsed = parseCoordsInput(coordsInput.value);
|
||||||
@@ -36,37 +36,37 @@ function centerOnCity() {
|
|||||||
props.mapApi.flyTo(city.latitude, city.longitude, city.zoom ?? 12);
|
props.mapApi.flyTo(city.latitude, city.longitude, city.zoom ?? 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyCenter() {
|
function clearCoords() {
|
||||||
void copyText(formatCoords(props.centerCoords.lat, props.centerCoords.lng));
|
coordsInput.value = "";
|
||||||
}
|
|
||||||
|
|
||||||
function copyInput() {
|
|
||||||
void copyText(coordsInput.value);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="coords-tools">
|
<div class="coords-tools toolbar-group">
|
||||||
<label class="coords-label">Центрировать на:</label>
|
<div class="coord-field">
|
||||||
<input
|
<input
|
||||||
v-model="coordsInput"
|
v-model="coordsInput"
|
||||||
type="text"
|
type="text"
|
||||||
class="coord-input"
|
class="coord-input"
|
||||||
placeholder="48.65, 37.67"
|
placeholder="lat, lng"
|
||||||
|
title="Координаты центра"
|
||||||
@keydown.enter="centerOnInput"
|
@keydown.enter="centerOnInput"
|
||||||
/>
|
/>
|
||||||
<button type="button" class="icon-btn" title="Перейти" @click="centerOnInput">➜</button>
|
<button
|
||||||
<button type="button" class="icon-btn" title="Копировать" @click="copyInput">⎘</button>
|
v-if="coordsInput"
|
||||||
|
type="button"
|
||||||
|
class="coord-clear"
|
||||||
|
title="Очистить"
|
||||||
|
@click="clearCoords"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="icon-btn" title="Перейти к координатам" @click="centerOnInput">➜</button>
|
||||||
|
|
||||||
<select v-model="selectedCity" class="city-select" @change="centerOnCity">
|
<select v-model="selectedCity" class="city-select" title="Город" @change="centerOnCity">
|
||||||
<option value="" disabled>🏘 Город</option>
|
<option value="" disabled>🏘</option>
|
||||||
<option v-for="city in CITY_PRESETS" :key="city.name" :value="city.name">
|
<option v-for="city in CITY_PRESETS" :key="city.name" :value="city.name">
|
||||||
{{ city.name }}
|
{{ city.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<span class="current-center-label">Текущий центр:</span>
|
|
||||||
<span class="current-coords">{{ formatCoords(centerCoords.lat, centerCoords.lng) }}</span>
|
|
||||||
<button type="button" class="icon-btn" title="Копировать центр" @click="copyCenter">⎘</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const props = defineProps<{
|
|||||||
topic: string;
|
topic: string;
|
||||||
sourceType: string;
|
sourceType: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
statusText?: string;
|
||||||
|
statusError?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -33,8 +35,10 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const dateInput = ref<HTMLInputElement | null>(null);
|
const dateInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const rangeBtnRef = ref<HTMLButtonElement | null>(null);
|
||||||
const rangeOpen = ref(false);
|
const rangeOpen = ref(false);
|
||||||
const filtersOpen = ref(false);
|
const filtersOpen = ref(false);
|
||||||
|
const dropdownPos = ref({ top: 0, left: 0 });
|
||||||
let picker: flatpickr.Instance | null = null;
|
let picker: flatpickr.Instance | null = null;
|
||||||
|
|
||||||
const availableDates = computed(() => props.filters?.available_dates ?? []);
|
const availableDates = computed(() => props.filters?.available_dates ?? []);
|
||||||
@@ -74,6 +78,31 @@ function selectRange(preset: DateRangePreset) {
|
|||||||
emit("apply");
|
emit("apply");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleRangeOpen(event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!rangeOpen.value && rangeBtnRef.value) {
|
||||||
|
const rect = rangeBtnRef.value.getBoundingClientRect();
|
||||||
|
dropdownPos.value = { top: rect.bottom + 4, left: rect.left };
|
||||||
|
}
|
||||||
|
rangeOpen.value = !rangeOpen.value;
|
||||||
|
if (rangeOpen.value) {
|
||||||
|
filtersOpen.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFiltersOpen(event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
filtersOpen.value = !filtersOpen.value;
|
||||||
|
if (filtersOpen.value) {
|
||||||
|
rangeOpen.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdowns() {
|
||||||
|
rangeOpen.value = false;
|
||||||
|
filtersOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
emit("update:region", "");
|
emit("update:region", "");
|
||||||
emit("update:topic", "");
|
emit("update:topic", "");
|
||||||
@@ -82,6 +111,8 @@ function resetFilters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
document.addEventListener("click", closeDropdowns);
|
||||||
|
|
||||||
if (!dateInput.value) return;
|
if (!dateInput.value) return;
|
||||||
picker = flatpickr(dateInput.value, {
|
picker = flatpickr(dateInput.value, {
|
||||||
locale: Russian,
|
locale: Russian,
|
||||||
@@ -101,6 +132,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener("click", closeDropdowns);
|
||||||
picker?.destroy();
|
picker?.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,7 +147,8 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="map-toolbar">
|
<div class="map-toolbar">
|
||||||
<div class="date-navigator">
|
<div class="map-toolbar-inner">
|
||||||
|
<div class="toolbar-group date-navigator">
|
||||||
<button type="button" class="nav-btn" title="Первая дата" @click="navigateDate('first')">❮❮</button>
|
<button type="button" class="nav-btn" title="Первая дата" @click="navigateDate('first')">❮❮</button>
|
||||||
<button type="button" class="nav-btn" title="Предыдущий" @click="navigateDate('prev')">❮</button>
|
<button type="button" class="nav-btn" title="Предыдущий" @click="navigateDate('prev')">❮</button>
|
||||||
<div class="date-selector">
|
<div class="date-selector">
|
||||||
@@ -126,13 +159,23 @@ watch(
|
|||||||
|
|
||||||
<div class="filter-btn-container">
|
<div class="filter-btn-container">
|
||||||
<button
|
<button
|
||||||
|
ref="rangeBtnRef"
|
||||||
type="button"
|
type="button"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
title="Период"
|
title="Период"
|
||||||
:class="{ active: rangePreset !== 'all' }"
|
:class="{ active: rangePreset !== 'all' }"
|
||||||
@click="rangeOpen = !rangeOpen"
|
@click="toggleRangeOpen"
|
||||||
>⏳</button>
|
>⏳</button>
|
||||||
<div v-if="rangeOpen" class="dropdown-content">
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="rangeOpen"
|
||||||
|
class="dropdown-content range-dropdown dropdown-fixed"
|
||||||
|
:style="{ top: `${dropdownPos.top}px`, left: `${dropdownPos.left}px` }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="opt in RANGE_OPTIONS"
|
v-for="opt in RANGE_OPTIONS"
|
||||||
:key="opt.value"
|
:key="opt.value"
|
||||||
@@ -144,16 +187,18 @@ watch(
|
|||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Teleport>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="filter-row">
|
<div class="toolbar-sep" />
|
||||||
<button type="button" class="filter-btn mobile-toggle" @click="filtersOpen = !filtersOpen">📁</button>
|
|
||||||
|
|
||||||
<div class="filter-controls" :class="{ open: filtersOpen }">
|
<div class="toolbar-group filter-row">
|
||||||
|
<button type="button" class="filter-btn mobile-toggle" title="Фильтры" @click="toggleFiltersOpen">📁</button>
|
||||||
|
|
||||||
|
<div class="filter-controls" :class="{ open: filtersOpen }" @click.stop>
|
||||||
<select
|
<select
|
||||||
:value="region"
|
:value="region"
|
||||||
class="filter-select"
|
class="filter-select"
|
||||||
|
title="Регион"
|
||||||
@change="emit('update:region', ($event.target as HTMLSelectElement).value); emit('apply')"
|
@change="emit('update:region', ($event.target as HTMLSelectElement).value); emit('apply')"
|
||||||
>
|
>
|
||||||
<option value="">Все регионы</option>
|
<option value="">Все регионы</option>
|
||||||
@@ -163,6 +208,7 @@ watch(
|
|||||||
<select
|
<select
|
||||||
:value="topic"
|
:value="topic"
|
||||||
class="filter-select"
|
class="filter-select"
|
||||||
|
title="Тема"
|
||||||
@change="emit('update:topic', ($event.target as HTMLSelectElement).value); emit('apply')"
|
@change="emit('update:topic', ($event.target as HTMLSelectElement).value); emit('apply')"
|
||||||
>
|
>
|
||||||
<option value="">Все темы</option>
|
<option value="">Все темы</option>
|
||||||
@@ -172,6 +218,7 @@ watch(
|
|||||||
<select
|
<select
|
||||||
:value="sourceType"
|
:value="sourceType"
|
||||||
class="filter-select"
|
class="filter-select"
|
||||||
|
title="Источник"
|
||||||
@change="emit('update:sourceType', ($event.target as HTMLSelectElement).value); emit('apply')"
|
@change="emit('update:sourceType', ($event.target as HTMLSelectElement).value); emit('apply')"
|
||||||
>
|
>
|
||||||
<option value="">Все источники</option>
|
<option value="">Все источники</option>
|
||||||
@@ -180,8 +227,22 @@ watch(
|
|||||||
|
|
||||||
<button type="button" class="btn btn-sm" @click="resetFilters">Сбросить</button>
|
<button type="button" class="btn btn-sm" @click="resetFilters">Сбросить</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span v-if="loading" class="toolbar-status">Загрузка...</span>
|
<div class="toolbar-sep" />
|
||||||
|
|
||||||
|
<slot name="coords" />
|
||||||
|
|
||||||
|
<div class="toolbar-sep" />
|
||||||
|
|
||||||
|
<slot name="search" />
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="statusText"
|
||||||
|
class="toolbar-status"
|
||||||
|
:class="{ 'toolbar-status-error': statusError }"
|
||||||
|
>{{ statusText }}</span>
|
||||||
|
<span v-else-if="loading" class="toolbar-status">Загрузка...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ function selectPlace(place: PlaceSearchResult) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="place-search">
|
<div class="place-search toolbar-group">
|
||||||
<input
|
<input
|
||||||
v-model="query"
|
v-model="query"
|
||||||
type="text"
|
type="text"
|
||||||
class="search-input"
|
class="search-input"
|
||||||
placeholder="Поиск населённого пункта"
|
placeholder="Поиск НП"
|
||||||
|
title="Поиск населённого пункта"
|
||||||
@keydown.enter="runSearch"
|
@keydown.enter="runSearch"
|
||||||
/>
|
/>
|
||||||
<button type="button" class="icon-btn" title="Найти" :disabled="searching" @click="runSearch">
|
<button type="button" class="icon-btn" title="Найти" :disabled="searching" @click="runSearch">
|
||||||
|
|||||||
@@ -1,33 +1,62 @@
|
|||||||
.map-toolbar {
|
.map-toolbar {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-direction: column;
|
z-index: 50;
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
background: #d4d4d4;
|
||||||
|
border-bottom: 1px solid #b0b0b0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-toolbar-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
min-height: 38px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: #aaa;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-navigator {
|
.date-navigator {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn,
|
.nav-btn,
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
width: 30px;
|
width: 28px;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
border: 1px solid #ccc;
|
min-width: 28px;
|
||||||
border-radius: 4px;
|
border: 1px solid #aaa;
|
||||||
background: #f0f0f0;
|
border-radius: 3px;
|
||||||
|
background: #ececec;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn:hover,
|
.nav-btn:hover,
|
||||||
@@ -36,22 +65,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.date-selector {
|
.date-selector {
|
||||||
width: 90px;
|
width: 82px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-picker-input {
|
.date-picker-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
padding: 0 6px;
|
padding: 0 4px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #aaa;
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn-container {
|
.filter-btn-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-content {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn.active {
|
.filter-btn.active {
|
||||||
@@ -59,17 +103,13 @@
|
|||||||
background: #e6f2ff;
|
background: #e6f2ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-content {
|
.range-dropdown {
|
||||||
position: absolute;
|
white-space: nowrap;
|
||||||
top: 100%;
|
}
|
||||||
left: 0;
|
|
||||||
z-index: 1000;
|
.dropdown-fixed {
|
||||||
background: #fff;
|
position: fixed;
|
||||||
border: 1px solid #ddd;
|
z-index: 10000;
|
||||||
border-radius: 5px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
margin-top: 4px;
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.range-option {
|
.range-option {
|
||||||
@@ -94,88 +134,127 @@
|
|||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-controls {
|
.filter-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-select {
|
.filter-select {
|
||||||
height: 30px;
|
height: 28px;
|
||||||
padding: 0 8px;
|
padding: 0 6px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #aaa;
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
|
max-width: 130px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select option {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-status {
|
.toolbar-status {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.8rem;
|
padding-left: 0.5rem;
|
||||||
color: #888;
|
font-size: 0.75rem;
|
||||||
|
color: #555;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-status-error {
|
||||||
|
color: #c62828;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coords-tools {
|
.coords-tools {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.25rem;
|
||||||
padding: 0.4rem 0.75rem;
|
font-size: 0.8rem;
|
||||||
background: #fafafa;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.coords-label,
|
.coord-field {
|
||||||
.current-center-label {
|
position: relative;
|
||||||
color: #555;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coord-input,
|
.coord-input,
|
||||||
.search-input {
|
.search-input {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 8px;
|
padding: 0 22px 0 8px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #aaa;
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
width: 150px;
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coord-input {
|
||||||
|
width: 200px;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coord-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coord-clear:hover {
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-select {
|
.city-select {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 6px;
|
max-width: 110px;
|
||||||
border: 1px solid #ccc;
|
padding: 0 4px;
|
||||||
border-radius: 4px;
|
border: 1px solid #aaa;
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-coords {
|
|
||||||
font-family: ui-monospace, monospace;
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border: 1px solid #ccc;
|
min-width: 28px;
|
||||||
border-radius: 4px;
|
border: 1px solid #aaa;
|
||||||
background: #f0f0f0;
|
border-radius: 3px;
|
||||||
|
background: #ececec;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: #e0e0e0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-search {
|
.place-search {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.25rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +273,7 @@
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-btn {
|
.result-btn {
|
||||||
@@ -222,16 +302,6 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-tools-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
background: #fafafa;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-content {
|
.map-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -240,35 +310,67 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-status {
|
.map-toolbar .btn-sm {
|
||||||
padding: 0.35rem 0.75rem;
|
height: 28px;
|
||||||
font-size: 0.8rem;
|
padding: 0 8px;
|
||||||
color: #666;
|
font-size: 0.75rem;
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-status.error {
|
@media (max-width: 1100px) {
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.filter-controls {
|
.filter-controls {
|
||||||
display: none;
|
display: none;
|
||||||
width: 100%;
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-controls.open {
|
.filter-controls.open {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-toggle {
|
.mobile-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 769px) {
|
@media (min-width: 1101px) {
|
||||||
.mobile-toggle {
|
.mobile-toggle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.coord-input {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-select {
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-sep {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface ParseJob {
|
|||||||
source_type: string;
|
source_type: string;
|
||||||
source_config: Record<string, unknown>;
|
source_config: Record<string, unknown>;
|
||||||
schedule: string | null;
|
schedule: string | null;
|
||||||
|
interval_seconds: number;
|
||||||
|
is_active: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
last_run_at: string | null;
|
last_run_at: string | null;
|
||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
@@ -13,6 +15,14 @@ export interface ParseJobCreate {
|
|||||||
source_type: string;
|
source_type: string;
|
||||||
source_config: Record<string, unknown>;
|
source_config: Record<string, unknown>;
|
||||||
schedule?: string | null;
|
schedule?: string | null;
|
||||||
|
interval_seconds?: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseJobUpdate {
|
||||||
|
source_config?: Record<string, unknown>;
|
||||||
|
interval_seconds?: number;
|
||||||
|
is_active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventRecord {
|
export interface EventRecord {
|
||||||
|
|||||||
@@ -309,16 +309,17 @@ onUnmounted(() => {
|
|||||||
v-model:source-type="sourceType"
|
v-model:source-type="sourceType"
|
||||||
:filters="mapFilters"
|
:filters="mapFilters"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:status-text="error || `${objects.length} объектов`"
|
||||||
|
:status-error="Boolean(error)"
|
||||||
@apply="loadObjects"
|
@apply="loadObjects"
|
||||||
/>
|
>
|
||||||
|
<template #coords>
|
||||||
<div class="map-tools-row">
|
|
||||||
<CoordsTools :map-api="mapApi" :center-coords="centerCoords" />
|
<CoordsTools :map-api="mapApi" :center-coords="centerCoords" />
|
||||||
|
</template>
|
||||||
|
<template #search>
|
||||||
<PlaceSearch :map-api="mapApi" />
|
<PlaceSearch :map-api="mapApi" />
|
||||||
</div>
|
</template>
|
||||||
|
</MapToolbar>
|
||||||
<div v-if="error" class="map-status error">{{ error }}</div>
|
|
||||||
<div v-else class="map-status">{{ objects.length }} объектов на карте</div>
|
|
||||||
|
|
||||||
<div class="map-content">
|
<div class="map-content">
|
||||||
<MapView
|
<MapView
|
||||||
|
|||||||
@@ -1,21 +1,55 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from "vue";
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
import { createJob, fetchJobs, retryJob } from "../api/admin";
|
import { createJob, deleteJob, fetchJobs, retryJob, updateJob } from "../api/admin";
|
||||||
import type { ParseJob } from "../types/admin";
|
import type { ParseJob } from "../types/admin";
|
||||||
|
|
||||||
|
const INTERVAL_OPTIONS = [
|
||||||
|
{ value: 900, label: "15 мин" },
|
||||||
|
{ value: 1800, label: "30 мин" },
|
||||||
|
{ value: 3600, label: "1 ч" },
|
||||||
|
{ value: 10800, label: "3 ч" },
|
||||||
|
{ value: 21600, label: "6 ч" },
|
||||||
|
{ value: 86400, label: "24 ч" },
|
||||||
|
];
|
||||||
|
|
||||||
const jobs = ref<ParseJob[]>([]);
|
const jobs = ref<ParseJob[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
|
const editingJob = ref<ParseJob | null>(null);
|
||||||
|
const editForm = ref({
|
||||||
|
channel: "",
|
||||||
|
limit: 50,
|
||||||
|
interval_seconds: 3600,
|
||||||
|
is_active: true,
|
||||||
|
});
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
source_type: "telegram",
|
source_type: "telegram",
|
||||||
channel: "",
|
channel: "",
|
||||||
limit: 50,
|
limit: 50,
|
||||||
|
interval_seconds: 3600,
|
||||||
});
|
});
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function channelFromConfig(config: Record<string, unknown>): string {
|
||||||
|
return typeof config.channel === "string" ? config.channel : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitFromConfig(config: Record<string, unknown>): number {
|
||||||
|
const limit = config.limit;
|
||||||
|
return typeof limit === "number" ? limit : 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInterval(seconds: number): string {
|
||||||
|
const opt = INTERVAL_OPTIONS.find((item) => item.value === seconds);
|
||||||
|
if (opt) return opt.label;
|
||||||
|
if (seconds < 3600) return `${Math.round(seconds / 60)} мин`;
|
||||||
|
if (seconds < 86400) return `${Math.round(seconds / 3600)} ч`;
|
||||||
|
return `${Math.round(seconds / 86400)} д`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadJobs() {
|
async function loadJobs() {
|
||||||
try {
|
try {
|
||||||
jobs.value = await fetchJobs();
|
jobs.value = await fetchJobs();
|
||||||
@@ -42,6 +76,8 @@ async function handleSubmit() {
|
|||||||
channel: form.value.channel.trim(),
|
channel: form.value.channel.trim(),
|
||||||
limit: form.value.limit,
|
limit: form.value.limit,
|
||||||
},
|
},
|
||||||
|
interval_seconds: form.value.interval_seconds,
|
||||||
|
is_active: true,
|
||||||
});
|
});
|
||||||
form.value.channel = "";
|
form.value.channel = "";
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
@@ -52,6 +88,61 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEdit(job: ParseJob) {
|
||||||
|
editingJob.value = job;
|
||||||
|
editForm.value = {
|
||||||
|
channel: channelFromConfig(job.source_config),
|
||||||
|
limit: limitFromConfig(job.source_config),
|
||||||
|
interval_seconds: job.interval_seconds,
|
||||||
|
is_active: job.is_active,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEdit() {
|
||||||
|
editingJob.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit() {
|
||||||
|
if (!editingJob.value) return;
|
||||||
|
if (!editForm.value.channel.trim()) {
|
||||||
|
error.value = "Укажите канал Telegram";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await updateJob(editingJob.value.id, {
|
||||||
|
source_config: {
|
||||||
|
channel: editForm.value.channel.trim(),
|
||||||
|
limit: editForm.value.limit,
|
||||||
|
},
|
||||||
|
interval_seconds: editForm.value.interval_seconds,
|
||||||
|
is_active: editForm.value.is_active,
|
||||||
|
});
|
||||||
|
closeEdit();
|
||||||
|
await loadJobs();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : "Не удалось сохранить задание";
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(job: ParseJob) {
|
||||||
|
if (!window.confirm(`Удалить парсер #${job.id} (${channelFromConfig(job.source_config)})?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
await deleteJob(job.id);
|
||||||
|
if (editingJob.value?.id === job.id) closeEdit();
|
||||||
|
await loadJobs();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : "Не удалось удалить задание";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRetry(jobId: number) {
|
async function handleRetry(jobId: number) {
|
||||||
try {
|
try {
|
||||||
await retryJob(jobId);
|
await retryJob(jobId);
|
||||||
@@ -88,7 +179,12 @@ onUnmounted(() => {
|
|||||||
<h2 class="page-heading">Парсеры</h2>
|
<h2 class="page-heading">Парсеры</h2>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Новое задание</h3>
|
<h3>Новый парсер</h3>
|
||||||
|
<p class="hint">
|
||||||
|
Активные парсеры подхватываются real-time listener (новые посты сразу в БД).
|
||||||
|
Дополнительно batch-прогон по интервалу забирает последние N постов.
|
||||||
|
Дубликаты по <code>source_url</code> не записываются.
|
||||||
|
</p>
|
||||||
<form class="admin-form" @submit.prevent="handleSubmit">
|
<form class="admin-form" @submit.prevent="handleSubmit">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>
|
<label>
|
||||||
@@ -105,10 +201,18 @@ onUnmounted(() => {
|
|||||||
Лимит
|
Лимит
|
||||||
<input v-model.number="form.limit" type="number" min="1" max="1000" />
|
<input v-model.number="form.limit" type="number" min="1" max="1000" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Интервал
|
||||||
|
<select v-model.number="form.interval_seconds">
|
||||||
|
<option v-for="opt in INTERVAL_OPTIONS" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||||
{{ submitting ? "Создание..." : "Создать задание" }}
|
{{ submitting ? "Создание..." : "Создать парсер" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -116,14 +220,16 @@ onUnmounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h3>Задания <span v-if="loading" class="muted">(загрузка...)</span></h3>
|
<h3>Парсеры <span v-if="loading" class="muted">(загрузка...)</span></h3>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Источник</th>
|
<th>Канал</th>
|
||||||
<th>Конфиг</th>
|
<th>Лимит</th>
|
||||||
|
<th>Интервал</th>
|
||||||
|
<th>Активен</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Последний запуск</th>
|
<th>Последний запуск</th>
|
||||||
<th>Ошибка</th>
|
<th>Ошибка</th>
|
||||||
@@ -132,30 +238,122 @@ onUnmounted(() => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="jobs.length === 0 && !loading">
|
<tr v-if="jobs.length === 0 && !loading">
|
||||||
<td colspan="7" class="empty">Нет заданий</td>
|
<td colspan="9" class="empty">Нет парсеров</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="job in jobs" :key="job.id">
|
<tr v-for="job in jobs" :key="job.id">
|
||||||
<td>{{ job.id }}</td>
|
<td>{{ job.id }}</td>
|
||||||
<td>{{ job.source_type }}</td>
|
<td>{{ channelFromConfig(job.source_config) }}</td>
|
||||||
<td class="mono">{{ JSON.stringify(job.source_config) }}</td>
|
<td>{{ limitFromConfig(job.source_config) }}</td>
|
||||||
|
<td>{{ formatInterval(job.interval_seconds) }}</td>
|
||||||
|
<td>{{ job.is_active ? "да" : "нет" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="statusClass(job.status)">{{ job.status }}</span>
|
<span class="badge" :class="statusClass(job.status)">{{ job.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDate(job.last_run_at) }}</td>
|
<td>{{ formatDate(job.last_run_at) }}</td>
|
||||||
<td class="error-cell">{{ job.last_error ?? "—" }}</td>
|
<td class="error-cell">{{ job.last_error ?? "—" }}</td>
|
||||||
<td>
|
<td class="actions-cell">
|
||||||
|
<button class="btn btn-sm" type="button" @click="openEdit(job)">Изменить</button>
|
||||||
<button
|
<button
|
||||||
v-if="job.status === 'failed' || job.status === 'error'"
|
v-if="job.status === 'failed' || job.status === 'error'"
|
||||||
class="btn btn-sm"
|
class="btn btn-sm"
|
||||||
|
type="button"
|
||||||
@click="handleRetry(job.id)"
|
@click="handleRetry(job.id)"
|
||||||
>
|
>
|
||||||
Повторить
|
Повторить
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-danger"
|
||||||
|
type="button"
|
||||||
|
:disabled="job.status === 'running'"
|
||||||
|
@click="handleDelete(job)"
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div v-if="editingJob" class="modal-backdrop" @click.self="closeEdit">
|
||||||
|
<div class="modal card">
|
||||||
|
<h3>Редактировать парсер #{{ editingJob.id }}</h3>
|
||||||
|
<form class="admin-form" @submit.prevent="handleSaveEdit">
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
Канал
|
||||||
|
<input v-model="editForm.channel" type="text" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Лимит
|
||||||
|
<input v-model.number="editForm.limit" type="number" min="1" max="1000" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Интервал
|
||||||
|
<select v-model.number="editForm.interval_seconds">
|
||||||
|
<option v-for="opt in INTERVAL_OPTIONS" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input v-model="editForm.is_active" type="checkbox" />
|
||||||
|
<span>Активен (периодический запуск)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn" @click="closeEdit">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||||
|
{{ submitting ? "Сохранение..." : "Сохранить" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell .btn {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: #b91c1c;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
width: min(520px, 92vw);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"""CP worker: poll Redis jobs, parse Telegram, ingest to CA."""
|
"""CP worker: Redis jobs + real-time Telethon listener."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import redis
|
import redis
|
||||||
@@ -17,6 +16,8 @@ from workers.sources.telegram_client import (
|
|||||||
fetch_channel_posts,
|
fetch_channel_posts,
|
||||||
normalize_channel,
|
normalize_channel,
|
||||||
)
|
)
|
||||||
|
from workers.sources.telegram_listener import TelegramListener
|
||||||
|
from workers.sources.telegram_session import close_shared_client, get_shared_client
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
logger = logging.getLogger("cp-worker")
|
logger = logging.getLogger("cp-worker")
|
||||||
@@ -26,19 +27,30 @@ JOB_QUEUE_KEY = "cp:jobs"
|
|||||||
CA_API_URL = os.getenv("CA_API_URL", "http://ca-api:8000")
|
CA_API_URL = os.getenv("CA_API_URL", "http://ca-api:8000")
|
||||||
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "dev-internal-token")
|
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "dev-internal-token")
|
||||||
POLL_TIMEOUT = int(os.getenv("WORKER_POLL_TIMEOUT", "5"))
|
POLL_TIMEOUT = int(os.getenv("WORKER_POLL_TIMEOUT", "5"))
|
||||||
|
LISTENER_ENABLED = os.getenv("TELEGRAM_LISTENER_ENABLED", "true").lower() not in (
|
||||||
|
"0",
|
||||||
|
"false",
|
||||||
|
"no",
|
||||||
|
"off",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_redis() -> redis.Redis:
|
def get_redis() -> redis.Redis:
|
||||||
return redis.from_url(REDIS_URL, decode_responses=True)
|
return redis.from_url(REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
|
||||||
async def process_telegram_job(job_id: int, source_config: dict) -> tuple[list[dict], str | None]:
|
async def process_telegram_job(
|
||||||
|
job_id: int,
|
||||||
|
source_config: dict,
|
||||||
|
*,
|
||||||
|
client=None,
|
||||||
|
) -> tuple[list[dict], str | None]:
|
||||||
channel = source_config.get("channel", "creamy_caprice")
|
channel = source_config.get("channel", "creamy_caprice")
|
||||||
limit = int(source_config.get("limit", 100))
|
limit = int(source_config.get("limit", 100))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
username = normalize_channel(channel)
|
username = normalize_channel(channel)
|
||||||
posts = await fetch_channel_posts(username, limit=limit)
|
posts = await fetch_channel_posts(username, limit=limit, client=client)
|
||||||
except (TelegramConfigError, TelegramAuthError, ValueError) as exc:
|
except (TelegramConfigError, TelegramAuthError, ValueError) as exc:
|
||||||
return [], str(exc)
|
return [], str(exc)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -60,7 +72,14 @@ async def post_ingest(job_id: int, events: list[dict]) -> None:
|
|||||||
headers={"X-Internal-Token": INTERNAL_TOKEN},
|
headers={"X-Internal-Token": INTERNAL_TOKEN},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
logger.info("Ingested %s events for job %s: %s", len(events), job_id, response.json())
|
result = response.json()
|
||||||
|
logger.info(
|
||||||
|
"Ingested job %s: new=%s skipped=%s map=%s",
|
||||||
|
job_id,
|
||||||
|
result.get("ingested", 0),
|
||||||
|
result.get("skipped", 0),
|
||||||
|
result.get("map_objects_synced", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def patch_job_status(job_id: int, status: str, error: str | None = None) -> None:
|
async def patch_job_status(job_id: int, status: str, error: str | None = None) -> None:
|
||||||
@@ -76,7 +95,7 @@ async def patch_job_status(job_id: int, status: str, error: str | None = None) -
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
async def handle_job(payload: dict) -> None:
|
async def handle_job(payload: dict, *, tg_client=None) -> None:
|
||||||
job_id = payload["job_id"]
|
job_id = payload["job_id"]
|
||||||
source_type = payload["source_type"]
|
source_type = payload["source_type"]
|
||||||
source_config = payload.get("source_config", {})
|
source_config = payload.get("source_config", {})
|
||||||
@@ -85,7 +104,7 @@ async def handle_job(payload: dict) -> None:
|
|||||||
await patch_job_status(job_id, "running")
|
await patch_job_status(job_id, "running")
|
||||||
|
|
||||||
if source_type == "telegram":
|
if source_type == "telegram":
|
||||||
events, error = await process_telegram_job(job_id, source_config)
|
events, error = await process_telegram_job(job_id, source_config, client=tg_client)
|
||||||
else:
|
else:
|
||||||
events, error = [], f"Unsupported source_type: {source_type}"
|
events, error = [], f"Unsupported source_type: {source_type}"
|
||||||
|
|
||||||
@@ -104,28 +123,54 @@ async def handle_job(payload: dict) -> None:
|
|||||||
await patch_job_status(job_id, "failed", error=str(exc))
|
await patch_job_status(job_id, "failed", error=str(exc))
|
||||||
|
|
||||||
|
|
||||||
async def worker_loop() -> None:
|
async def worker_loop(*, tg_client=None) -> None:
|
||||||
r = get_redis()
|
r = get_redis()
|
||||||
logger.info("CP worker started, polling %s", JOB_QUEUE_KEY)
|
logger.info("CP worker started, polling %s", JOB_QUEUE_KEY)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
item = r.blpop(JOB_QUEUE_KEY, timeout=POLL_TIMEOUT)
|
item = await asyncio.to_thread(r.blpop, JOB_QUEUE_KEY, POLL_TIMEOUT)
|
||||||
if not item:
|
if not item:
|
||||||
continue
|
continue
|
||||||
_, raw = item
|
_, raw = item
|
||||||
payload = json.loads(raw)
|
payload = json.loads(raw)
|
||||||
await handle_job(payload)
|
await handle_job(payload, tg_client=tg_client)
|
||||||
except redis.RedisError as exc:
|
except redis.RedisError as exc:
|
||||||
logger.error("Redis error: %s", exc)
|
logger.error("Redis error: %s", exc)
|
||||||
time.sleep(3)
|
await asyncio.sleep(3)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Unexpected worker error")
|
logger.exception("Unexpected worker error")
|
||||||
time.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_with_listener() -> None:
|
||||||
|
client = await get_shared_client()
|
||||||
|
listener = TelegramListener(client)
|
||||||
|
worker_task = asyncio.create_task(worker_loop(tg_client=client))
|
||||||
|
listener_task = asyncio.create_task(listener.run())
|
||||||
|
|
||||||
|
logger.info("Telegram listener enabled (shared session with batch worker)")
|
||||||
|
try:
|
||||||
|
await client.run_until_disconnected()
|
||||||
|
finally:
|
||||||
|
worker_task.cancel()
|
||||||
|
listener_task.cancel()
|
||||||
|
await close_shared_client()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_batch_only() -> None:
|
||||||
|
logger.info("Telegram listener disabled")
|
||||||
|
await worker_loop(tg_client=None)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
asyncio.run(worker_loop())
|
try:
|
||||||
|
if LISTENER_ENABLED:
|
||||||
|
asyncio.run(run_with_listener())
|
||||||
|
else:
|
||||||
|
asyncio.run(run_batch_only())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from telethon import TelegramClient
|
||||||
from telethon.errors import AuthKeyUnregisteredError, SessionPasswordNeededError
|
from telethon.errors import AuthKeyUnregisteredError, SessionPasswordNeededError
|
||||||
|
|
||||||
from workers.models import TelegramPost
|
from workers.models import TelegramPost
|
||||||
@@ -46,15 +47,36 @@ def _build_post_url(channel: str, message_id: int) -> str:
|
|||||||
return f"https://t.me/{username}/{message_id}"
|
return f"https://t.me/{username}/{message_id}"
|
||||||
|
|
||||||
|
|
||||||
async def fetch_channel_posts(channel: str, limit: int = 100) -> list[TelegramPost]:
|
def message_to_post(message, channel: str) -> TelegramPost | None:
|
||||||
|
text = (message.text or message.message or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
username = normalize_channel(channel)
|
||||||
|
return TelegramPost(
|
||||||
|
id=message.id,
|
||||||
|
text=text,
|
||||||
|
date=message.date,
|
||||||
|
url=_build_post_url(username, message.id),
|
||||||
|
channel=username,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_channel_posts(
|
||||||
|
channel: str,
|
||||||
|
limit: int = 100,
|
||||||
|
*,
|
||||||
|
client: TelegramClient | None = None,
|
||||||
|
) -> list[TelegramPost]:
|
||||||
limit = max(1, min(limit, MAX_POSTS))
|
limit = max(1, min(limit, MAX_POSTS))
|
||||||
|
username = normalize_channel(channel)
|
||||||
|
own_client = client is None
|
||||||
|
|
||||||
|
if own_client:
|
||||||
try:
|
try:
|
||||||
api_id, api_hash, session_path = _get_config()
|
api_id, api_hash, session_path = _get_config()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise TelegramConfigError(str(exc)) from exc
|
raise TelegramConfigError(str(exc)) from exc
|
||||||
|
|
||||||
username = normalize_channel(channel)
|
|
||||||
|
|
||||||
if not Path(session_path).exists():
|
if not Path(session_path).exists():
|
||||||
raise TelegramAuthError(
|
raise TelegramAuthError(
|
||||||
f"Файл сессии не найден: {session_path}. "
|
f"Файл сессии не найден: {session_path}. "
|
||||||
@@ -73,17 +95,9 @@ async def fetch_channel_posts(channel: str, limit: int = 100) -> list[TelegramPo
|
|||||||
|
|
||||||
entity = await client.get_entity(username)
|
entity = await client.get_entity(username)
|
||||||
async for message in client.iter_messages(entity, limit=limit):
|
async for message in client.iter_messages(entity, limit=limit):
|
||||||
if not message.text:
|
post = message_to_post(message, username)
|
||||||
continue
|
if post:
|
||||||
posts.append(
|
posts.append(post)
|
||||||
TelegramPost(
|
|
||||||
id=message.id,
|
|
||||||
text=message.text.strip(),
|
|
||||||
date=message.date,
|
|
||||||
url=_build_post_url(username, message.id),
|
|
||||||
channel=username,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except AuthKeyUnregisteredError as exc:
|
except AuthKeyUnregisteredError as exc:
|
||||||
raise TelegramAuthError(
|
raise TelegramAuthError(
|
||||||
"Сессия Telegram недействительна. Переавторизуйтесь: python scripts/telegram_auth.py"
|
"Сессия Telegram недействительна. Переавторизуйтесь: python scripts/telegram_auth.py"
|
||||||
@@ -97,3 +111,12 @@ async def fetch_channel_posts(channel: str, limit: int = 100) -> list[TelegramPo
|
|||||||
await client.disconnect()
|
await client.disconnect()
|
||||||
|
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
|
assert client is not None
|
||||||
|
posts = []
|
||||||
|
entity = await client.get_entity(username)
|
||||||
|
async for message in client.iter_messages(entity, limit=limit):
|
||||||
|
post = message_to_post(message, username)
|
||||||
|
if post:
|
||||||
|
posts.append(post)
|
||||||
|
return posts
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Real-time Telethon listener for active Telegram parse jobs."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from telethon import TelegramClient, events
|
||||||
|
|
||||||
|
from workers.converter import event_record_to_ingest
|
||||||
|
from workers.parsers.telegram_events import parse_event_post
|
||||||
|
from workers.sources.telegram_client import message_to_post, normalize_channel
|
||||||
|
|
||||||
|
logger = logging.getLogger("cp-listener")
|
||||||
|
|
||||||
|
CA_API_URL = os.getenv("CA_API_URL", "http://ca-api:8000")
|
||||||
|
INTERNAL_TOKEN = os.getenv("INTERNAL_TOKEN", "dev-internal-token")
|
||||||
|
REFRESH_SECONDS = int(os.getenv("TELEGRAM_LISTENER_REFRESH_SECONDS", "60"))
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramListener:
|
||||||
|
def __init__(self, client: TelegramClient) -> None:
|
||||||
|
self.client = client
|
||||||
|
self._channels: dict[str, int] = {}
|
||||||
|
self._chat_ids: set[int] = set()
|
||||||
|
self._handlers_registered = False
|
||||||
|
|
||||||
|
async def fetch_subscriptions(self) -> dict[str, int]:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as http:
|
||||||
|
response = await http.get(
|
||||||
|
f"{CA_API_URL}/internal/listener/subscriptions",
|
||||||
|
headers={"X-Internal-Token": INTERNAL_TOKEN},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
channels: dict[str, int] = {}
|
||||||
|
for item in data:
|
||||||
|
raw = item.get("channel")
|
||||||
|
job_id = item.get("job_id")
|
||||||
|
if not raw or job_id is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
key = normalize_channel(str(raw))
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Skip invalid channel in subscription: %r", raw)
|
||||||
|
continue
|
||||||
|
channels.setdefault(key, int(job_id))
|
||||||
|
return channels
|
||||||
|
|
||||||
|
async def refresh_subscriptions(self) -> None:
|
||||||
|
try:
|
||||||
|
channels = await self.fetch_subscriptions()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to load listener subscriptions")
|
||||||
|
return
|
||||||
|
|
||||||
|
if channels == self._channels:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._channels = channels
|
||||||
|
self._chat_ids = set()
|
||||||
|
for username in channels:
|
||||||
|
try:
|
||||||
|
entity = await self.client.get_entity(username)
|
||||||
|
self._chat_ids.add(entity.id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Cannot resolve channel entity: %s", username)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Listener subscriptions updated: %s",
|
||||||
|
", ".join(sorted(channels)) or "(none)",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _ingest_post(self, channel: str, post) -> None:
|
||||||
|
record = parse_event_post(post)
|
||||||
|
event = event_record_to_ingest(record)
|
||||||
|
job_id = self._channels.get(normalize_channel(channel))
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as http:
|
||||||
|
response = await http.post(
|
||||||
|
f"{CA_API_URL}/internal/ingest",
|
||||||
|
json={
|
||||||
|
"job_id": job_id,
|
||||||
|
"events": [event],
|
||||||
|
"listener": True,
|
||||||
|
},
|
||||||
|
headers={"X-Internal-Token": INTERNAL_TOKEN},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
logger.info(
|
||||||
|
"Listener ingest %s: new=%s skipped=%s",
|
||||||
|
post.url,
|
||||||
|
result.get("ingested", 0),
|
||||||
|
result.get("skipped", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _channel_from_event(self, event: events.common.EventCommon) -> str | None:
|
||||||
|
chat = event.chat
|
||||||
|
if chat is None:
|
||||||
|
return None
|
||||||
|
username = getattr(chat, "username", None)
|
||||||
|
if username:
|
||||||
|
return normalize_channel(username)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _process_messages(self, event: events.common.EventCommon, messages: list[Any]) -> None:
|
||||||
|
channel = self._channel_from_event(event)
|
||||||
|
if not channel or channel not in self._channels:
|
||||||
|
return
|
||||||
|
if event.chat_id not in self._chat_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
post = message_to_post(message, channel)
|
||||||
|
if not post:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await self._ingest_post(channel, post)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Listener ingest failed for %s", post.url)
|
||||||
|
|
||||||
|
def register_handlers(self) -> None:
|
||||||
|
if self._handlers_registered:
|
||||||
|
return
|
||||||
|
|
||||||
|
@self.client.on(events.NewMessage(incoming=True))
|
||||||
|
async def on_new_message(event: events.NewMessage.Event) -> None:
|
||||||
|
if event.grouped_id:
|
||||||
|
return
|
||||||
|
await self._process_messages(event, [event.message])
|
||||||
|
|
||||||
|
@self.client.on(events.Album)
|
||||||
|
async def on_album(event: events.Album.Event) -> None:
|
||||||
|
await self._process_messages(event, list(event.messages))
|
||||||
|
|
||||||
|
self._handlers_registered = True
|
||||||
|
logger.info("Telegram NewMessage/Album handlers registered")
|
||||||
|
|
||||||
|
async def refresh_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
await self.refresh_subscriptions()
|
||||||
|
await asyncio.sleep(REFRESH_SECONDS)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
self.register_handlers()
|
||||||
|
await self.refresh_subscriptions()
|
||||||
|
await self.refresh_loop()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Единое подключение Telethon для listener и batch-заданий."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.errors import AuthKeyUnregisteredError, SessionPasswordNeededError
|
||||||
|
|
||||||
|
from workers.sources.telegram_settings import create_client, get_api_credentials, get_session_path
|
||||||
|
from workers.sources.telegram_client import TelegramAuthError, TelegramConfigError
|
||||||
|
|
||||||
|
_shared_client: TelegramClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_shared_client() -> TelegramClient:
|
||||||
|
global _shared_client
|
||||||
|
if _shared_client is not None and _shared_client.is_connected():
|
||||||
|
return _shared_client
|
||||||
|
|
||||||
|
try:
|
||||||
|
api_id, api_hash = get_api_credentials()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise TelegramConfigError(str(exc)) from exc
|
||||||
|
|
||||||
|
session_path = get_session_path()
|
||||||
|
if not Path(session_path).exists():
|
||||||
|
raise TelegramAuthError(
|
||||||
|
f"Файл сессии не найден: {session_path}. "
|
||||||
|
"Выполните: python scripts/telegram_auth.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = create_client(session_path, api_id, api_hash)
|
||||||
|
try:
|
||||||
|
await client.connect()
|
||||||
|
if not await client.is_user_authorized():
|
||||||
|
raise TelegramAuthError(
|
||||||
|
"Telegram-сессия не авторизована. Выполните: python scripts/telegram_auth.py"
|
||||||
|
)
|
||||||
|
except AuthKeyUnregisteredError as exc:
|
||||||
|
raise TelegramAuthError(
|
||||||
|
"Сессия Telegram недействительна. Переавторизуйтесь: python scripts/telegram_auth.py"
|
||||||
|
) from exc
|
||||||
|
except SessionPasswordNeededError as exc:
|
||||||
|
raise TelegramAuthError(
|
||||||
|
"Для аккаунта включена 2FA. Авторизуйтесь через scripts/telegram_auth.py."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
_shared_client = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_shared_client() -> None:
|
||||||
|
global _shared_client
|
||||||
|
if _shared_client is not None:
|
||||||
|
await _shared_client.disconnect()
|
||||||
|
_shared_client = None
|
||||||
@@ -48,6 +48,8 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
TELEGRAM_SESSION_PATH: /data/telegram.session
|
TELEGRAM_SESSION_PATH: /data/telegram.session
|
||||||
|
TELEGRAM_LISTENER_ENABLED: "true"
|
||||||
|
TELEGRAM_LISTENER_REFRESH_SECONDS: "60"
|
||||||
CA_API_URL: http://ca-api:8000
|
CA_API_URL: http://ca-api:8000
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
INTERNAL_TOKEN: dev-internal-token
|
INTERNAL_TOKEN: dev-internal-token
|
||||||
|
|||||||
Reference in New Issue
Block a user