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
+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() { }
}
}