From 98224c7a9feae2bdfa815316ef72dffa6bad44c1 Mon Sep 17 00:00:00 2001 From: kislovdm Date: Thu, 6 Aug 2026 19:47:16 +0300 Subject: [PATCH] =?UTF-8?q?TourMonitor:=20=D0=BC=D0=BE=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20=D1=86=D0=B5=D0=BD=20=D1=82?= =?UTF-8?q?=D1=83=D1=80=D0=BE=D0=B2=20Level.Travel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 13 + .gitignore | 10 + Dockerfile | 50 +++ README.md | 106 ++++++ TourMonitor.slnx | 8 + docker-compose.yml | 31 ++ .../Camoufox/CamoufoxBrowserSession.cs | 97 +++++ src/TourMonitor/Camoufox/CamoufoxInstaller.cs | 153 ++++++++ .../Camoufox/CamoufoxReleaseAsset.cs | 6 + .../Camoufox/CamoufoxReleaseResolver.cs | 50 +++ src/TourMonitor/Jobs/DailyScanJob.cs | 234 ++++++++++++ .../LevelTravel/CamoufoxApiTransport.cs | 64 ++++ src/TourMonitor/LevelTravel/IApiTransport.cs | 17 + .../LevelTravel/LevelTravelClient.cs | 177 ++++++++++ .../LevelTravel/LevelTravelCrypto.cs | 104 ++++++ src/TourMonitor/LevelTravel/Models.cs | 195 ++++++++++ src/TourMonitor/LevelTravel/SignHelper.cs | 73 ++++ .../Notifications/ReportBuilder.cs | 222 ++++++++++++ .../Notifications/TelegramNotifier.cs | 80 +++++ src/TourMonitor/Options.cs | 71 ++++ src/TourMonitor/Program.cs | 94 +++++ .../Properties/launchSettings.json | 14 + src/TourMonitor/Storage/PriceStore.cs | 334 ++++++++++++++++++ src/TourMonitor/TourMonitor.csproj | 21 ++ src/TourMonitor/Web/CalendarPage.cs | 163 +++++++++ src/TourMonitor/appsettings.Development.json | 8 + src/TourMonitor/appsettings.json | 62 ++++ tests/TourMonitor.Tests/CalendarPageTests.cs | 118 +++++++ tests/TourMonitor.Tests/CryptoTests.cs | 53 +++ .../Fixtures/enqueue_encrypted.txt | 1 + .../LevelTravelIntegrationTests.cs | 146 ++++++++ tests/TourMonitor.Tests/PriceStoreTests.cs | 162 +++++++++ tests/TourMonitor.Tests/ReportBuilderTests.cs | 244 +++++++++++++ tests/TourMonitor.Tests/SignTests.cs | 110 ++++++ tests/TourMonitor.Tests/TestData.cs | 19 + .../TourMonitor.Tests.csproj | 29 ++ tests/TourMonitor.Tests/UnitTest1.cs | 10 + 37 files changed, 3349 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 TourMonitor.slnx create mode 100644 docker-compose.yml create mode 100644 src/TourMonitor/Camoufox/CamoufoxBrowserSession.cs create mode 100644 src/TourMonitor/Camoufox/CamoufoxInstaller.cs create mode 100644 src/TourMonitor/Camoufox/CamoufoxReleaseAsset.cs create mode 100644 src/TourMonitor/Camoufox/CamoufoxReleaseResolver.cs create mode 100644 src/TourMonitor/Jobs/DailyScanJob.cs create mode 100644 src/TourMonitor/LevelTravel/CamoufoxApiTransport.cs create mode 100644 src/TourMonitor/LevelTravel/IApiTransport.cs create mode 100644 src/TourMonitor/LevelTravel/LevelTravelClient.cs create mode 100644 src/TourMonitor/LevelTravel/LevelTravelCrypto.cs create mode 100644 src/TourMonitor/LevelTravel/Models.cs create mode 100644 src/TourMonitor/LevelTravel/SignHelper.cs create mode 100644 src/TourMonitor/Notifications/ReportBuilder.cs create mode 100644 src/TourMonitor/Notifications/TelegramNotifier.cs create mode 100644 src/TourMonitor/Options.cs create mode 100644 src/TourMonitor/Program.cs create mode 100644 src/TourMonitor/Properties/launchSettings.json create mode 100644 src/TourMonitor/Storage/PriceStore.cs create mode 100644 src/TourMonitor/TourMonitor.csproj create mode 100644 src/TourMonitor/Web/CalendarPage.cs create mode 100644 src/TourMonitor/appsettings.Development.json create mode 100644 src/TourMonitor/appsettings.json create mode 100644 tests/TourMonitor.Tests/CalendarPageTests.cs create mode 100644 tests/TourMonitor.Tests/CryptoTests.cs create mode 100644 tests/TourMonitor.Tests/Fixtures/enqueue_encrypted.txt create mode 100644 tests/TourMonitor.Tests/LevelTravelIntegrationTests.cs create mode 100644 tests/TourMonitor.Tests/PriceStoreTests.cs create mode 100644 tests/TourMonitor.Tests/ReportBuilderTests.cs create mode 100644 tests/TourMonitor.Tests/SignTests.cs create mode 100644 tests/TourMonitor.Tests/TestData.cs create mode 100644 tests/TourMonitor.Tests/TourMonitor.Tests.csproj create mode 100644 tests/TourMonitor.Tests/UnitTest1.cs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..59d1a6d --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..863f5a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +Data/ +*.db +*.db-shm +*.db-wal +.env +.vs/ +.idea/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbaaa15 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..af4ab18 --- /dev/null +++ b/README.md @@ -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= +``` + +- Дашборд 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/` — юнит- и интеграционные тесты diff --git a/TourMonitor.slnx b/TourMonitor.slnx new file mode 100644 index 0000000..34ae1ba --- /dev/null +++ b/TourMonitor.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..269f469 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/src/TourMonitor/Camoufox/CamoufoxBrowserSession.cs b/src/TourMonitor/Camoufox/CamoufoxBrowserSession.cs new file mode 100644 index 0000000..b9f8eab --- /dev/null +++ b/src/TourMonitor/Camoufox/CamoufoxBrowserSession.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Options; +using Microsoft.Playwright; + +namespace TourMonitor.Camoufox; + +/// +/// Долгоживущая сессия Camoufox: один браузер на весь скан. Лениво запускается, +/// открывает level.travel для установки кук и реального браузерного контекста, +/// дальше API-запросы идут из этой же страницы. +/// +public sealed class CamoufoxBrowserSession : IAsyncDisposable +{ + private readonly ICamoufoxInstaller _installer; + private readonly CamoufoxOptions _options; + private readonly ILogger _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 options, ILogger logger) + { + _installer = installer; + _options = options.Value; + _logger = logger; + } + + public async Task 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(); +} diff --git a/src/TourMonitor/Camoufox/CamoufoxInstaller.cs b/src/TourMonitor/Camoufox/CamoufoxInstaller.cs new file mode 100644 index 0000000..a50b055 --- /dev/null +++ b/src/TourMonitor/Camoufox/CamoufoxInstaller.cs @@ -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 EnsureInstalledAsync(string version, string installDirectory, string? downloadUrlOverride, CancellationToken cancellationToken = default); +} + +/// Скачивает патченный Firefox (Camoufox) из релизов и распаковывает его. +public sealed class CamoufoxInstaller : ICamoufoxInstaller +{ + private readonly ICamoufoxReleaseResolver _releaseResolver; + private readonly ILogger _logger; + + public CamoufoxInstaller(ICamoufoxReleaseResolver releaseResolver, ILogger 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 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. + } + } +} diff --git a/src/TourMonitor/Camoufox/CamoufoxReleaseAsset.cs b/src/TourMonitor/Camoufox/CamoufoxReleaseAsset.cs new file mode 100644 index 0000000..f6d1aec --- /dev/null +++ b/src/TourMonitor/Camoufox/CamoufoxReleaseAsset.cs @@ -0,0 +1,6 @@ +namespace TourMonitor.Camoufox; + +public sealed record CamoufoxReleaseAsset( + string DownloadUrl, + bool IsTarGzArchive, + string ExecutableName); diff --git a/src/TourMonitor/Camoufox/CamoufoxReleaseResolver.cs b/src/TourMonitor/Camoufox/CamoufoxReleaseResolver.cs new file mode 100644 index 0000000..e792672 --- /dev/null +++ b/src/TourMonitor/Camoufox/CamoufoxReleaseResolver.cs @@ -0,0 +1,50 @@ +using System.Runtime.InteropServices; + +namespace TourMonitor.Camoufox; + +public interface ICamoufoxReleaseResolver +{ + CamoufoxReleaseAsset Resolve(string version, string? downloadUrlOverride); +} + +/// Резолвит ассет патченного Firefox из релизов daijro/camoufox по ОС/архитектуре. +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."); + } +} diff --git a/src/TourMonitor/Jobs/DailyScanJob.cs b/src/TourMonitor/Jobs/DailyScanJob.cs new file mode 100644 index 0000000..01033cb --- /dev/null +++ b/src/TourMonitor/Jobs/DailyScanJob.cs @@ -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; + +/// +/// Ежедневный скан: для каждой даты заезда — поиск туров по обоим отелям, +/// сохранение цен, сравнение со вчерашним днём и отправка отчёта в Telegram. +/// +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 _logger; + + public DailyScanJob( + LevelTravelClient client, + PriceStore store, + TelegramNotifier notifier, + CamoufoxBrowserSession browser, + IOptions options, + ILogger 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(); + 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(); + + if (offers.Count == 0) + { + _logger.LogInformation("Отель {Hotel} на {Date}: туров нет (статус: {Status})", + hotel.Name, dateKey, string.Join(",", status.Status?.Values.Distinct() ?? Array.Empty())); + } + + await _store.SaveScanAsync(hotel.Id, dateKey, todayKey, offers, ct); + } + } + + private static List FlattenOffers(int hotelId, string dateKey, RoomRatesResponse rates) + { + var offers = new List(); + 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?> 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(); + + 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 + { + 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 $"📊 Мониторинг туров · {string.Join(", ", paramParts)}\nЗаезды: {range}"; + } +} diff --git a/src/TourMonitor/LevelTravel/CamoufoxApiTransport.cs b/src/TourMonitor/LevelTravel/CamoufoxApiTransport.cs new file mode 100644 index 0000000..f0d1a3a --- /dev/null +++ b/src/TourMonitor/LevelTravel/CamoufoxApiTransport.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Playwright; +using TourMonitor.Camoufox; + +namespace TourMonitor.LevelTravel; + +/// +/// Транспорт через реальный браузер Camoufox: fetch выполняется из контекста страницы +/// level.travel, что даёт настоящий браузерный фингерпринт, куки и Origin. +/// +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 _logger; + + public CamoufoxApiTransport( + CamoufoxBrowserSession session, + IOptions options, + ILogger logger) + { + _session = session; + _options = options.Value; + _logger = logger; + } + + public async Task SendAsync(HttpMethod method, string url, string? jsonBody, CancellationToken ct = default) + { + var page = await _session.GetPageAsync(ct); + + var headers = new Dictionary + { + ["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(FetchScript, new + { + method = method.Method.ToUpperInvariant(), + url, + headers, + body = jsonBody, + }); + return response; + } +} diff --git a/src/TourMonitor/LevelTravel/IApiTransport.cs b/src/TourMonitor/LevelTravel/IApiTransport.cs new file mode 100644 index 0000000..19a0064 --- /dev/null +++ b/src/TourMonitor/LevelTravel/IApiTransport.cs @@ -0,0 +1,17 @@ +namespace TourMonitor.LevelTravel; + +/// Сырой ответ API (статус + тело) до расшифровки/дешифровки. +public sealed class ApiTransportResponse +{ + public int Status { get; set; } + public string Body { get; set; } = ""; +} + +/// +/// Транспорт запросов к API. Единственная реализация — реальный браузер Camoufox +/// (CamoufoxApiTransport): fetch из контекста страницы level.travel обходит анти-бот периметр. +/// +public interface IApiTransport +{ + Task SendAsync(HttpMethod method, string url, string? jsonBody, CancellationToken ct = default); +} diff --git a/src/TourMonitor/LevelTravel/LevelTravelClient.cs b/src/TourMonitor/LevelTravel/LevelTravelClient.cs new file mode 100644 index 0000000..c88541d --- /dev/null +++ b/src/TourMonitor/LevelTravel/LevelTravelClient.cs @@ -0,0 +1,177 @@ +using System.Net; +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace TourMonitor.LevelTravel; + +/// +/// Клиент публичного API Level.Travel (подпись запросов + расшифровка ответов воспроизведены с веб-клиента). +/// Транспорт (CamoufoxApiTransport) инжектируется отдельно. +/// +public sealed class LevelTravelClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + private readonly IApiTransport _transport; + private readonly LevelTravelOptions _options; + private readonly ILogger _logger; + + public LevelTravelClient(IApiTransport transport, IOptions options, ILogger logger) + { + _transport = transport; + _options = options.Value; + _logger = logger; + } + + public async Task MultiEnqueueAsync(IReadOnlyList searchParams, CancellationToken ct = default) + { + var body = new MultiEnqueueRequest { SearchParams = searchParams.ToList() }; + return await PostAsync("/search/multi_enqueue", body, ct); + } + + public async Task GetStatusAsync(string requestId, CancellationToken ct = default) + { + var parameters = new Dictionary + { + ["request_id"] = requestId, + ["show_size"] = "true", + }; + return await GetAsync("/search/status", parameters, ct); + } + + public async Task GetRoomRatesAsync(string hotelId, string requestId, CancellationToken ct = default) + { + var body = new RoomRatesRequest + { + HotelId = hotelId, + RequestId = requestId, + Filters = new RoomRatesFilters(), + }; + return await PostAsync("/hotel_search/room_rates", body, ct); + } + + /// Ожидает завершения поиска: completeness = 100 или все операторы в терминальном статусе. + public async Task WaitForSearchAsync(string requestId, int pollIntervalSeconds, int timeoutSeconds, CancellationToken ct = default) + { + var startedAt = DateTime.UtcNow; + var timeout = TimeSpan.FromSeconds(timeoutSeconds); + var terminal = new HashSet { "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(); + 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 GetAsync(string path, IDictionary parameters, CancellationToken ct) + { + var sign = SignHelper.ComputeGet(path, parameters, _options.ApiKey, _options.SignSalt, _options.ApiVersion); + + var query = new List(); + 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(HttpMethod.Get, url, null, ct), $"GET {path}"); + } + + private async Task PostAsync(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(HttpMethod.Post, url, payloadWithSign, ct), $"POST {path}"); + } + + internal static IEnumerable 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 SendAndDeserializeAsync(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(LevelTravelCrypto.DecryptToJson(body, _options.SecretBoxKeys)); + } + + private async Task ExecuteWithRetryAsync(Func> 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(string json) + { + try + { + return JsonSerializer.Deserialize(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] + "..."; +} diff --git a/src/TourMonitor/LevelTravel/LevelTravelCrypto.cs b/src/TourMonitor/LevelTravel/LevelTravelCrypto.cs new file mode 100644 index 0000000..ec4c44d --- /dev/null +++ b/src/TourMonitor/LevelTravel/LevelTravelCrypto.cs @@ -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; + +/// +/// Расшифровка ответов API Level.Travel. +/// Формат тела (если начинается с "!/"): base64url( +/// байт(23 XOR индекс ключа), nonce(24), NaCl secretbox(тег(16) + шифротекст) ) +/// затем zlib-inflate и JSON. +/// Secretbox = XSalsa20-Poly1305: ключ Poly1305 = первые 32 байта keystream, тег MAC — в начале. +/// +public static class LevelTravelCrypto +{ + private const string Prefix = "!/"; + + /// Декодирует ответ в строку JSON; если тело не зашифровано — возвращает как есть. + public static string DecryptToJson(string body, IReadOnlyList 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 a, ReadOnlySpan 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) { } +} diff --git a/src/TourMonitor/LevelTravel/Models.cs b/src/TourMonitor/LevelTravel/Models.cs new file mode 100644 index 0000000..6e7105f --- /dev/null +++ b/src/TourMonitor/LevelTravel/Models.cs @@ -0,0 +1,195 @@ +using System.Text.Json.Serialization; + +namespace TourMonitor.LevelTravel; + +public sealed class MultiEnqueueRequest +{ + [JsonPropertyName("search_params")] + public List 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(); + + [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 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? 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 Meals { get; set; } = new(); + + [JsonPropertyName("free_cancel")] + public bool FreeCancel { get; set; } + + [JsonPropertyName("confirmability")] + public object? Confirmability { get; set; } + + [JsonPropertyName("payment_benefits")] + public List PaymentBenefits { get; set; } = new(); +} + +public sealed class RoomRatesResponse +{ + [JsonPropertyName("success")] + public bool Success { get; set; } + + [JsonPropertyName("result")] + public List 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 MealTypes { get; set; } = new(); + + [JsonPropertyName("room")] + public RoomInfo Room { get; set; } = new(); + + [JsonPropertyName("offers")] + public Dictionary> 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; } +} diff --git a/src/TourMonitor/LevelTravel/SignHelper.cs b/src/TourMonitor/LevelTravel/SignHelper.cs new file mode 100644 index 0000000..c0ee3b7 --- /dev/null +++ b/src/TourMonitor/LevelTravel/SignHelper.cs @@ -0,0 +1,73 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace TourMonitor.LevelTravel; + +/// +/// Воспроизводит алгоритм подписи запросов веб-клиента Level.Travel: +/// sign = md5( sorted(значения параметров, JSON без кавычек).join("") + последний_сегмент_пути + key + salt ) +/// Значения рекурсивно разворачиваются (массивы/объекты — по значениям), пустые массивы/объекты пропускаются. +/// +public static class SignHelper +{ + public static string ComputeGet(string path, IDictionary parameters, string key, string salt, string apiVersion) + { + var withMeta = new Dictionary(parameters) + { + ["key"] = key, + ["api_version"] = apiVersion, + ["js"] = "true", + }; + return Compute(path, withMeta.Values, key, salt); + } + + public static string Compute(string path, IEnumerable values, string key, string salt) + { + var flattened = new List(); + Flatten(values, flattened); + + var mapped = new List(); + 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 values, List 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().ToList(); + if (dictItems.Count > 0) + Flatten(dictItems, output); + break; + case System.Collections.IEnumerable seq: + // как Object.values(array) в JS: пустой массив значений не даёт + var items = seq.Cast().ToList(); + if (items.Count > 0) + Flatten(items, output); + break; + default: + output.Add(value); + break; + } + } + } +} diff --git a/src/TourMonitor/Notifications/ReportBuilder.cs b/src/TourMonitor/Notifications/ReportBuilder.cs new file mode 100644 index 0000000..25e2989 --- /dev/null +++ b/src/TourMonitor/Notifications/ReportBuilder.cs @@ -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); + +/// Собирает HTML-сообщение для Telegram: секция на каждый отель с топом дешёвых дат и дельтами. +public static class ReportBuilder +{ + private static readonly CultureInfo Ru = CultureInfo.GetCultureInfo("ru-RU"); + + /// Собирает сообщение целиком (для обратной совместимости/тестов). Возвращает null, если нет данных ни по одному отелю. + public static string? Build( + string header, + int pricesPerMonth, + IReadOnlyList<(int HotelId, string Name)> hotels, + IReadOnlyList lines) + { + var messages = BuildMessages(header, pricesPerMonth, hotels, lines); + return messages is null ? null : string.Join("\n\n", messages); + } + + /// + /// Собирает список сообщений для Telegram: первое — заголовок + первый отель, + /// каждое следующее — отдельный отель. Сообщения длиннее лимита Telegram (4096) + /// разбиваются на несколько. Возвращает null, если данных нет ни по одному отелю. + /// + public static IReadOnlyList? BuildMessages( + string header, + int pricesPerMonth, + IReadOnlyList<(int HotelId, string Name)> hotels, + IReadOnlyList lines, + string? calendarUrlBase = null) + { + var messages = new List(); + 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(); + foreach (var message in messages) + result.AddRange(SplitMessage(message)); + return result; + } + + /// + /// Секция для одного отеля; null, если по отелю нет данных. + /// В каждом месяце диапазона показывается до дешёвых дат. + /// + public static string? BuildHotelSection( + string hotelName, + int pricesPerMonth, + IEnumerable 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("🏝 ").Append(HtmlEncode(hotelName)).Append(""); + + if (calendarUrl is not null) + { + sb.AppendLine(); + sb.Append("🗓 календарь цен"); + } + + foreach (var month in byMonth) + { + sb.AppendLine(); + sb.Append("📅 ").Append(HtmlEncode(MonthName(month.Key))).Append(""); + + foreach (var line in month.OrderBy(l => l.Price).ThenBy(l => l.DateFrom).Take(pricesPerMonth)) + { + sb.AppendLine(); + sb.Append(" 📅 ").Append(FormatDate(line.DateFrom)).Append(" — ") + .Append(FormatPrice(line.Price)).Append(" ₽"); + + 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("") + .Append(HtmlEncode(line.RoomName)).Append(""); + } + sb.Append(" · ").Append(HtmlEncode(line.OperatorName)); + } + } + + return sb.ToString(); + } + + /// Ключ месяца (yyyyMM) для группировки; неизвестные даты — в отдельной группе в конце. + 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 "—"; + } + + /// + /// Делит сообщение на части, не превышающие лимит Telegram (4096 симв.) — по границам + /// строк, а слишком длинную одиночную строку режет принудительно. + /// + public static IEnumerable 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); +} diff --git a/src/TourMonitor/Notifications/TelegramNotifier.cs b/src/TourMonitor/Notifications/TelegramNotifier.cs new file mode 100644 index 0000000..4357e17 --- /dev/null +++ b/src/TourMonitor/Notifications/TelegramNotifier.cs @@ -0,0 +1,80 @@ +using System.Net.Http.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace TourMonitor.Notifications; + +/// Отправка сообщений в Telegram через Bot API (прямой HTTP, без сторонних пакетов). +public sealed class TelegramNotifier +{ + private readonly HttpClient _http; + private readonly TelegramOptions _options; + private readonly ILogger _logger; + + public TelegramNotifier(HttpClient http, IOptions options, ILogger logger) + { + _http = http; + _options = options.Value; + _logger = logger; + } + + public bool IsConfigured => + !string.IsNullOrWhiteSpace(_options.BotToken) && !string.IsNullOrWhiteSpace(_options.ChatId); + + /// Отправляет несколько сообщений по очереди; возвращает число отправленных. + public async Task SendMessagesAsync(IReadOnlyList 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 SendMessageAsync(string text, CancellationToken ct = default) + { + if (!IsConfigured) + { + _logger.LogWarning("Telegram не настроен (BotToken/ChatId) — сообщение не отправлено."); + return false; + } + + var payload = new Dictionary + { + ["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; + } +} diff --git a/src/TourMonitor/Options.cs b/src/TourMonitor/Options.cs new file mode 100644 index 0000000..9796e61 --- /dev/null +++ b/src/TourMonitor/Options.cs @@ -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 SecretBoxKeys { get; set; } = new(); +} + +public sealed class HotelOptions +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + /// Направление поиска отеля. + public string ToCity { get; set; } = ""; + /// Страна направления. + public string ToCountry { get; set; } = ""; + /// Количество ночей для отеля. + public int Nights { get; set; } = 9; +} + +public sealed class MonitorOptions +{ + public List Hotels { get; set; } = new(); + public string DepartureCity { get; set; } = "Moscow"; + public string DepartureCountry { get; set; } = "RU"; + public int Adults { get; set; } = 2; + public List KidsAges { get; set; } = new(); + public int DateRangeDays { get; set; } = 90; + /// Окно скана начинается не с сегодняшнего дня, а с отступа в днях (минимум). + public int StartOffsetDays { get; set; } = 60; + /// Сколько дешёвых дат показывать в отчёте для каждого месяца диапазона. + 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"; + /// Публичный базовый URL (для ссылки на календарь цен в отчёте). Пусто — ссылка не добавляется. + 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"; +} + +/// Настройки анти-детект браузера Camoufox (патченный Firefox + Playwright). +public sealed class CamoufoxOptions +{ + /// Тег релиза daijro/camoufox. + public string Version { get; set; } = "v152.0.4-beta.28"; + /// Каталог установки (может быть относительным к корню приложения). + public string InstallDirectory { get; set; } = "Data/camoufox"; + public string? DownloadUrlOverride { get; set; } + public bool Headless { get; set; } = true; + /// Страница для установки браузерной сессии и кук. + public string SessionUrl { get; set; } = "https://level.travel/"; +} diff --git a/src/TourMonitor/Program.cs b/src/TourMonitor/Program.cs new file mode 100644 index 0000000..b0b7766 --- /dev/null +++ b/src/TourMonitor/Program.cs @@ -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(builder.Configuration.GetSection("LevelTravel")); +builder.Services.Configure(builder.Configuration.GetSection("Monitor")); +builder.Services.Configure(builder.Configuration.GetSection("Telegram")); +builder.Services.Configure(builder.Configuration.GetSection("Schedule")); +builder.Services.Configure(builder.Configuration.GetSection("Camoufox")); + +builder.Services.AddHangfire(config => config + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UseInMemoryStorage()); +builder.Services.AddHangfireServer(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(20)); + +builder.Services.AddSingleton(sp => +{ + var monitor = sp.GetRequiredService>().Value; + var contentRoot = sp.GetRequiredService().ContentRootPath; + var dbPath = Path.IsPathRooted(monitor.DbPath) + ? monitor.DbPath + : Path.Combine(contentRoot, monitor.DbPath); + return new PriceStore(dbPath, sp.GetService>()); +}); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.UseHangfireDashboard("/hangfire", new DashboardOptions +{ + // По умолчанию Hangfire отдаёт дашборд только с localhost (401 с других хостов); + // мониторинг должен быть доступен по LAN, поэтому разрешаем все источники. + Authorization = new[] { new AllowAllDashboardAuthorizationFilter() }, +}); + +var schedule = app.Services.GetRequiredService>().Value; +var timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.TimeZoneId); +RecurringJob.AddOrUpdate( + "daily-scan", + job => job.ExecuteAsync(CancellationToken.None), + schedule.Cron, + new RecurringJobOptions { TimeZone = timeZone }); + +var monitor = app.Services.GetRequiredService>().Value; +if (monitor.RunOnStart) + BackgroundJob.Enqueue(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(); + +/// Разрешает доступ к Hangfire-дашборду с любого хоста (по умолчанию — только localhost). +internal sealed class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter +{ + public bool Authorize(DashboardContext context) => true; +} + +public partial class Program; diff --git a/src/TourMonitor/Properties/launchSettings.json b/src/TourMonitor/Properties/launchSettings.json new file mode 100644 index 0000000..d299099 --- /dev/null +++ b/src/TourMonitor/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/src/TourMonitor/Storage/PriceStore.cs b/src/TourMonitor/Storage/PriceStore.cs new file mode 100644 index 0000000..b762a29 --- /dev/null +++ b/src/TourMonitor/Storage/PriceStore.cs @@ -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); + +/// Минимальная известная цена по (отель, дата заезда) — строка календаря цен. +/// +/// Данные календаря по конкретной дате тура: цена из последнего сканирования (BestPrice), +/// цена предыдущего скана (PreviousPrice, null если его не было), ссылка на бронирование +/// лучшего оффера (Link) и диапазон минимальной цены по датам сканирования (MinPrice–MaxPrice). +/// +public sealed record CalendarEntry(int HotelId, string DateFrom, int BestPrice, int? PreviousPrice, string Link, int MinPrice, int MaxPrice); + +/// SQLite-хранилище истории цен и ежедневных минимумов по (отель, дата заезда). +public sealed class PriceStore +{ + private readonly string _connectionString; + private readonly ILogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + + public PriceStore(string dbPath, ILogger? 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); + }); + } + + /// Сохраняет офферы скана; лучший по цене помечается и пишется в daily_best. + public async Task SaveScanAsync(int hotelId, string dateFrom, string date, IReadOnlyList 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); + }); + } + + /// Лучшая цена за дату проверки, предшествующую (ближайшая). + public async Task 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; + } + + /// Исторический минимум до даты проверки (для определения «новой минималки»). + public async Task 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; + } + + /// Лучшие предложения за сегодня по всем (отель, дата заезда). + public async Task> GetTodayBestsAsync(string date, CancellationToken ct = default) + { + var result = new List(); + 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; + } + + /// + /// Данные календаря по каждой дате тура: цена из последнего сканирования, цена предыдущего + /// скана для дельты, ссылка на бронирование лучшего оффера и диапазон минимальной цены + /// по всем датам сканирования (как «прыгала» цена самого дешёвого номера). + /// + public async Task> GetCalendarAsync(CancellationToken ct = default) + { + var result = new List(); + 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)); + + /// Последний лучший оффер для (отель, дата заезда, дата проверки) — детали для отчёта. + public async Task 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 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, IDisposable + { + public static readonly NullLogger Instance = new(); + public IDisposable? BeginScope(TState state) where TState : notnull => this; + public bool IsEnabled(LogLevel logLevel) => false; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { } + public void Dispose() { } + } +} diff --git a/src/TourMonitor/TourMonitor.csproj b/src/TourMonitor/TourMonitor.csproj new file mode 100644 index 0000000..c10de28 --- /dev/null +++ b/src/TourMonitor/TourMonitor.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/src/TourMonitor/Web/CalendarPage.cs b/src/TourMonitor/Web/CalendarPage.cs new file mode 100644 index 0000000..3c1a25d --- /dev/null +++ b/src/TourMonitor/Web/CalendarPage.cs @@ -0,0 +1,163 @@ +using System.Globalization; +using System.Net; +using System.Text; + +namespace TourMonitor.Web; + +/// +/// Генерирует простые HTML-страницы «календаря цен»: сетка месяцев, в каждой ячейке — +/// цена из последнего сканирования на эту дату заезда (ссылка на бронирование лучшего +/// оффера) или пусто, если цены нет, и стрелка с изменением против предыдущего скана. +/// +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("

