Commit: e6960c8
Parent: eefa7af

Fix Claudes mess

Mårten Åsberg committed on 2026-06-03 at 21:52
Partly my mess for giving "bad" instructions.
BfiMonitor.csproj +13 -5
diff --git a/BfiMonitor.csproj b/BfiMonitor.csproj
index 38982ea..9536a52 100644
@@ -7,6 +7,14 @@
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<Content
Condition="'$(Configuration)' == 'Debug'"
Include="appsettings.Development.json"
CopyToOutputDirectory="PreserveNewest"
/>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos\sms.proto" GrpcServices="Client" />
</ItemGroup>
<ItemGroup>
@@ -14,15 +22,15 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
<PackageReference Include="Google.Protobuf" Version="3.35.0" />
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.80.0" />
<PackageReference Include="Grpc.Tools" Version="2.80.0">
<PackageReference Include="Grpc.Tools" Version="2.81.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="11.0.0-preview.3.26207.106" />
<PackageReference Include="Microsoft.Playwright" Version="1.59.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.*" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="11.0.0-preview.4.26230.115" />
<PackageReference Include="Microsoft.Playwright" Version="1.60.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.4.26230.115" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
</ItemGroup>
</Project>
BfiScreeningCheckerJob.cs +51 -66
diff --git a/BfiScreeningCheckerJob.cs b/BfiScreeningCheckerJob.cs
index 80bec31..5b982ff 100644
@@ -7,88 +7,49 @@ using Quartz;
internal sealed class BfiScreeningCheckerJob(
PlaywrightBrowserService browserService,
ScreeningRepository repository,
IOptions<MonitorOptions> options,
ILogger<BfiScreeningCheckerJob> logger
IOptionsMonitor<MonitorOptions> options,
ILogger<BfiScreeningCheckerJob> logger,
TimeProvider timeProvider
) : IJob
{
private static readonly Action<ILogger, int, Exception?> LogCheckComplete = LoggerMessage.Define<int>(
LogLevel.Information,
new EventId(1, nameof(LogCheckComplete)),
"Check complete — {Count} available screening(s) across all pages"
);
private static readonly Action<ILogger, string, string, Exception?> LogSmsScheduled = LoggerMessage.Define<
string,
string
>(
LogLevel.Information,
new EventId(2, nameof(LogSmsScheduled)),
"Scheduled SMS to {Phone} for screening {ScreeningId}"
);
public async Task Execute(IJobExecutionContext context)
{
var opts = options.Value;
var latestHtml = await repository.LatestHtml(context.CancellationToken);
logger.LogLatestHtml(latestHtml);
var opts = options.CurrentValue;
var page = await browserService.NewPageAsync();
try
{
var available = new HashSet<string>();
logger.LogFetchingSite(opts.Url);
await page.GotoAsync(opts.Url, new() { WaitUntil = WaitUntilState.DOMContentLoaded });
await page.WaitForSelectorAsync(".result-box-item");
while (true)
var resultsElement = await page.WaitForSelectorAsync(opts.Selector);
if (resultsElement is null)
{
var availableItems = await page.QuerySelectorAllAsync(".result-box-item:has(.item-link.good)");
foreach (var item in availableItems)
{
var startDateEl = await item.QuerySelectorAsync(".start-date");
if (startDateEl is null)
continue;
var dateText = (await startDateEl.InnerTextAsync()).Trim();
if (!string.IsNullOrEmpty(dateText))
available.Add(dateText);
}
// Follow the next-page link if present.
var nextLink = await page.QuerySelectorAsync("#av-next-link a");
if (nextLink is null)
break;
var nextHref = await nextLink.GetAttributeAsync("href");
if (string.IsNullOrEmpty(nextHref))
break;
var nextUrl = new Uri(new Uri(page.Url), nextHref).ToString();
await page.GotoAsync(nextUrl, new() { WaitUntil = WaitUntilState.DOMContentLoaded });
await page.WaitForSelectorAsync(".result-box-item");
logger.LogNoResultsElementFound(opts.Selector);
return;
}
var currentHtml = await resultsElement.InnerHTMLAsync();
logger.LogCurrentHtml(currentHtml);
LogCheckComplete(logger, available.Count, null);
foreach (var screeningId in available)
if (currentHtml == latestHtml)
{
if (await repository.HasBeenNotifiedAsync(screeningId))
continue;
await repository.MarkNotifiedAsync(screeningId);
var message = $"BFI IMAX tickets available! {screeningId} — Book now: {opts.Url}";
logger.LogHtmlNotUpdated();
return;
}
logger.LogHtmlUpdated();
foreach (var phone in opts.PhoneNumbers)
{
var dataMap = new JobDataMap { { "phoneNumber", phone }, { "message", message } };
await repository.InsertNewDetection(currentHtml, timeProvider.GetUtcNow());
var job = JobBuilder
.Create<SendSmsJob>()
.WithIdentity(Guid.NewGuid().ToString(), "sms")
.UsingJobData(dataMap)
.Build();
var message = $"Site updated: {opts.Url}";
foreach (var phoneNumber in opts.PhoneNumbers)
{
var dataMap = new JobDataMap { { "phoneNumber", phoneNumber }, { "message", message } };
var trigger = TriggerBuilder.Create().StartNow().Build();
var trigger = TriggerBuilder.Create().ForJob(SendSmsJob.Key).UsingJobData(dataMap).StartNow().Build();
await context.Scheduler.ScheduleJob(job, trigger, context.CancellationToken);
LogSmsScheduled(logger, phone, screeningId, null);
}
await context.Scheduler.ScheduleJob(trigger, context.CancellationToken);
logger.LogSmsScheduled(phoneNumber);
}
}
finally
@@ -97,3 +58,27 @@ internal sealed class BfiScreeningCheckerJob(
}
}
}
internal static partial class BfiScreeningCheckerJobLoggerExtensions
{
[LoggerMessage(LogLevel.Information, "Latest HTML detected was {LatestHtml}")]
public static partial void LogLatestHtml(this ILogger logger, string? latestHtml);
[LoggerMessage(LogLevel.Warning, "No {ElementSelector} element found")]
public static partial void LogNoResultsElementFound(this ILogger logger, string elementSelector);
[LoggerMessage(LogLevel.Information, "Fetching site {Url}")]
public static partial void LogFetchingSite(this ILogger logger, string url);
[LoggerMessage(LogLevel.Information, "Current HTML detected is {CurrentHtml}")]
public static partial void LogCurrentHtml(this ILogger logger, string? currentHtml);
[LoggerMessage(LogLevel.Information, "HTML has not been updated since previous visit")]
public static partial void LogHtmlNotUpdated(this ILogger logger);
[LoggerMessage(LogLevel.Information, "HTML has been updated since previous visit")]
public static partial void LogHtmlUpdated(this ILogger logger);
[LoggerMessage(LogLevel.Information, "Scheduled SMS to {PhoneNumber}")]
public static partial void LogSmsScheduled(this ILogger logger, string phoneNumber);
}
MonitorOptions.cs +1 -0
diff --git a/MonitorOptions.cs b/MonitorOptions.cs
index 0dee0ed..12e776f 100644
@@ -2,6 +2,7 @@ internal sealed class MonitorOptions
{
public int IntervalSeconds { get; set; } = 300;
public string Url { get; set; } = "";
public string Selector { get; set; } = ".detailed-search-results";
public string[] PhoneNumbers { get; set; } = [];
public string SmsSenderAddress { get; set; } = "http://localhost:50051";
public bool Headless { get; set; } = true;
PlaywrightBrowserService.cs +11 -9
diff --git a/PlaywrightBrowserService.cs b/PlaywrightBrowserService.cs
index 6fa1013..80722fb 100644
@@ -4,13 +4,13 @@ using Microsoft.Playwright;
internal sealed class PlaywrightBrowserService(IOptions<MonitorOptions> options) : IHostedService, IAsyncDisposable
{
private IPlaywright? _playwright;
private IBrowser? _browser;
private IPlaywright? playwright;
private IBrowser? browser;
public async Task StartAsync(CancellationToken cancellationToken)
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(
playwright = await Playwright.CreateAsync();
browser = await playwright.Chromium.LaunchAsync(
new BrowserTypeLaunchOptions
{
Headless = options.Value.Headless,
@@ -19,7 +19,7 @@ internal sealed class PlaywrightBrowserService(IOptions<MonitorOptions> options)
"--disable-blink-features=AutomationControlled",
// "new" headless mode has a much closer fingerprint to headed Chrome
// than the legacy headless implementation, helping bypass bot detection.
"--headless=new",
.. options.Value.Headless ? ["--headless=new"] : Array.Empty<string>(),
],
}
);
@@ -29,7 +29,7 @@ internal sealed class PlaywrightBrowserService(IOptions<MonitorOptions> options)
public async Task<IPage> NewPageAsync()
{
var page = await _browser!.NewPageAsync();
var page = await browser!.NewPageAsync();
// Patch navigator.webdriver before any page script runs so Cloudflare sees undefined.
await page.AddInitScriptAsync("Object.defineProperty(navigator,'webdriver',{get:()=>undefined})");
return page;
@@ -37,8 +37,10 @@ internal sealed class PlaywrightBrowserService(IOptions<MonitorOptions> options)
public async ValueTask DisposeAsync()
{
if (_browser is not null)
await _browser.CloseAsync();
_playwright?.Dispose();
if (browser is not null)
{
await browser.DisposeAsync();
}
playwright?.Dispose();
}
}
Program.cs +5 -3
diff --git a/Program.cs b/Program.cs
index 7bbfc9d..3afaa34 100644
@@ -7,7 +7,7 @@ using Quartz;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<MonitorOptions>(builder.Configuration.GetSection("Monitor"));
builder.Services.AddOptions<MonitorOptions>().BindConfiguration("Monitor");
builder.Services.AddGrpcClient<SmsSender.SmsSenderClient>(
(sp, o) =>
@@ -22,7 +22,9 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService<PlaywrightBrowserS
builder.Services.AddSingleton<ScreeningRepository>();
var intervalSeconds = builder.Configuration.GetValue<int>("Monitor:IntervalSeconds", 300);
builder.Services.AddSingleton(TimeProvider.System);
var intervalSeconds = builder.Configuration.GetValue("Monitor:IntervalSeconds", 300);
builder.Services.AddQuartz(q =>
{
@@ -34,7 +36,7 @@ builder.Services.AddQuartz(q =>
.WithSimpleSchedule(s => s.WithIntervalInSeconds(intervalSeconds).RepeatForever())
);
q.AddJob<SendSmsJob>(JobKey.Create(nameof(SendSmsJob)), j => j.StoreDurably());
q.AddJob<SendSmsJob>(SendSmsJob.Key, j => j.StoreDurably());
});
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);
Properties/launchSettings.json +1 -1
diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json
index a1d7943..c3f3d80 100644
@@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
"DOTNET_ENVIRONMENT": "Development"
}
}
}
ScreeningRepository.cs +14 -15
diff --git a/ScreeningRepository.cs b/ScreeningRepository.cs
index fb6d65b..7a95791 100644
@@ -3,42 +3,41 @@ using Microsoft.Extensions.Configuration;
internal sealed class ScreeningRepository
{
private readonly string _connectionString;
private readonly string connectionString;
public ScreeningRepository(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("Screenings") ?? "DataSource=screenings.db";
connectionString = configuration.GetConnectionString("Screenings") ?? "DataSource=screenings.db";
Initialize();
}
private void Initialize()
{
using var connection = new SqliteConnection(_connectionString);
using var connection = new SqliteConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
"CREATE TABLE IF NOT EXISTS NotifiedScreenings (Id TEXT PRIMARY KEY, NotifiedAt TEXT NOT NULL)";
"CREATE TABLE IF NOT EXISTS DetectedHtml (Id INTEGER PRIMARY KEY AUTOINCREMENT, Html TEXT NOT NULL, DetectedAt TEXT NOT NULL)";
command.ExecuteNonQuery();
}
public async Task<bool> HasBeenNotifiedAsync(string screeningId)
public async Task<string?> LatestHtml(CancellationToken cancellationToken)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = "SELECT 1 FROM NotifiedScreenings WHERE Id = $id";
command.Parameters.AddWithValue("$id", screeningId);
return await command.ExecuteScalarAsync() is not null;
command.CommandText = "SELECT Html FROM DetectedHtml ORDER BY DetectedAt DESC LIMIT 1";
return await command.ExecuteScalarAsync(cancellationToken) as string;
}
public async Task MarkNotifiedAsync(string screeningId)
public async Task InsertNewDetection(string html, DateTimeOffset detectedAt)
{
await using var connection = new SqliteConnection(_connectionString);
await using var connection = new SqliteConnection(connectionString);
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = "INSERT OR IGNORE INTO NotifiedScreenings (Id, NotifiedAt) VALUES ($id, $at)";
command.Parameters.AddWithValue("$id", screeningId);
command.Parameters.AddWithValue("$at", DateTimeOffset.UtcNow.ToString("O"));
command.CommandText = "INSERT INTO DetectedHtml (Html, DetectedAt) VALUES ($html, $at)";
command.Parameters.AddWithValue("$html", html);
command.Parameters.AddWithValue("$at", detectedAt.ToString("O"));
await command.ExecuteNonQueryAsync();
}
}
SendSmsJob.cs +20 -5
diff --git a/SendSmsJob.cs b/SendSmsJob.cs
index 7fb7873..9a899b9 100644
@@ -4,19 +4,34 @@ using Quartz;
internal sealed class SendSmsJob(SmsSender.SmsSenderClient smsSender, ILogger<SendSmsJob> logger) : IJob
{
public static JobKey Key { get; } = JobKey.Create(nameof(SendSmsJob));
public async Task Execute(IJobExecutionContext context)
{
var phone = context.MergedJobDataMap.GetString("phoneNumber")!;
var phoneNumber = context.MergedJobDataMap.GetString("phoneNumber")!;
var message = context.MergedJobDataMap.GetString("message")!;
var response = await smsSender.SendSmsAsync(
new SmsRequest { RecipientPhoneNumber = phone, Content = message },
new SmsRequest { RecipientPhoneNumber = phoneNumber, Content = message },
cancellationToken: context.CancellationToken
);
if (response.Status == SmsStatus.Success)
logger.LogInformation("SMS sent to {Phone}", phone);
if (response.Status is SmsStatus.Success)
{
logger.LogSmsSent(phoneNumber);
}
else
logger.LogWarning("SMS to {Phone} returned status {Status}", phone, "response.Status");
{
logger.LogSmsFailed(phoneNumber, response.Status);
}
}
}
internal static partial class SendSmsJobLoggerExtensions
{
[LoggerMessage(LogLevel.Information, "Successfully sent SMS to {PhoneNumber}")]
public static partial void LogSmsSent(this ILogger logger, string phoneNumber);
[LoggerMessage(LogLevel.Error, "Failed to send SMS to {PhoneNumber} with status {SmsStatus}")]
public static partial void LogSmsFailed(this ILogger logger, string phoneNumber, SmsStatus smsStatus);
}
appsettings.Development.json +3 -1
diff --git a/appsettings.Development.json b/appsettings.Development.json
index 8062560..1c33198 100644
@@ -1,6 +1,8 @@
{
"Monitor": {
"Headless": false
"Headless": false,
"IntervalSeconds": 3600,
"SmsSenderAddress": "http://localhost:50051"
},
"Logging": {
"LogLevel": {
appsettings.json +2 -3
diff --git a/appsettings.json b/appsettings.json
index 828051f..144c68a 100644
@@ -4,9 +4,8 @@
},
"Monitor": {
"IntervalSeconds": 300,
"Url": "https://whatson.bfi.org.uk/imax/Online/default.asp?BOparam::WScontent::loadArticle::permalink=project-hail-mary",
"PhoneNumbers": ["+46722177038"],
"SmsSenderAddress": "http://localhost:50051"
"Url": "https://whatson.bfi.org.uk/imax/Online/default.asp?BOparam::WScontent::loadArticle::permalink=odyssey-the-film-imax-70mm-2026",
"PhoneNumbers": ["+46722177038"]
},
"Logging": {
"LogLevel": {
global.json +1 -1
diff --git a/global.json b/global.json
index ef31c07..92499a1 100644
@@ -1,5 +1,5 @@
{
"sdk": {
"version": "11.0.100-preview.2.26159.112"
"version": "11.0.100-preview.4.26230.115"
}
}
screenings.db +0 -0
diff --git a/screenings.db b/screenings.db
new file mode 100644
index 0000000..a93ee44
Binary files /dev/null and b/screenings.db differ