TourMonitor: мониторинг цен туров Level.Travel

This commit is contained in:
kislovdm
2026-08-06 19:47:16 +03:00
commit 98224c7a9f
37 changed files with 3349 additions and 0 deletions
@@ -0,0 +1,97 @@
using Microsoft.Extensions.Options;
using Microsoft.Playwright;
namespace TourMonitor.Camoufox;
/// <summary>
/// Долгоживущая сессия Camoufox: один браузер на весь скан. Лениво запускается,
/// открывает level.travel для установки кук и реального браузерного контекста,
/// дальше API-запросы идут из этой же страницы.
/// </summary>
public sealed class CamoufoxBrowserSession : IAsyncDisposable
{
private readonly ICamoufoxInstaller _installer;
private readonly CamoufoxOptions _options;
private readonly ILogger<CamoufoxBrowserSession> _logger;
private readonly SemaphoreSlim _lock = new(1, 1);
private IPlaywright? _playwright;
private IBrowser? _browser;
private IPage? _page;
private bool _started;
public CamoufoxBrowserSession(ICamoufoxInstaller installer, IOptions<CamoufoxOptions> options, ILogger<CamoufoxBrowserSession> logger)
{
_installer = installer;
_options = options.Value;
_logger = logger;
}
public async Task<IPage> GetPageAsync(CancellationToken cancellationToken = default)
{
await _lock.WaitAsync(cancellationToken);
try
{
if (_started && _page is not null)
return _page;
var executablePath = await _installer.EnsureInstalledAsync(
_options.Version, _options.InstallDirectory, _options.DownloadUrlOverride, cancellationToken);
_logger.LogInformation("Запуск браузерной сессии Camoufox: {ExecutablePath}", executablePath);
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Firefox.LaunchAsync(new BrowserTypeLaunchOptions
{
ExecutablePath = executablePath,
Headless = _options.Headless,
});
_page = await _browser.NewPageAsync();
_logger.LogInformation("Открытие {SessionUrl} для установки сессии...", _options.SessionUrl);
await _page.GotoAsync(_options.SessionUrl, new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
Timeout = 60_000,
});
_started = true;
_logger.LogInformation("Браузерная сессия готова, текущий URL: {Url}", _page.Url);
return _page;
}
finally
{
_lock.Release();
}
}
public async Task CloseAsync()
{
await _lock.WaitAsync();
try
{
if (_page is not null)
{
try { await _page.CloseAsync(); } catch { /* ignore */ }
_page = null;
}
if (_browser is not null)
{
try { await _browser.CloseAsync(); } catch { /* ignore */ }
_browser = null;
}
if (_playwright is not null)
{
_playwright.Dispose();
_playwright = null;
}
_started = false;
_logger.LogInformation("Браузерная сессия Camoufox закрыта.");
}
finally
{
_lock.Release();
}
}
public async ValueTask DisposeAsync() => await CloseAsync();
}
@@ -0,0 +1,153 @@
using System.Diagnostics;
using System.Formats.Tar;
using System.IO.Compression;
using TourMonitor.Camoufox;
namespace TourMonitor.Camoufox;
public interface ICamoufoxInstaller
{
string? GetInstalledExecutablePathOrNull(string installDirectory);
Task<string> EnsureInstalledAsync(string version, string installDirectory, string? downloadUrlOverride, CancellationToken cancellationToken = default);
}
/// <summary>Скачивает патченный Firefox (Camoufox) из релизов и распаковывает его.</summary>
public sealed class CamoufoxInstaller : ICamoufoxInstaller
{
private readonly ICamoufoxReleaseResolver _releaseResolver;
private readonly ILogger<CamoufoxInstaller> _logger;
public CamoufoxInstaller(ICamoufoxReleaseResolver releaseResolver, ILogger<CamoufoxInstaller> logger)
{
_releaseResolver = releaseResolver;
_logger = logger;
}
public string? GetInstalledExecutablePathOrNull(string installDirectory)
{
var candidate = Path.Combine(installDirectory, ExecutableName());
return File.Exists(candidate) ? candidate : null;
}
public async Task<string> EnsureInstalledAsync(string version, string installDirectory, string? downloadUrlOverride, CancellationToken cancellationToken = default)
{
Directory.CreateDirectory(installDirectory);
var installed = GetInstalledExecutablePathOrNull(installDirectory);
if (installed is not null)
return installed;
_logger.LogInformation("Camoufox не найден. Скачивание и установка...");
var asset = _releaseResolver.Resolve(version, downloadUrlOverride);
var archivePath = Path.Combine(Path.GetTempPath(), Path.GetFileName(new Uri(asset.DownloadUrl).LocalPath));
await DownloadAsync(asset.DownloadUrl, archivePath, cancellationToken);
ExtractArchive(archivePath, installDirectory, asset.IsTarGzArchive);
TryDeleteFile(archivePath);
var discovered = LocateExecutable(installDirectory, asset.ExecutableName);
EnsureExecutablePermission(discovered);
_logger.LogInformation("Camoufox установлен: {Path}", discovered);
return discovered;
}
private static string ExecutableName() =>
OperatingSystem.IsWindows() ? "camoufox.exe" : "camoufox";
private async Task DownloadAsync(string url, string destinationPath, CancellationToken cancellationToken)
{
using var httpClient = new HttpClient();
using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength;
await using var source = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destination = File.Create(destinationPath);
var buffer = new byte[81920];
long readTotal = 0;
var lastPrintedPercent = -1;
int read;
while ((read = await source.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
{
await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
readTotal += read;
if (totalBytes is > 0)
{
var percent = (int)Math.Min(100, readTotal * 100 / totalBytes.Value);
if (percent != lastPrintedPercent)
{
lastPrintedPercent = percent;
_logger.LogInformation("[DOWNLOAD] {ProgressPercent}%", percent);
}
}
}
if (totalBytes is null && readTotal > 0)
_logger.LogInformation("[DOWNLOAD] {DownloadedBytes} байт (размер неизвестен)", readTotal);
}
private static void ExtractArchive(string archivePath, string installDirectory, bool isTarGzArchive)
{
if (isTarGzArchive)
{
using var archiveStream = File.OpenRead(archivePath);
using var gzipStream = new GZipStream(archiveStream, CompressionMode.Decompress);
TarFile.ExtractToDirectory(gzipStream, installDirectory, overwriteFiles: true);
return;
}
ZipFile.ExtractToDirectory(archivePath, installDirectory, overwriteFiles: true);
}
private static string LocateExecutable(string installDirectory, string executableName)
{
var directPath = Path.Combine(installDirectory, executableName);
if (File.Exists(directPath))
return directPath;
var nested = Directory
.EnumerateFiles(installDirectory, executableName, SearchOption.AllDirectories)
.FirstOrDefault();
if (nested is not null)
return nested;
throw new FileNotFoundException($"Исполняемый файл Camoufox не найден после распаковки: {executableName}");
}
private static void EnsureExecutablePermission(string path)
{
if (OperatingSystem.IsWindows())
return;
try
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = "chmod",
ArgumentList = { "+x", path },
RedirectStandardError = true,
RedirectStandardOutput = true,
});
process?.WaitForExit();
}
catch
{
// Some environments already preserve executable permissions.
}
}
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
File.Delete(path);
}
catch
{
// Ignore temp cleanup failures.
}
}
}
@@ -0,0 +1,6 @@
namespace TourMonitor.Camoufox;
public sealed record CamoufoxReleaseAsset(
string DownloadUrl,
bool IsTarGzArchive,
string ExecutableName);
@@ -0,0 +1,50 @@
using System.Runtime.InteropServices;
namespace TourMonitor.Camoufox;
public interface ICamoufoxReleaseResolver
{
CamoufoxReleaseAsset Resolve(string version, string? downloadUrlOverride);
}
/// <summary>Резолвит ассет патченного Firefox из релизов daijro/camoufox по ОС/архитектуре.</summary>
public sealed class CamoufoxReleaseResolver : ICamoufoxReleaseResolver
{
public CamoufoxReleaseAsset Resolve(string version, string? downloadUrlOverride)
{
if (!string.IsNullOrWhiteSpace(downloadUrlOverride))
{
var fileName = Path.GetFileName(new Uri(downloadUrlOverride).LocalPath);
var isTarGz = fileName.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase);
return new CamoufoxReleaseAsset(
downloadUrlOverride,
isTarGz,
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "camoufox.exe" : "camoufox");
}
var assetVersion = version.StartsWith("v", StringComparison.OrdinalIgnoreCase)
? version[1..]
: version;
var assetPrefix = $"https://github.com/daijro/camoufox/releases/download/{version}/camoufox-{assetVersion}";
var executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "camoufox.exe" : "camoufox";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return new CamoufoxReleaseAsset($"{assetPrefix}-win.x86_64.zip", false, executableName);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
var arch = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x86_64";
return new CamoufoxReleaseAsset($"{assetPrefix}-lin.{arch}.zip", false, executableName);
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
var arch = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "arm64" : "x86_64";
return new CamoufoxReleaseAsset($"{assetPrefix}-mac.{arch}.zip", false, executableName);
}
throw new PlatformNotSupportedException("Unsupported OS platform for Camoufox installation.");
}
}
+234
View File
@@ -0,0 +1,234 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TourMonitor.Camoufox;
using TourMonitor.LevelTravel;
using TourMonitor.Notifications;
using TourMonitor.Storage;
namespace TourMonitor.Jobs;
/// <summary>
/// Ежедневный скан: для каждой даты заезда — поиск туров по обоим отелям,
/// сохранение цен, сравнение со вчерашним днём и отправка отчёта в Telegram.
/// </summary>
public sealed class DailyScanJob
{
private readonly LevelTravelClient _client;
private readonly PriceStore _store;
private readonly TelegramNotifier _notifier;
private readonly CamoufoxBrowserSession _browser;
private readonly MonitorOptions _options;
private readonly ILogger<DailyScanJob> _logger;
public DailyScanJob(
LevelTravelClient client,
PriceStore store,
TelegramNotifier notifier,
CamoufoxBrowserSession browser,
IOptions<MonitorOptions> options,
ILogger<DailyScanJob> logger)
{
_client = client;
_store = store;
_notifier = notifier;
_browser = browser;
_options = options.Value;
_logger = logger;
}
public async Task ExecuteAsync(CancellationToken ct = default)
{
try
{
_logger.LogInformation("Скан запущен: отелей={Hotels}, дат={Dates}", _options.Hotels.Count, _options.DateRangeDays);
await _store.EnsureCreatedAsync(ct);
var today = DateTime.Today;
var start = today.AddDays(_options.StartOffsetDays);
var dates = Enumerable.Range(0, _options.DateRangeDays)
.Select(offset => start.AddDays(offset))
.ToList();
var todayKey = today.ToString("yyyy-MM-dd");
var scannedDates = 0;
await Parallel.ForEachAsync(
dates,
new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, _options.MaxParallelDates), CancellationToken = ct },
async (date, token) =>
{
try
{
await ScanDateAsync(date, todayKey, token);
Interlocked.Increment(ref scannedDates);
_logger.LogInformation("Дата {Date} — готово ({Scanned}/{Total})", date.ToString("yyyy-MM-dd"), Volatile.Read(ref scannedDates), dates.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Ошибка скана даты {Date}", date.ToString("yyyy-MM-dd"));
}
});
var messages = await BuildReportAsync(todayKey, ct);
if (messages is null)
{
_logger.LogWarning("Отчёт пуст — данных по отелям нет.");
return;
}
var sent = await _notifier.SendMessagesAsync(messages, ct);
_logger.LogInformation("Отчёт отправлен в Telegram: {Sent}/{Total}", sent, messages.Count);
}
finally
{
await _browser.CloseAsync();
}
}
private async Task ScanDateAsync(DateTime date, string todayKey, CancellationToken ct)
{
var dateKey = date.ToString("yyyy-MM-dd");
var searchParams = new List<SearchParam>();
foreach (var hotel in _options.Hotels)
{
searchParams.Add(new SearchParam
{
HotelIds = hotel.Id,
FromCity = _options.DepartureCity,
FromCountry = _options.DepartureCountry,
ToCity = hotel.ToCity,
ToCountry = hotel.ToCountry,
StartDate = dateKey,
Nights = hotel.Nights.ToString(),
Adults = _options.Adults,
Kids = _options.KidsAges.Count,
KidsAges = _options.KidsAges.ToArray(),
FlexDates = false,
SearchType = "package",
});
}
var enqueue = await _client.MultiEnqueueAsync(searchParams, ct);
if (!enqueue.Success || enqueue.SearchRequests.Count != _options.Hotels.Count)
{
_logger.LogWarning("multi_enqueue для {Date} вернул {Count} поисков (ожидалось {Expected})",
dateKey, enqueue.SearchRequests.Count, _options.Hotels.Count);
return;
}
for (var i = 0; i < _options.Hotels.Count; i++)
{
var hotel = _options.Hotels[i];
var requestId = enqueue.SearchRequests[i].RequestId;
var status = await _client.WaitForSearchAsync(
requestId, _options.PollIntervalSeconds, _options.SearchTimeoutSeconds, ct);
var rates = await _client.GetRoomRatesAsync(hotel.Id.ToString(), requestId, ct);
var offers = rates.Success
? FlattenOffers(hotel.Id, dateKey, rates)
: new List<StoredOffer>();
if (offers.Count == 0)
{
_logger.LogInformation("Отель {Hotel} на {Date}: туров нет (статус: {Status})",
hotel.Name, dateKey, string.Join(",", status.Status?.Values.Distinct() ?? Array.Empty<string>()));
}
await _store.SaveScanAsync(hotel.Id, dateKey, todayKey, offers, ct);
}
}
private static List<StoredOffer> FlattenOffers(int hotelId, string dateKey, RoomRatesResponse rates)
{
var offers = new List<StoredOffer>();
if (rates.Result is null)
return offers;
foreach (var roomRate in rates.Result)
{
foreach (var (meal, tourOffers) in roomRate.Offers)
{
foreach (var offer in tourOffers)
{
offers.Add(new StoredOffer(
hotelId,
dateKey,
meal,
roomRate.Room.Id,
roomRate.Room.NameRu,
offer.OperatorId,
offer.OperatorName,
offer.Price,
offer.PricePerNight,
offer.Id,
offer.Link));
}
}
}
return offers;
}
private async Task<IReadOnlyList<string>?> BuildReportAsync(string todayKey, CancellationToken ct)
{
var windowStart = DateTime.Today.AddDays(_options.StartOffsetDays);
var windowEnd = windowStart.AddDays(_options.DateRangeDays);
var todayBests = (await _store.GetTodayBestsAsync(todayKey, ct))
.Where(best => DateTime.TryParse(best.DateFrom, out var date) && date >= windowStart && date < windowEnd)
.ToList();
if (todayBests.Count == 0)
return null;
var header = BuildHeader();
var lines = new List<ReportLine>();
foreach (var best in todayBests)
{
var offer = await _store.GetBestOfferAsync(best.HotelId, best.DateFrom, todayKey, ct);
var previous = await _store.GetBestBeforeAsync(best.HotelId, best.DateFrom, todayKey, ct);
var historical = await _store.GetHistoricalMinBeforeAsync(best.HotelId, best.DateFrom, todayKey, ct);
lines.Add(new ReportLine(
best.HotelId,
best.DateFrom,
best.BestPrice,
offer?.Meal ?? "",
offer?.RoomName ?? "",
offer?.OperatorName ?? "",
offer?.Link ?? "",
previous?.BestPrice,
historical?.BestPrice));
}
return ReportBuilder.BuildMessages(
header,
_options.PricesPerMonth,
_options.Hotels.Select(h => (h.Id, h.Name)).ToList(),
lines,
_options.CalendarUrlBase);
}
private string BuildHeader()
{
var nightsDesc = _options.Hotels.Count == 0
? ""
: _options.Hotels.Select(h => h.Nights).Distinct().Count() == 1
? $"{_options.Hotels[0].Nights} ночей"
: string.Join(", ", _options.Hotels.Select(h => $"{h.Name}: {h.Nights} ночей"));
var paramParts = new List<string>
{
nightsDesc,
$"{_options.Adults}+{_options.KidsAges.Count} чел",
};
if (_options.KidsAges.Count > 0)
paramParts.Add($"ребёнок {string.Join(",", _options.KidsAges)} лет");
var dates = Enumerable.Range(0, _options.DateRangeDays)
.Select(offset => DateTime.Today.AddDays(_options.StartOffsetDays).AddDays(offset).ToString("dd.MM"))
.ToList();
var range = dates.Count == 0 ? "" : $"{dates[0]}–{dates[^1]}";
return $"📊 <b>Мониторинг туров</b> · {string.Join(", ", paramParts)}\nЗаезды: {range}";
}
}
@@ -0,0 +1,64 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Playwright;
using TourMonitor.Camoufox;
namespace TourMonitor.LevelTravel;
/// <summary>
/// Транспорт через реальный браузер Camoufox: fetch выполняется из контекста страницы
/// level.travel, что даёт настоящий браузерный фингерпринт, куки и Origin.
/// </summary>
public sealed class CamoufoxApiTransport : IApiTransport
{
private const string FetchScript = """
async ({ method, url, headers, body }) => {
const res = await fetch(url, {
method,
headers,
body: body === undefined || body === null ? undefined : body,
});
const text = await res.text();
return { status: res.status, body: text };
}
""";
private readonly CamoufoxBrowserSession _session;
private readonly LevelTravelOptions _options;
private readonly ILogger<CamoufoxApiTransport> _logger;
public CamoufoxApiTransport(
CamoufoxBrowserSession session,
IOptions<LevelTravelOptions> options,
ILogger<CamoufoxApiTransport> logger)
{
_session = session;
_options = options.Value;
_logger = logger;
}
public async Task<ApiTransportResponse> SendAsync(HttpMethod method, string url, string? jsonBody, CancellationToken ct = default)
{
var page = await _session.GetPageAsync(ct);
var headers = new Dictionary<string, string>
{
["X-Cnt"] = _options.Country,
["X-Lang"] = _options.Language,
["X-Cur"] = _options.Currency,
["Accept-Language"] = $"{_options.Language}-{_options.Country.ToUpperInvariant()},{_options.Language};q=0.9",
["Accept"] = $"application/vnd.leveltravel.v{_options.ApiVersion}",
["Authorization"] = $"Token token=\"{_options.ApiKey}\"",
["Content-Type"] = "application/json",
};
var response = await page.EvaluateAsync<ApiTransportResponse>(FetchScript, new
{
method = method.Method.ToUpperInvariant(),
url,
headers,
body = jsonBody,
});
return response;
}
}
@@ -0,0 +1,17 @@
namespace TourMonitor.LevelTravel;
/// <summary>Сырой ответ API (статус + тело) до расшифровки/дешифровки.</summary>
public sealed class ApiTransportResponse
{
public int Status { get; set; }
public string Body { get; set; } = "";
}
/// <summary>
/// Транспорт запросов к API. Единственная реализация — реальный браузер Camoufox
/// (CamoufoxApiTransport): fetch из контекста страницы level.travel обходит анти-бот периметр.
/// </summary>
public interface IApiTransport
{
Task<ApiTransportResponse> SendAsync(HttpMethod method, string url, string? jsonBody, CancellationToken ct = default);
}
@@ -0,0 +1,177 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace TourMonitor.LevelTravel;
/// <summary>
/// Клиент публичного API Level.Travel (подпись запросов + расшифровка ответов воспроизведены с веб-клиента).
/// Транспорт (CamoufoxApiTransport) инжектируется отдельно.
/// </summary>
public sealed class LevelTravelClient
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
private readonly IApiTransport _transport;
private readonly LevelTravelOptions _options;
private readonly ILogger<LevelTravelClient> _logger;
public LevelTravelClient(IApiTransport transport, IOptions<LevelTravelOptions> options, ILogger<LevelTravelClient> logger)
{
_transport = transport;
_options = options.Value;
_logger = logger;
}
public async Task<MultiEnqueueResponse> MultiEnqueueAsync(IReadOnlyList<SearchParam> searchParams, CancellationToken ct = default)
{
var body = new MultiEnqueueRequest { SearchParams = searchParams.ToList() };
return await PostAsync<MultiEnqueueResponse>("/search/multi_enqueue", body, ct);
}
public async Task<SearchStatus> GetStatusAsync(string requestId, CancellationToken ct = default)
{
var parameters = new Dictionary<string, object?>
{
["request_id"] = requestId,
["show_size"] = "true",
};
return await GetAsync<SearchStatus>("/search/status", parameters, ct);
}
public async Task<RoomRatesResponse> GetRoomRatesAsync(string hotelId, string requestId, CancellationToken ct = default)
{
var body = new RoomRatesRequest
{
HotelId = hotelId,
RequestId = requestId,
Filters = new RoomRatesFilters(),
};
return await PostAsync<RoomRatesResponse>("/hotel_search/room_rates", body, ct);
}
/// <summary>Ожидает завершения поиска: completeness = 100 или все операторы в терминальном статусе.</summary>
public async Task<SearchStatus> WaitForSearchAsync(string requestId, int pollIntervalSeconds, int timeoutSeconds, CancellationToken ct = default)
{
var startedAt = DateTime.UtcNow;
var timeout = TimeSpan.FromSeconds(timeoutSeconds);
var terminal = new HashSet<string> { "cached", "completed", "all_filtered", "no_results", "failed", "skipped" };
while (true)
{
ct.ThrowIfCancellationRequested();
var status = await GetStatusAsync(requestId, ct);
var statuses = status.Status?.Values.ToList() ?? new List<string>();
var allTerminal = statuses.Count > 0 && statuses.All(s => terminal.Contains(s));
if (status.Completeness >= 100 || allTerminal)
return status;
if (DateTime.UtcNow - startedAt > timeout)
throw new TimeoutException($"Поиск {requestId} не завершился за {timeoutSeconds} с (completeness={status.Completeness}).");
await Task.Delay(TimeSpan.FromSeconds(Math.Max(1, pollIntervalSeconds)), ct);
}
}
private async Task<T> GetAsync<T>(string path, IDictionary<string, object?> parameters, CancellationToken ct)
{
var sign = SignHelper.ComputeGet(path, parameters, _options.ApiKey, _options.SignSalt, _options.ApiVersion);
var query = new List<string>();
foreach (var (key, value) in parameters)
query.Add($"{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value?.ToString() ?? "")}");
query.Add($"key={_options.ApiKey}");
query.Add($"api_version={_options.ApiVersion}");
query.Add("js=true");
query.Add($"sign={sign}");
var url = $"{_options.BaseUrl}{path}?{string.Join("&", query)}";
return await ExecuteWithRetryAsync(() => SendAndDeserializeAsync<T>(HttpMethod.Get, url, null, ct), $"GET {path}");
}
private async Task<T> PostAsync<T>(string path, object body, CancellationToken ct)
{
var sign = SignHelper.Compute(path, GetSignValues(body), _options.ApiKey, _options.SignSalt);
var payload = JsonSerializer.Serialize(body, JsonOptions);
// sign добавляется последним полем, как это делает веб-клиент
var payloadWithSign = payload[..^1] + $",\"sign\":\"{sign}\"}}";
var url = $"{_options.BaseUrl}{path}";
return await ExecuteWithRetryAsync(() => SendAndDeserializeAsync<T>(HttpMethod.Post, url, payloadWithSign, ct), $"POST {path}");
}
internal static IEnumerable<object?> GetSignValues(object body)
{
// значения берутся в порядке сериализации (snake_case), порядок для подписи не важен — она сортируется
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(body, JsonOptions));
return doc.RootElement.EnumerateObject().Select(p => GetValue(p.Value)).ToList();
}
private static object? GetValue(JsonElement el) => el.ValueKind switch
{
JsonValueKind.String => el.GetString(),
JsonValueKind.Number => el.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Array => el.EnumerateArray().Select(GetValue).ToList(),
JsonValueKind.Object => el.EnumerateObject().Select(p => GetValue(p.Value)).ToList(),
_ => null,
};
private async Task<T> SendAndDeserializeAsync<T>(HttpMethod method, string url, string? payload, CancellationToken ct)
{
var response = await _transport.SendAsync(method, url, payload, ct);
var body = response.Body;
if (response.Status is (int)HttpStatusCode.Forbidden or (int)HttpStatusCode.Unauthorized)
throw new LevelTravelException($"API отклонило запрос ({response.Status}): {Truncate(body)}");
if (response.Status is < 200 or >= 300)
throw new LevelTravelException($"API вернуло {response.Status}: {Truncate(body)}");
// decoy-ответы анти-бота: 200 с телом {"message":"partner not found"} вместо данных
if (body.StartsWith("{\"message\":\"partner not found\"", StringComparison.Ordinal))
throw new LevelTravelException($"API вернуло decoy: {Truncate(body)}");
return Deserialize<T>(LevelTravelCrypto.DecryptToJson(body, _options.SecretBoxKeys));
}
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> action, string what)
{
var attempts = new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) };
Exception? last = null;
foreach (var delay in attempts)
{
try
{
return await action();
}
catch (Exception ex) when (ex is LevelTravelException or HttpRequestException or TaskCanceledException)
{
last = ex;
_logger.LogWarning("{What} не удалось ({Message}), повтор через {Delay}s", what, ex.Message, delay.TotalSeconds);
await Task.Delay(delay);
}
}
throw new LevelTravelException($"{what} не выполнено после {attempts.Length} попыток.", last!);
}
private static T Deserialize<T>(string json)
{
try
{
return JsonSerializer.Deserialize<T>(json, JsonOptions)!;
}
catch (JsonException ex)
{
throw new LevelTravelException($"Не удалось разобрать ответ: {Truncate(json, 300)}", ex);
}
}
private static string Truncate(string s, int length = 500) =>
s.Length <= length ? s : s[..length] + "...";
}
@@ -0,0 +1,104 @@
using System.IO.Compression;
using System.Text;
using Org.BouncyCastle.Crypto.Engines;
using Org.BouncyCastle.Crypto.Macs;
using Org.BouncyCastle.Crypto.Parameters;
namespace TourMonitor.LevelTravel;
/// <summary>
/// Расшифровка ответов API Level.Travel.
/// Формат тела (если начинается с "!/"): base64url(
/// байт(23 XOR индекс ключа), nonce(24), NaCl secretbox(тег(16) + шифротекст) )
/// затем zlib-inflate и JSON.
/// Secretbox = XSalsa20-Poly1305: ключ Poly1305 = первые 32 байта keystream, тег MAC — в начале.
/// </summary>
public static class LevelTravelCrypto
{
private const string Prefix = "!/";
/// <summary>Декодирует ответ в строку JSON; если тело не зашифровано — возвращает как есть.</summary>
public static string DecryptToJson(string body, IReadOnlyList<string> secretBoxKeys)
{
if (!body.StartsWith(Prefix, StringComparison.Ordinal))
return body;
byte[] payload;
try
{
payload = Convert.FromBase64String(body.Substring(2).Replace('-', '+').Replace('_', '/'));
}
catch (FormatException)
{
throw new LevelTravelException("Не удалось декодировать base64 в теле ответа.");
}
if (payload.Length < 25)
throw new LevelTravelException("Тело ответа слишком короткое для расшифровки.");
int keyIndex = (payload[0] ^ 23) - '0';
if (keyIndex < 1 || keyIndex > secretBoxKeys.Count)
throw new LevelTravelException($"Неизвестный индекс ключа расшифровки: {keyIndex}.");
byte[] key;
try
{
key = Convert.FromBase64String(secretBoxKeys[keyIndex - 1]);
}
catch (FormatException)
{
throw new LevelTravelException("Неверный формат ключа расшифровки в конфигурации.");
}
var nonce = payload.AsSpan(1, 24).ToArray();
var ciphertext = payload.AsSpan(25).ToArray();
var plaintext = SecretBoxOpen(ciphertext, nonce, key)
?? throw new LevelTravelException("Не удалось расшифровать ответ (неверный MAC).");
using var zlib = new ZLibStream(new MemoryStream(plaintext), CompressionMode.Decompress);
using var reader = new StreamReader(zlib, Encoding.UTF8);
return reader.ReadToEnd();
}
private static byte[]? SecretBoxOpen(byte[] ciphertext, byte[] nonce, byte[] key)
{
var engine = new XSalsa20Engine();
engine.Init(true, new ParametersWithIV(new KeyParameter(key), nonce));
var streamLength = 32 + ciphertext.Length;
var stream = new byte[streamLength];
engine.ProcessBytes(new byte[streamLength], 0, streamLength, stream, 0);
var polyKey = stream.AsSpan(0, 32).ToArray();
var data = ciphertext.AsSpan(16).ToArray();
var receivedTag = ciphertext.AsSpan(0, 16).ToArray();
var mac = new Poly1305();
mac.Init(new KeyParameter(polyKey));
mac.BlockUpdate(data, 0, data.Length);
var tag = new byte[16];
mac.DoFinal(tag, 0);
if (!FixedTimeEquals(tag, receivedTag))
return null;
for (var i = 0; i < data.Length; i++)
data[i] ^= stream[32 + i];
return data;
}
private static bool FixedTimeEquals(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b)
{
var diff = 0;
for (var i = 0; i < a.Length; i++)
diff |= a[i] ^ b[i];
return diff == 0;
}
}
public sealed class LevelTravelException : Exception
{
public LevelTravelException(string message) : base(message) { }
public LevelTravelException(string message, Exception inner) : base(message, inner) { }
}
+195
View File
@@ -0,0 +1,195 @@
using System.Text.Json.Serialization;
namespace TourMonitor.LevelTravel;
public sealed class MultiEnqueueRequest
{
[JsonPropertyName("search_params")]
public List<SearchParam> SearchParams { get; set; } = new();
}
public sealed class SearchParam
{
[JsonPropertyName("hotel_ids")]
public int HotelIds { get; set; }
[JsonPropertyName("from_city")]
public string FromCity { get; set; } = "";
[JsonPropertyName("from_country")]
public string FromCountry { get; set; } = "";
[JsonPropertyName("to_city")]
public string ToCity { get; set; } = "";
[JsonPropertyName("to_country")]
public string ToCountry { get; set; } = "";
[JsonPropertyName("start_date")]
public string StartDate { get; set; } = "";
[JsonPropertyName("nights")]
public string Nights { get; set; } = "";
[JsonPropertyName("adults")]
public int Adults { get; set; }
[JsonPropertyName("kids")]
public int Kids { get; set; }
[JsonPropertyName("kids_ages")]
public int[] KidsAges { get; set; } = Array.Empty<int>();
[JsonPropertyName("flex_dates")]
public bool FlexDates { get; set; }
[JsonPropertyName("search_type")]
public string SearchType { get; set; } = "package";
}
public sealed class MultiEnqueueResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("search_requests")]
public List<SearchRequest> SearchRequests { get; set; } = new();
}
public sealed class SearchRequest
{
[JsonPropertyName("request_id")]
public string RequestId { get; set; } = "";
}
public sealed class SearchStatus
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("status")]
public Dictionary<string, string>? Status { get; set; }
[JsonPropertyName("size")]
public long? Size { get; set; }
[JsonPropertyName("completeness")]
public int? Completeness { get; set; }
[JsonPropertyName("request_id")]
public string RequestId { get; set; } = "";
}
public sealed class RoomRatesRequest
{
[JsonPropertyName("hotel_id")]
public string HotelId { get; set; } = "";
[JsonPropertyName("request_id")]
public string RequestId { get; set; } = "";
[JsonPropertyName("filters")]
public RoomRatesFilters Filters { get; set; } = new();
}
public sealed class RoomRatesFilters
{
[JsonPropertyName("meals")]
public List<string> Meals { get; set; } = new();
[JsonPropertyName("free_cancel")]
public bool FreeCancel { get; set; }
[JsonPropertyName("confirmability")]
public object? Confirmability { get; set; }
[JsonPropertyName("payment_benefits")]
public List<object> PaymentBenefits { get; set; } = new();
}
public sealed class RoomRatesResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("result")]
public List<RoomRate> Result { get; set; } = new();
}
public sealed class RoomRate
{
[JsonPropertyName("ID")]
public string Id { get; set; } = "";
[JsonPropertyName("min_price")]
public int MinPrice { get; set; }
[JsonPropertyName("meal_types")]
public List<MealType> MealTypes { get; set; } = new();
[JsonPropertyName("room")]
public RoomInfo Room { get; set; } = new();
[JsonPropertyName("offers")]
public Dictionary<string, List<TourOffer>> Offers { get; set; } = new();
}
public sealed class MealType
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("min_price")]
public int MinPrice { get; set; }
}
public sealed class RoomInfo
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("name_ru")]
public string NameRu { get; set; } = "";
}
public sealed class TourOffer
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("nights_count")]
public int NightsCount { get; set; }
[JsonPropertyName("price")]
public int Price { get; set; }
[JsonPropertyName("price_per_night")]
public int PricePerNight { get; set; }
[JsonPropertyName("operator_id")]
public int OperatorId { get; set; }
[JsonPropertyName("operator_name")]
public string OperatorName { get; set; } = "";
[JsonPropertyName("start_date")]
public string StartDate { get; set; } = "";
[JsonPropertyName("link")]
public string Link { get; set; } = "";
[JsonPropertyName("extras")]
public OfferExtras? Extras { get; set; }
}
public sealed class OfferExtras
{
[JsonPropertyName("best_price")]
public bool BestPrice { get; set; }
[JsonPropertyName("instant_confirm")]
public bool InstantConfirm { get; set; }
[JsonPropertyName("cheap")]
public bool Cheap { get; set; }
}
+73
View File
@@ -0,0 +1,73 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace TourMonitor.LevelTravel;
/// <summary>
/// Воспроизводит алгоритм подписи запросов веб-клиента Level.Travel:
/// sign = md5( sorted(значения параметров, JSON без кавычек).join("") + последний_сегмент_пути + key + salt )
/// Значения рекурсивно разворачиваются (массивы/объекты — по значениям), пустые массивы/объекты пропускаются.
/// </summary>
public static class SignHelper
{
public static string ComputeGet(string path, IDictionary<string, object?> parameters, string key, string salt, string apiVersion)
{
var withMeta = new Dictionary<string, object?>(parameters)
{
["key"] = key,
["api_version"] = apiVersion,
["js"] = "true",
};
return Compute(path, withMeta.Values, key, salt);
}
public static string Compute(string path, IEnumerable<object?> values, string key, string salt)
{
var flattened = new List<object?>();
Flatten(values, flattened);
var mapped = new List<string>();
foreach (var value in flattened)
{
var json = JsonSerializer.Serialize(value);
if (json is "\"[]\"" or "\"{}\"" && value is not string)
json = "";
mapped.Add(json.Replace("\"", ""));
}
mapped.Sort(StringComparer.Ordinal);
var lastSegment = path.TrimEnd('/').Split('/').LastOrDefault() ?? "";
var source = string.Concat(mapped) + lastSegment + key + salt;
return Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(source)));
}
private static void Flatten(IEnumerable<object?> values, List<object?> output)
{
foreach (var value in values)
{
switch (value)
{
case string s:
output.Add(s.Trim());
break;
case System.Collections.IDictionary dict:
// как Object.values(obj) в JS: берём только значения
var dictItems = dict.Values.Cast<object?>().ToList();
if (dictItems.Count > 0)
Flatten(dictItems, output);
break;
case System.Collections.IEnumerable seq:
// как Object.values(array) в JS: пустой массив значений не даёт
var items = seq.Cast<object?>().ToList();
if (items.Count > 0)
Flatten(items, output);
break;
default:
output.Add(value);
break;
}
}
}
}
@@ -0,0 +1,222 @@
using System.Globalization;
using System.Net;
using System.Text;
namespace TourMonitor.Notifications;
public sealed record ReportLine(
int HotelId,
string DateFrom,
int Price,
string Meal,
string RoomName,
string OperatorName,
string Link,
int? PreviousPrice,
int? HistoricalMin);
/// <summary>Собирает HTML-сообщение для Telegram: секция на каждый отель с топом дешёвых дат и дельтами.</summary>
public static class ReportBuilder
{
private static readonly CultureInfo Ru = CultureInfo.GetCultureInfo("ru-RU");
/// <summary>Собирает сообщение целиком (для обратной совместимости/тестов). Возвращает null, если нет данных ни по одному отелю.</summary>
public static string? Build(
string header,
int pricesPerMonth,
IReadOnlyList<(int HotelId, string Name)> hotels,
IReadOnlyList<ReportLine> lines)
{
var messages = BuildMessages(header, pricesPerMonth, hotels, lines);
return messages is null ? null : string.Join("\n\n", messages);
}
/// <summary>
/// Собирает список сообщений для Telegram: первое — заголовок + первый отель,
/// каждое следующее — отдельный отель. Сообщения длиннее лимита Telegram (4096)
/// разбиваются на несколько. Возвращает null, если данных нет ни по одному отелю.
/// </summary>
public static IReadOnlyList<string>? BuildMessages(
string header,
int pricesPerMonth,
IReadOnlyList<(int HotelId, string Name)> hotels,
IReadOnlyList<ReportLine> lines,
string? calendarUrlBase = null)
{
var messages = new List<string>();
var first = true;
foreach (var hotel in hotels)
{
var section = BuildHotelSection(
hotel.Name,
pricesPerMonth,
lines.Where(l => l.HotelId == hotel.HotelId),
calendarUrlBase is null ? null : $"{calendarUrlBase.TrimEnd('/')}/prices/{hotel.HotelId}");
if (section is null)
continue;
messages.Add(first ? header + "\n\n" + section : section);
first = false;
}
if (messages.Count == 0)
return null;
var result = new List<string>();
foreach (var message in messages)
result.AddRange(SplitMessage(message));
return result;
}
/// <summary>
/// Секция для одного отеля; null, если по отелю нет данных.
/// В каждом месяце диапазона показывается до <paramref name="pricesPerMonth"/> дешёвых дат.
/// </summary>
public static string? BuildHotelSection(
string hotelName,
int pricesPerMonth,
IEnumerable<ReportLine> lines,
string? calendarUrl = null)
{
var byMonth = lines
.Where(l => l.Price > 0)
.GroupBy(l => MonthKey(l.DateFrom))
.OrderBy(g => g.Key)
.ToList();
if (byMonth.Count == 0)
return null;
var sb = new StringBuilder();
sb.Append("🏝 <b>").Append(HtmlEncode(hotelName)).Append("</b>");
if (calendarUrl is not null)
{
sb.AppendLine();
sb.Append("🗓 <a href=\"").Append(HtmlEncode(calendarUrl)).Append("\">календарь цен</a>");
}
foreach (var month in byMonth)
{
sb.AppendLine();
sb.Append("📅 <b>").Append(HtmlEncode(MonthName(month.Key))).Append("</b>");
foreach (var line in month.OrderBy(l => l.Price).ThenBy(l => l.DateFrom).Take(pricesPerMonth))
{
sb.AppendLine();
sb.Append(" 📅 ").Append(FormatDate(line.DateFrom)).Append(" — <b>")
.Append(FormatPrice(line.Price)).Append(" ₽</b>");
var delta = DescribeDelta(line);
if (delta is not null)
sb.Append(' ').Append(delta);
sb.AppendLine();
sb.Append(" 🍽 ").Append(HtmlEncode(line.Meal)).Append(" · ");
if (string.IsNullOrEmpty(line.Link))
{
sb.Append(HtmlEncode(line.RoomName));
}
else
{
sb.Append("<a href=\"").Append(HtmlEncode(FullLink(line.Link))).Append("\">")
.Append(HtmlEncode(line.RoomName)).Append("</a>");
}
sb.Append(" · ").Append(HtmlEncode(line.OperatorName));
}
}
return sb.ToString();
}
/// <summary>Ключ месяца (yyyyMM) для группировки; неизвестные даты — в отдельной группе в конце.</summary>
private static string MonthKey(string dateFrom)
{
if (DateTime.TryParse(dateFrom, out var date))
return date.ToString("yyyyMM", Ru);
return "999999";
}
private static string MonthName(string monthKey)
{
if (monthKey.Length == 6 && int.TryParse(monthKey, out var yyyyMM))
{
var name = new DateTime(yyyyMM / 100, yyyyMM % 100, 1).ToString("MMMM", Ru);
return char.ToUpper(name[0]) + name[1..];
}
return "—";
}
/// <summary>
/// Делит сообщение на части, не превышающие лимит Telegram (4096 симв.) — по границам
/// строк, а слишком длинную одиночную строку режет принудительно.
/// </summary>
public static IEnumerable<string> SplitMessage(string text, int maxLength = 4000)
{
if (string.IsNullOrEmpty(text))
yield break;
if (text.Length <= maxLength)
{
yield return text;
yield break;
}
var current = new StringBuilder();
foreach (var part in text.Split('\n'))
{
if (part.Length > maxLength)
{
if (current.Length > 0)
{
yield return current.ToString();
current.Clear();
}
for (var i = 0; i < part.Length; i += maxLength)
yield return part.Substring(i, Math.Min(maxLength, part.Length - i));
continue;
}
if (current.Length > 0 && current.Length + part.Length + 1 > maxLength)
{
yield return current.ToString();
current.Clear();
}
if (current.Length > 0)
current.Append('\n');
current.Append(part);
}
if (current.Length > 0)
yield return current.ToString();
}
private static string? DescribeDelta(ReportLine line)
{
if (line.PreviousPrice is null or <= 0)
return null;
var deltaPercent = (double)(line.Price - line.PreviousPrice.Value) / line.PreviousPrice.Value * 100;
var label = deltaPercent switch
{
< 0 => $"🔻 −{Math.Abs(deltaPercent):0}% (было {FormatPrice(line.PreviousPrice.Value)} ₽)",
> 0 => $"🔺 +{deltaPercent:0}% (было {FormatPrice(line.PreviousPrice.Value)} ₽)",
_ => "= (без изменений)",
};
if (line.HistoricalMin is { } min && line.Price < min)
return $"✨ новая минималка · {label}";
return label;
}
private static string FullLink(string link) =>
string.IsNullOrEmpty(link) ? "https://level.travel/" : "https://level.travel" + link;
private static string FormatDate(string dateFrom) =>
DateTime.TryParse(dateFrom, out var date) ? date.ToString("dd.MM", Ru) : dateFrom;
private static string FormatPrice(int price) =>
price.ToString("N0", Ru);
private static string HtmlEncode(string value) =>
WebUtility.HtmlEncode(value);
}
@@ -0,0 +1,80 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace TourMonitor.Notifications;
/// <summary>Отправка сообщений в Telegram через Bot API (прямой HTTP, без сторонних пакетов).</summary>
public sealed class TelegramNotifier
{
private readonly HttpClient _http;
private readonly TelegramOptions _options;
private readonly ILogger<TelegramNotifier> _logger;
public TelegramNotifier(HttpClient http, IOptions<TelegramOptions> options, ILogger<TelegramNotifier> logger)
{
_http = http;
_options = options.Value;
_logger = logger;
}
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(_options.BotToken) && !string.IsNullOrWhiteSpace(_options.ChatId);
/// <summary>Отправляет несколько сообщений по очереди; возвращает число отправленных.</summary>
public async Task<int> SendMessagesAsync(IReadOnlyList<string> messages, CancellationToken ct = default)
{
if (!IsConfigured)
{
_logger.LogWarning("Telegram не настроен (BotToken/ChatId) — сообщения не отправлены.");
return 0;
}
var sent = 0;
foreach (var text in messages)
{
if (await SendMessageAsync(text, ct))
sent++;
}
return sent;
}
public async Task<bool> SendMessageAsync(string text, CancellationToken ct = default)
{
if (!IsConfigured)
{
_logger.LogWarning("Telegram не настроен (BotToken/ChatId) — сообщение не отправлено.");
return false;
}
var payload = new Dictionary<string, object>
{
["chat_id"] = _options.ChatId!,
["text"] = text,
["parse_mode"] = "HTML",
["disable_web_page_preview"] = true,
};
for (var attempt = 0; attempt < 2; attempt++)
{
try
{
var url = $"https://api.telegram.org/bot{_options.BotToken}/sendMessage";
using var response = await _http.PostAsJsonAsync(url, payload, ct);
var body = await response.Content.ReadAsStringAsync(ct);
if (response.IsSuccessStatusCode)
return true;
_logger.LogWarning("Telegram вернул {(StatusCode)}: {Body}", (int)response.StatusCode, body.Length > 300 ? body[..300] : body);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
_logger.LogWarning("Telegram недоступен ({Message})", ex.Message);
}
await Task.Delay(TimeSpan.FromSeconds(3), ct);
}
return false;
}
}
+71
View File
@@ -0,0 +1,71 @@
namespace TourMonitor;
public sealed class LevelTravelOptions
{
public string BaseUrl { get; set; } = "https://api.level.travel";
public string ApiKey { get; set; } = "0fe9fb2ff35679322db5429b18a53aee";
public string SignSalt { get; set; } = "2qqRS1f8TyuF";
public string ApiVersion { get; set; } = "3.14";
public string Language { get; set; } = "ru";
public string Country { get; set; } = "ru";
public string Currency { get; set; } = "RUB";
public List<string> SecretBoxKeys { get; set; } = new();
}
public sealed class HotelOptions
{
public int Id { get; set; }
public string Name { get; set; } = "";
/// <summary>Направление поиска отеля.</summary>
public string ToCity { get; set; } = "";
/// <summary>Страна направления.</summary>
public string ToCountry { get; set; } = "";
/// <summary>Количество ночей для отеля.</summary>
public int Nights { get; set; } = 9;
}
public sealed class MonitorOptions
{
public List<HotelOptions> Hotels { get; set; } = new();
public string DepartureCity { get; set; } = "Moscow";
public string DepartureCountry { get; set; } = "RU";
public int Adults { get; set; } = 2;
public List<int> KidsAges { get; set; } = new();
public int DateRangeDays { get; set; } = 90;
/// <summary>Окно скана начинается не с сегодняшнего дня, а с отступа в днях (минимум).</summary>
public int StartOffsetDays { get; set; } = 60;
/// <summary>Сколько дешёвых дат показывать в отчёте для каждого месяца диапазона.</summary>
public int PricesPerMonth { get; set; } = 3;
public bool RunOnStart { get; set; }
public int MaxParallelDates { get; set; } = 5;
public int PollIntervalSeconds { get; set; } = 4;
public int SearchTimeoutSeconds { get; set; } = 180;
public string DbPath { get; set; } = "Data/tour_monitor.db";
/// <summary>Публичный базовый URL (для ссылки на календарь цен в отчёте). Пусто — ссылка не добавляется.</summary>
public string CalendarUrlBase { get; set; } = "";
}
public sealed class TelegramOptions
{
public string BotToken { get; set; } = "";
public string ChatId { get; set; } = "";
}
public sealed class ScheduleOptions
{
public string Cron { get; set; } = "0 30 7 * * ?";
public string TimeZoneId { get; set; } = "Europe/Moscow";
}
/// <summary>Настройки анти-детект браузера Camoufox (патченный Firefox + Playwright).</summary>
public sealed class CamoufoxOptions
{
/// <summary>Тег релиза daijro/camoufox.</summary>
public string Version { get; set; } = "v152.0.4-beta.28";
/// <summary>Каталог установки (может быть относительным к корню приложения).</summary>
public string InstallDirectory { get; set; } = "Data/camoufox";
public string? DownloadUrlOverride { get; set; }
public bool Headless { get; set; } = true;
/// <summary>Страница для установки браузерной сессии и кук.</summary>
public string SessionUrl { get; set; } = "https://level.travel/";
}
+94
View File
@@ -0,0 +1,94 @@
using Hangfire;
using Hangfire.Dashboard;
using Hangfire.InMemory;
using Microsoft.Extensions.Options;
using TourMonitor;
using TourMonitor.Camoufox;
using TourMonitor.Jobs;
using TourMonitor.LevelTravel;
using TourMonitor.Notifications;
using TourMonitor.Storage;
using TourMonitor.Web;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<LevelTravelOptions>(builder.Configuration.GetSection("LevelTravel"));
builder.Services.Configure<MonitorOptions>(builder.Configuration.GetSection("Monitor"));
builder.Services.Configure<TelegramOptions>(builder.Configuration.GetSection("Telegram"));
builder.Services.Configure<ScheduleOptions>(builder.Configuration.GetSection("Schedule"));
builder.Services.Configure<CamoufoxOptions>(builder.Configuration.GetSection("Camoufox"));
builder.Services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseInMemoryStorage());
builder.Services.AddHangfireServer();
builder.Services.AddSingleton<ICamoufoxReleaseResolver, CamoufoxReleaseResolver>();
builder.Services.AddSingleton<ICamoufoxInstaller, CamoufoxInstaller>();
builder.Services.AddSingleton<CamoufoxBrowserSession>();
builder.Services.AddSingleton<IApiTransport, CamoufoxApiTransport>();
builder.Services.AddSingleton<LevelTravelClient>();
builder.Services.AddHttpClient<TelegramNotifier>(client => client.Timeout = TimeSpan.FromSeconds(20));
builder.Services.AddSingleton(sp =>
{
var monitor = sp.GetRequiredService<IOptions<MonitorOptions>>().Value;
var contentRoot = sp.GetRequiredService<IHostEnvironment>().ContentRootPath;
var dbPath = Path.IsPathRooted(monitor.DbPath)
? monitor.DbPath
: Path.Combine(contentRoot, monitor.DbPath);
return new PriceStore(dbPath, sp.GetService<ILogger<PriceStore>>());
});
builder.Services.AddSingleton<DailyScanJob>();
var app = builder.Build();
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
// По умолчанию Hangfire отдаёт дашборд только с localhost (401 с других хостов);
// мониторинг должен быть доступен по LAN, поэтому разрешаем все источники.
Authorization = new[] { new AllowAllDashboardAuthorizationFilter() },
});
var schedule = app.Services.GetRequiredService<IOptions<ScheduleOptions>>().Value;
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(schedule.TimeZoneId);
RecurringJob.AddOrUpdate<DailyScanJob>(
"daily-scan",
job => job.ExecuteAsync(CancellationToken.None),
schedule.Cron,
new RecurringJobOptions { TimeZone = timeZone });
var monitor = app.Services.GetRequiredService<IOptions<MonitorOptions>>().Value;
if (monitor.RunOnStart)
BackgroundJob.Enqueue<DailyScanJob>(job => job.ExecuteAsync(CancellationToken.None));
var calendarBaseUrl = monitor.CalendarUrlBase.TrimEnd('/');
app.MapGet("/prices", (PriceStore store) => Results.Content(
CalendarPage.Index("Мониторинг туров", monitor.Hotels.Select(h => (h.Id, h.Name)).ToList(), calendarBaseUrl),
"text/html; charset=utf-8"));
app.MapGet("/prices/{hotelId:int}", async (int hotelId, PriceStore store, CancellationToken ct) =>
{
var hotel = monitor.Hotels.FirstOrDefault(h => h.Id == hotelId);
if (hotel is null)
return Results.NotFound();
var entries = (await store.GetCalendarAsync(ct))
.Where(e => e.HotelId == hotelId)
.Select(e => (e.DateFrom, e.BestPrice, e.PreviousPrice, e.Link, e.MinPrice, e.MaxPrice))
.ToList();
var windowStart = DateTime.Today.AddDays(monitor.StartOffsetDays);
var html = CalendarPage.Hotel(hotel.Name, hotelId, entries, windowStart, monitor.DateRangeDays, calendarBaseUrl);
return Results.Content(html, "text/html; charset=utf-8");
});
app.Run();
/// <summary>Разрешает доступ к Hangfire-дашборду с любого хоста (по умолчанию — только localhost).</summary>
internal sealed class AllowAllDashboardAuthorizationFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context) => true;
}
public partial class Program;
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5199",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+334
View File
@@ -0,0 +1,334 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
namespace TourMonitor.Storage;
public sealed record StoredOffer(
int HotelId,
string DateFrom,
string Meal,
int? RoomId,
string? RoomName,
int OperatorId,
string OperatorName,
int Price,
int PricePerNight,
string OfferId,
string Link);
public sealed record DailyBest(int HotelId, string DateFrom, string Date, int BestPrice, string OfferId);
/// <summary>Минимальная известная цена по (отель, дата заезда) — строка календаря цен.</summary>
/// <summary>
/// Данные календаря по конкретной дате тура: цена из последнего сканирования (BestPrice),
/// цена предыдущего скана (PreviousPrice, null если его не было), ссылка на бронирование
/// лучшего оффера (Link) и диапазон минимальной цены по датам сканирования (MinPrice–MaxPrice).
/// </summary>
public sealed record CalendarEntry(int HotelId, string DateFrom, int BestPrice, int? PreviousPrice, string Link, int MinPrice, int MaxPrice);
/// <summary>SQLite-хранилище истории цен и ежедневных минимумов по (отель, дата заезда).</summary>
public sealed class PriceStore
{
private readonly string _connectionString;
private readonly ILogger<PriceStore> _logger;
private readonly SemaphoreSlim _lock = new(1, 1);
public PriceStore(string dbPath, ILogger<PriceStore>? logger = null)
{
var directory = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
_connectionString = new SqliteConnectionStringBuilder { DataSource = dbPath, Cache = SqliteCacheMode.Shared }.ToString();
_logger = logger ?? NullLogger.Instance;
}
public async Task EnsureCreatedAsync(CancellationToken ct = default)
{
await ExecuteAsync(async connection =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = """
CREATE TABLE IF NOT EXISTS price_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hotel_id INTEGER NOT NULL,
date_from TEXT NOT NULL,
checked_at TEXT NOT NULL,
meal TEXT NOT NULL DEFAULT '',
room_id INTEGER,
room_name TEXT,
operator_id INTEGER NOT NULL,
operator_name TEXT NOT NULL DEFAULT '',
price INTEGER NOT NULL,
price_per_night INTEGER,
offer_id TEXT NOT NULL DEFAULT '',
link TEXT NOT NULL DEFAULT '',
is_best INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS ix_history ON price_history (hotel_id, date_from, checked_at);
CREATE TABLE IF NOT EXISTS daily_best (
hotel_id INTEGER NOT NULL,
date_from TEXT NOT NULL,
date TEXT NOT NULL,
best_price INTEGER NOT NULL,
offer_id TEXT NOT NULL DEFAULT '',
PRIMARY KEY (hotel_id, date_from, date)
);
""";
await cmd.ExecuteNonQueryAsync(ct);
});
}
/// <summary>Сохраняет офферы скана; лучший по цене помечается и пишется в daily_best.</summary>
public async Task SaveScanAsync(int hotelId, string dateFrom, string date, IReadOnlyList<StoredOffer> offers, CancellationToken ct = default)
{
var checkedAt = DateTime.UtcNow.ToString("O");
var best = offers.MinBy(o => o.Price);
await ExecuteAsync(async connection =>
{
await using var transaction = await connection.BeginTransactionAsync(ct);
var insert = connection.CreateCommand();
insert.CommandText = """
INSERT INTO price_history
(hotel_id, date_from, checked_at, meal, room_id, room_name, operator_id, operator_name,
price, price_per_night, offer_id, link, is_best)
VALUES
($hotel_id, $date_from, $checked_at, $meal, $room_id, $room_name, $operator_id, $operator_name,
$price, $price_per_night, $offer_id, $link, $is_best)
""";
insert.Parameters.Add("$hotel_id", SqliteType.Integer);
insert.Parameters.Add("$date_from", SqliteType.Text);
insert.Parameters.Add("$checked_at", SqliteType.Text);
insert.Parameters.Add("$meal", SqliteType.Text);
insert.Parameters.Add("$room_id", SqliteType.Integer);
insert.Parameters.Add("$room_name", SqliteType.Text);
insert.Parameters.Add("$operator_id", SqliteType.Integer);
insert.Parameters.Add("$operator_name", SqliteType.Text);
insert.Parameters.Add("$price", SqliteType.Integer);
insert.Parameters.Add("$price_per_night", SqliteType.Integer);
insert.Parameters.Add("$offer_id", SqliteType.Text);
insert.Parameters.Add("$link", SqliteType.Text);
insert.Parameters.Add("$is_best", SqliteType.Integer);
foreach (var offer in offers)
{
insert.Parameters["$hotel_id"].Value = offer.HotelId;
insert.Parameters["$date_from"].Value = offer.DateFrom;
insert.Parameters["$checked_at"].Value = checkedAt;
insert.Parameters["$meal"].Value = offer.Meal;
insert.Parameters["$room_id"].Value = (object?)offer.RoomId ?? DBNull.Value;
insert.Parameters["$room_name"].Value = (object?)offer.RoomName ?? DBNull.Value;
insert.Parameters["$operator_id"].Value = offer.OperatorId;
insert.Parameters["$operator_name"].Value = offer.OperatorName;
insert.Parameters["$price"].Value = offer.Price;
insert.Parameters["$price_per_night"].Value = (object?)offer.PricePerNight ?? DBNull.Value;
insert.Parameters["$offer_id"].Value = offer.OfferId;
insert.Parameters["$link"].Value = offer.Link;
insert.Parameters["$is_best"].Value = offer == best ? 1 : 0;
await insert.ExecuteNonQueryAsync(ct);
}
if (best is not null)
{
var upsert = connection.CreateCommand();
upsert.CommandText = """
INSERT INTO daily_best (hotel_id, date_from, date, best_price, offer_id)
VALUES ($hotel_id, $date_from, $date, $price, $offer_id)
ON CONFLICT (hotel_id, date_from, date) DO UPDATE SET best_price = excluded.best_price, offer_id = excluded.offer_id
""";
upsert.Parameters.AddWithValue("$hotel_id", hotelId);
upsert.Parameters.AddWithValue("$date_from", dateFrom);
upsert.Parameters.AddWithValue("$date", date);
upsert.Parameters.AddWithValue("$price", best.Price);
upsert.Parameters.AddWithValue("$offer_id", best.OfferId);
await upsert.ExecuteNonQueryAsync(ct);
}
await transaction.CommitAsync(ct);
});
}
/// <summary>Лучшая цена за дату проверки, предшествующую <paramref name="beforeDate"/> (ближайшая).</summary>
public async Task<DailyBest?> GetBestBeforeAsync(int hotelId, string dateFrom, string beforeDate, CancellationToken ct = default)
{
DailyBest? result = null;
await ExecuteAsync(async connection =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = """
SELECT hotel_id, date_from, date, best_price, offer_id
FROM daily_best
WHERE hotel_id = $hotel_id AND date_from = $date_from AND date < $before
ORDER BY date DESC
LIMIT 1
""";
cmd.Parameters.AddWithValue("$hotel_id", hotelId);
cmd.Parameters.AddWithValue("$date_from", dateFrom);
cmd.Parameters.AddWithValue("$before", beforeDate);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
result = ReadDailyBest(reader);
});
return result;
}
/// <summary>Исторический минимум до даты проверки (для определения «новой минималки»).</summary>
public async Task<DailyBest?> GetHistoricalMinBeforeAsync(int hotelId, string dateFrom, string beforeDate, CancellationToken ct = default)
{
DailyBest? result = null;
await ExecuteAsync(async connection =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = """
SELECT hotel_id, date_from, date, best_price, offer_id
FROM daily_best
WHERE hotel_id = $hotel_id AND date_from = $date_from AND date < $before
ORDER BY best_price ASC
LIMIT 1
""";
cmd.Parameters.AddWithValue("$hotel_id", hotelId);
cmd.Parameters.AddWithValue("$date_from", dateFrom);
cmd.Parameters.AddWithValue("$before", beforeDate);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
result = ReadDailyBest(reader);
});
return result;
}
/// <summary>Лучшие предложения за сегодня по всем (отель, дата заезда).</summary>
public async Task<List<DailyBest>> GetTodayBestsAsync(string date, CancellationToken ct = default)
{
var result = new List<DailyBest>();
await ExecuteAsync(async connection =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT hotel_id, date_from, date, best_price, offer_id FROM daily_best WHERE date = $date";
cmd.Parameters.AddWithValue("$date", date);
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
result.Add(ReadDailyBest(reader));
});
return result;
}
/// <summary>
/// Данные календаря по каждой дате тура: цена из последнего сканирования, цена предыдущего
/// скана для дельты, ссылка на бронирование лучшего оффера и диапазон минимальной цены
/// по всем датам сканирования (как «прыгала» цена самого дешёвого номера).
/// </summary>
public async Task<List<CalendarEntry>> GetCalendarAsync(CancellationToken ct = default)
{
var result = new List<CalendarEntry>();
await ExecuteAsync(async connection =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = """
SELECT db.hotel_id, db.date_from, db.best_price,
(SELECT p.best_price FROM daily_best p
WHERE p.hotel_id = db.hotel_id AND p.date_from = db.date_from
AND p.date < db.date
ORDER BY p.date DESC LIMIT 1) AS previous_price,
(SELECT ph.link FROM price_history ph
WHERE ph.hotel_id = db.hotel_id AND ph.date_from = db.date_from
AND ph.checked_at = (SELECT MAX(checked_at) FROM price_history
WHERE hotel_id = db.hotel_id AND date_from = db.date_from)
ORDER BY ph.price ASC LIMIT 1) AS link,
(SELECT MIN(h.best_price) FROM daily_best h
WHERE h.hotel_id = db.hotel_id AND h.date_from = db.date_from) AS min_price,
(SELECT MAX(h2.best_price) FROM daily_best h2
WHERE h2.hotel_id = db.hotel_id AND h2.date_from = db.date_from) AS max_price
FROM daily_best db
JOIN (
SELECT hotel_id, date_from, MAX(date) AS latest
FROM daily_best
GROUP BY hotel_id, date_from
) m ON m.hotel_id = db.hotel_id AND m.date_from = db.date_from AND m.latest = db.date
""";
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var previous = reader.IsDBNull(3) ? (int?)null : reader.GetInt32(3);
var link = reader.IsDBNull(4) ? "" : reader.GetString(4);
var min = reader.IsDBNull(5) ? 0 : reader.GetInt32(5);
var max = reader.IsDBNull(6) ? 0 : reader.GetInt32(6);
result.Add(new CalendarEntry(reader.GetInt32(0), reader.GetString(1), reader.GetInt32(2), previous, link, min, max));
}
});
return result;
}
private static DailyBest ReadDailyBest(SqliteDataReader reader) =>
new(
reader.GetInt32(0),
reader.GetString(1),
reader.GetString(2),
reader.GetInt32(3),
reader.GetString(4));
/// <summary>Последний лучший оффер для (отель, дата заезда, дата проверки) — детали для отчёта.</summary>
public async Task<StoredOffer?> GetBestOfferAsync(int hotelId, string dateFrom, string date, CancellationToken ct = default)
{
StoredOffer? result = null;
await ExecuteAsync(async connection =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = """
SELECT hotel_id, date_from, meal, room_id, room_name, operator_id, operator_name,
price, price_per_night, offer_id, link
FROM price_history
WHERE hotel_id = $hotel_id AND date_from = $date_from AND checked_at = (
SELECT MAX(checked_at) FROM price_history
WHERE hotel_id = $hotel_id AND date_from = $date_from
)
ORDER BY price ASC
LIMIT 1
""";
cmd.Parameters.AddWithValue("$hotel_id", hotelId);
cmd.Parameters.AddWithValue("$date_from", dateFrom);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
result = ReadOffer(reader);
});
return result;
}
private static StoredOffer ReadOffer(SqliteDataReader reader) =>
new(
reader.GetInt32(0),
reader.GetString(1),
reader.GetString(2),
reader.IsDBNull(3) ? null : reader.GetInt32(3),
reader.IsDBNull(4) ? null : reader.GetString(4),
reader.GetInt32(5),
reader.GetString(6),
reader.GetInt32(7),
reader.IsDBNull(8) ? 0 : reader.GetInt32(8),
reader.GetString(9),
reader.GetString(10));
private async Task ExecuteAsync(Func<SqliteConnection, Task> action)
{
await _lock.WaitAsync();
try
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await action(connection);
}
finally
{
_lock.Release();
}
}
private sealed class NullLogger : ILogger<PriceStore>, IDisposable
{
public static readonly NullLogger Instance = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => this;
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
public void Dispose() { }
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="TourMonitor.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" />
<PackageReference Include="Hangfire" Version="1.8.24" />
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.Playwright" Version="1.54.0" />
</ItemGroup>
</Project>
+163
View File
@@ -0,0 +1,163 @@
using System.Globalization;
using System.Net;
using System.Text;
namespace TourMonitor.Web;
/// <summary>
/// Генерирует простые HTML-страницы «календаря цен»: сетка месяцев, в каждой ячейке —
/// цена из последнего сканирования на эту дату заезда (ссылка на бронирование лучшего
/// оффера) или пусто, если цены нет, и стрелка с изменением против предыдущего скана.
/// </summary>
public static class CalendarPage
{
private static readonly CultureInfo Ru = CultureInfo.GetCultureInfo("ru-RU");
public static string Index(string title, IReadOnlyList<(int HotelId, string Name)> hotels, string baseUrl)
{
var sb = new StringBuilder();
sb.Append(HtmlOpen(title));
sb.Append("<h1>📊 ").Append(HtmlEncode(title)).Append("</h1>");
sb.Append("<p>Выберите отель, чтобы посмотреть цены по датам заезда:</p><div class=\"cards\">");
foreach (var hotel in hotels)
{
var url = $"{baseUrl}/prices/{hotel.HotelId}";
sb.Append("<a class=\"card\" href=\"").Append(HtmlEncode(url)).Append("\">")
.Append("<h2>🏝 ").Append(HtmlEncode(hotel.Name)).Append("</h2>")
.Append("<p>открыть календарь →</p></a>");
}
sb.Append("</div>");
sb.Append(HtmlClose());
return sb.ToString();
}
public static string Hotel(
string hotelName,
int hotelId,
IReadOnlyList<(string DateFrom, int Price, int? Previous, string Link, int Min, int Max)> entries,
DateTime windowStart,
int windowDays,
string baseUrl)
{
var byDate = new Dictionary<DateOnly, (int Price, int? Prev, string Link, int Min, int Max)>();
foreach (var (dateFrom, price, prev, link, min, max) in entries)
if (DateOnly.TryParse(dateFrom, out var d))
byDate[d] = (price, prev, link, min, max);
var months = new SortedDictionary<string, List<DateOnly>>();
for (var i = 0; i < windowDays; i++)
{
var day = DateOnly.FromDateTime(windowStart.AddDays(i));
var key = day.ToString("yyyy-MM", Ru);
if (!months.TryGetValue(key, out var list))
months[key] = list = new List<DateOnly>();
list.Add(day);
}
var sb = new StringBuilder();
sb.Append(HtmlOpen(hotelName));
sb.Append("<a class=\"back\" href=\"").Append(HtmlEncode($"{baseUrl}/prices")).Append("\">← все отели</a>");
sb.Append("<h1>🏝 ").Append(HtmlEncode(hotelName)).Append("</h1>");
sb.Append("<p class=\"hint\">Цена из последнего сканирования · ")
.Append(windowStart.ToString("dd.MM.yyyy", Ru)).Append(" — ")
.Append(windowStart.AddDays(windowDays - 1).ToString("dd.MM.yyyy", Ru))
.Append(" · под ценой — диапазон минимальной цены по датам сканирования</p>");
foreach (var (monthKey, days) in months)
{
var first = days[0];
var monthTitle = first.ToString("MMMM yyyy", Ru);
sb.Append("<h2>").Append(HtmlEncode(char.ToUpper(monthTitle[0]) + monthTitle[1..])).Append("</h2>");
sb.Append("<div class=\"calendar\">");
foreach (var weekday in new[] { "Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс" })
sb.Append("<div class=\"dow\">").Append(weekday).Append("</div>");
var leadDays = ((int)first.DayOfWeek + 6) % 7; // Пн = 0
for (var i = 0; i < leadDays; i++)
sb.Append("<div class=\"cell empty\"></div>");
foreach (var day in days)
{
var weekend = day.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
var priceInfo = byDate.GetValueOrDefault(day);
if (priceInfo.Price > 0)
{
sb.Append("<div class=\"cell price").Append(weekend ? " weekend" : "").Append("\">")
.Append("<span class=\"d\">").Append(day.Day).Append("</span>");
if (string.IsNullOrEmpty(priceInfo.Link))
{
sb.Append("<span class=\"p\">").Append(priceInfo.Price.ToString("N0", Ru)).Append("</span>");
}
else
{
sb.Append("<a class=\"p book\" href=\"").Append(HtmlEncode(FullLink(priceInfo.Link))).Append("\">")
.Append(priceInfo.Price.ToString("N0", Ru)).Append("</a>");
}
sb.Append(RangeHtml(priceInfo.Min, priceInfo.Max))
.Append(DeltaHtml(priceInfo.Price, priceInfo.Prev))
.Append("</div>");
}
else
{
sb.Append("<div class=\"cell nodata").Append(weekend ? " weekend" : "").Append("\">")
.Append("<span class=\"d\">").Append(day.Day).Append("</span>")
.Append("<span class=\"p\">—</span></div>");
}
}
sb.Append("</div>");
}
sb.Append(HtmlClose());
return sb.ToString();
}
/// <summary>Диапазон минимальной цены на дату тура по датам сканирования; пусто, если данных нет.</summary>
private static string RangeHtml(int min, int max)
{
if (min <= 0 || max < min)
return "";
return $"<span class=\"range\">{min.ToString("N0", Ru)}–{max.ToString("N0", Ru)}</span>";
}
/// <summary>Стрелка изменения цены против предыдущего скана; пусто, если предыдущего скана не было.</summary>
private static string DeltaHtml(int price, int? previous)
{
if (previous is null or <= 0)
return "";
var diff = price - previous.Value;
if (diff == 0)
return "<span class=\"chg same\">=</span>";
var pct = Math.Round((double)diff / previous.Value * 100);
var arrow = diff < 0 ? "↓" : "↑";
return $"<span class=\"chg {(diff < 0 ? "down" : "up")}\">{arrow} {Math.Abs(pct)}%</span>";
}
private static string FullLink(string link) =>
string.IsNullOrEmpty(link) ? "https://level.travel/" : "https://level.travel" + link;
private static string HtmlOpen(string title) =>
"<!DOCTYPE html><html lang=\"ru\"><head><meta charset=\"utf-8\">" +
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
"<title>" + HtmlEncode(title) + "</title><style>" +
"body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;padding:24px;}" +
"h1{font-size:22px;margin:0 0 8px;}h2{font-size:16px;color:#94a3b8;margin:28px 0 8px;text-transform:capitalize;}" +
".back{color:#38bdf8;text-decoration:none;font-size:13px;}.hint{color:#94a3b8;font-size:13px;margin:0 0 8px;}" +
".cards{display:flex;gap:16px;flex-wrap:wrap;}.card{display:block;background:#1e293b;border:1px solid #334155;border-radius:12px;padding:20px;color:#e2e8f0;text-decoration:none;width:280px;}" +
".card h2{margin:0 0 6px;color:#e2e8f0;}.card p{color:#94a3b8;margin:0;font-size:13px;}" +
".calendar{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;max-width:840px;}" +
".dow{text-align:center;font-size:11px;color:#64748b;padding:4px 0;}" +
".cell{background:#1e293b;border:1px solid #334155;border-radius:8px;padding:6px;text-align:center;min-height:52px;display:flex;flex-direction:column;justify-content:space-between;}" +
".cell .d{font-size:12px;color:#94a3b8;}.cell .p{font-size:12px;font-weight:600;color:#4ade80;}" +
".cell .p.book{text-decoration:none;}" +
".cell .range{font-size:10px;color:#94a3b8;}" +
".cell .chg{font-size:10px;font-weight:500;line-height:1.2;}.cell .chg.down{color:#4ade80;}.cell .chg.up{color:#fb923c;}.cell .chg.same{color:#64748b;}" +
".cell.nodata .p{color:#475569;font-weight:400;}" +
".cell.weekend{border-color:#3b82f6;}" +
".cell.empty{background:transparent;border-color:transparent;}" +
"</style></head><body>";
private static string HtmlClose() => "</body></html>";
private static string HtmlEncode(string value) => WebUtility.HtmlEncode(value);
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+62
View File
@@ -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": "*"
}