📊 ").Append(HtmlEncode(title)).Append("

"); + sb.Append("

Выберите отель, чтобы посмотреть цены по датам заезда:

"); + foreach (var hotel in hotels) + { + var url = $"{baseUrl}/prices/{hotel.HotelId}"; + sb.Append("") + .Append("

🏝 ").Append(HtmlEncode(hotel.Name)).Append("

") + .Append("

открыть календарь →

"); + } + sb.Append("
"); + 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(); + 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>(); + 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(); + list.Add(day); + } + + var sb = new StringBuilder(); + sb.Append(HtmlOpen(hotelName)); + sb.Append("← все отели"); + sb.Append("

🏝 ").Append(HtmlEncode(hotelName)).Append("

"); + sb.Append("

Цена из последнего сканирования · ") + .Append(windowStart.ToString("dd.MM.yyyy", Ru)).Append(" — ") + .Append(windowStart.AddDays(windowDays - 1).ToString("dd.MM.yyyy", Ru)) + .Append(" · под ценой — диапазон минимальной цены по датам сканирования

"); + + foreach (var (monthKey, days) in months) + { + var first = days[0]; + var monthTitle = first.ToString("MMMM yyyy", Ru); + sb.Append("

").Append(HtmlEncode(char.ToUpper(monthTitle[0]) + monthTitle[1..])).Append("

