mirror of
https://github.com/alexhopeoconnor/bom-local-service.git
synced 2026-10-03 21:21:59 +10:00
Major architectural changes: - Replace monolithic ScrapingService with workflow-based system - Extract scraping logic into discrete, testable steps - Add configuration-driven selectors, JavaScript templates, and text patterns - Implement step registry and workflow factory for extensibility - Add PageState tracking and step dependencies - Make IWorkflow generic to support different response types New components: - Scraping steps organized by category (Navigation, Search, Map, Metadata, Capture) - BaseScrapingStep base class with common dependencies - ScrapingContext for shared state between steps - RadarScrapingWorkflow orchestrating step execution - SelectorService for configurable element selection - Configuration models (SelectorConfig, TextPatternsConfig, JavaScriptTemplatesConfig) Performance and monitoring: - Add step-level timing metrics with historical tracking - Log step durations and compare to averages - Warn on slow steps/workflows (50%+ slower than average) - Integrate metrics with CacheService Configuration improvements: - Externalize all selectors to appsettings.json - Externalize JavaScript templates to appsettings.json - Externalize text patterns to appsettings.json - Support Docker configuration via mounted appsettings.json - Remove hardcoded waits, use configurable timeouts Bug fixes: - Fix DebugService collection modification exception - Fix ResetToFirstFrameStep click interception with JavaScript fallback - Remove minutes ago fallback, use timestamp parsing only - Update timestamp parsing for new BOM website format Documentation: - Update README with new architecture details - Add configuration section with Docker guidance - Add development guidelines for extending scraping system - Add performance monitoring documentation
193 lines
8.2 KiB
C#
193 lines
8.2 KiB
C#
using BomLocalService.Services.Interfaces;
|
|
using BomLocalService.Services.Scraping;
|
|
using BomLocalService.Utilities;
|
|
using Microsoft.Playwright;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace BomLocalService.Services.Scraping.Steps.Search;
|
|
|
|
public class SelectSearchResultStep : BaseScrapingStep
|
|
{
|
|
public override string Name => "SelectSearchResult";
|
|
public override string[] Prerequisites => new[] { "WaitForSearchResults" };
|
|
|
|
public SelectSearchResultStep(
|
|
ILogger<SelectSearchResultStep> logger,
|
|
ISelectorService selectorService,
|
|
IDebugService debugService,
|
|
IConfiguration configuration)
|
|
: base(logger, selectorService, debugService, configuration)
|
|
{
|
|
}
|
|
|
|
public override bool CanExecute(ScrapingContext context)
|
|
{
|
|
return context.CurrentState >= PageState.SearchResultsVisible;
|
|
}
|
|
|
|
public override async Task<ScrapingStepResult> ExecuteAsync(ScrapingContext context, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
Logger.LogInformation("Step {Step}: Looking for matching search result for {Suburb}, {State}", Name, context.Suburb, context.State);
|
|
|
|
var suburbLower = context.Suburb.ToLower().Trim();
|
|
var stateLower = context.State.ToLower().Trim();
|
|
|
|
// Get the actual count from the summary element
|
|
var resultsTitle = SelectorService.GetLocator(context.Page, Selectors.ResultsTitle);
|
|
var summaryText = await resultsTitle.TextContentAsync();
|
|
int? actualCount = null;
|
|
|
|
if (!string.IsNullOrEmpty(summaryText))
|
|
{
|
|
var countMatch = Regex.Match(summaryText, TextPatterns.ResultsCountPattern, RegexOptions.IgnoreCase);
|
|
if (countMatch.Success && countMatch.Groups.Count >= 3 && countMatch.Groups[2].Success)
|
|
{
|
|
if (int.TryParse(countMatch.Groups[2].Value, out var total))
|
|
{
|
|
actualCount = total;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch all result data
|
|
List<(string name, string desc, string fullText)> results = new();
|
|
try
|
|
{
|
|
var resultData = await context.Page.EvaluateAsync<string[][]>(JavaScriptTemplates.ExtractSearchResults);
|
|
|
|
results = resultData.Select(arr => (
|
|
name: arr.Length > 0 ? arr[0] : "",
|
|
desc: arr.Length > 1 ? arr[1] : "",
|
|
fullText: arr.Length > 2 ? arr[2] : ""
|
|
)).ToList();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogWarning(ex, "Failed to extract structured result data, falling back to text content");
|
|
var resultTexts = await context.Page.EvaluateAsync<string[]>(JavaScriptTemplates.ExtractSearchResultsFallback);
|
|
results = resultTexts.Select(text => (name: "", desc: "", fullText: text)).ToList();
|
|
}
|
|
|
|
if (actualCount.HasValue)
|
|
{
|
|
Logger.LogInformation("Found {Count} location results (summary: {Summary})", actualCount.Value, summaryText?.Trim());
|
|
if (results.Count > actualCount.Value)
|
|
{
|
|
results = results.Take(actualCount.Value).ToList();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Logger.LogInformation("Found {Count} search results", results.Count);
|
|
}
|
|
|
|
int? matchingIndex = null;
|
|
int bestMatchScore = -1;
|
|
|
|
for (int i = 0; i < results.Count; i++)
|
|
{
|
|
var (name, desc, fullText) = results[i];
|
|
var nameLower = name.ToLower().Trim();
|
|
var descLower = desc.ToLower().Trim();
|
|
var fullTextLower = fullText.ToLower();
|
|
|
|
Logger.LogInformation("Checking result {Index}: Name='{Name}', Desc='{Desc}', FullText='{FullText}'",
|
|
i, name, desc, fullText.Length > 100 ? fullText.Substring(0, 100) + "..." : fullText);
|
|
|
|
var matchesSuburb = false;
|
|
var matchScore = 0;
|
|
|
|
if (!string.IsNullOrEmpty(name))
|
|
{
|
|
if (nameLower == suburbLower)
|
|
{
|
|
matchesSuburb = true;
|
|
matchScore = 100;
|
|
}
|
|
else if (nameLower.StartsWith(suburbLower + " ") || nameLower.StartsWith(suburbLower + "("))
|
|
{
|
|
matchesSuburb = true;
|
|
matchScore = 80;
|
|
}
|
|
else if (nameLower.Contains("(" + suburbLower + ")") || nameLower.Contains("(" + suburbLower + " "))
|
|
{
|
|
matchesSuburb = true;
|
|
matchScore = 60;
|
|
}
|
|
else if (nameLower.Contains(suburbLower))
|
|
{
|
|
matchesSuburb = true;
|
|
matchScore = 40;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (fullTextLower.Contains(suburbLower))
|
|
{
|
|
matchesSuburb = true;
|
|
matchScore = 20;
|
|
}
|
|
}
|
|
|
|
var matchesState = false;
|
|
if (!string.IsNullOrEmpty(desc))
|
|
{
|
|
matchesState = StateAbbreviationHelper.MatchesState(descLower, stateLower);
|
|
}
|
|
if (!matchesState)
|
|
{
|
|
matchesState = StateAbbreviationHelper.MatchesState(fullTextLower, stateLower);
|
|
}
|
|
|
|
Logger.LogInformation("Result {Index}: matchesSuburb={MatchesSuburb} (score={Score}), matchesState={MatchesState}",
|
|
i, matchesSuburb, matchScore, matchesState);
|
|
|
|
if (matchesSuburb && matchesState && matchScore > bestMatchScore)
|
|
{
|
|
matchingIndex = i;
|
|
bestMatchScore = matchScore;
|
|
Logger.LogInformation("New best match found: {Name} - {Desc} (score: {Score})", name, desc, matchScore);
|
|
}
|
|
}
|
|
|
|
if (matchingIndex.HasValue)
|
|
{
|
|
Logger.LogInformation("Using best matching result at index {Index} with score {Score}", matchingIndex.Value, bestMatchScore);
|
|
}
|
|
else
|
|
{
|
|
Logger.LogInformation("No exact match found, using first result");
|
|
}
|
|
|
|
context.SearchResults = results;
|
|
context.SelectedResultIndex = matchingIndex;
|
|
|
|
// Click the matching result
|
|
var resultsList = context.Page.Locator("ul[aria-labelledby='location-results-title']");
|
|
var allResults = resultsList.Locator("li.bom-linklist__item[role='listitem']");
|
|
var resultToClick = matchingIndex.HasValue ? allResults.Nth(matchingIndex.Value) : allResults.First;
|
|
|
|
await resultToClick.ClickAsync();
|
|
await SaveDebugAsync(context, 4, "search_result_selected", cancellationToken);
|
|
|
|
// Wait for forecast page to load
|
|
await context.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 15000 });
|
|
var dynamicContentWaitMs = Configuration.GetValue<int>("Screenshot:DynamicContentWaitMs", 2000);
|
|
await context.Page.WaitForTimeoutAsync(dynamicContentWaitMs);
|
|
await SaveDebugAsync(context, 5, "forecast_page_loaded", cancellationToken);
|
|
|
|
context.CurrentState = PageState.ForecastPageLoaded;
|
|
return ScrapingStepResult.Successful();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Step {Step} failed", Name);
|
|
await SaveErrorDebugAsync(context, $"Failed to select search result: {ex.Message}", cancellationToken);
|
|
return ScrapingStepResult.Failed($"Failed to select search result: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|