Commit: eefa7af
Parent: 5d65780

Real HTML checking in Playwright

Mårten Åsberg committed on 2026-05-01 at 17:05
BfiScreeningCheckerJob.cs +50 -15
diff --git a/BfiScreeningCheckerJob.cs b/BfiScreeningCheckerJob.cs
index 193262e..80bec31 100644
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Playwright;
using Quartz;
[DisallowConcurrentExecution]
@@ -10,34 +11,68 @@ internal sealed class BfiScreeningCheckerJob(
ILogger<BfiScreeningCheckerJob> logger
) : 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 page = await browserService.NewPageAsync();
try
{
await page.GotoAsync(opts.Url);
// Booking links on BFI What's On pages have "book" in the href.
// Each href is unique per performance and encodes the date/time/seat map.
var bookingLinks = await page.QuerySelectorAllAsync("a[href*='book' i], a[href*='seatmap' i]");
var available = new List<string>();
foreach (var link in bookingLinks)
var available = new HashSet<string>();
await page.GotoAsync(opts.Url, new() { WaitUntil = WaitUntilState.DOMContentLoaded });
await page.WaitForSelectorAsync(".result-box-item");
while (true)
{
var href = await link.GetAttributeAsync("href");
if (!string.IsNullOrWhiteSpace(href))
available.Add(href);
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.LogInformation("Check complete — {Count} booking link(s) found on {Url}", available.Count, opts.Url);
LogCheckComplete(logger, available.Count, null);
foreach (var href in available)
foreach (var screeningId in available)
{
if (await repository.HasBeenNotifiedAsync(href))
if (await repository.HasBeenNotifiedAsync(screeningId))
continue;
await repository.MarkNotifiedAsync(href);
await repository.MarkNotifiedAsync(screeningId);
var message = $"BFI IMAX tickets available! Book now: {href}";
var message = $"BFI IMAX tickets available! {screeningId} — Book now: {opts.Url}";
foreach (var phone in opts.PhoneNumbers)
{
@@ -52,7 +87,7 @@ internal sealed class BfiScreeningCheckerJob(
var trigger = TriggerBuilder.Create().StartNow().Build();
await context.Scheduler.ScheduleJob(job, trigger, context.CancellationToken);
logger.LogInformation("Scheduled SMS to {Phone} for screening {Href}", phone, href);
LogSmsScheduled(logger, phone, screeningId, null);
}
}
}
MonitorOptions.cs +1 -0
diff --git a/MonitorOptions.cs b/MonitorOptions.cs
index 9035b40..0dee0ed 100644
@@ -4,4 +4,5 @@ internal sealed class MonitorOptions
public string Url { get; set; } = "";
public string[] PhoneNumbers { get; set; } = [];
public string SmsSenderAddress { get; set; } = "http://localhost:50051";
public bool Headless { get; set; } = true;
}
PlaywrightBrowserService.cs +22 -3
diff --git a/PlaywrightBrowserService.cs b/PlaywrightBrowserService.cs
index 6d29a64..6fa1013 100644
@@ -1,7 +1,8 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Microsoft.Playwright;
internal sealed class PlaywrightBrowserService : IHostedService, IAsyncDisposable
internal sealed class PlaywrightBrowserService(IOptions<MonitorOptions> options) : IHostedService, IAsyncDisposable
{
private IPlaywright? _playwright;
private IBrowser? _browser;
@@ -9,12 +10,30 @@ internal sealed class PlaywrightBrowserService : IHostedService, IAsyncDisposabl
public async Task StartAsync(CancellationToken cancellationToken)
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
_browser = await _playwright.Chromium.LaunchAsync(
new BrowserTypeLaunchOptions
{
Headless = options.Value.Headless,
Args =
[
"--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",
],
}
);
}
public async Task StopAsync(CancellationToken cancellationToken) => await DisposeAsync();
public Task<IPage> NewPageAsync() => _browser!.NewPageAsync();
public async Task<IPage> 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;
}
public async ValueTask DisposeAsync()
{
SendSmsJob.cs +1 -1
diff --git a/SendSmsJob.cs b/SendSmsJob.cs
index aad8cd2..7fb7873 100644
@@ -17,6 +17,6 @@ internal sealed class SendSmsJob(SmsSender.SmsSenderClient smsSender, ILogger<Se
if (response.Status == SmsStatus.Success)
logger.LogInformation("SMS sent to {Phone}", phone);
else
logger.LogWarning("SMS to {Phone} returned status {Status}", phone, response.Status);
logger.LogWarning("SMS to {Phone} returned status {Status}", phone, "response.Status");
}
}
appsettings.Development.json +3 -0
diff --git a/appsettings.Development.json b/appsettings.Development.json
index a6e86ac..8062560 100644
@@ -1,4 +1,7 @@
{
"Monitor": {
"Headless": false
},
"Logging": {
"LogLevel": {
"Default": "Debug",
appsettings.json +2 -2
diff --git a/appsettings.json b/appsettings.json
index 9e60228..828051f 100644
@@ -4,8 +4,8 @@
},
"Monitor": {
"IntervalSeconds": 300,
"Url": "https://whatson.bfi.org.uk/imax/Online/default.asp?BOparam::WScontent::loadArticle::permalink=odyssey-the-film-imax-70mm-2026",
"PhoneNumbers": [],
"Url": "https://whatson.bfi.org.uk/imax/Online/default.asp?BOparam::WScontent::loadArticle::permalink=project-hail-mary",
"PhoneNumbers": ["+46722177038"],
"SmsSenderAddress": "http://localhost:50051"
},
"Logging": {