"); + sb.Append("
"); + foreach (var weekday in new[] { "Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс" }) + sb.Append("
").Append(weekday).Append("
"); + + var leadDays = ((int)first.DayOfWeek + 6) % 7; // Пн = 0 + for (var i = 0; i < leadDays; i++) + sb.Append("
"); + + 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("
") + .Append("").Append(day.Day).Append(""); + + if (string.IsNullOrEmpty(priceInfo.Link)) + { + sb.Append("").Append(priceInfo.Price.ToString("N0", Ru)).Append(""); + } + else + { + sb.Append("") + .Append(priceInfo.Price.ToString("N0", Ru)).Append(""); + } + + sb.Append(RangeHtml(priceInfo.Min, priceInfo.Max)) + .Append(DeltaHtml(priceInfo.Price, priceInfo.Prev)) + .Append("
"); + } + else + { + sb.Append("
") + .Append("").Append(day.Day).Append("") + .Append("—
"); + } + } + sb.Append("
"); + } + + sb.Append(HtmlClose()); + return sb.ToString(); + } + + /// Диапазон минимальной цены на дату тура по датам сканирования; пусто, если данных нет. + private static string RangeHtml(int min, int max) + { + if (min <= 0 || max < min) + return ""; + return $"{min.ToString("N0", Ru)}–{max.ToString("N0", Ru)}"; + } + + /// Стрелка изменения цены против предыдущего скана; пусто, если предыдущего скана не было. + private static string DeltaHtml(int price, int? previous) + { + if (previous is null or <= 0) + return ""; + var diff = price - previous.Value; + if (diff == 0) + return "="; + var pct = Math.Round((double)diff / previous.Value * 100); + var arrow = diff < 0 ? "↓" : "↑"; + return $"{arrow} {Math.Abs(pct)}%"; + } + + private static string FullLink(string link) => + string.IsNullOrEmpty(link) ? "https://level.travel/" : "https://level.travel" + link; + + private static string HtmlOpen(string title) => + "" + + "" + + "" + HtmlEncode(title) + ""; + private static string HtmlClose() => ""; + + private static string HtmlEncode(string value) => WebUtility.HtmlEncode(value); +} diff --git a/src/TourMonitor/appsettings.Development.json b/src/TourMonitor/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/TourMonitor/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/TourMonitor/appsettings.json b/src/TourMonitor/appsettings.json new file mode 100644 index 0000000..ce64a5c --- /dev/null +++ b/src/TourMonitor/appsettings.json @@ -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": "*" +} diff --git a/tests/TourMonitor.Tests/CalendarPageTests.cs b/tests/TourMonitor.Tests/CalendarPageTests.cs new file mode 100644 index 0000000..ba3f315 --- /dev/null +++ b/tests/TourMonitor.Tests/CalendarPageTests.cs @@ -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("
", html); + Assert.Contains("Цена из последнего сканирования", html); + Assert.Contains("410\u00A0000–550\u00A0000", 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("↓ 12%", html); + Assert.Contains("↑ 2%", html); + Assert.Contains("=", 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("475\u00A0447", html); + Assert.Contains("460\u00A0000", 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("290\u00A0494–574\u00A0875", html); + Assert.Contains("100\u00A0000–130\u00A0000", 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); + } +} diff --git a/tests/TourMonitor.Tests/CryptoTests.cs b/tests/TourMonitor.Tests/CryptoTests.cs new file mode 100644 index 0000000..36da357 --- /dev/null +++ b/tests/TourMonitor.Tests/CryptoTests.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using TourMonitor.LevelTravel; + +namespace TourMonitor.Tests; + +public class CryptoTests +{ + /// + /// Реальный зашифрованный ответ API (captured: /tmp/lt_probe/cap_search/069-resp.json). + /// Проверяет secretbox-дешифровку (XSalsa20-Poly1305) и zlib-inflate. + /// + [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); + } + + /// Незашифрованные тела (v3.7 без секрета) возвращаются как есть. + [Fact] + public void DecryptToJson_PassesThrough_PlainBody() + { + const string plain = "{\"success\":true}"; + + var result = LevelTravelCrypto.DecryptToJson(plain, TestData.SecretBoxKeys); + + Assert.Equal(plain, result); + } + + /// Мусор после префикса "!/" должен давать осмысленную ошибку, а не исключение-дамп. + [Fact] + public void DecryptToJson_Throws_LevelTravelException_OnGarbage() + { + Assert.Throws(() => LevelTravelCrypto.DecryptToJson("!/not-base64!!", TestData.SecretBoxKeys)); + } + + /// Неизвестный индекс ключа (не 1..7) отклоняется. + [Fact] + public void DecryptToJson_Throws_OnUnknownKeyIndex() + { + // первый байт выбирает индекс: (payload[0] ^ 23) - '0' должен быть вне 1..7 + var body = "!/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + + var ex = Assert.Throws(() => LevelTravelCrypto.DecryptToJson(body, TestData.SecretBoxKeys)); + Assert.Contains("индекс ключа", ex.Message); + } +} diff --git a/tests/TourMonitor.Tests/Fixtures/enqueue_encrypted.txt b/tests/TourMonitor.Tests/Fixtures/enqueue_encrypted.txt new file mode 100644 index 0000000..6a2be1f --- /dev/null +++ b/tests/TourMonitor.Tests/Fixtures/enqueue_encrypted.txt @@ -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= \ No newline at end of file diff --git a/tests/TourMonitor.Tests/LevelTravelIntegrationTests.cs b/tests/TourMonitor.Tests/LevelTravelIntegrationTests.cs new file mode 100644 index 0000000..d6b76f2 --- /dev/null +++ b/tests/TourMonitor.Tests/LevelTravelIntegrationTests.cs @@ -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; + +/// +/// Живые проверки публичного API Level.Travel через браузер Camoufox (единственный транспорт). +/// Включаются только явно: +/// LT_LIVE_TESTS=1 dotnet test +/// Без переменной окружения тесты «проходят» без выполнения запросов. +/// +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 BuildSessionAsync() + { + var installDir = Path.Combine(Path.GetTempPath(), $"camoufox_test_{Guid.NewGuid():N}"); + var session = new CamoufoxBrowserSession( + new CamoufoxInstaller(new CamoufoxReleaseResolver(), NullLogger.Instance), + Options.Create(new CamoufoxOptions { InstallDirectory = installDir, Headless = true }), + NullLogger.Instance); + await session.GetPageAsync(); + return session; + } + + private static LevelTravelClient BuildClient(CamoufoxBrowserSession session) + { + var transport = new CamoufoxApiTransport(session, Options.Create(BuildOptions()), NullLogger.Instance); + return new LevelTravelClient(transport, Options.Create(BuildOptions()), NullLogger.Instance); + } + + private static List 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.Instance), + session, + Options.Create(monitor), + NullLogger.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); + } + } +} diff --git a/tests/TourMonitor.Tests/PriceStoreTests.cs b/tests/TourMonitor.Tests/PriceStoreTests.cs new file mode 100644 index 0000000..99ba5dd --- /dev/null +++ b/tests/TourMonitor.Tests/PriceStoreTests.cs @@ -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 { 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()); + + 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 { Offer(500_000) }); + await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-07", new List { 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 { Offer(500_000) }); + await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-05", new List { Offer(470_000) }); + await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-06", new List { 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 { Offer(500_000) }); + await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-06", new List { Offer(440_000) }); + await store.SaveScanAsync(9099454, "2026-10-06", "2026-08-06", new List { 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 { Offer(100_000, dateFrom: "2026-10-05") }); + await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-06", new List { Offer(130_000, dateFrom: "2026-10-05") }); + await store.SaveScanAsync(9099454, "2026-10-05", "2026-08-07", new List { 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 + { + 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 + { + 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 { Offer(500_000), Offer(460_000, "Стандарт", "ANEX Tour") }); + await store.SaveScanAsync(9099454, "2026-08-20", "2026-08-07", new List { 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); + } +} diff --git a/tests/TourMonitor.Tests/ReportBuilderTests.cs b/tests/TourMonitor.Tests/ReportBuilderTests.cs new file mode 100644 index 0000000..c330225 --- /dev/null +++ b/tests/TourMonitor.Tests/ReportBuilderTests.cs @@ -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("Август", section); + Assert.Contains("Сентябрь", 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📅 "); + 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("Август", 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())); + } + + [Fact] + public void Build_IncludesHeader_AndOnlyNonEmptySections() + { + var sections = ReportBuilder.Build( + "📊 Мониторинг туров · 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()); + + Assert.Null(result); + } + + [Fact] + public void Html_SpecialCharacters_AreEscaped() + { + var line = Line(roomName: "Стандарт x", operatorName: "ANEX & Co"); + + var section = ReportBuilder.BuildHotelSection("Sunrise Tucana", 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("Делюкс с видом на сад", 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("Мониторинг туров", + 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()); + + 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\n🗓 ")); + } + + [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)); + } +} diff --git a/tests/TourMonitor.Tests/SignTests.cs b/tests/TourMonitor.Tests/SignTests.cs new file mode 100644 index 0000000..2167f26 --- /dev/null +++ b/tests/TourMonitor.Tests/SignTests.cs @@ -0,0 +1,110 @@ +using TourMonitor.LevelTravel; + +namespace TourMonitor.Tests; + +public class SignTests +{ + /// + /// Эталонный знак, снятый с реального запроса веб-клиента level.travel + /// (GET /search/enqueue, зафиксирован в /tmp/lt_probe/cap_search/040-req.json). + /// + [Fact] + public void GetEnqueueSign_Matches_CapturedRequest() + { + var parameters = new Dictionary + { + ["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); + } + + /// + /// Золотой знак для POST /search/multi_enqueue — снят с реального запроса сайта + /// (captured: /tmp/lt_probe/cap_hotel, подпись в теле afaac79c2e0c81be5ce10fd584cc3d44). + /// Проверяет полный путь: сериализация тела + флаттен + подпись. + /// + [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); + } + + /// Знак не зависит от порядка параметров (JS-клиент сортирует значения). + [Fact] + public void Compute_IsOrderIndependent() + { + var values = new List { "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); + } + + /// Строки обрезаются по краям, как в JS (value.trim()). + [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); + } +} diff --git a/tests/TourMonitor.Tests/TestData.cs b/tests/TourMonitor.Tests/TestData.cs new file mode 100644 index 0000000..bd24a96 --- /dev/null +++ b/tests/TourMonitor.Tests/TestData.cs @@ -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=", + ]; +} diff --git a/tests/TourMonitor.Tests/TourMonitor.Tests.csproj b/tests/TourMonitor.Tests/TourMonitor.Tests.csproj new file mode 100644 index 0000000..5acbf15 --- /dev/null +++ b/tests/TourMonitor.Tests/TourMonitor.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/TourMonitor.Tests/UnitTest1.cs b/tests/TourMonitor.Tests/UnitTest1.cs new file mode 100644 index 0000000..195ec86 --- /dev/null +++ b/tests/TourMonitor.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace TourMonitor.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +}