TourMonitor: мониторинг цен туров Level.Travel
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
# Telegram-бот (получите у @BotFather) и ID чата (у @userinfobot)
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
# Запускать скан сразу при старте (для первого наполнения базы), true/false
|
||||||
|
RUN_ON_START=true
|
||||||
|
|
||||||
|
# Горизонт дат в днях и размер топа в отчёте
|
||||||
|
DATE_RANGE_DAYS=90
|
||||||
|
TOP_N=5
|
||||||
|
|
||||||
|
# Порт веб-интерфейса (дашборд Hangfire на /hangfire)
|
||||||
|
PORT=8080
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
Data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
|
.vs/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY TourMonitor.slnx ./
|
||||||
|
COPY src/TourMonitor/TourMonitor.csproj src/TourMonitor/
|
||||||
|
RUN dotnet restore src/TourMonitor/TourMonitor.csproj
|
||||||
|
|
||||||
|
COPY src/ src/
|
||||||
|
RUN dotnet publish src/TourMonitor/TourMonitor.csproj -c Release -o /app --no-restore
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
libgtk-3-0 \
|
||||||
|
libdbus-glib-1-2 \
|
||||||
|
libasound2t64 \
|
||||||
|
libnss3 \
|
||||||
|
libnspr4 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdrm2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libx11-6 \
|
||||||
|
libx11-xcb1 \
|
||||||
|
libxcb1 \
|
||||||
|
libxext6 \
|
||||||
|
libxi6 \
|
||||||
|
libxtst6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app .
|
||||||
|
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV Camoufox__InstallDirectory=/opt/camoufox
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "TourMonitor.dll"]
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# TourMonitor — мониторинг туров Level.Travel
|
||||||
|
|
||||||
|
Ежедневно проверяет цены на туры (9 ночей, 2 взрослых + ребёнок 5 лет) из Москвы в отели
|
||||||
|
**Sunrise Tucana Resort** и **Posh Club By Sunrise Tucana Resort** (Макади Бей, Египет)
|
||||||
|
на окно с +60 дней от сегодня на 90 дней вперёд и отправляет в Telegram отчёт с топом
|
||||||
|
дешёвых дат и дельтой цен.
|
||||||
|
|
||||||
|
Работает напрямую с публичным веб-API level.travel (подпись запросов и расшифровка
|
||||||
|
ответов воспроизведены с JS-бандла сайта), без партнёрского токена.
|
||||||
|
|
||||||
|
Запросы к API выполняются из настоящего браузера **Camoufox** (запатченный Firefox)
|
||||||
|
через Playwright: это обходит анти-бот периметр по фингерпринту, который режет даже
|
||||||
|
корректно подписанные запросы из обычного HTTP-клиента. Браузер скачивается и
|
||||||
|
устанавливается автоматически при первом запуске.
|
||||||
|
|
||||||
|
## Отчёт в Telegram
|
||||||
|
|
||||||
|
```
|
||||||
|
📊 Мониторинг туров · 9 ночей, 2+1 чел, ребёнок 5 лет
|
||||||
|
Заезды: 06.08–03.11
|
||||||
|
|
||||||
|
🏝 Sunrise Tucana Resort
|
||||||
|
📅 20.08 — 418 000 ₽ 🔻 −12% (было 475 447 ₽)
|
||||||
|
🍽 AI · Полулюкс · Level.Travel
|
||||||
|
🔗 забронировать
|
||||||
|
📅 02.09 — 501 000 ₽ ✨ новая минималка
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Секция показывается только если по отелю есть предложения. Каждый отель приходит
|
||||||
|
**отдельным сообщением**; если сообщение длиннее лимита Telegram (4096 символов), оно
|
||||||
|
автоматически разбивается на несколько.
|
||||||
|
|
||||||
|
## Как запустить
|
||||||
|
|
||||||
|
Требуется .NET 10 SDK.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# первый скан сразу (наполнение базы) + отправка отчёта:
|
||||||
|
dotnet run --project src/TourMonitor -- Monitor__RunOnStart=true \
|
||||||
|
--Telegram__BotToken=<токен> --Telegram__ChatId=<id>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Дашборд Hangfire (журнал задач, кнопка «Run now»): http://localhost:5000/hangfire
|
||||||
|
- Календарь цен: http://localhost:5000/prices (список отелей → сетка дат с минимальной ценой)
|
||||||
|
- Ежедневный скан по умолчанию в 07:30 по Москве (`Schedule:Cron`, `Schedule:TimeZoneId`)
|
||||||
|
- База SQLite: `Data/tour_monitor.db` (история цен + ежедневные минимумы)
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Всё через `appsettings.json` или переменные окружения (`LevelTravel__…`, `Monitor__…`,
|
||||||
|
`Telegram__…`, `Schedule__…`):
|
||||||
|
|
||||||
|
| Ключ | По умолчанию |
|
||||||
|
|---|---|
|
||||||
|
| `Telegram:BotToken`, `Telegram:ChatId` | пусто (отчёт не отправляется) |
|
||||||
|
| `Monitor:Hotels` | Sunrise Tucana 9099454, Posh Club 9151153, Riu Sri Lanka 9067553 |
|
||||||
|
| `Monitor:Adults`, `Monitor:KidsAges` | 2, `[5]` |
|
||||||
|
| `Monitor:DateRangeDays` | 150 |
|
||||||
|
| `Monitor:StartOffsetDays` | 30 (окно начинается с +30 дней от сегодня) |
|
||||||
|
| `Monitor:PricesPerMonth` | 3 (сколько дешёвых дат показывать в отчёте для каждого месяца) |
|
||||||
|
| `Monitor:CalendarUrlBase` | пусто (ссылка на календарь цен в отчёте; `http://192.168.1.6:8084` на хосте) |
|
||||||
|
| `Monitor:RunOnStart` | false |
|
||||||
|
| `Monitor:MaxParallelDates` | 5 (даты сканируются параллельно) |
|
||||||
|
| `Camoufox:Version` | `v152.0.4-beta.28` |
|
||||||
|
| `Camoufox:InstallDirectory` | `Data/camoufox` |
|
||||||
|
| `Camoufox:Headless` | true |
|
||||||
|
| `Schedule:Cron` | `0 30 7 * * ?` |
|
||||||
|
| `Schedule:TimeZoneId` | `Europe/Moscow` |
|
||||||
|
|
||||||
|
Ключи API и расшифровки (`LevelTravel:ApiKey`, `LevelTravel:SecretBoxKeys`) — из открытого
|
||||||
|
JS-бандла сайта; менять не нужно.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # заполнить TELEGRAM_BOT_TOKEN и TELEGRAM_CHAT_ID
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Данные — в volume `tour-monitor-data` (`/app/Data/tour_monitor.db`).
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet test # юнит: подпись, расшифровка, хранилище, отчёт
|
||||||
|
LT_LIVE_TESTS=1 dotnet test # + живые проверки против api.level.travel (через браузер Camoufox)
|
||||||
|
```
|
||||||
|
|
||||||
|
Живые тесты ходят в реальный API и поэтому опциональны: `LT_LIVE_TESTS=1` включает их,
|
||||||
|
без переменной они «проходят» без запросов. API защищён анти-бот периметром по
|
||||||
|
фингепринту/IP: в моменты блокировки он отвечает decoy'ами — `403`,
|
||||||
|
`{"message":"partner not found"}` (с кодом 200) или HTML-404 вместо данных.
|
||||||
|
Браузерный транспорт (Camoufox) эти блоки обходит и распознаёт decoy'ы.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
- `src/TourMonitor/LevelTravel/` — подпись (`SignHelper`), расшифровка (`LevelTravelCrypto`,
|
||||||
|
XSalsa20-Poly1305 + zlib), клиент `multi_enqueue`/`status`/`room_rates` поверх
|
||||||
|
транспорта `IApiTransport` (браузер Camoufox)
|
||||||
|
- `src/TourMonitor/Camoufox/` — установка Camoufox (скачивание из релизов), сессия
|
||||||
|
браузера и fetch-транспорт через Playwright
|
||||||
|
- `src/TourMonitor/Storage/PriceStore.cs` — SQLite: история цен, ежедневные минимумы
|
||||||
|
- `src/TourMonitor/Jobs/DailyScanJob.cs` — сам скан: 90 дат × отели из конфига, параллельно
|
||||||
|
- `src/TourMonitor/Notifications/` — Telegram и сборка отчёта
|
||||||
|
- `tests/TourMonitor.Tests/` — юнит- и интеграционные тесты
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/TourMonitor/TourMonitor.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/TourMonitor.Tests/TourMonitor.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
tour-monitor:
|
||||||
|
build: .
|
||||||
|
container_name: tour-monitor
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
Monitor__RunOnStart: "${RUN_ON_START:-false}"
|
||||||
|
Monitor__DateRangeDays: "${DATE_RANGE_DAYS:-150}"
|
||||||
|
Monitor__StartOffsetDays: "${START_OFFSET_DAYS:-30}"
|
||||||
|
Monitor__PricesPerMonth: "${PRICES_PER_MONTH:-3}"
|
||||||
|
Monitor__CalendarUrlBase: "${CALENDAR_URL_BASE:-http://192.168.1.6:8084}"
|
||||||
|
Camoufox__Version: "${CAMOUFOX_VERSION:-v152.0.4-beta.28}"
|
||||||
|
Camoufox__InstallDirectory: /opt/camoufox
|
||||||
|
Telegram__BotToken: "${TELEGRAM_BOT_TOKEN:-}"
|
||||||
|
Telegram__ChatId: "${TELEGRAM_CHAT_ID:-}"
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8080}:8080"
|
||||||
|
volumes:
|
||||||
|
- tour-monitor-data:/app/Data
|
||||||
|
- tour-monitor-camoufox:/opt/camoufox
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/hangfire"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tour-monitor-data:
|
||||||
|
tour-monitor-camoufox:
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace TourMonitor.Camoufox;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Долгоживущая сессия Camoufox: один браузер на весь скан. Лениво запускается,
|
||||||
|
/// открывает level.travel для установки кук и реального браузерного контекста,
|
||||||
|
/// дальше API-запросы идут из этой же страницы.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CamoufoxBrowserSession : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly ICamoufoxInstaller _installer;
|
||||||
|
private readonly CamoufoxOptions _options;
|
||||||
|
private readonly ILogger<CamoufoxBrowserSession> _logger;
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
|
private IPlaywright? _playwright;
|
||||||
|
private IBrowser? _browser;
|
||||||
|
private IPage? _page;
|
||||||
|
private bool _started;
|
||||||
|
|
||||||
|
public CamoufoxBrowserSession(ICamoufoxInstaller installer, IOptions<CamoufoxOptions> options, ILogger<CamoufoxBrowserSession> logger)
|
||||||
|
{
|
||||||
|
_installer = installer;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IPage> GetPageAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_started && _page is not null)
|
||||||
|
return _page;
|
||||||
|
|
||||||
|
var executablePath = await _installer.EnsureInstalledAsync(
|
||||||
|
_options.Version, _options.InstallDirectory, _options.DownloadUrlOverride, cancellationToken);
|
||||||
|
_logger.LogInformation("Запуск браузерной сессии Camoufox: {ExecutablePath}", executablePath);
|
||||||
|
|
||||||
|
_playwright = await Playwright.CreateAsync();
|
||||||
|
_browser = await _playwright.Firefox.LaunchAsync(new BrowserTypeLaunchOptions
|
||||||
|
{
|
||||||
|
ExecutablePath = executablePath,
|
||||||
|
Headless = _options.Headless,
|
||||||
|
});
|
||||||
|
|
||||||
|
_page = await _browser.NewPageAsync();
|
||||||
|
_logger.LogInformation("Открытие {SessionUrl} для установки сессии...", _options.SessionUrl);
|
||||||
|
await _page.GotoAsync(_options.SessionUrl, new PageGotoOptions
|
||||||
|
{
|
||||||
|
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||||
|
Timeout = 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
_started = true;
|
||||||
|
_logger.LogInformation("Браузерная сессия готова, текущий URL: {Url}", _page.Url);
|
||||||
|
return _page;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CloseAsync()
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_page is not null)
|
||||||
|
{
|
||||||
|
try { await _page.CloseAsync(); } catch { /* ignore */ }
|
||||||
|
_page = null;
|
||||||
|
}
|
||||||
|
if (_browser is not null)
|
||||||
|
{
|
||||||
|
try { await _browser.CloseAsync(); } catch { /* ignore */ }
|
||||||
|
_browser = null;
|
||||||
|
}
|
||||||
|
if (_playwright is not null)
|
||||||
|
{
|
||||||
|
_playwright.Dispose();
|
||||||
|
_playwright = null;
|
||||||
|
}
|
||||||
|
_started = false;
|
||||||
|
_logger.LogInformation("Браузерная сессия Camoufox закрыта.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() => await CloseAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Formats.Tar;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using TourMonitor.Camoufox;
|
||||||
|
|
||||||
|
namespace TourMonitor.Camoufox;
|
||||||
|
|
||||||
|
public interface ICamoufoxInstaller
|
||||||
|
{
|
||||||
|
string? GetInstalledExecutablePathOrNull(string installDirectory);
|
||||||
|
Task<string> EnsureInstalledAsync(string version, string installDirectory, string? downloadUrlOverride, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Скачивает патченный Firefox (Camoufox) из релизов и распаковывает его.</summary>
|
||||||
|
public sealed class CamoufoxInstaller : ICamoufoxInstaller
|
||||||
|
{
|
||||||
|
private readonly ICamoufoxReleaseResolver _releaseResolver;
|
||||||
|
private readonly ILogger<CamoufoxInstaller> _logger;
|
||||||
|
|
||||||
|
public CamoufoxInstaller(ICamoufoxReleaseResolver releaseResolver, ILogger<CamoufoxInstaller> logger)
|
||||||
|
{
|
||||||
|
_releaseResolver = releaseResolver;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetInstalledExecutablePathOrNull(string installDirectory)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(installDirectory, ExecutableName());
|
||||||
|
return File.Exists(candidate) ? candidate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> EnsureInstalledAsync(string version, string installDirectory, string? downloadUrlOverride, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(installDirectory);
|
||||||
|
|
||||||
|
var installed = GetInstalledExecutablePathOrNull(installDirectory);
|
||||||
|
if (installed is not null)
|
||||||
|
return installed;
|
||||||
|
|
||||||
|
_logger.LogInformation("Camoufox не найден. Скачивание и установка...");
|
||||||
|
var asset = _releaseResolver.Resolve(version, downloadUrlOverride);
|
||||||
|
var archivePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(new Uri(asset.DownloadUrl).LocalPath));
|
||||||
|
|
||||||
|
await DownloadAsync(asset.DownloadUrl, archivePath, cancellationToken);
|
||||||
|
ExtractArchive(archivePath, installDirectory, asset.IsTarGzArchive);
|
||||||
|
TryDeleteFile(archivePath);
|
||||||
|
|
||||||
|
var discovered = LocateExecutable(installDirectory, asset.ExecutableName);
|
||||||
|
EnsureExecutablePermission(discovered);
|
||||||
|
_logger.LogInformation("Camoufox установлен: {Path}", discovered);
|
||||||
|
return discovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExecutableName() =>
|
||||||
|
OperatingSystem.IsWindows() ? "camoufox.exe" : "camoufox";
|
||||||
|
|
||||||
|
private async Task DownloadAsync(string url, string destinationPath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var httpClient = new HttpClient();
|
||||||
|
using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var totalBytes = response.Content.Headers.ContentLength;
|
||||||
|
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
await using var destination = File.Create(destinationPath);
|
||||||
|
|
||||||
|
var buffer = new byte[81920];
|
||||||
|
long readTotal = 0;
|
||||||
|
var lastPrintedPercent = -1;
|
||||||
|
|
||||||
|
int read;
|
||||||
|
while ((read = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
|
||||||
|
{
|
||||||
|
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||||
|
readTotal += read;
|
||||||
|
if (totalBytes is > 0)
|
||||||
|
{
|
||||||
|
var percent = (int)Math.Min(100, readTotal * 100 / totalBytes.Value);
|
||||||
|
if (percent != lastPrintedPercent)
|
||||||
|
{
|
||||||
|
lastPrintedPercent = percent;
|
||||||
|
_logger.LogInformation("[DOWNLOAD] {ProgressPercent}%", percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalBytes is null && readTotal > 0)
|
||||||
|
_logger.LogInformation("[DOWNLOAD] {DownloadedBytes} байт (размер неизвестен)", readTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ExtractArchive(string archivePath, string installDirectory, bool isTarGzArchive)
|
||||||
|
{
|
||||||
|
if (isTarGzArchive)
|
||||||
|
{
|
||||||
|
using var archiveStream = File.OpenRead(archivePath);
|
||||||
|
using var gzipStream = new GZipStream(archiveStream, CompressionMode.Decompress);
|
||||||
|
TarFile.ExtractToDirectory(gzipStream, installDirectory, overwriteFiles: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ZipFile.ExtractToDirectory(archivePath, installDirectory, overwriteFiles: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string LocateExecutable(string installDirectory, string executableName)
|
||||||
|
{
|
||||||
|
var directPath = Path.Combine(installDirectory, executableName);
|
||||||
|
if (File.Exists(directPath))
|
||||||
|
return directPath;
|
||||||
|
|
||||||
|
var nested = Directory
|
||||||
|
.EnumerateFiles(installDirectory, executableName, SearchOption.AllDirectories)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (nested is not null)
|
||||||
|
return nested;
|
||||||
|
|
||||||
|
throw new FileNotFoundException($"Исполняемый файл Camoufox не найден после распаковки: {executableName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureExecutablePermission(string path)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var process = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "chmod",
|
||||||
|
ArgumentList = { "+x", path },
|
||||||
|
RedirectStandardError = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
});
|
||||||
|
process?.WaitForExit();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Some environments already preserve executable permissions.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryDeleteFile(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore temp cleanup failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace TourMonitor.Camoufox;
|
||||||
|
|
||||||
|
public sealed record CamoufoxReleaseAsset(
|
||||||
|
string DownloadUrl,
|
||||||
|
bool IsTarGzArchive,
|
||||||
|
string ExecutableName);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace TourMonitor.Camoufox;
|
||||||
|
|
||||||
|
public interface ICamoufoxReleaseResolver
|
||||||
|
{
|
||||||
|
CamoufoxReleaseAsset Resolve(string version, string? downloadUrlOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Резолвит ассет патченного Firefox из релизов daijro/camoufox по ОС/архитектуре.</summary>
|
||||||
|
public sealed class CamoufoxReleaseResolver : ICamoufoxReleaseResolver
|
||||||
|
{
|
||||||
|
public CamoufoxReleaseAsset Resolve(string version, string? downloadUrlOverride)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(downloadUrlOverride))
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(new Uri(downloadUrlOverride).LocalPath);
|
||||||
|
var isTarGz = fileName.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return new CamoufoxReleaseAsset(
|
||||||
|
downloadUrlOverride,
|
||||||
|
isTarGz,
|
||||||
|
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "camoufox.exe" : "camoufox");
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetVersion = version.StartsWith("v", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? version[1..]
|
||||||
|
: version;
|
||||||
|
var assetPrefix = $"https://github.com/daijro/camoufox/releases/download/{version}/camoufox-{assetVersion}";
|
||||||
|
var executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "camoufox.exe" : "camoufox";
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
return new CamoufoxReleaseAsset($"{assetPrefix}-win.x86_64.zip", false, executableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
{
|
||||||
|
var arch = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x86_64";
|
||||||
|
return new CamoufoxReleaseAsset($"{assetPrefix}-lin.{arch}.zip", false, executableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
{
|
||||||
|
var arch = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x86_64";
|
||||||
|
return new CamoufoxReleaseAsset($"{assetPrefix}-mac.{arch}.zip", false, executableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new PlatformNotSupportedException("Unsupported OS platform for Camoufox installation.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TourMonitor.Camoufox;
|
||||||
|
using TourMonitor.LevelTravel;
|
||||||
|
using TourMonitor.Notifications;
|
||||||
|
using TourMonitor.Storage;
|
||||||
|
|
||||||
|
namespace TourMonitor.Jobs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ежедневный скан: для каждой даты заезда — поиск туров по обоим отелям,
|
||||||
|
/// сохранение цен, сравнение со вчерашним днём и отправка отчёта в Telegram.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DailyScanJob
|
||||||
|
{
|
||||||
|
private readonly LevelTravelClient _client;
|
||||||
|
private readonly PriceStore _store;
|
||||||
|
private readonly TelegramNotifier _notifier;
|
||||||
|
private readonly CamoufoxBrowserSession _browser;
|
||||||
|
private readonly MonitorOptions _options;
|
||||||
|
private readonly ILogger<DailyScanJob> _logger;
|
||||||
|
|
||||||
|
public DailyScanJob(
|
||||||
|
LevelTravelClient client,
|
||||||
|
PriceStore store,
|
||||||
|
TelegramNotifier notifier,
|
||||||
|
CamoufoxBrowserSession browser,
|
||||||
|
IOptions<MonitorOptions> options,
|
||||||
|
ILogger<DailyScanJob> logger)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_store = store;
|
||||||
|
_notifier = notifier;
|
||||||
|
_browser = browser;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Скан запущен: отелей={Hotels}, дат={Dates}", _options.Hotels.Count, _options.DateRangeDays);
|
||||||
|
await _store.EnsureCreatedAsync(ct);
|
||||||
|
|
||||||
|
var today = DateTime.Today;
|
||||||
|
var start = today.AddDays(_options.StartOffsetDays);
|
||||||
|
var dates = Enumerable.Range(0, _options.DateRangeDays)
|
||||||
|
.Select(offset => start.AddDays(offset))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var todayKey = today.ToString("yyyy-MM-dd");
|
||||||
|
var scannedDates = 0;
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(
|
||||||
|
dates,
|
||||||
|
new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, _options.MaxParallelDates), CancellationToken = ct },
|
||||||
|
async (date, token) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ScanDateAsync(date, todayKey, token);
|
||||||
|
Interlocked.Increment(ref scannedDates);
|
||||||
|
_logger.LogInformation("Дата {Date} — готово ({Scanned}/{Total})", date.ToString("yyyy-MM-dd"), Volatile.Read(ref scannedDates), dates.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Ошибка скана даты {Date}", date.ToString("yyyy-MM-dd"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var messages = await BuildReportAsync(todayKey, ct);
|
||||||
|
if (messages is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Отчёт пуст — данных по отелям нет.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sent = await _notifier.SendMessagesAsync(messages, ct);
|
||||||
|
_logger.LogInformation("Отчёт отправлен в Telegram: {Sent}/{Total}", sent, messages.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _browser.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ScanDateAsync(DateTime date, string todayKey, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var dateKey = date.ToString("yyyy-MM-dd");
|
||||||
|
var searchParams = new List<SearchParam>();
|
||||||
|
foreach (var hotel in _options.Hotels)
|
||||||
|
{
|
||||||
|
searchParams.Add(new SearchParam
|
||||||
|
{
|
||||||
|
HotelIds = hotel.Id,
|
||||||
|
FromCity = _options.DepartureCity,
|
||||||
|
FromCountry = _options.DepartureCountry,
|
||||||
|
ToCity = hotel.ToCity,
|
||||||
|
ToCountry = hotel.ToCountry,
|
||||||
|
StartDate = dateKey,
|
||||||
|
Nights = hotel.Nights.ToString(),
|
||||||
|
Adults = _options.Adults,
|
||||||
|
Kids = _options.KidsAges.Count,
|
||||||
|
KidsAges = _options.KidsAges.ToArray(),
|
||||||
|
FlexDates = false,
|
||||||
|
SearchType = "package",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var enqueue = await _client.MultiEnqueueAsync(searchParams, ct);
|
||||||
|
if (!enqueue.Success || enqueue.SearchRequests.Count != _options.Hotels.Count)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("multi_enqueue для {Date} вернул {Count} поисков (ожидалось {Expected})",
|
||||||
|
dateKey, enqueue.SearchRequests.Count, _options.Hotels.Count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < _options.Hotels.Count; i++)
|
||||||
|
{
|
||||||
|
var hotel = _options.Hotels[i];
|
||||||
|
var requestId = enqueue.SearchRequests[i].RequestId;
|
||||||
|
|
||||||
|
var status = await _client.WaitForSearchAsync(
|
||||||
|
requestId, _options.PollIntervalSeconds, _options.SearchTimeoutSeconds, ct);
|
||||||
|
|
||||||
|
var rates = await _client.GetRoomRatesAsync(hotel.Id.ToString(), requestId, ct);
|
||||||
|
var offers = rates.Success
|
||||||
|
? FlattenOffers(hotel.Id, dateKey, rates)
|
||||||
|
: new List<StoredOffer>();
|
||||||
|
|
||||||
|
if (offers.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Отель {Hotel} на {Date}: туров нет (статус: {Status})",
|
||||||
|
hotel.Name, dateKey, string.Join(",", status.Status?.Values.Distinct() ?? Array.Empty<string>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _store.SaveScanAsync(hotel.Id, dateKey, todayKey, offers, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<StoredOffer> FlattenOffers(int hotelId, string dateKey, RoomRatesResponse rates)
|
||||||
|
{
|
||||||
|
var offers = new List<StoredOffer>();
|
||||||
|
if (rates.Result is null)
|
||||||
|
return offers;
|
||||||
|
foreach (var roomRate in rates.Result)
|
||||||
|
{
|
||||||
|
foreach (var (meal, tourOffers) in roomRate.Offers)
|
||||||
|
{
|
||||||
|
foreach (var offer in tourOffers)
|
||||||
|
{
|
||||||
|
offers.Add(new StoredOffer(
|
||||||
|
hotelId,
|
||||||
|
dateKey,
|
||||||
|
meal,
|
||||||
|
roomRate.Room.Id,
|
||||||
|
roomRate.Room.NameRu,
|
||||||
|
offer.OperatorId,
|
||||||
|
offer.OperatorName,
|
||||||
|
offer.Price,
|
||||||
|
offer.PricePerNight,
|
||||||
|
offer.Id,
|
||||||
|
offer.Link));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return offers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<string>?> BuildReportAsync(string todayKey, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var windowStart = DateTime.Today.AddDays(_options.StartOffsetDays);
|
||||||
|
var windowEnd = windowStart.AddDays(_options.DateRangeDays);
|
||||||
|
|
||||||
|
var todayBests = (await _store.GetTodayBestsAsync(todayKey, ct))
|
||||||
|
.Where(best => DateTime.TryParse(best.DateFrom, out var date) && date >= windowStart && date < windowEnd)
|
||||||
|
.ToList();
|
||||||
|
if (todayBests.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var header = BuildHeader();
|
||||||
|
var lines = new List<ReportLine>();
|
||||||
|
|
||||||
|
foreach (var best in todayBests)
|
||||||
|
{
|
||||||
|
var offer = await _store.GetBestOfferAsync(best.HotelId, best.DateFrom, todayKey, ct);
|
||||||
|
var previous = await _store.GetBestBeforeAsync(best.HotelId, best.DateFrom, todayKey, ct);
|
||||||
|
var historical = await _store.GetHistoricalMinBeforeAsync(best.HotelId, best.DateFrom, todayKey, ct);
|
||||||
|
|
||||||
|
lines.Add(new ReportLine(
|
||||||
|
best.HotelId,
|
||||||
|
best.DateFrom,
|
||||||
|
best.BestPrice,
|
||||||
|
offer?.Meal ?? "",
|
||||||
|
offer?.RoomName ?? "",
|
||||||
|
offer?.OperatorName ?? "",
|
||||||
|
offer?.Link ?? "",
|
||||||
|
previous?.BestPrice,
|
||||||
|
historical?.BestPrice));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReportBuilder.BuildMessages(
|
||||||
|
header,
|
||||||
|
_options.PricesPerMonth,
|
||||||
|
_options.Hotels.Select(h => (h.Id, h.Name)).ToList(),
|
||||||
|
lines,
|
||||||
|
_options.CalendarUrlBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildHeader()
|
||||||
|
{
|
||||||
|
var nightsDesc = _options.Hotels.Count == 0
|
||||||
|
? ""
|
||||||
|
: _options.Hotels.Select(h => h.Nights).Distinct().Count() == 1
|
||||||
|
? $"{_options.Hotels[0].Nights} ночей"
|
||||||
|
: string.Join(", ", _options.Hotels.Select(h => $"{h.Name}: {h.Nights} ночей"));
|
||||||
|
|
||||||
|
var paramParts = new List<string>
|
||||||
|
{
|
||||||
|
nightsDesc,
|
||||||
|
$"{_options.Adults}+{_options.KidsAges.Count} чел",
|
||||||
|
};
|
||||||
|
if (_options.KidsAges.Count > 0)
|
||||||
|
paramParts.Add($"ребёнок {string.Join(",", _options.KidsAges)} лет");
|
||||||
|
|
||||||
|
var dates = Enumerable.Range(0, _options.DateRangeDays)
|
||||||
|
.Select(offset => DateTime.Today.AddDays(_options.StartOffsetDays).AddDays(offset).ToString("dd.MM"))
|
||||||
|
.ToList();
|
||||||
|
var range = dates.Count == 0 ? "" : $"{dates[0]}–{dates[^1]}";
|
||||||
|
|
||||||
|
return $"📊 <b>Мониторинг туров</b> · {string.Join(", ", paramParts)}\nЗаезды: {range}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
using TourMonitor.Camoufox;
|
||||||
|
|
||||||
|
namespace TourMonitor.LevelTravel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Транспорт через реальный браузер Camoufox: fetch выполняется из контекста страницы
|
||||||
|
/// level.travel, что даёт настоящий браузерный фингерпринт, куки и Origin.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CamoufoxApiTransport : IApiTransport
|
||||||
|
{
|
||||||
|
private const string FetchScript = """
|
||||||
|
async ({ method, url, headers, body }) => {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined || body === null ? undefined : body,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return { status: res.status, body: text };
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private readonly CamoufoxBrowserSession _session;
|
||||||
|
private readonly LevelTravelOptions _options;
|
||||||
|
private readonly ILogger<CamoufoxApiTransport> _logger;
|
||||||
|
|
||||||
|
public CamoufoxApiTransport(
|
||||||
|
CamoufoxBrowserSession session,
|
||||||
|
IOptions<LevelTravelOptions> options,
|
||||||
|
ILogger<CamoufoxApiTransport> logger)
|
||||||
|
{
|
||||||
|
_session = session;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiTransportResponse> SendAsync(HttpMethod method, string url, string? jsonBody, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var page = await _session.GetPageAsync(ct);
|
||||||
|
|
||||||
|
var headers = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["X-Cnt"] = _options.Country,
|
||||||
|
["X-Lang"] = _options.Language,
|
||||||
|
["X-Cur"] = _options.Currency,
|
||||||
|
["Accept-Language"] = $"{_options.Language}-{_options.Country.ToUpperInvariant()},{_options.Language};q=0.9",
|
||||||
|
["Accept"] = $"application/vnd.leveltravel.v{_options.ApiVersion}",
|
||||||
|
["Authorization"] = $"Token token=\"{_options.ApiKey}\"",
|
||||||
|
["Content-Type"] = "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await page.EvaluateAsync<ApiTransportResponse>(FetchScript, new
|
||||||
|
{
|
||||||
|
method = method.Method.ToUpperInvariant(),
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
body = jsonBody,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace TourMonitor.LevelTravel;
|
||||||
|
|
||||||
|
/// <summary>Сырой ответ API (статус + тело) до расшифровки/дешифровки.</summary>
|
||||||
|
public sealed class ApiTransportResponse
|
||||||
|
{
|
||||||
|
public int Status { get; set; }
|
||||||
|
public string Body { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Транспорт запросов к API. Единственная реализация — реальный браузер Camoufox
|
||||||
|
/// (CamoufoxApiTransport): fetch из контекста страницы level.travel обходит анти-бот периметр.
|
||||||
|
/// </summary>
|
||||||
|
public interface IApiTransport
|
||||||
|
{
|
||||||
|
Task<ApiTransportResponse> SendAsync(HttpMethod method, string url, string? jsonBody, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace TourMonitor.LevelTravel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Клиент публичного API Level.Travel (подпись запросов + расшифровка ответов воспроизведены с веб-клиента).
|
||||||
|
/// Транспорт (CamoufoxApiTransport) инжектируется отдельно.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LevelTravelClient
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly IApiTransport _transport;
|
||||||
|
private readonly LevelTravelOptions _options;
|
||||||
|
private readonly ILogger<LevelTravelClient> _logger;
|
||||||
|
|
||||||
|
public LevelTravelClient(IApiTransport transport, IOptions<LevelTravelOptions> options, ILogger<LevelTravelClient> logger)
|
||||||
|
{
|
||||||
|
_transport = transport;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MultiEnqueueResponse> MultiEnqueueAsync(IReadOnlyList<SearchParam> searchParams, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var body = new MultiEnqueueRequest { SearchParams = searchParams.ToList() };
|
||||||
|
return await PostAsync<MultiEnqueueResponse>("/search/multi_enqueue", body, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SearchStatus> GetStatusAsync(string requestId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["request_id"] = requestId,
|
||||||
|
["show_size"] = "true",
|
||||||
|
};
|
||||||
|
return await GetAsync<SearchStatus>("/search/status", parameters, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RoomRatesResponse> GetRoomRatesAsync(string hotelId, string requestId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var body = new RoomRatesRequest
|
||||||
|
{
|
||||||
|
HotelId = hotelId,
|
||||||
|
RequestId = requestId,
|
||||||
|
Filters = new RoomRatesFilters(),
|
||||||
|
};
|
||||||
|
return await PostAsync<RoomRatesResponse>("/hotel_search/room_rates", body, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ожидает завершения поиска: completeness = 100 или все операторы в терминальном статусе.</summary>
|
||||||
|
public async Task<SearchStatus> WaitForSearchAsync(string requestId, int pollIntervalSeconds, int timeoutSeconds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var startedAt = DateTime.UtcNow;
|
||||||
|
var timeout = TimeSpan.FromSeconds(timeoutSeconds);
|
||||||
|
var terminal = new HashSet<string> { "cached", "completed", "all_filtered", "no_results", "failed", "skipped" };
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var status = await GetStatusAsync(requestId, ct);
|
||||||
|
|
||||||
|
var statuses = status.Status?.Values.ToList() ?? new List<string>();
|
||||||
|
var allTerminal = statuses.Count > 0 && statuses.All(s => terminal.Contains(s));
|
||||||
|
if (status.Completeness >= 100 || allTerminal)
|
||||||
|
return status;
|
||||||
|
|
||||||
|
if (DateTime.UtcNow - startedAt > timeout)
|
||||||
|
throw new TimeoutException($"Поиск {requestId} не завершился за {timeoutSeconds} с (completeness={status.Completeness}).");
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(Math.Max(1, pollIntervalSeconds)), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> GetAsync<T>(string path, IDictionary<string, object?> parameters, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sign = SignHelper.ComputeGet(path, parameters, _options.ApiKey, _options.SignSalt, _options.ApiVersion);
|
||||||
|
|
||||||
|
var query = new List<string>();
|
||||||
|
foreach (var (key, value) in parameters)
|
||||||
|
query.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value?.ToString() ?? "")}");
|
||||||
|
query.Add($"key={_options.ApiKey}");
|
||||||
|
query.Add($"api_version={_options.ApiVersion}");
|
||||||
|
query.Add("js=true");
|
||||||
|
query.Add($"sign={sign}");
|
||||||
|
|
||||||
|
var url = $"{_options.BaseUrl}{path}?{string.Join("&", query)}";
|
||||||
|
return await ExecuteWithRetryAsync(() => SendAndDeserializeAsync<T>(HttpMethod.Get, url, null, ct), $"GET {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> PostAsync<T>(string path, object body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var sign = SignHelper.Compute(path, GetSignValues(body), _options.ApiKey, _options.SignSalt);
|
||||||
|
var payload = JsonSerializer.Serialize(body, JsonOptions);
|
||||||
|
// sign добавляется последним полем, как это делает веб-клиент
|
||||||
|
var payloadWithSign = payload[..^1] + $",\"sign\":\"{sign}\"}}";
|
||||||
|
var url = $"{_options.BaseUrl}{path}";
|
||||||
|
return await ExecuteWithRetryAsync(() => SendAndDeserializeAsync<T>(HttpMethod.Post, url, payloadWithSign, ct), $"POST {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IEnumerable<object?> GetSignValues(object body)
|
||||||
|
{
|
||||||
|
// значения берутся в порядке сериализации (snake_case), порядок для подписи не важен — она сортируется
|
||||||
|
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(body, JsonOptions));
|
||||||
|
return doc.RootElement.EnumerateObject().Select(p => GetValue(p.Value)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? GetValue(JsonElement el) => el.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => el.GetString(),
|
||||||
|
JsonValueKind.Number => el.GetDouble(),
|
||||||
|
JsonValueKind.True => true,
|
||||||
|
JsonValueKind.False => false,
|
||||||
|
JsonValueKind.Null => null,
|
||||||
|
JsonValueKind.Array => el.EnumerateArray().Select(GetValue).ToList(),
|
||||||
|
JsonValueKind.Object => el.EnumerateObject().Select(p => GetValue(p.Value)).ToList(),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<T> SendAndDeserializeAsync<T>(HttpMethod method, string url, string? payload, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var response = await _transport.SendAsync(method, url, payload, ct);
|
||||||
|
var body = response.Body;
|
||||||
|
|
||||||
|
if (response.Status is (int)HttpStatusCode.Forbidden or (int)HttpStatusCode.Unauthorized)
|
||||||
|
throw new LevelTravelException($"API отклонило запрос ({response.Status}): {Truncate(body)}");
|
||||||
|
if (response.Status is < 200 or >= 300)
|
||||||
|
throw new LevelTravelException($"API вернуло {response.Status}: {Truncate(body)}");
|
||||||
|
// decoy-ответы анти-бота: 200 с телом {"message":"partner not found"} вместо данных
|
||||||
|
if (body.StartsWith("{\"message\":\"partner not found\"", StringComparison.Ordinal))
|
||||||
|
throw new LevelTravelException($"API вернуло decoy: {Truncate(body)}");
|
||||||
|
|
||||||
|
return Deserialize<T>(LevelTravelCrypto.DecryptToJson(body, _options.SecretBoxKeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> action, string what)
|
||||||
|
{
|
||||||
|
var attempts = new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) };
|
||||||
|
Exception? last = null;
|
||||||
|
foreach (var delay in attempts)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await action();
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is LevelTravelException or HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
last = ex;
|
||||||
|
_logger.LogWarning("{What} не удалось ({Message}), повтор через {Delay}s", what, ex.Message, delay.TotalSeconds);
|
||||||
|
await Task.Delay(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new LevelTravelException($"{what} не выполнено после {attempts.Length} попыток.", last!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T Deserialize<T>(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<T>(json, JsonOptions)!;
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
throw new LevelTravelException($"Не удалось разобрать ответ: {Truncate(json, 300)}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string s, int length = 500) =>
|
||||||
|
s.Length <= length ? s : s[..length] + "...";
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Text;
|
||||||
|
using Org.BouncyCastle.Crypto.Engines;
|
||||||
|
using Org.BouncyCastle.Crypto.Macs;
|
||||||
|
using Org.BouncyCastle.Crypto.Parameters;
|
||||||
|
|
||||||
|
namespace TourMonitor.LevelTravel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Расшифровка ответов API Level.Travel.
|
||||||
|
/// Формат тела (если начинается с "!/"): base64url(
|
||||||
|
/// байт(23 XOR индекс ключа), nonce(24), NaCl secretbox(тег(16) + шифротекст) )
|
||||||
|
/// затем zlib-inflate и JSON.
|
||||||
|
/// Secretbox = XSalsa20-Poly1305: ключ Poly1305 = первые 32 байта keystream, тег MAC — в начале.
|
||||||
|
/// </summary>
|
||||||
|
public static class LevelTravelCrypto
|
||||||
|
{
|
||||||
|
private const string Prefix = "!/";
|
||||||
|
|
||||||
|
/// <summary>Декодирует ответ в строку JSON; если тело не зашифровано — возвращает как есть.</summary>
|
||||||
|
public static string DecryptToJson(string body, IReadOnlyList<string> secretBoxKeys)
|
||||||
|
{
|
||||||
|
if (!body.StartsWith(Prefix, StringComparison.Ordinal))
|
||||||
|
return body;
|
||||||
|
|
||||||
|
byte[] payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = Convert.FromBase64String(body.Substring(2).Replace('-', '+').Replace('_', '/'));
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
throw new LevelTravelException("Не удалось декодировать base64 в теле ответа.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.Length < 25)
|
||||||
|
throw new LevelTravelException("Тело ответа слишком короткое для расшифровки.");
|
||||||
|
|
||||||
|
int keyIndex = (payload[0] ^ 23) - '0';
|
||||||
|
if (keyIndex < 1 || keyIndex > secretBoxKeys.Count)
|
||||||
|
throw new LevelTravelException($"Неизвестный индекс ключа расшифровки: {keyIndex}.");
|
||||||
|
|
||||||
|
byte[] key;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
key = Convert.FromBase64String(secretBoxKeys[keyIndex - 1]);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
throw new LevelTravelException("Неверный формат ключа расшифровки в конфигурации.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonce = payload.AsSpan(1, 24).ToArray();
|
||||||
|
var ciphertext = payload.AsSpan(25).ToArray();
|
||||||
|
|
||||||
|
var plaintext = SecretBoxOpen(ciphertext, nonce, key)
|
||||||
|
?? throw new LevelTravelException("Не удалось расшифровать ответ (неверный MAC).");
|
||||||
|
|
||||||
|
using var zlib = new ZLibStream(new MemoryStream(plaintext), CompressionMode.Decompress);
|
||||||
|
using var reader = new StreamReader(zlib, Encoding.UTF8);
|
||||||
|
return reader.ReadToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[]? SecretBoxOpen(byte[] ciphertext, byte[] nonce, byte[] key)
|
||||||
|
{
|
||||||
|
var engine = new XSalsa20Engine();
|
||||||
|
engine.Init(true, new ParametersWithIV(new KeyParameter(key), nonce));
|
||||||
|
|
||||||
|
var streamLength = 32 + ciphertext.Length;
|
||||||
|
var stream = new byte[streamLength];
|
||||||
|
engine.ProcessBytes(new byte[streamLength], 0, streamLength, stream, 0);
|
||||||
|
|
||||||
|
var polyKey = stream.AsSpan(0, 32).ToArray();
|
||||||
|
var data = ciphertext.AsSpan(16).ToArray();
|
||||||
|
var receivedTag = ciphertext.AsSpan(0, 16).ToArray();
|
||||||
|
|
||||||
|
var mac = new Poly1305();
|
||||||
|
mac.Init(new KeyParameter(polyKey));
|
||||||
|
mac.BlockUpdate(data, 0, data.Length);
|
||||||
|
var tag = new byte[16];
|
||||||
|
mac.DoFinal(tag, 0);
|
||||||
|
|
||||||
|
if (!FixedTimeEquals(tag, receivedTag))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
for (var i = 0; i < data.Length; i++)
|
||||||
|
data[i] ^= stream[32 + i];
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool FixedTimeEquals(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b)
|
||||||
|
{
|
||||||
|
var diff = 0;
|
||||||
|
for (var i = 0; i < a.Length; i++)
|
||||||
|
diff |= a[i] ^ b[i];
|
||||||
|
return diff == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LevelTravelException : Exception
|
||||||
|
{
|
||||||
|
public LevelTravelException(string message) : base(message) { }
|
||||||
|
public LevelTravelException(string message, Exception inner) : base(message, inner) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace TourMonitor.LevelTravel;
|
||||||
|
|
||||||
|
public sealed class MultiEnqueueRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("search_params")]
|
||||||
|
public List<SearchParam> SearchParams { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SearchParam
|
||||||
|
{
|
||||||
|
[JsonPropertyName("hotel_ids")]
|
||||||
|
public int HotelIds { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("from_city")]
|
||||||
|
public string FromCity { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("from_country")]
|
||||||
|
public string FromCountry { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("to_city")]
|
||||||
|
public string ToCity { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("to_country")]
|
||||||
|
public string ToCountry { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("start_date")]
|
||||||
|
public string StartDate { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("nights")]
|
||||||
|
public string Nights { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("adults")]
|
||||||
|
public int Adults { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("kids")]
|
||||||
|
public int Kids { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("kids_ages")]
|
||||||
|
public int[] KidsAges { get; set; } = Array.Empty<int>();
|
||||||
|
|
||||||
|
[JsonPropertyName("flex_dates")]
|
||||||
|
public bool FlexDates { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("search_type")]
|
||||||
|
public string SearchType { get; set; } = "package";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MultiEnqueueResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("success")]
|
||||||
|
public bool Success { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("search_requests")]
|
||||||
|
public List<SearchRequest> SearchRequests { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SearchRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("request_id")]
|
||||||
|
public string RequestId { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SearchStatus
|
||||||
|
{
|
||||||
|
[JsonPropertyName("success")]
|
||||||
|
public bool Success { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public Dictionary<string, string>? Status { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("size")]
|
||||||
|
public long? Size { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("completeness")]
|
||||||
|
public int? Completeness { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("request_id")]
|
||||||
|
public string RequestId { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RoomRatesRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("hotel_id")]
|
||||||
|
public string HotelId { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("request_id")]
|
||||||
|
public string RequestId { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("filters")]
|
||||||
|
public RoomRatesFilters Filters { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RoomRatesFilters
|
||||||
|
{
|
||||||
|
[JsonPropertyName("meals")]
|
||||||
|
public List<string> Meals { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("free_cancel")]
|
||||||
|
public bool FreeCancel { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("confirmability")]
|
||||||
|
public object? Confirmability { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("payment_benefits")]
|
||||||
|
public List<object> PaymentBenefits { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RoomRatesResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("success")]
|
||||||
|
public bool Success { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("result")]
|
||||||
|
public List<RoomRate> Result { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RoomRate
|
||||||
|
{
|
||||||
|
[JsonPropertyName("ID")]
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("min_price")]
|
||||||
|
public int MinPrice { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("meal_types")]
|
||||||
|
public List<MealType> MealTypes { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("room")]
|
||||||
|
public RoomInfo Room { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("offers")]
|
||||||
|
public Dictionary<string, List<TourOffer>> Offers { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MealType
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("min_price")]
|
||||||
|
public int MinPrice { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RoomInfo
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("name_ru")]
|
||||||
|
public string NameRu { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TourOffer
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("nights_count")]
|
||||||
|
public int NightsCount { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("price")]
|
||||||
|
public int Price { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("price_per_night")]
|
||||||
|
public int PricePerNight { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("operator_id")]
|
||||||
|
public int OperatorId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("operator_name")]
|
||||||
|
public string OperatorName { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("start_date")]
|
||||||
|
public string StartDate { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("link")]
|
||||||
|
public string Link { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("extras")]
|
||||||
|
public OfferExtras? Extras { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OfferExtras
|
||||||
|
{
|
||||||
|
[JsonPropertyName("best_price")]
|
||||||
|
public bool BestPrice { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("instant_confirm")]
|
||||||
|
public bool InstantConfirm { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("cheap")]
|
||||||
|
public bool Cheap { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace TourMonitor.LevelTravel;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Воспроизводит алгоритм подписи запросов веб-клиента Level.Travel:
|
||||||
|
/// sign = md5( sorted(значения параметров, JSON без кавычек).join("") + последний_сегмент_пути + key + salt )
|
||||||
|
/// Значения рекурсивно разворачиваются (массивы/объекты — по значениям), пустые массивы/объекты пропускаются.
|
||||||
|
/// </summary>
|
||||||
|
public static class SignHelper
|
||||||
|
{
|
||||||
|
public static string ComputeGet(string path, IDictionary<string, object?> parameters, string key, string salt, string apiVersion)
|
||||||
|
{
|
||||||
|
var withMeta = new Dictionary<string, object?>(parameters)
|
||||||
|
{
|
||||||
|
["key"] = key,
|
||||||
|
["api_version"] = apiVersion,
|
||||||
|
["js"] = "true",
|
||||||
|
};
|
||||||
|
return Compute(path, withMeta.Values, key, salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Compute(string path, IEnumerable<object?> values, string key, string salt)
|
||||||
|
{
|
||||||
|
var flattened = new List<object?>();
|
||||||
|
Flatten(values, flattened);
|
||||||
|
|
||||||
|
var mapped = new List<string>();
|
||||||
|
foreach (var value in flattened)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(value);
|
||||||
|
if (json is "\"[]\"" or "\"{}\"" && value is not string)
|
||||||
|
json = "";
|
||||||
|
mapped.Add(json.Replace("\"", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
mapped.Sort(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var lastSegment = path.TrimEnd('/').Split('/').LastOrDefault() ?? "";
|
||||||
|
var source = string.Concat(mapped) + lastSegment + key + salt;
|
||||||
|
return Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(source)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Flatten(IEnumerable<object?> values, List<object?> output)
|
||||||
|
{
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
switch (value)
|
||||||
|
{
|
||||||
|
case string s:
|
||||||
|
output.Add(s.Trim());
|
||||||
|
break;
|
||||||
|
case System.Collections.IDictionary dict:
|
||||||
|
// как Object.values(obj) в JS: берём только значения
|
||||||
|
var dictItems = dict.Values.Cast<object?>().ToList();
|
||||||
|
if (dictItems.Count > 0)
|
||||||
|
Flatten(dictItems, output);
|
||||||
|
break;
|
||||||
|
case System.Collections.IEnumerable seq:
|
||||||
|
// как Object.values(array) в JS: пустой массив значений не даёт
|
||||||
|
var items = seq.Cast<object?>().ToList();
|
||||||
|
if (items.Count > 0)
|
||||||
|
Flatten(items, output);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
output.Add(value);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace TourMonitor.Notifications;
|
||||||
|
|
||||||
|
public sealed record ReportLine(
|
||||||
|
int HotelId,
|
||||||
|
string DateFrom,
|
||||||
|
int Price,
|
||||||
|
string Meal,
|
||||||
|
string RoomName,
|
||||||
|
string OperatorName,
|
||||||
|
string Link,
|
||||||
|
int? PreviousPrice,
|
||||||
|
int? HistoricalMin);
|
||||||
|
|
||||||
|
/// <summary>Собирает HTML-сообщение для Telegram: секция на каждый отель с топом дешёвых дат и дельтами.</summary>
|
||||||
|
public static class ReportBuilder
|
||||||
|
{
|
||||||
|
private static readonly CultureInfo Ru = CultureInfo.GetCultureInfo("ru-RU");
|
||||||
|
|
||||||
|
/// <summary>Собирает сообщение целиком (для обратной совместимости/тестов). Возвращает null, если нет данных ни по одному отелю.</summary>
|
||||||
|
public static string? Build(
|
||||||
|
string header,
|
||||||
|
int pricesPerMonth,
|
||||||
|
IReadOnlyList<(int HotelId, string Name)> hotels,
|
||||||
|
IReadOnlyList<ReportLine> lines)
|
||||||
|
{
|
||||||
|
var messages = BuildMessages(header, pricesPerMonth, hotels, lines);
|
||||||
|
return messages is null ? null : string.Join("\n\n", messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Собирает список сообщений для Telegram: первое — заголовок + первый отель,
|
||||||
|
/// каждое следующее — отдельный отель. Сообщения длиннее лимита Telegram (4096)
|
||||||
|
/// разбиваются на несколько. Возвращает null, если данных нет ни по одному отелю.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<string>? BuildMessages(
|
||||||
|
string header,
|
||||||
|
int pricesPerMonth,
|
||||||
|
IReadOnlyList<(int HotelId, string Name)> hotels,
|
||||||
|
IReadOnlyList<ReportLine> lines,
|
||||||
|
string? calendarUrlBase = null)
|
||||||
|
{
|
||||||
|
var messages = new List<string>();
|
||||||
|
var first = true;
|
||||||
|
foreach (var hotel in hotels)
|
||||||
|
{
|
||||||
|
var section = BuildHotelSection(
|
||||||
|
hotel.Name,
|
||||||
|
pricesPerMonth,
|
||||||
|
lines.Where(l => l.HotelId == hotel.HotelId),
|
||||||
|
calendarUrlBase is null ? null : $"{calendarUrlBase.TrimEnd('/')}/prices/{hotel.HotelId}");
|
||||||
|
if (section is null)
|
||||||
|
continue;
|
||||||
|
messages.Add(first ? header + "\n\n" + section : section);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var result = new List<string>();
|
||||||
|
foreach (var message in messages)
|
||||||
|
result.AddRange(SplitMessage(message));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Секция для одного отеля; null, если по отелю нет данных.
|
||||||
|
/// В каждом месяце диапазона показывается до <paramref name="pricesPerMonth"/> дешёвых дат.
|
||||||
|
/// </summary>
|
||||||
|
public static string? BuildHotelSection(
|
||||||
|
string hotelName,
|
||||||
|
int pricesPerMonth,
|
||||||
|
IEnumerable<ReportLine> lines,
|
||||||
|
string? calendarUrl = null)
|
||||||
|
{
|
||||||
|
var byMonth = lines
|
||||||
|
.Where(l => l.Price > 0)
|
||||||
|
.GroupBy(l => MonthKey(l.DateFrom))
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (byMonth.Count == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append("🏝 <b>").Append(HtmlEncode(hotelName)).Append("</b>");
|
||||||
|
|
||||||
|
if (calendarUrl is not null)
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append("🗓 <a href=\"").Append(HtmlEncode(calendarUrl)).Append("\">календарь цен</a>");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var month in byMonth)
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append("📅 <b>").Append(HtmlEncode(MonthName(month.Key))).Append("</b>");
|
||||||
|
|
||||||
|
foreach (var line in month.OrderBy(l => l.Price).ThenBy(l => l.DateFrom).Take(pricesPerMonth))
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(" 📅 ").Append(FormatDate(line.DateFrom)).Append(" — <b>")
|
||||||
|
.Append(FormatPrice(line.Price)).Append(" ₽</b>");
|
||||||
|
|
||||||
|
var delta = DescribeDelta(line);
|
||||||
|
if (delta is not null)
|
||||||
|
sb.Append(' ').Append(delta);
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append(" 🍽 ").Append(HtmlEncode(line.Meal)).Append(" · ");
|
||||||
|
if (string.IsNullOrEmpty(line.Link))
|
||||||
|
{
|
||||||
|
sb.Append(HtmlEncode(line.RoomName));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append("<a href=\"").Append(HtmlEncode(FullLink(line.Link))).Append("\">")
|
||||||
|
.Append(HtmlEncode(line.RoomName)).Append("</a>");
|
||||||
|
}
|
||||||
|
sb.Append(" · ").Append(HtmlEncode(line.OperatorName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ключ месяца (yyyyMM) для группировки; неизвестные даты — в отдельной группе в конце.</summary>
|
||||||
|
private static string MonthKey(string dateFrom)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParse(dateFrom, out var date))
|
||||||
|
return date.ToString("yyyyMM", Ru);
|
||||||
|
return "999999";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string MonthName(string monthKey)
|
||||||
|
{
|
||||||
|
if (monthKey.Length == 6 && int.TryParse(monthKey, out var yyyyMM))
|
||||||
|
{
|
||||||
|
var name = new DateTime(yyyyMM / 100, yyyyMM % 100, 1).ToString("MMMM", Ru);
|
||||||
|
return char.ToUpper(name[0]) + name[1..];
|
||||||
|
}
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Делит сообщение на части, не превышающие лимит Telegram (4096 симв.) — по границам
|
||||||
|
/// строк, а слишком длинную одиночную строку режет принудительно.
|
||||||
|
/// </summary>
|
||||||
|
public static IEnumerable<string> SplitMessage(string text, int maxLength = 4000)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
yield break;
|
||||||
|
if (text.Length <= maxLength)
|
||||||
|
{
|
||||||
|
yield return text;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = new StringBuilder();
|
||||||
|
foreach (var part in text.Split('\n'))
|
||||||
|
{
|
||||||
|
if (part.Length > maxLength)
|
||||||
|
{
|
||||||
|
if (current.Length > 0)
|
||||||
|
{
|
||||||
|
yield return current.ToString();
|
||||||
|
current.Clear();
|
||||||
|
}
|
||||||
|
for (var i = 0; i < part.Length; i += maxLength)
|
||||||
|
yield return part.Substring(i, Math.Min(maxLength, part.Length - i));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Length > 0 && current.Length + part.Length + 1 > maxLength)
|
||||||
|
{
|
||||||
|
yield return current.ToString();
|
||||||
|
current.Clear();
|
||||||
|
}
|
||||||
|
if (current.Length > 0)
|
||||||
|
current.Append('\n');
|
||||||
|
current.Append(part);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.Length > 0)
|
||||||
|
yield return current.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? DescribeDelta(ReportLine line)
|
||||||
|
{
|
||||||
|
if (line.PreviousPrice is null or <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var deltaPercent = (double)(line.Price - line.PreviousPrice.Value) / line.PreviousPrice.Value * 100;
|
||||||
|
var label = deltaPercent switch
|
||||||
|
{
|
||||||
|
< 0 => $"🔻 −{Math.Abs(deltaPercent):0}% (было {FormatPrice(line.PreviousPrice.Value)} ₽)",
|
||||||
|
> 0 => $"🔺 +{deltaPercent:0}% (было {FormatPrice(line.PreviousPrice.Value)} ₽)",
|
||||||
|
_ => "= (без изменений)",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (line.HistoricalMin is { } min && line.Price < min)
|
||||||
|
return $"✨ новая минималка · {label}";
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FullLink(string link) =>
|
||||||
|
string.IsNullOrEmpty(link) ? "https://level.travel/" : "https://level.travel" + link;
|
||||||
|
|
||||||
|
private static string FormatDate(string dateFrom) =>
|
||||||
|
DateTime.TryParse(dateFrom, out var date) ? date.ToString("dd.MM", Ru) : dateFrom;
|
||||||
|
|
||||||
|
private static string FormatPrice(int price) =>
|
||||||
|
price.ToString("N0", Ru);
|
||||||
|
|
||||||
|
private static string HtmlEncode(string value) =>
|
||||||
|
WebUtility.HtmlEncode(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace TourMonitor.Notifications;
|
||||||
|
|
||||||
|
/// <summary>Отправка сообщений в Telegram через Bot API (прямой HTTP, без сторонних пакетов).</summary>
|
||||||
|
public sealed class TelegramNotifier
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly TelegramOptions _options;
|
||||||
|
private readonly ILogger<TelegramNotifier> _logger;
|
||||||
|
|
||||||
|
public TelegramNotifier(HttpClient http, IOptions<TelegramOptions> options, ILogger<TelegramNotifier> logger)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConfigured =>
|
||||||
|
!string.IsNullOrWhiteSpace(_options.BotToken) && !string.IsNullOrWhiteSpace(_options.ChatId);
|
||||||
|
|
||||||
|
/// <summary>Отправляет несколько сообщений по очереди; возвращает число отправленных.</summary>
|
||||||
|
public async Task<int> SendMessagesAsync(IReadOnlyList<string> messages, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!IsConfigured)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Telegram не настроен (BotToken/ChatId) — сообщения не отправлены.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sent = 0;
|
||||||
|
foreach (var text in messages)
|
||||||
|
{
|
||||||
|
if (await SendMessageAsync(text, ct))
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SendMessageAsync(string text, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!IsConfigured)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Telegram не настроен (BotToken/ChatId) — сообщение не отправлено.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["chat_id"] = _options.ChatId!,
|
||||||
|
["text"] = text,
|
||||||
|
["parse_mode"] = "HTML",
|
||||||
|
["disable_web_page_preview"] = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < 2; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"https://api.telegram.org/bot{_options.BotToken}/sendMessage";
|
||||||
|
using var response = await _http.PostAsJsonAsync(url, payload, ct);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
_logger.LogWarning("Telegram вернул {(StatusCode)}: {Body}", (int)response.StatusCode, body.Length > 300 ? body[..300] : body);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Telegram недоступен ({Message})", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(3), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
namespace TourMonitor;
|
||||||
|
|
||||||
|
public sealed class LevelTravelOptions
|
||||||
|
{
|
||||||
|
public string BaseUrl { get; set; } = "https://api.level.travel";
|
||||||
|
public string ApiKey { get; set; } = "0fe9fb2ff35679322db5429b18a53aee";
|
||||||
|
public string SignSalt { get; set; } = "2qqRS1f8TyuF";
|
||||||
|
public string ApiVersion { get; set; } = "3.14";
|
||||||
|
public string Language { get; set; } = "ru";
|
||||||
|
public string Country { get; set; } = "ru";
|
||||||
|
public string Currency { get; set; } = "RUB";
|
||||||
|
public List<string> SecretBoxKeys { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HotelOptions
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
/// <summary>Направление поиска отеля.</summary>
|
||||||
|
public string ToCity { get; set; } = "";
|
||||||
|
/// <summary>Страна направления.</summary>
|
||||||
|
public string ToCountry { get; set; } = "";
|
||||||
|
/// <summary>Количество ночей для отеля.</summary>
|
||||||
|
public int Nights { get; set; } = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MonitorOptions
|
||||||
|
{
|
||||||
|
public List<HotelOptions> Hotels { get; set; } = new();
|
||||||
|
public string DepartureCity { get; set; } = "Moscow";
|
||||||
|
public string DepartureCountry { get; set; } = "RU";
|
||||||
|
public int Adults { get; set; } = 2;
|
||||||
|
public List<int> KidsAges { get; set; } = new();
|
||||||
|
public int DateRangeDays { get; set; } = 90;
|
||||||
|
/// <summary>Окно скана начинается не с сегодняшнего дня, а с отступа в днях (минимум).</summary>
|
||||||
|
public int StartOffsetDays { get; set; } = 60;
|
||||||
|
/// <summary>Сколько дешёвых дат показывать в отчёте для каждого месяца диапазона.</summary>
|
||||||
|
public int PricesPerMonth { get; set; } = 3;
|
||||||
|
public bool RunOnStart { get; set; }
|
||||||
|
public int MaxParallelDates { get; set; } = 5;
|
||||||
|
public int PollIntervalSeconds { get; set; } = 4;
|
||||||
|
public int SearchTimeoutSeconds { get; set; } = 180;
|
||||||
|
public string DbPath { get; set; } = "Data/tour_monitor.db";
|
||||||
|
/// <summary>Публичный базовый URL (для ссылки на календарь цен в отчёте). Пусто — ссылка не добавляется.</summary>
|
||||||
|
public string CalendarUrlBase { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TelegramOptions
|
||||||
|
{
|
||||||
|
public string BotToken { get; set; } = "";
|
||||||
|
public string ChatId { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ScheduleOptions
|
||||||
|
{
|
||||||
|
public string Cron { get; set; } = "0 30 7 * * ?";
|
||||||
|
public string TimeZoneId { get; set; } = "Europe/Moscow";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Настройки анти-детект браузера Camoufox (патченный Firefox + Playwright).</summary>
|
||||||
|
public sealed class CamoufoxOptions
|
||||||
|
{
|
||||||
|
/// <summary>Тег релиза daijro/camoufox.</summary>
|
||||||
|
public string Version { get; set; } = "v152.0.4-beta.28";
|
||||||
|
/// <summary>Каталог установки (может быть относительным к корню приложения).</summary>
|
||||||
|
public string InstallDirectory { get; set; } = "Data/camoufox";
|
||||||
|
public string? DownloadUrlOverride { get; set; }
|
||||||
|
public bool Headless { get; set; } = true;
|
||||||
|
/// <summary>Страница для установки браузерной сессии и кук.</summary>
|
||||||
|
public string SessionUrl { get; set; } = "https://level.travel/";
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using Hangfire;
|
||||||
|
using Hangfire.Dashboard;
|
||||||
|
using Hangfire.InMemory;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TourMonitor;
|
||||||
|
using TourMonitor.Camoufox;
|
||||||
|
using TourMonitor.Jobs;
|
||||||
|
using TourMonitor.LevelTravel;
|
||||||
|
using TourMonitor.Notifications;
|
||||||
|
using TourMonitor.Storage;
|
||||||
|
using TourMonitor.Web;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.Configure<LevelTravelOptions>(builder.Configuration.GetSection("LevelTravel"));
|
||||||
|
builder.Services.Configure<MonitorOptions>(builder.Configuration.GetSection("Monitor"));
|
||||||
|
builder.Services.Configure<TelegramOptions>(builder.Configuration.GetSection("Telegram"));
|
||||||
|
builder.Services.Configure<ScheduleOptions>(builder.Configuration.GetSection("Schedule"));
|
||||||
|
builder.Services.Configure<CamoufoxOptions>(builder.Configuration.GetSection("Camoufox"));
|
||||||
|
|
||||||
|
builder.Services.AddHangfire(config => config
|
||||||
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||||
|
.UseSimpleAssemblyNameTypeSerializer()
|
||||||
|
.UseRecommendedSerializerSettings()
|
||||||
|
.UseInMemoryStorage());
|
||||||
|
builder.Services.AddHangfireServer();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<ICamoufoxReleaseResolver, CamoufoxReleaseResolver>();
|
||||||
|
builder.Services.AddSingleton<ICamoufoxInstaller, CamoufoxInstaller>();
|
||||||
|
builder.Services.AddSingleton<CamoufoxBrowserSession>();
|
||||||
|
builder.Services.AddSingleton<IApiTransport, CamoufoxApiTransport>();
|
||||||
|
builder.Services.AddSingleton<LevelTravelClient>();
|
||||||
|
builder.Services.AddHttpClient<TelegramNotifier>(client => client.Timeout = TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
|
builder.Services.AddSingleton(sp =>
|
||||||
|
{
|
||||||
|
var monitor = sp.GetRequiredService<IOptions<MonitorOptions>>().Value;
|
||||||
|
var contentRoot = sp.GetRequiredService<IHostEnvironment>().ContentRootPath;
|
||||||
|
var dbPath = Path.IsPathRooted(monitor.DbPath)
|
||||||
|
? monitor.DbPath
|
||||||
|
: Path.Combine(contentRoot, monitor.DbPath);
|
||||||
|
return new PriceStore(dbPath, sp.GetService<ILogger<PriceStore>>());
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<DailyScanJob>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||||
|
{
|
||||||
|
// По умолчанию Hangfire отдаёт дашборд только с localhost (401 с других хостов);
|
||||||
|
// мониторинг должен быть доступен по LAN, поэтому разрешаем все источники.
|
||||||
|
Authorization = new[] { new AllowAllDashboardAuthorizationFilter() },
|
||||||
|
});
|
||||||
|
|
||||||
|
var schedule = app.Services.GetRequiredService<IOptions<ScheduleOptions>>().Value;
|
||||||
|
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.TimeZoneId);
|
||||||
|
RecurringJob.AddOrUpdate<DailyScanJob>(
|
||||||
|
"daily-scan",
|
||||||
|
job => job.ExecuteAsync(CancellationToken.None),
|
||||||
|
schedule.Cron,
|
||||||
|
new RecurringJobOptions { TimeZone = timeZone });
|
||||||
|
|
||||||
|
var monitor = app.Services.GetRequiredService<IOptions<MonitorOptions>>().Value;
|
||||||
|
if (monitor.RunOnStart)
|
||||||
|
BackgroundJob.Enqueue<DailyScanJob>(job => job.ExecuteAsync(CancellationToken.None));
|
||||||
|
|
||||||
|
var calendarBaseUrl = monitor.CalendarUrlBase.TrimEnd('/');
|
||||||
|
app.MapGet("/prices", (PriceStore store) => Results.Content(
|
||||||
|
CalendarPage.Index("Мониторинг туров", monitor.Hotels.Select(h => (h.Id, h.Name)).ToList(), calendarBaseUrl),
|
||||||
|
"text/html; charset=utf-8"));
|
||||||
|
app.MapGet("/prices/{hotelId:int}", async (int hotelId, PriceStore store, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var hotel = monitor.Hotels.FirstOrDefault(h => h.Id == hotelId);
|
||||||
|
if (hotel is null)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
|
var entries = (await store.GetCalendarAsync(ct))
|
||||||
|
.Where(e => e.HotelId == hotelId)
|
||||||
|
.Select(e => (e.DateFrom, e.BestPrice, e.PreviousPrice, e.Link, e.MinPrice, e.MaxPrice))
|
||||||
|
.ToList();
|
||||||
|
var windowStart = DateTime.Today.AddDays(monitor.StartOffsetDays);
|
||||||
|
var html = CalendarPage.Hotel(hotel.Name, hotelId, entries, windowStart, monitor.DateRangeDays, calendarBaseUrl);
|
||||||
|
return Results.Content(html, "text/html; charset=utf-8");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.Run();
|
||||||
|
|
||||||
|
/// <summary>Разрешает доступ к Hangfire-дашборду с любого хоста (по умолчанию — только localhost).</summary>
|
||||||
|
internal sealed class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter
|
||||||
|
{
|
||||||
|
public bool Authorize(DashboardContext context) => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class Program;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"applicationUrl": "http://localhost:5199",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace TourMonitor.Storage;
|
||||||
|
|
||||||
|
public sealed record StoredOffer(
|
||||||
|
int HotelId,
|
||||||
|
string DateFrom,
|
||||||
|
string Meal,
|
||||||
|
int? RoomId,
|
||||||
|
string? RoomName,
|
||||||
|
int OperatorId,
|
||||||
|
string OperatorName,
|
||||||
|
int Price,
|
||||||
|
int PricePerNight,
|
||||||
|
string OfferId,
|
||||||
|
string Link);
|
||||||
|
|
||||||
|
public sealed record DailyBest(int HotelId, string DateFrom, string Date, int BestPrice, string OfferId);
|
||||||
|
|
||||||
|
/// <summary>Минимальная известная цена по (отель, дата заезда) — строка календаря цен.</summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Данные календаря по конкретной дате тура: цена из последнего сканирования (BestPrice),
|
||||||
|
/// цена предыдущего скана (PreviousPrice, null если его не было), ссылка на бронирование
|
||||||
|
/// лучшего оффера (Link) и диапазон минимальной цены по датам сканирования (MinPrice–MaxPrice).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CalendarEntry(int HotelId, string DateFrom, int BestPrice, int? PreviousPrice, string Link, int MinPrice, int MaxPrice);
|
||||||
|
|
||||||
|
/// <summary>SQLite-хранилище истории цен и ежедневных минимумов по (отель, дата заезда).</summary>
|
||||||
|
public sealed class PriceStore
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
private readonly ILogger<PriceStore> _logger;
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
|
public PriceStore(string dbPath, ILogger<PriceStore>? logger = null)
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(dbPath);
|
||||||
|
if (!string.IsNullOrEmpty(directory))
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
_connectionString = new SqliteConnectionStringBuilder { DataSource = dbPath, Cache = SqliteCacheMode.Shared }.ToString();
|
||||||
|
_logger = logger ?? NullLogger.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnsureCreatedAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await ExecuteAsync(async connection =>
|
||||||
|
{
|
||||||
|
await using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
CREATE TABLE IF NOT EXISTS price_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hotel_id INTEGER NOT NULL,
|
||||||
|
date_from TEXT NOT NULL,
|
||||||
|
checked_at TEXT NOT NULL,
|
||||||
|
meal TEXT NOT NULL DEFAULT '',
|
||||||
|
room_id INTEGER,
|
||||||
|
room_name TEXT,
|
||||||
|
operator_id INTEGER NOT NULL,
|
||||||
|
operator_name TEXT NOT NULL DEFAULT '',
|
||||||
|
price INTEGER NOT NULL,
|
||||||
|
price_per_night INTEGER,
|
||||||
|
offer_id TEXT NOT NULL DEFAULT '',
|
||||||
|
link TEXT NOT NULL DEFAULT '',
|
||||||
|
is_best INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_history ON price_history (hotel_id, date_from, checked_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_best (
|
||||||
|
hotel_id INTEGER NOT NULL,
|
||||||
|
date_from TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
best_price INTEGER NOT NULL,
|
||||||
|
offer_id TEXT NOT NULL DEFAULT '',
|
||||||
|
PRIMARY KEY (hotel_id, date_from, date)
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Сохраняет офферы скана; лучший по цене помечается и пишется в daily_best.</summary>
|
||||||
|
public async Task SaveScanAsync(int hotelId, string dateFrom, string date, IReadOnlyList<StoredOffer> offers, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var checkedAt = DateTime.UtcNow.ToString("O");
|
||||||
|
var best = offers.MinBy(o => o.Price);
|
||||||
|
|
||||||
|
await ExecuteAsync(async connection =>
|
||||||
|
{
|
||||||
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||||
|
var insert = connection.CreateCommand();
|
||||||
|
insert.CommandText = """
|
||||||
|
INSERT INTO price_history
|
||||||
|
(hotel_id, date_from, checked_at, meal, room_id, room_name, operator_id, operator_name,
|
||||||
|
price, price_per_night, offer_id, link, is_best)
|
||||||
|
VALUES
|
||||||
|
($hotel_id, $date_from, $checked_at, $meal, $room_id, $room_name, $operator_id, $operator_name,
|
||||||
|
$price, $price_per_night, $offer_id, $link, $is_best)
|
||||||
|
""";
|
||||||
|
insert.Parameters.Add("$hotel_id", SqliteType.Integer);
|
||||||
|
insert.Parameters.Add("$date_from", SqliteType.Text);
|
||||||
|
insert.Parameters.Add("$checked_at", SqliteType.Text);
|
||||||
|
insert.Parameters.Add("$meal", SqliteType.Text);
|
||||||
|
insert.Parameters.Add("$room_id", SqliteType.Integer);
|
||||||
|
insert.Parameters.Add("$room_name", SqliteType.Text);
|
||||||
|
insert.Parameters.Add("$operator_id", SqliteType.Integer);
|
||||||
|
insert.Parameters.Add("$operator_name", SqliteType.Text);
|
||||||
|
insert.Parameters.Add("$price", SqliteType.Integer);
|
||||||
|
insert.Parameters.Add("$price_per_night", SqliteType.Integer);
|
||||||
|
insert.Parameters.Add("$offer_id", SqliteType.Text);
|
||||||
|
insert.Parameters.Add("$link", SqliteType.Text);
|
||||||
|
insert.Parameters.Add("$is_best", SqliteType.Integer);
|
||||||
|
|
||||||
|
foreach (var offer in offers)
|
||||||
|
{
|
||||||
|
insert.Parameters["$hotel_id"].Value = offer.HotelId;
|
||||||
|
insert.Parameters["$date_from"].Value = offer.DateFrom;
|
||||||
|
insert.Parameters["$checked_at"].Value = checkedAt;
|
||||||
|
insert.Parameters["$meal"].Value = offer.Meal;
|
||||||
|
insert.Parameters["$room_id"].Value = (object?)offer.RoomId ?? DBNull.Value;
|
||||||
|
insert.Parameters["$room_name"].Value = (object?)offer.RoomName ?? DBNull.Value;
|
||||||
|
insert.Parameters["$operator_id"].Value = offer.OperatorId;
|
||||||
|
insert.Parameters["$operator_name"].Value = offer.OperatorName;
|
||||||
|
insert.Parameters["$price"].Value = offer.Price;
|
||||||
|
insert.Parameters["$price_per_night"].Value = (object?)offer.PricePerNight ?? DBNull.Value;
|
||||||
|
insert.Parameters["$offer_id"].Value = offer.OfferId;
|
||||||
|
insert.Parameters["$link"].Value = offer.Link;
|
||||||
|
insert.Parameters["$is_best"].Value = offer == best ? 1 : 0;
|
||||||
|
await insert.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best is not null)
|
||||||
|
{
|
||||||
|
var upsert = connection.CreateCommand();
|
||||||
|
upsert.CommandText = """
|
||||||
|
INSERT INTO daily_best (hotel_id, date_from, date, best_price, offer_id)
|
||||||
|
VALUES ($hotel_id, $date_from, $date, $price, $offer_id)
|
||||||
|
ON CONFLICT (hotel_id, date_from, date) DO UPDATE SET best_price = excluded.best_price, offer_id = excluded.offer_id
|
||||||
|
""";
|
||||||
|
upsert.Parameters.AddWithValue("$hotel_id", hotelId);
|
||||||
|
upsert.Parameters.AddWithValue("$date_from", dateFrom);
|
||||||
|
upsert.Parameters.AddWithValue("$date", date);
|
||||||
|
upsert.Parameters.AddWithValue("$price", best.Price);
|
||||||
|
upsert.Parameters.AddWithValue("$offer_id", best.OfferId);
|
||||||
|
await upsert.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(ct);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Лучшая цена за дату проверки, предшествующую <paramref name="beforeDate"/> (ближайшая).</summary>
|
||||||
|
public async Task<DailyBest?> GetBestBeforeAsync(int hotelId, string dateFrom, string beforeDate, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
DailyBest? result = null;
|
||||||
|
await ExecuteAsync(async connection =>
|
||||||
|
{
|
||||||
|
await using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT hotel_id, date_from, date, best_price, offer_id
|
||||||
|
FROM daily_best
|
||||||
|
WHERE hotel_id = $hotel_id AND date_from = $date_from AND date < $before
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$hotel_id", hotelId);
|
||||||
|
cmd.Parameters.AddWithValue("$date_from", dateFrom);
|
||||||
|
cmd.Parameters.AddWithValue("$before", beforeDate);
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
if (await reader.ReadAsync(ct))
|
||||||
|
result = ReadDailyBest(reader);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Исторический минимум до даты проверки (для определения «новой минималки»).</summary>
|
||||||
|
public async Task<DailyBest?> GetHistoricalMinBeforeAsync(int hotelId, string dateFrom, string beforeDate, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
DailyBest? result = null;
|
||||||
|
await ExecuteAsync(async connection =>
|
||||||
|
{
|
||||||
|
await using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT hotel_id, date_from, date, best_price, offer_id
|
||||||
|
FROM daily_best
|
||||||
|
WHERE hotel_id = $hotel_id AND date_from = $date_from AND date < $before
|
||||||
|
ORDER BY best_price ASC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$hotel_id", hotelId);
|
||||||
|
cmd.Parameters.AddWithValue("$date_from", dateFrom);
|
||||||
|
cmd.Parameters.AddWithValue("$before", beforeDate);
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
if (await reader.ReadAsync(ct))
|
||||||
|
result = ReadDailyBest(reader);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Лучшие предложения за сегодня по всем (отель, дата заезда).</summary>
|
||||||
|
public async Task<List<DailyBest>> GetTodayBestsAsync(string date, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var result = new List<DailyBest>();
|
||||||
|
await ExecuteAsync(async connection =>
|
||||||
|
{
|
||||||
|
await using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT hotel_id, date_from, date, best_price, offer_id FROM daily_best WHERE date = $date";
|
||||||
|
cmd.Parameters.AddWithValue("$date", date);
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
result.Add(ReadDailyBest(reader));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Данные календаря по каждой дате тура: цена из последнего сканирования, цена предыдущего
|
||||||
|
/// скана для дельты, ссылка на бронирование лучшего оффера и диапазон минимальной цены
|
||||||
|
/// по всем датам сканирования (как «прыгала» цена самого дешёвого номера).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<CalendarEntry>> GetCalendarAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var result = new List<CalendarEntry>();
|
||||||
|
await ExecuteAsync(async connection =>
|
||||||
|
{
|
||||||
|
await using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT db.hotel_id, db.date_from, db.best_price,
|
||||||
|
(SELECT p.best_price FROM daily_best p
|
||||||
|
WHERE p.hotel_id = db.hotel_id AND p.date_from = db.date_from
|
||||||
|
AND p.date < db.date
|
||||||
|
ORDER BY p.date DESC LIMIT 1) AS previous_price,
|
||||||
|
(SELECT ph.link FROM price_history ph
|
||||||
|
WHERE ph.hotel_id = db.hotel_id AND ph.date_from = db.date_from
|
||||||
|
AND ph.checked_at = (SELECT MAX(checked_at) FROM price_history
|
||||||
|
WHERE hotel_id = db.hotel_id AND date_from = db.date_from)
|
||||||
|
ORDER BY ph.price ASC LIMIT 1) AS link,
|
||||||
|
(SELECT MIN(h.best_price) FROM daily_best h
|
||||||
|
WHERE h.hotel_id = db.hotel_id AND h.date_from = db.date_from) AS min_price,
|
||||||
|
(SELECT MAX(h2.best_price) FROM daily_best h2
|
||||||
|
WHERE h2.hotel_id = db.hotel_id AND h2.date_from = db.date_from) AS max_price
|
||||||
|
FROM daily_best db
|
||||||
|
JOIN (
|
||||||
|
SELECT hotel_id, date_from, MAX(date) AS latest
|
||||||
|
FROM daily_best
|
||||||
|
GROUP BY hotel_id, date_from
|
||||||
|
) m ON m.hotel_id = db.hotel_id AND m.date_from = db.date_from AND m.latest = db.date
|
||||||
|
""";
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
var previous = reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3);
|
||||||
|
var link = reader.IsDBNull(4) ? "" : reader.GetString(4);
|
||||||
|
var min = reader.IsDBNull(5) ? 0 : reader.GetInt32(5);
|
||||||
|
var max = reader.IsDBNull(6) ? 0 : reader.GetInt32(6);
|
||||||
|
result.Add(new CalendarEntry(reader.GetInt32(0), reader.GetString(1), reader.GetInt32(2), previous, link, min, max));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DailyBest ReadDailyBest(SqliteDataReader reader) =>
|
||||||
|
new(
|
||||||
|
reader.GetInt32(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetInt32(3),
|
||||||
|
reader.GetString(4));
|
||||||
|
|
||||||
|
/// <summary>Последний лучший оффер для (отель, дата заезда, дата проверки) — детали для отчёта.</summary>
|
||||||
|
public async Task<StoredOffer?> GetBestOfferAsync(int hotelId, string dateFrom, string date, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
StoredOffer? result = null;
|
||||||
|
await ExecuteAsync(async connection =>
|
||||||
|
{
|
||||||
|
await using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT hotel_id, date_from, meal, room_id, room_name, operator_id, operator_name,
|
||||||
|
price, price_per_night, offer_id, link
|
||||||
|
FROM price_history
|
||||||
|
WHERE hotel_id = $hotel_id AND date_from = $date_from AND checked_at = (
|
||||||
|
SELECT MAX(checked_at) FROM price_history
|
||||||
|
WHERE hotel_id = $hotel_id AND date_from = $date_from
|
||||||
|
)
|
||||||
|
ORDER BY price ASC
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("$hotel_id", hotelId);
|
||||||
|
cmd.Parameters.AddWithValue("$date_from", dateFrom);
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||||
|
if (await reader.ReadAsync(ct))
|
||||||
|
result = ReadOffer(reader);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StoredOffer ReadOffer(SqliteDataReader reader) =>
|
||||||
|
new(
|
||||||
|
reader.GetInt32(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.IsDBNull(3) ? null : reader.GetInt32(3),
|
||||||
|
reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||||
|
reader.GetInt32(5),
|
||||||
|
reader.GetString(6),
|
||||||
|
reader.GetInt32(7),
|
||||||
|
reader.IsDBNull(8) ? 0 : reader.GetInt32(8),
|
||||||
|
reader.GetString(9),
|
||||||
|
reader.GetString(10));
|
||||||
|
|
||||||
|
private async Task ExecuteAsync(Func<SqliteConnection, Task> action)
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection(_connectionString);
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await action(connection);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class NullLogger : ILogger<PriceStore>, IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NullLogger Instance = new();
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => this;
|
||||||
|
public bool IsEnabled(LogLevel logLevel) => false;
|
||||||
|
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="TourMonitor.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" />
|
||||||
|
<PackageReference Include="Hangfire" Version="1.8.24" />
|
||||||
|
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.Playwright" Version="1.54.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace TourMonitor.Web;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Генерирует простые HTML-страницы «календаря цен»: сетка месяцев, в каждой ячейке —
|
||||||
|
/// цена из последнего сканирования на эту дату заезда (ссылка на бронирование лучшего
|
||||||
|
/// оффера) или пусто, если цены нет, и стрелка с изменением против предыдущего скана.
|
||||||
|
/// </summary>
|
||||||
|
public static class CalendarPage
|
||||||
|
{
|
||||||
|
private static readonly CultureInfo Ru = CultureInfo.GetCultureInfo("ru-RU");
|
||||||
|
|
||||||
|
public static string Index(string title, IReadOnlyList<(int HotelId, string Name)> hotels, string baseUrl)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append(HtmlOpen(title));
|
||||||
|
sb.Append("<h1>📊 ").Append(HtmlEncode(title)).Append("</h1>");
|
||||||
|
sb.Append("<p>Выберите отель, чтобы посмотреть цены по датам заезда:</p><div class=\"cards\">");
|
||||||
|
foreach (var hotel in hotels)
|
||||||
|
{
|
||||||
|
var url = $"{baseUrl}/prices/{hotel.HotelId}";
|
||||||
|
sb.Append("<a class=\"card\" href=\"").Append(HtmlEncode(url)).Append("\">")
|
||||||
|
.Append("<h2>🏝 ").Append(HtmlEncode(hotel.Name)).Append("</h2>")
|
||||||
|
.Append("<p>открыть календарь →</p></a>");
|
||||||
|
}
|
||||||
|
sb.Append("</div>");
|
||||||
|
sb.Append(HtmlClose());
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Hotel(
|
||||||
|
string hotelName,
|
||||||
|
int hotelId,
|
||||||
|
IReadOnlyList<(string DateFrom, int Price, int? Previous, string Link, int Min, int Max)> entries,
|
||||||
|
DateTime windowStart,
|
||||||
|
int windowDays,
|
||||||
|
string baseUrl)
|
||||||
|
{
|
||||||
|
var byDate = new Dictionary<DateOnly, (int Price, int? Prev, string Link, int Min, int Max)>();
|
||||||
|
foreach (var (dateFrom, price, prev, link, min, max) in entries)
|
||||||
|
if (DateOnly.TryParse(dateFrom, out var d))
|
||||||
|
byDate[d] = (price, prev, link, min, max);
|
||||||
|
|
||||||
|
var months = new SortedDictionary<string, List<DateOnly>>();
|
||||||
|
for (var i = 0; i < windowDays; i++)
|
||||||
|
{
|
||||||
|
var day = DateOnly.FromDateTime(windowStart.AddDays(i));
|
||||||
|
var key = day.ToString("yyyy-MM", Ru);
|
||||||
|
if (!months.TryGetValue(key, out var list))
|
||||||
|
months[key] = list = new List<DateOnly>();
|
||||||
|
list.Add(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append(HtmlOpen(hotelName));
|
||||||
|
sb.Append("<a class=\"back\" href=\"").Append(HtmlEncode($"{baseUrl}/prices")).Append("\">← все отели</a>");
|
||||||
|
sb.Append("<h1>🏝 ").Append(HtmlEncode(hotelName)).Append("</h1>");
|
||||||
|
sb.Append("<p class=\"hint\">Цена из последнего сканирования · ")
|
||||||
|
.Append(windowStart.ToString("dd.MM.yyyy", Ru)).Append(" — ")
|
||||||
|
.Append(windowStart.AddDays(windowDays - 1).ToString("dd.MM.yyyy", Ru))
|
||||||
|
.Append(" · под ценой — диапазон минимальной цены по датам сканирования</p>");
|
||||||
|
|
||||||
|
foreach (var (monthKey, days) in months)
|
||||||
|
{
|
||||||
|
var first = days[0];
|
||||||
|
var monthTitle = first.ToString("MMMM yyyy", Ru);
|
||||||
|
sb.Append("<h2>").Append(HtmlEncode(char.ToUpper(monthTitle[0]) + monthTitle[1..])).Append("</h2>");
|
||||||
|
sb.Append("<div class=\"calendar\">");
|
||||||
|
foreach (var weekday in new[] { "Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс" })
|
||||||
|
sb.Append("<div class=\"dow\">").Append(weekday).Append("</div>");
|
||||||
|
|
||||||
|
var leadDays = ((int)first.DayOfWeek + 6) % 7; // Пн = 0
|
||||||
|
for (var i = 0; i < leadDays; i++)
|
||||||
|
sb.Append("<div class=\"cell empty\"></div>");
|
||||||
|
|
||||||
|
foreach (var day in days)
|
||||||
|
{
|
||||||
|
var weekend = day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
|
||||||
|
var priceInfo = byDate.GetValueOrDefault(day);
|
||||||
|
if (priceInfo.Price > 0)
|
||||||
|
{
|
||||||
|
sb.Append("<div class=\"cell price").Append(weekend ? " weekend" : "").Append("\">")
|
||||||
|
.Append("<span class=\"d\">").Append(day.Day).Append("</span>");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(priceInfo.Link))
|
||||||
|
{
|
||||||
|
sb.Append("<span class=\"p\">").Append(priceInfo.Price.ToString("N0", Ru)).Append("</span>");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append("<a class=\"p book\" href=\"").Append(HtmlEncode(FullLink(priceInfo.Link))).Append("\">")
|
||||||
|
.Append(priceInfo.Price.ToString("N0", Ru)).Append("</a>");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(RangeHtml(priceInfo.Min, priceInfo.Max))
|
||||||
|
.Append(DeltaHtml(priceInfo.Price, priceInfo.Prev))
|
||||||
|
.Append("</div>");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append("<div class=\"cell nodata").Append(weekend ? " weekend" : "").Append("\">")
|
||||||
|
.Append("<span class=\"d\">").Append(day.Day).Append("</span>")
|
||||||
|
.Append("<span class=\"p\">—</span></div>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.Append("</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(HtmlClose());
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Диапазон минимальной цены на дату тура по датам сканирования; пусто, если данных нет.</summary>
|
||||||
|
private static string RangeHtml(int min, int max)
|
||||||
|
{
|
||||||
|
if (min <= 0 || max < min)
|
||||||
|
return "";
|
||||||
|
return $"<span class=\"range\">{min.ToString("N0", Ru)}–{max.ToString("N0", Ru)}</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Стрелка изменения цены против предыдущего скана; пусто, если предыдущего скана не было.</summary>
|
||||||
|
private static string DeltaHtml(int price, int? previous)
|
||||||
|
{
|
||||||
|
if (previous is null or <= 0)
|
||||||
|
return "";
|
||||||
|
var diff = price - previous.Value;
|
||||||
|
if (diff == 0)
|
||||||
|
return "<span class=\"chg same\">=</span>";
|
||||||
|
var pct = Math.Round((double)diff / previous.Value * 100);
|
||||||
|
var arrow = diff < 0 ? "↓" : "↑";
|
||||||
|
return $"<span class=\"chg {(diff < 0 ? "down" : "up")}\">{arrow} {Math.Abs(pct)}%</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FullLink(string link) =>
|
||||||
|
string.IsNullOrEmpty(link) ? "https://level.travel/" : "https://level.travel" + link;
|
||||||
|
|
||||||
|
private static string HtmlOpen(string title) =>
|
||||||
|
"<!DOCTYPE html><html lang=\"ru\"><head><meta charset=\"utf-8\">" +
|
||||||
|
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
|
||||||
|
"<title>" + HtmlEncode(title) + "</title><style>" +
|
||||||
|
"body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;padding:24px;}" +
|
||||||
|
"h1{font-size:22px;margin:0 0 8px;}h2{font-size:16px;color:#94a3b8;margin:28px 0 8px;text-transform:capitalize;}" +
|
||||||
|
".back{color:#38bdf8;text-decoration:none;font-size:13px;}.hint{color:#94a3b8;font-size:13px;margin:0 0 8px;}" +
|
||||||
|
".cards{display:flex;gap:16px;flex-wrap:wrap;}.card{display:block;background:#1e293b;border:1px solid #334155;border-radius:12px;padding:20px;color:#e2e8f0;text-decoration:none;width:280px;}" +
|
||||||
|
".card h2{margin:0 0 6px;color:#e2e8f0;}.card p{color:#94a3b8;margin:0;font-size:13px;}" +
|
||||||
|
".calendar{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;max-width:840px;}" +
|
||||||
|
".dow{text-align:center;font-size:11px;color:#64748b;padding:4px 0;}" +
|
||||||
|
".cell{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:6px;text-align:center;min-height:52px;display:flex;flex-direction:column;justify-content:space-between;}" +
|
||||||
|
".cell .d{font-size:12px;color:#94a3b8;}.cell .p{font-size:12px;font-weight:600;color:#4ade80;}" +
|
||||||
|
".cell .p.book{text-decoration:none;}" +
|
||||||
|
".cell .range{font-size:10px;color:#94a3b8;}" +
|
||||||
|
".cell .chg{font-size:10px;font-weight:500;line-height:1.2;}.cell .chg.down{color:#4ade80;}.cell .chg.up{color:#fb923c;}.cell .chg.same{color:#64748b;}" +
|
||||||
|
".cell.nodata .p{color:#475569;font-weight:400;}" +
|
||||||
|
".cell.weekend{border-color:#3b82f6;}" +
|
||||||
|
".cell.empty{background:transparent;border-color:transparent;}" +
|
||||||
|
"</style></head><body>";
|
||||||
|
private static string HtmlClose() => "</body></html>";
|
||||||
|
|
||||||
|
private static string HtmlEncode(string value) => WebUtility.HtmlEncode(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"LevelTravel": {
|
||||||
|
"BaseUrl": "https://api.level.travel",
|
||||||
|
"ApiKey": "0fe9fb2ff35679322db5429b18a53aee",
|
||||||
|
"SignSalt": "2qqRS1f8TyuF",
|
||||||
|
"ApiVersion": "3.14",
|
||||||
|
"Language": "ru",
|
||||||
|
"Country": "ru",
|
||||||
|
"Currency": "RUB",
|
||||||
|
"SecretBoxKeys": [
|
||||||
|
"B1stAQVPJzJ015iJU8A1DP+i+d/5AbgIZavVPYKInrQ=",
|
||||||
|
"6+uSlOycGhU6LmgIz1sXsnK6VuZHpPtVaiRbgF7WCg0=",
|
||||||
|
"fve2pJpiBkJ5raA6EQCk2A21NDsFoP102jbDljzKDyc=",
|
||||||
|
"1IsckNgz0hwlqpVT0ZsLvjAdd3VhANj9+770p+aKuns=",
|
||||||
|
"DZllQsSt/82U/LdrlmoT+KNR578sK0y8E5709wjPPJQ=",
|
||||||
|
"XRoiUKs7d0dXVjcvvhsYUZ6Oo/ekYXZR9p+21aT6DOI=",
|
||||||
|
"3GV0SKSSEpNHxkwyU3VrCLgYJWrodLZJIZfpvJ3MVQY="
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Monitor": {
|
||||||
|
"Hotels": [
|
||||||
|
{ "Id": 9099454, "Name": "Sunrise Tucana Resort", "ToCity": "Makadi Bay", "ToCountry": "EG", "Nights": 9 },
|
||||||
|
{ "Id": 9151153, "Name": "Posh Club By Sunrise Tucana Resort", "ToCity": "Makadi Bay", "ToCountry": "EG", "Nights": 9 },
|
||||||
|
{ "Id": 9067553, "Name": "Riu Sri Lanka", "ToCity": "Sri Lanka", "ToCountry": "LK", "Nights": 9 }
|
||||||
|
],
|
||||||
|
"DepartureCity": "Moscow",
|
||||||
|
"DepartureCountry": "RU",
|
||||||
|
"Adults": 2,
|
||||||
|
"KidsAges": [ 5 ],
|
||||||
|
"DateRangeDays": 150,
|
||||||
|
"StartOffsetDays": 30,
|
||||||
|
"PricesPerMonth": 3,
|
||||||
|
"RunOnStart": false,
|
||||||
|
"MaxParallelDates": 5,
|
||||||
|
"PollIntervalSeconds": 4,
|
||||||
|
"SearchTimeoutSeconds": 180,
|
||||||
|
"DbPath": "Data/tour_monitor.db",
|
||||||
|
"CalendarUrlBase": "http://192.168.1.6:8084"
|
||||||
|
},
|
||||||
|
"Camoufox": {
|
||||||
|
"Version": "v152.0.4-beta.28",
|
||||||
|
"InstallDirectory": "Data/camoufox",
|
||||||
|
"DownloadUrlOverride": "",
|
||||||
|
"Headless": true,
|
||||||
|
"SessionUrl": "https://level.travel/"
|
||||||
|
},
|
||||||
|
"Telegram": {
|
||||||
|
"BotToken": "8706279287:AAH7A7mYB5sKbT0prEsoBMT29YdhI193RnQ",
|
||||||
|
"ChatId": "-1004444891243"
|
||||||
|
},
|
||||||
|
"Schedule": {
|
||||||
|
"Cron": "0 30 7 * * ?",
|
||||||
|
"TimeZoneId": "Europe/Moscow"
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using TourMonitor.Web;
|
||||||
|
|
||||||
|
namespace TourMonitor.Tests;
|
||||||
|
|
||||||
|
public class CalendarPageTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Hotel_RendersMonthsWithPricesAndEmptyCells()
|
||||||
|
{
|
||||||
|
var start = new DateTime(2026, 10, 5);
|
||||||
|
var html = CalendarPage.Hotel(
|
||||||
|
"Sunrise Tucana Resort",
|
||||||
|
9099454,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
("2026-10-05", 475_447, (int?)null, "/package_details/offer-1", 410_000, 550_000),
|
||||||
|
("2026-10-06", 460_000, (int?)null, "/package_details/offer-2", 460_000, 460_000),
|
||||||
|
},
|
||||||
|
start,
|
||||||
|
windowDays: 3,
|
||||||
|
baseUrl: "http://192.168.1.6:8084");
|
||||||
|
|
||||||
|
Assert.Contains("Sunrise Tucana Resort", html);
|
||||||
|
Assert.Contains("Октябрь", html);
|
||||||
|
Assert.Contains("475\u00A0447", html);
|
||||||
|
Assert.Contains("460\u00A0000", html);
|
||||||
|
Assert.Contains("<div class=\"cell nodata\">", html);
|
||||||
|
Assert.Contains("Цена из последнего сканирования", html);
|
||||||
|
Assert.Contains("<span class=\"range\">410\u00A0000–550\u00A0000</span>", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hotel_ShowsDeltaArrows_WithPercent()
|
||||||
|
{
|
||||||
|
var html = CalendarPage.Hotel(
|
||||||
|
"Sunrise Tucana Resort",
|
||||||
|
9099454,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
("2026-10-05", 475_447, (int?)540_000, "", 0, 0), // дешевле
|
||||||
|
("2026-10-06", 460_000, (int?)450_000, "", 0, 0), // дороже
|
||||||
|
("2026-10-07", 500_000, (int?)500_000, "", 0, 0), // без изменений
|
||||||
|
},
|
||||||
|
new DateTime(2026, 10, 5),
|
||||||
|
windowDays: 3,
|
||||||
|
baseUrl: "");
|
||||||
|
|
||||||
|
Assert.Contains("<span class=\"chg down\">↓ 12%</span>", html);
|
||||||
|
Assert.Contains("<span class=\"chg up\">↑ 2%</span>", html);
|
||||||
|
Assert.Contains("<span class=\"chg same\">=</span>", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hotel_NoArrow_WhenNoPreviousScan()
|
||||||
|
{
|
||||||
|
var html = CalendarPage.Hotel(
|
||||||
|
"Sunrise Tucana Resort",
|
||||||
|
9099454,
|
||||||
|
new[] { ("2026-10-05", 475_447, (int?)null, "", 0, 0) },
|
||||||
|
new DateTime(2026, 10, 5),
|
||||||
|
windowDays: 1,
|
||||||
|
baseUrl: "");
|
||||||
|
|
||||||
|
Assert.DoesNotContain("<span class=\"chg", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hotel_PriceLinksToBooking_WhenLinkPresent()
|
||||||
|
{
|
||||||
|
var html = CalendarPage.Hotel(
|
||||||
|
"Sunrise Tucana Resort",
|
||||||
|
9099454,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
("2026-10-05", 475_447, (int?)null, "/package_details/offer-1?hotel_id=9099454", 0, 0),
|
||||||
|
("2026-10-06", 460_000, (int?)null, "", 0, 0),
|
||||||
|
},
|
||||||
|
new DateTime(2026, 10, 5),
|
||||||
|
windowDays: 2,
|
||||||
|
baseUrl: "");
|
||||||
|
|
||||||
|
Assert.Contains("<a class=\"p book\" href=\"https://level.travel/package_details/offer-1?hotel_id=9099454\">475\u00A0447</a>", html);
|
||||||
|
Assert.Contains("<span class=\"p\">460\u00A0000</span>", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hotel_ShowsCheapestRoomRange_InCellsAndHint()
|
||||||
|
{
|
||||||
|
var html = CalendarPage.Hotel(
|
||||||
|
"Sunrise Tucana Resort",
|
||||||
|
9099454,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
("2026-10-05", 475_447, (int?)null, "/package_details/offer-1", 290_494, 574_875),
|
||||||
|
("2026-10-06", 460_000, (int?)null, "", 100_000, 130_000),
|
||||||
|
},
|
||||||
|
new DateTime(2026, 10, 5),
|
||||||
|
windowDays: 2,
|
||||||
|
baseUrl: "");
|
||||||
|
|
||||||
|
Assert.Contains("<span class=\"range\">290\u00A0494–574\u00A0875</span>", html);
|
||||||
|
Assert.Contains("<span class=\"range\">100\u00A0000–130\u00A0000</span>", html);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Index_ListsHotelsWithLinks()
|
||||||
|
{
|
||||||
|
var html = CalendarPage.Index(
|
||||||
|
"Мониторинг туров",
|
||||||
|
new[] { (9099454, "Sunrise Tucana Resort"), (9151153, "Posh Club By Sunrise Tucana Resort") },
|
||||||
|
"http://192.168.1.6:8084");
|
||||||
|
|
||||||
|
Assert.Contains("Sunrise Tucana Resort", html);
|
||||||
|
Assert.Contains("Posh Club By Sunrise Tucana Resort", html);
|
||||||
|
Assert.Contains("/prices/9099454", html);
|
||||||
|
Assert.Contains("/prices/9151153", html);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using TourMonitor.LevelTravel;
|
||||||
|
|
||||||
|
namespace TourMonitor.Tests;
|
||||||
|
|
||||||
|
public class CryptoTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Реальный зашифрованный ответ API (captured: /tmp/lt_probe/cap_search/069-resp.json).
|
||||||
|
/// Проверяет secretbox-дешифровку (XSalsa20-Poly1305) и zlib-inflate.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecryptToJson_Decrypts_CapturedEnqueueResponse()
|
||||||
|
{
|
||||||
|
var fixture = File.ReadAllText(Path.Combine("Fixtures", "enqueue_encrypted.txt"));
|
||||||
|
|
||||||
|
var json = LevelTravelCrypto.DecryptToJson(fixture, TestData.SecretBoxKeys);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
|
||||||
|
var requestId = doc.RootElement.GetProperty("request_id").GetString();
|
||||||
|
Assert.Equal("MjEzfDIyNXwxMTgxMjN8MTA1Nnx8MjAyNi0wOC0yMCwyMDI2LTA4LTIwfDB8OSw5fDJ8MHx8fDB8MHx8fDY=", requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Незашифрованные тела (v3.7 без секрета) возвращаются как есть.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecryptToJson_PassesThrough_PlainBody()
|
||||||
|
{
|
||||||
|
const string plain = "{\"success\":true}";
|
||||||
|
|
||||||
|
var result = LevelTravelCrypto.DecryptToJson(plain, TestData.SecretBoxKeys);
|
||||||
|
|
||||||
|
Assert.Equal(plain, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Мусор после префикса "!/" должен давать осмысленную ошибку, а не исключение-дамп.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecryptToJson_Throws_LevelTravelException_OnGarbage()
|
||||||
|
{
|
||||||
|
Assert.Throws<LevelTravelException>(() => LevelTravelCrypto.DecryptToJson("!/not-base64!!", TestData.SecretBoxKeys));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Неизвестный индекс ключа (не 1..7) отклоняется.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void DecryptToJson_Throws_OnUnknownKeyIndex()
|
||||||
|
{
|
||||||
|
// первый байт выбирает индекс: (payload[0] ^ 23) - '0' должен быть вне 1..7
|
||||||
|
var body = "!/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
|
||||||
|
var ex = Assert.Throws<LevelTravelException>(() => LevelTravelCrypto.DecryptToJson(body, TestData.SecretBoxKeys));
|
||||||
|
Assert.Contains("индекс ключа", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
!/JVgjbNqIjUXS695mQj1bxLs2kSyLaK-F6ezZ89YKmB8gT6utv3_tGUpgpJm34UJEKVB7vGSAvQRAcWrNOGV9sDAU0ixFwTm0JfZKlPy5DXDYW0my_CFhx-4_MRmT_6Q_B6QsWlX4xA9w0wXJ8iqiEOSSygHOaErwTYFsaIFTnBENFzEZivIiCGSgSU2uSTmKMEFHchOrArrkd_YeXp0G63Nb8WGlZ7ccrDEioq8HhOsngiclTzt4ZQZJU4nauinzu3tUlOU860b17r9Fq7ewIlnZa0iTTWIWD8BPJjOtKTU6jH7lUcq1r1sNct6WbD98oDHCob1gb2UdTc_YvCu9MVcRZYkQlgHrc64KlVHo8ldImajKCIrNVDOE_Q-A7ecqAE8YpSRSlgYtq9duV2kU7C97AMySPO-QyvPqwhWoBawbIHfXWoqgjDxakRxt1RTwA1k5p2rxAK9L11qbT77eF06JV2_aI25Vi6Mwo_Fn3ML5rPlgGqijcXo1EOWiqTlNfxjUemE4kqpBpdy4OX4VC6EQrTzlTqdrMZrERpHLtZDfuZFt7-x9NhW8dW1P-pTC-BuR0Vdk2IseoA3Mio_Lu_FE8_oMjHwiBnArgrFh9PX6Ppewdma9ND_AYrTB5Td1tH1nFGfWJUnMegsdmvYe38btVD1j7hJzTnw-TMA8QvO_hms-byzLcTrZpib84JZ4xgNt65soPYkmhZdGq2vR6RbA0PtqCfRHoN54jI8ZfjV88WtSY-J7WdxpgGWn68fIaoIUdptuW5GT90Y6YOo9kr3DmsoKz8Dk-3vER7VfNhChDf32qBFCdHKUErBJbFoKOHK23yKjP38OI29BN8FMM8fXbNP1h_MbwAdyvuvRkUCjuCLYmVyGlu0OpxsOsF--zLoinmHFIgOw73zYD3pxVHev5hOQDfEnaPw-SvUBGFZ2DFZhjztgBECUdKBkGPY0nuGGsokmXiSR71O2Gk4fP3b3tAsVhrhmVXX40HdnU0dWAlW7ng6x9s7YPe3iOgL4ybpBcZwqaydivYOIMM9dxkFxVO4TInBwFUkkorgGytbv_Qz0KQIxcb4TDBQL1eBFH6TXJX4BcxbJ_cmsaXjtrzegw_o77JC5qyYUgU0KIWAEqYt6ZRJRjXir7faqk4chLnc=
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TourMonitor.Camoufox;
|
||||||
|
using TourMonitor.Jobs;
|
||||||
|
using TourMonitor.LevelTravel;
|
||||||
|
using TourMonitor.Notifications;
|
||||||
|
using TourMonitor.Storage;
|
||||||
|
|
||||||
|
namespace TourMonitor.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Живые проверки публичного API Level.Travel через браузер Camoufox (единственный транспорт).
|
||||||
|
/// Включаются только явно:
|
||||||
|
/// LT_LIVE_TESTS=1 dotnet test
|
||||||
|
/// Без переменной окружения тесты «проходят» без выполнения запросов.
|
||||||
|
/// </summary>
|
||||||
|
public class LevelTravelIntegrationTests
|
||||||
|
{
|
||||||
|
private const string SunriseTucana = "9099454";
|
||||||
|
private const string PoshClub = "9151153";
|
||||||
|
|
||||||
|
private static bool LiveEnabled => Environment.GetEnvironmentVariable("LT_LIVE_TESTS") == "1";
|
||||||
|
|
||||||
|
private static LevelTravelOptions BuildOptions() =>
|
||||||
|
new() { SecretBoxKeys = TestData.SecretBoxKeys.ToList() };
|
||||||
|
|
||||||
|
private static async Task<CamoufoxBrowserSession> BuildSessionAsync()
|
||||||
|
{
|
||||||
|
var installDir = Path.Combine(Path.GetTempPath(), $"camoufox_test_{Guid.NewGuid():N}");
|
||||||
|
var session = new CamoufoxBrowserSession(
|
||||||
|
new CamoufoxInstaller(new CamoufoxReleaseResolver(), NullLogger<CamoufoxInstaller>.Instance),
|
||||||
|
Options.Create(new CamoufoxOptions { InstallDirectory = installDir, Headless = true }),
|
||||||
|
NullLogger<CamoufoxBrowserSession>.Instance);
|
||||||
|
await session.GetPageAsync();
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LevelTravelClient BuildClient(CamoufoxBrowserSession session)
|
||||||
|
{
|
||||||
|
var transport = new CamoufoxApiTransport(session, Options.Create(BuildOptions()), NullLogger<CamoufoxApiTransport>.Instance);
|
||||||
|
return new LevelTravelClient(transport, Options.Create(BuildOptions()), NullLogger<LevelTravelClient>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SearchParam> BuildParams(string date)
|
||||||
|
{
|
||||||
|
var hotels = new[] { SunriseTucana, PoshClub };
|
||||||
|
return hotels.Select(h => new SearchParam
|
||||||
|
{
|
||||||
|
HotelIds = int.Parse(h),
|
||||||
|
FromCity = "Moscow",
|
||||||
|
FromCountry = "RU",
|
||||||
|
ToCity = "Makadi Bay",
|
||||||
|
ToCountry = "EG",
|
||||||
|
StartDate = date,
|
||||||
|
Nights = "9",
|
||||||
|
Adults = 2,
|
||||||
|
Kids = 1,
|
||||||
|
KidsAges = new[] { 5 },
|
||||||
|
FlexDates = false,
|
||||||
|
SearchType = "package",
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Live_Enqueue_Status_RoomRates_Flow()
|
||||||
|
{
|
||||||
|
if (!LiveEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var session = await BuildSessionAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = BuildClient(session);
|
||||||
|
var date = DateTime.Today.AddDays(3).ToString("yyyy-MM-dd");
|
||||||
|
|
||||||
|
var enqueue = await client.MultiEnqueueAsync(BuildParams(date));
|
||||||
|
Assert.True(enqueue.Success);
|
||||||
|
Assert.Equal(2, enqueue.SearchRequests.Count);
|
||||||
|
|
||||||
|
var hotelIds = new[] { SunriseTucana, PoshClub };
|
||||||
|
var totalOffers = 0;
|
||||||
|
for (var i = 0; i < enqueue.SearchRequests.Count; i++)
|
||||||
|
{
|
||||||
|
var status = await client.WaitForSearchAsync(enqueue.SearchRequests[i].RequestId, 4, 240);
|
||||||
|
Assert.True(status.Completeness >= 100 || status.Status?.Count > 0);
|
||||||
|
|
||||||
|
var rates = await client.GetRoomRatesAsync(hotelIds[i], enqueue.SearchRequests[i].RequestId);
|
||||||
|
if (rates.Success)
|
||||||
|
totalOffers += rates.Result.Sum(r => r.Offers.Values.Sum(o => o.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(totalOffers > 0, "API вернуло пустой результат по обоим отелям.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await session.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Live_DailyScan_Persists_Offers()
|
||||||
|
{
|
||||||
|
if (!LiveEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var dbPath = Path.Combine(Path.GetTempPath(), $"tour_monitor_scan_{Guid.NewGuid():N}.db");
|
||||||
|
var session = await BuildSessionAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store = new PriceStore(dbPath);
|
||||||
|
await store.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var monitor = new MonitorOptions
|
||||||
|
{
|
||||||
|
Hotels =
|
||||||
|
{
|
||||||
|
new() { Id = int.Parse(SunriseTucana), Name = "Sunrise Tucana Resort", ToCity = "Makadi Bay", ToCountry = "EG", Nights = 9 },
|
||||||
|
new() { Id = int.Parse(PoshClub), Name = "Posh Club By Sunrise Tucana Resort", ToCity = "Makadi Bay", ToCountry = "EG", Nights = 9 },
|
||||||
|
},
|
||||||
|
DateRangeDays = 1,
|
||||||
|
KidsAges = { 5 },
|
||||||
|
MaxParallelDates = 2,
|
||||||
|
SearchTimeoutSeconds = 240,
|
||||||
|
};
|
||||||
|
|
||||||
|
var job = new DailyScanJob(
|
||||||
|
BuildClient(session),
|
||||||
|
store,
|
||||||
|
new TelegramNotifier(new HttpClient(), Options.Create(new TelegramOptions()), NullLogger<TelegramNotifier>.Instance),
|
||||||
|
session,
|
||||||
|
Options.Create(monitor),
|
||||||
|
NullLogger<DailyScanJob>.Instance);
|
||||||
|
|
||||||
|
await job.ExecuteAsync();
|
||||||
|
|
||||||
|
var bests = await store.GetTodayBestsAsync(DateTime.Today.ToString("yyyy-MM-dd"));
|
||||||
|
Assert.NotEmpty(bests);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await session.CloseAsync();
|
||||||
|
if (File.Exists(dbPath))
|
||||||
|
File.Delete(dbPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using TourMonitor.Storage;
|
||||||
|
|
||||||
|
namespace TourMonitor.Tests;
|
||||||
|
|
||||||
|
public class PriceStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"tour_monitor_test_{Guid.NewGuid():N}.db");
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (File.Exists(_dbPath))
|
||||||
|
File.Delete(_dbPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StoredOffer Offer(int price, string room = "Полулюкс", string operatorName = "Level.Travel", string? link = null, string dateFrom = "2026-08-20") =>
|
||||||
|
new(9099454, dateFrom, "AI", 12, room, 77, operatorName, price, price / 9, "offer-1", link ?? $"/package_details/offer-1?hotel_id=9099454");
|
||||||
|
|
||||||
|
private PriceStore NewStore() => new(_dbPath);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveScan_WritesHistory_AndDailyBest()
|
||||||
|
{
|
||||||
|
var store = NewStore();
|
||||||
|
await store.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var offers = new List<StoredOffer> { Offer(500_000), Offer(475_447) };
|
||||||
|
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", offers);
|
||||||
|
|
||||||
|
var bests = await store.GetTodayBestsAsync("2026-08-06");
|
||||||
|
var best = Assert.Single(bests);
|
||||||
|
Assert.Equal(475_447, best.BestPrice);
|
||||||
|
Assert.Equal("offer-1", best.OfferId);
|
||||||
|
|
||||||
|
var offer = await store.GetBestOfferAsync(9099454, "2026-08-20", "2026-08-06");
|
||||||
|
Assert.NotNull(offer);
|
||||||
|
Assert.Equal(475_447, offer!.Price);
|
||||||
|
Assert.Equal("Полулюкс", offer.RoomName);
|
||||||
|
Assert.Equal("AI", offer.Meal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveScan_EmptyOffers_DoesNotCreateDailyBest()
|
||||||
|
{
|
||||||
|
var store = NewStore();
|
||||||
|
await store.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", new List<StoredOffer>());
|
||||||
|
|
||||||
|
Assert.Empty(await store.GetTodayBestsAsync("2026-08-06"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SecondScan_UpsertsDailyBest_AndKeepsHistory()
|
||||||
|
{
|
||||||
|
var store = NewStore();
|
||||||
|
await store.EnsureCreatedAsync();
|
||||||
|
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", new List<StoredOffer> { Offer(500_000) });
|
||||||
|
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-07", new List<StoredOffer> { Offer(450_000, "Люкс") });
|
||||||
|
|
||||||
|
var todayBests = await store.GetTodayBestsAsync("2026-08-07");
|
||||||
|
var best = Assert.Single(todayBests);
|
||||||
|
Assert.Equal(450_000, best.BestPrice);
|
||||||
|
|
||||||
|
var previous = await store.GetBestBeforeAsync(9099454, "2026-08-20", "2026-08-07");
|
||||||
|
Assert.NotNull(previous);
|
||||||
|
Assert.Equal(500_000, previous!.BestPrice);
|
||||||
|
Assert.Equal("2026-08-06", previous.Date);
|
||||||
|
|
||||||
|
var historical = await store.GetHistoricalMinBeforeAsync(9099454, "2026-08-20", "2026-08-07");
|
||||||
|
Assert.Equal(500_000, historical!.BestPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HistoricalMin_TakesLowestOfAllPreviousDays()
|
||||||
|
{
|
||||||
|
var store = NewStore();
|
||||||
|
await store.EnsureCreatedAsync();
|
||||||
|
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-04", new List<StoredOffer> { Offer(500_000) });
|
||||||
|
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-05", new List<StoredOffer> { Offer(470_000) });
|
||||||
|
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", new List<StoredOffer> { Offer(490_000) });
|
||||||
|
|
||||||
|
var historical = await store.GetHistoricalMinBeforeAsync(9099454, "2026-08-20", "2026-08-07");
|
||||||
|
|
||||||
|
Assert.Equal(470_000, historical!.BestPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCalendar_ReturnsLastScanPrice_AndPreviousForDelta()
|
||||||
|
{
|
||||||
|
var store = NewStore();
|
||||||
|
await store.EnsureCreatedAsync();
|
||||||
|
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-05", new List<StoredOffer> { Offer(500_000) });
|
||||||
|
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-06", new List<StoredOffer> { Offer(440_000) });
|
||||||
|
await store.SaveScanAsync(9099454, "2026-10-06", "2026-08-06", new List<StoredOffer> { Offer(470_000) });
|
||||||
|
|
||||||
|
var entries = await store.GetCalendarAsync();
|
||||||
|
|
||||||
|
var first = entries.Single(e => e.DateFrom == "2026-10-05");
|
||||||
|
Assert.Equal(440_000, first.BestPrice); // цена из последнего скана, а не накопленный минимум
|
||||||
|
Assert.Equal(500_000, first.PreviousPrice);
|
||||||
|
|
||||||
|
var second = entries.Single(e => e.DateFrom == "2026-10-06");
|
||||||
|
Assert.Equal(470_000, second.BestPrice);
|
||||||
|
Assert.Null(second.PreviousPrice); // первого скана не было — дельты нет
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCalendar_Range_IsMinMaxOfBestPrice_OverScanDates()
|
||||||
|
{
|
||||||
|
var store = NewStore();
|
||||||
|
await store.EnsureCreatedAsync();
|
||||||
|
// одна и та же дата тура, три разные даты сканирования
|
||||||
|
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-05", new List<StoredOffer> { Offer(100_000, dateFrom: "2026-10-05") });
|
||||||
|
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-06", new List<StoredOffer> { Offer(130_000, dateFrom: "2026-10-05") });
|
||||||
|
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-07", new List<StoredOffer> { Offer(120_000, dateFrom: "2026-10-05") });
|
||||||
|
|
||||||
|
var entry = (await store.GetCalendarAsync()).Single(e => e.DateFrom == "2026-10-05");
|
||||||
|
|
||||||
|
Assert.Equal(120_000, entry.BestPrice); // из последнего скана
|
||||||
|
Assert.Equal(100_000, entry.MinPrice); // мин по датам сканирования
|
||||||
|
Assert.Equal(130_000, entry.MaxPrice); // макс по датам сканирования
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCalendar_ReturnsBookingLink_ForBestOfferOfLastScan()
|
||||||
|
{
|
||||||
|
var store = NewStore();
|
||||||
|
await store.EnsureCreatedAsync();
|
||||||
|
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-05",
|
||||||
|
new List<StoredOffer>
|
||||||
|
{
|
||||||
|
Offer(500_000, "Стандарт", "ANEX Tour", link: "/package_details/offer-old", dateFrom: "2026-10-05"),
|
||||||
|
Offer(475_000, "Делюкс", "Pegas", link: "/package_details/offer-best", dateFrom: "2026-10-05"),
|
||||||
|
});
|
||||||
|
await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-06",
|
||||||
|
new List<StoredOffer>
|
||||||
|
{
|
||||||
|
Offer(440_000, "Люкс", "Level.Travel", link: "/package_details/offer-latest", dateFrom: "2026-10-05"),
|
||||||
|
Offer(620_000, "Стандарт", "Pegas", link: "/package_details/offer-expensive", dateFrom: "2026-10-05"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var entry = (await store.GetCalendarAsync()).Single(e => e.DateFrom == "2026-10-05");
|
||||||
|
|
||||||
|
Assert.Equal(440_000, entry.BestPrice);
|
||||||
|
Assert.Equal("/package_details/offer-latest", entry.Link); // ссылка из последнего скана, а не старого
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetBestOffer_ReturnsCheapestOfLatestScan()
|
||||||
|
{
|
||||||
|
var store = NewStore();
|
||||||
|
await store.EnsureCreatedAsync();
|
||||||
|
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", new List<StoredOffer> { Offer(500_000), Offer(460_000, "Стандарт", "ANEX Tour") });
|
||||||
|
await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-07", new List<StoredOffer> { Offer(510_000), Offer(475_000, "Делюкс", "Pegas") });
|
||||||
|
|
||||||
|
var offer = await store.GetBestOfferAsync(9099454, "2026-08-20", "2026-08-07");
|
||||||
|
|
||||||
|
Assert.Equal(475_000, offer!.Price);
|
||||||
|
Assert.Equal("Делюкс", offer.RoomName);
|
||||||
|
Assert.Equal("Pegas", offer.OperatorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
using TourMonitor.Notifications;
|
||||||
|
|
||||||
|
namespace TourMonitor.Tests;
|
||||||
|
|
||||||
|
public class ReportBuilderTests
|
||||||
|
{
|
||||||
|
private static ReportLine Line(
|
||||||
|
int hotelId = 9099454,
|
||||||
|
string dateFrom = "2026-08-20",
|
||||||
|
int price = 475_447,
|
||||||
|
string meal = "AI",
|
||||||
|
string roomName = "Полулюкс",
|
||||||
|
string operatorName = "Level.Travel",
|
||||||
|
string link = "/package_details/offer-1",
|
||||||
|
int? previous = null,
|
||||||
|
int? historicalMin = null) =>
|
||||||
|
new(hotelId, dateFrom, price, meal, roomName, operatorName, link, previous, historicalMin);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildHotelSection_GroupsByMonth_ShowsTopPricesPerMonth()
|
||||||
|
{
|
||||||
|
var lines = new[]
|
||||||
|
{
|
||||||
|
Line(dateFrom: "2026-08-30", price: 520_000),
|
||||||
|
Line(dateFrom: "2026-08-20", price: 475_447),
|
||||||
|
Line(dateFrom: "2026-09-02", price: 501_000),
|
||||||
|
Line(dateFrom: "2026-09-03", price: 490_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 2, lines);
|
||||||
|
|
||||||
|
Assert.NotNull(section);
|
||||||
|
Assert.Contains("Sunrise Tucana Resort", section);
|
||||||
|
Assert.Contains("<b>Август</b>", section);
|
||||||
|
Assert.Contains("<b>Сентябрь</b>", section);
|
||||||
|
Assert.Contains("20.08", section);
|
||||||
|
Assert.Contains("30.08", section);
|
||||||
|
Assert.Contains("03.09", section);
|
||||||
|
Assert.Contains("02.09", section);
|
||||||
|
|
||||||
|
var months = section.Split("\n📅 <b>");
|
||||||
|
Assert.Equal(3, months.Length); // отель + Август + Сентябрь
|
||||||
|
Assert.Equal(2, months[1].Split("\n 📅 ").Length - 1); // Август — 2 дешёвых
|
||||||
|
Assert.Equal(2, months[2].Split("\n 📅 ").Length - 1); // Сентябрь — 2 дешёвых
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildHotelSection_TakesNoMoreThanPricesPerMonth_WithinEachMonth()
|
||||||
|
{
|
||||||
|
var lines = new[]
|
||||||
|
{
|
||||||
|
Line(dateFrom: "2026-08-10", price: 500_000),
|
||||||
|
Line(dateFrom: "2026-08-11", price: 510_000),
|
||||||
|
Line(dateFrom: "2026-08-12", price: 520_000),
|
||||||
|
Line(dateFrom: "2026-08-13", price: 530_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 2, lines);
|
||||||
|
|
||||||
|
Assert.NotNull(section);
|
||||||
|
Assert.Contains("<b>Август</b>", section);
|
||||||
|
Assert.Contains("10.08", section);
|
||||||
|
Assert.Contains("11.08", section);
|
||||||
|
Assert.DoesNotContain("12.08", section);
|
||||||
|
Assert.DoesNotContain("13.08", section);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DescribeDelta_ShowsDrop_WithPercentage()
|
||||||
|
{
|
||||||
|
var line = Line(price: 475_447, previous: 540_000);
|
||||||
|
|
||||||
|
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
|
||||||
|
|
||||||
|
Assert.Contains("🔻 −12% (было 540\u00A0000 ₽)", section);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DescribeDelta_ShowsNoChange_WhenPricesEqual()
|
||||||
|
{
|
||||||
|
var line = Line(price: 475_447, previous: 475_447);
|
||||||
|
|
||||||
|
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
|
||||||
|
|
||||||
|
Assert.Contains("= (без изменений)", section);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DescribeDelta_MarksNewMinimum()
|
||||||
|
{
|
||||||
|
var line = Line(price: 410_000, previous: 420_000, historicalMin: 415_000);
|
||||||
|
|
||||||
|
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
|
||||||
|
|
||||||
|
Assert.Contains("✨ новая минималка", section);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DescribeDelta_OmitsDelta_WhenNoPrevious()
|
||||||
|
{
|
||||||
|
var line = Line(previous: null);
|
||||||
|
|
||||||
|
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
|
||||||
|
|
||||||
|
Assert.DoesNotContain("было", section);
|
||||||
|
Assert.DoesNotContain("🔻", section);
|
||||||
|
Assert.DoesNotContain("🔺", section);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildHotelSection_ReturnsNull_WhenNoData()
|
||||||
|
{
|
||||||
|
Assert.Null(ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, Array.Empty<ReportLine>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_IncludesHeader_AndOnlyNonEmptySections()
|
||||||
|
{
|
||||||
|
var sections = ReportBuilder.Build(
|
||||||
|
"📊 <b>Мониторинг туров</b> · 9 ночей, 2+1 чел",
|
||||||
|
5,
|
||||||
|
new[] { (9099454, "Sunrise Tucana Resort"), (9151153, "Posh Club By Sunrise Tucana Resort") },
|
||||||
|
new[] { Line(hotelId: 9099454) });
|
||||||
|
|
||||||
|
Assert.NotNull(sections);
|
||||||
|
Assert.Contains("Мониторинг туров", sections);
|
||||||
|
Assert.Contains("Sunrise Tucana Resort", sections);
|
||||||
|
Assert.DoesNotContain("Posh Club", sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_ReturnsNull_WhenNoLines()
|
||||||
|
{
|
||||||
|
var result = ReportBuilder.Build(
|
||||||
|
"header", 5,
|
||||||
|
new[] { (9099454, "Sunrise Tucana Resort") },
|
||||||
|
Array.Empty<ReportLine>());
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Html_SpecialCharacters_AreEscaped()
|
||||||
|
{
|
||||||
|
var line = Line(roomName: "Стандарт <b>x</b>", operatorName: "ANEX & Co");
|
||||||
|
|
||||||
|
var section = ReportBuilder.BuildHotelSection("Sunrise <i>Tucana</i>", 5, new[] { line });
|
||||||
|
|
||||||
|
Assert.Contains("Sunrise <i>Tucana</i>", section);
|
||||||
|
Assert.Contains("Стандарт <b>x</b>", section);
|
||||||
|
Assert.Contains("ANEX & Co", section);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoomName_IsLinked_InsteadOfReserveLink()
|
||||||
|
{
|
||||||
|
var line = Line(link: "/package_details/offer-1?hotel_id=9099454", roomName: "Делюкс с видом на сад");
|
||||||
|
|
||||||
|
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
|
||||||
|
|
||||||
|
Assert.Contains("https://level.travel/package_details/offer-1?hotel_id=9099454", section);
|
||||||
|
Assert.Contains("<a href=\"https://level.travel/package_details/offer-1?hotel_id=9099454\">Делюкс с видом на сад</a>", section);
|
||||||
|
Assert.DoesNotContain("забронировать", section);
|
||||||
|
Assert.DoesNotContain("🔗", section);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RoomName_IsNotLinked_WhenLinkEmpty()
|
||||||
|
{
|
||||||
|
var line = Line(link: "", roomName: "Делюкс с видом на сад");
|
||||||
|
|
||||||
|
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { line });
|
||||||
|
|
||||||
|
Assert.Contains("🍽 AI · Делюкс с видом на сад · Level.Travel", section);
|
||||||
|
Assert.DoesNotContain("<a href=", section);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildMessages_SplitsHotelsIntoSeparateMessages()
|
||||||
|
{
|
||||||
|
var messages = ReportBuilder.BuildMessages(
|
||||||
|
"📊 <b>Мониторинг туров</b>",
|
||||||
|
10,
|
||||||
|
new[] { (9099454, "Sunrise Tucana Resort"), (9151153, "Posh Club By Sunrise Tucana Resort") },
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
Line(hotelId: 9099454, price: 400000),
|
||||||
|
Line(hotelId: 9151153, price: 500000),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.NotNull(messages);
|
||||||
|
Assert.Equal(2, messages.Count);
|
||||||
|
Assert.Contains("Мониторинг туров", messages[0]);
|
||||||
|
Assert.Contains("Sunrise Tucana Resort", messages[0]);
|
||||||
|
Assert.DoesNotContain("Posh Club", messages[0]);
|
||||||
|
Assert.Contains("Posh Club By Sunrise Tucana Resort", messages[1]);
|
||||||
|
Assert.DoesNotContain("Мониторинг туров", messages[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildMessages_ReturnsNull_WhenNoLines()
|
||||||
|
{
|
||||||
|
var messages = ReportBuilder.BuildMessages(
|
||||||
|
"header", 10,
|
||||||
|
new[] { (9099454, "Sunrise Tucana Resort") },
|
||||||
|
Array.Empty<ReportLine>());
|
||||||
|
|
||||||
|
Assert.Null(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildHotelSection_WithCalendarUrl_AddsCalendarLink_RightAfterHotelName()
|
||||||
|
{
|
||||||
|
var section = ReportBuilder.BuildHotelSection(
|
||||||
|
"Sunrise Tucana Resort", 5, new[] { Line(price: 400000) },
|
||||||
|
calendarUrl: "http://192.168.1.6:8084/prices/9099454");
|
||||||
|
|
||||||
|
Assert.Contains("календарь цен", section);
|
||||||
|
Assert.Contains("http://192.168.1.6:8084/prices/9099454", section);
|
||||||
|
Assert.Contains("Sunrise Tucana Resort</b>\n🗓 <a href=", section);
|
||||||
|
Assert.True(section.IndexOf("🗓") < section.IndexOf("📅 <b>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildHotelSection_WithoutCalendarUrl_HasNoCalendarLink()
|
||||||
|
{
|
||||||
|
var section = ReportBuilder.BuildHotelSection("Sunrise Tucana Resort", 5, new[] { Line(price: 400000) });
|
||||||
|
|
||||||
|
Assert.DoesNotContain("календарь цен", section);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SplitMessage_ChunksOversizedTextAtLineBoundaries()
|
||||||
|
{
|
||||||
|
var lines = Enumerable.Range(0, 50).Select(i => $"📅 10.10 — 475 447 ₽\n 🍽 AI · Полулюкс · Level.Travel").ToList();
|
||||||
|
var text = string.Join("\n", lines);
|
||||||
|
|
||||||
|
var chunks = ReportBuilder.SplitMessage(text, maxLength: 500).ToList();
|
||||||
|
|
||||||
|
Assert.True(chunks.Count > 1);
|
||||||
|
Assert.All(chunks, c => Assert.True(c.Length <= 500));
|
||||||
|
Assert.Equal(text, string.Join("\n", chunks));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using TourMonitor.LevelTravel;
|
||||||
|
|
||||||
|
namespace TourMonitor.Tests;
|
||||||
|
|
||||||
|
public class SignTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Эталонный знак, снятый с реального запроса веб-клиента level.travel
|
||||||
|
/// (GET /search/enqueue, зафиксирован в /tmp/lt_probe/cap_search/040-req.json).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void GetEnqueueSign_Matches_CapturedRequest()
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["start_date"] = "2026-08-20",
|
||||||
|
["nights"] = 9,
|
||||||
|
["adults"] = 2,
|
||||||
|
["kids"] = 0,
|
||||||
|
["kids_ages"] = "",
|
||||||
|
["from_city"] = "Moscow",
|
||||||
|
["from_country"] = "RU",
|
||||||
|
["to_city"] = "Hurghada",
|
||||||
|
["to_country"] = "EG",
|
||||||
|
["search_type"] = "package",
|
||||||
|
["flex_dates"] = 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
var sign = SignHelper.ComputeGet("/search/enqueue", parameters, TestData.ApiKey, TestData.SignSalt, TestData.ApiVersion);
|
||||||
|
|
||||||
|
Assert.Equal("60367bd68251236a1e0820c00a99cb92", sign);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Золотой знак для POST /search/multi_enqueue — снят с реального запроса сайта
|
||||||
|
/// (captured: /tmp/lt_probe/cap_hotel, подпись в теле afaac79c2e0c81be5ce10fd584cc3d44).
|
||||||
|
/// Проверяет полный путь: сериализация тела + флаттен + подпись.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void PostMultiEnqueueSign_Matches_CapturedRequest()
|
||||||
|
{
|
||||||
|
var body = new MultiEnqueueRequest
|
||||||
|
{
|
||||||
|
SearchParams =
|
||||||
|
{
|
||||||
|
new SearchParam
|
||||||
|
{
|
||||||
|
HotelIds = 9099454,
|
||||||
|
FromCity = "Moscow",
|
||||||
|
FromCountry = "RU",
|
||||||
|
ToCity = "Makadi Bay",
|
||||||
|
ToCountry = "EG",
|
||||||
|
StartDate = "2026-08-20",
|
||||||
|
Nights = "9",
|
||||||
|
Adults = 2,
|
||||||
|
Kids = 1,
|
||||||
|
FlexDates = false,
|
||||||
|
KidsAges = new[] { 5 },
|
||||||
|
SearchType = "package",
|
||||||
|
},
|
||||||
|
new SearchParam
|
||||||
|
{
|
||||||
|
HotelIds = 9099454,
|
||||||
|
FromCity = "Moscow",
|
||||||
|
FromCountry = "RU",
|
||||||
|
ToCity = "Makadi Bay",
|
||||||
|
ToCountry = "EG",
|
||||||
|
StartDate = "2026-08-20",
|
||||||
|
Nights = "7..11",
|
||||||
|
Adults = 2,
|
||||||
|
Kids = 1,
|
||||||
|
FlexDates = true,
|
||||||
|
KidsAges = new[] { 5 },
|
||||||
|
SearchType = "package",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var sign = SignHelper.Compute(
|
||||||
|
"/search/multi_enqueue",
|
||||||
|
LevelTravelClient.GetSignValues(body),
|
||||||
|
TestData.ApiKey,
|
||||||
|
TestData.SignSalt);
|
||||||
|
|
||||||
|
Assert.Equal("afaac79c2e0c81be5ce10fd584cc3d44", sign);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Знак не зависит от порядка параметров (JS-клиент сортирует значения).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Compute_IsOrderIndependent()
|
||||||
|
{
|
||||||
|
var values = new List<object?> { "2026-08-20", 9, 2, 0, "", "Moscow", "RU", "Hurghada", "EG", "package", 0 };
|
||||||
|
var reversed = Enumerable.Reverse(values).ToList();
|
||||||
|
|
||||||
|
var a = SignHelper.Compute("/search/enqueue", values, TestData.ApiKey, TestData.SignSalt);
|
||||||
|
var b = SignHelper.Compute("/search/enqueue", reversed, TestData.ApiKey, TestData.SignSalt);
|
||||||
|
|
||||||
|
Assert.Equal(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Строки обрезаются по краям, как в JS (value.trim()).</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Flatten_TrimsStrings()
|
||||||
|
{
|
||||||
|
var signA = SignHelper.Compute("/search/status", new object?[] { " abc ", "1" }, TestData.ApiKey, TestData.SignSalt);
|
||||||
|
var signB = SignHelper.Compute("/search/status", new object?[] { "abc", "1" }, TestData.ApiKey, TestData.SignSalt);
|
||||||
|
|
||||||
|
Assert.Equal(signA, signB);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace TourMonitor.Tests;
|
||||||
|
|
||||||
|
public static class TestData
|
||||||
|
{
|
||||||
|
public const string ApiKey = "0fe9fb2ff35679322db5429b18a53aee";
|
||||||
|
public const string SignSalt = "2qqRS1f8TyuF";
|
||||||
|
public const string ApiVersion = "3.14";
|
||||||
|
|
||||||
|
public static readonly string[] SecretBoxKeys =
|
||||||
|
[
|
||||||
|
"B1stAQVPJzJ015iJU8A1DP+i+d/5AbgIZavVPYKInrQ=",
|
||||||
|
"6+uSlOycGhU6LmgIz1sXsnK6VuZHpPtVaiRbgF7WCg0=",
|
||||||
|
"fve2pJpiBkJ5raA6EQCk2A21NDsFoP102jbDljzKDyc=",
|
||||||
|
"1IsckNgz0hwlqpVT0ZsLvjAdd3VhANj9+770p+aKuns=",
|
||||||
|
"DZllQsSt/82U/LdrlmoT+KNR578sK0y8E5709wjPPJQ=",
|
||||||
|
"XRoiUKs7d0dXVjcvvhsYUZ6Oo/ekYXZR9p+21aT6DOI=",
|
||||||
|
"3GV0SKSSEpNHxkwyU3VrCLgYJWrodLZJIZfpvJ3MVQY=",
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\TourMonitor\TourMonitor.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Fixtures\**" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace TourMonitor.Tests;
|
||||||
|
|
||||||
|
public class UnitTest1
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Test1()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user