Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,3 @@ nunit-*.xml
/Weather4Agents.API/appsettings.Development.json
.DS_Store
.continue
docs/ProviderExamples/
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Changelog
## v1.1.0
- Moved forecast reliability percentage from hourly slot level to day level (`DayWeather.ReliabilityPerc` / `DayForecastEntry.ReliabilityPerc`)
- Added per-slot precipitation probability (`HoursWeatherDetails.PrecipitationProbabilityPerc`), exposed when the provider supplies it
- Updated 3bMeteo scraper to support v3 website layout (new `table-previsioni-ora` table structure and today-page detection)
- Fixed temperature parsing to avoid false match with `unit-tempo` CSS class
## v1.0.5
- Updated 3bmeteo web scraper (3bmeteo website was updated)
## v1.0.4
- Updated 3bmeteo web scraper (3bmeteo website was refactored)
## v1.0.3
- OpenApi definitions in `<endpoint>/openapi/v1.json`
- Refactoring of api endpoints definitions
## v1.0.2
- Fixed bug with mapping of word "velature sparse" (3bmeteo)
- Custom integration for Home Assistant (Integrations/HomeAssistant)
## v1.0.1
- Removed Mediatr dependency and implemented a base service to handle CQRS pattern
- Improved appsettings loading for development mode
- Docker image for arm64 (raspberry) and amd64
- New endpoint to get 7 days forecast
## v1.0.0
- Initial release
2 changes: 1 addition & 1 deletion Integrations/HomeAssistant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Add a weather forecast card to any dashboard:

```yaml
type: weather-forecast
entity: weather.weather4agents_nembro
entity: weather.weather4agents_bergamo
forecast_type: daily # or "hourly"
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"data_description": {
"base_url": "Full URL of the Weather4Agents API, e.g. http://192.168.1.10:8080",
"location": "City name as recognised by the API, e.g. nembro",
"location": "City name as recognised by the API, e.g. bergamo",
"scan_interval": "How often (in minutes) to refresh weather data. Min 5, max 1440."
}
}
Expand Down
18 changes: 1 addition & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,5 @@ Yu can consume APIs to get weather data
![alt text](./docs/images/n8n-integration.png)

# Changelog
## v1.0.5
- Updated 3bmeteo web scraper (3bmeteo website was updated)
## v1.0.4
- Updated 3bmeteo web scraper (3bmeteo website was refactored)
## v1.0.3
- OpenApi definitions in `<endpoint>/openapi/v1.json`
- Refactoring of api endpoints definitions
## v1.0.2
- Fixed bug with mapping of word "velature sparse" (3bmeteo)
- Custom integration for Home Assistant (Integrations/HomeAssistant)
## v1.0.1
- Removed Mediatr dependency and implemented a base service to handle CQRS pattern
- Improved appsettings loading for development mode
- Docker image for arm64 (raspberry) and amd64
- New endpoint to get 7 days forecast
## v1.0.0
- Initial release
See [CHANGELOG.md](CHANGELOG.md) for the full version history.

6 changes: 3 additions & 3 deletions Weather4Agents.API/Controllers/WeatherController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public WeatherController(IDispatcher dispatcher)
}

/// <summary>
/// Retrieves the weather forecast for the next <paramref name="numberOfDays"/> days using the specified provider (or the default one if omitted).
/// Forecast for the next <paramref name="numberOfDays"/> days
/// </summary>
/// <param name="location">Location name. If location contains spaces, use URL encoding.</param>
/// <param name="numberOfDays">Number of days to return</param>
Expand All @@ -45,7 +45,7 @@ public async Task<IActionResult> GetForecastByDays(string location, int numberOf
}

/// <summary>
/// Retrieves the 7-day weather forecast using the specified provider (or the default one if omitted).
/// 7-day weather forecast
/// </summary>
/// <param name="location">Location name. If location contains spaces, use URL encoding.</param>
/// <param name="provider">Optional provider name. If omitted, the default provider is used.</param>
Expand All @@ -67,7 +67,7 @@ public async Task<IActionResult> GetWeekForecast(string location, [FromQuery] st
}

/// <summary>
/// Retrieves the weather for a specific day using the specified provider (or the default one if omitted).
/// Weather for a specific day
/// </summary>
/// <param name="location">Location name. If location contains spaces, use URL encoding.</param>
/// <param name="date">Date for which to retrieve weather information.</param>
Expand Down
8 changes: 7 additions & 1 deletion Weather4Agents.Application/DTOs/WeekForecastResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@ namespace Weather4Agents.Application.DTOs;

public class WeekForecastResponse
{
// UTC Format
/// <summary>
/// UTC Format
/// </summary>
public DateTimeOffset LastUpdatedAt { get; set; }
public IEnumerable<DayForecastEntry> Forecast { get; set; } = [];
}

public class DayForecastEntry
{
public DateOnly Date { get; set; }
/// <summary>
/// Accuracy (0-100%)
/// </summary>
public int ReliabilityPerc { get; set; } = 100;
public IEnumerable<HoursWeatherDetails> HoursDetails { get; set; } = [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public async Task<WeekForecastResponse> HandleAsync(GetWeekForecastQuery query,
.Select(d => new DayForecastEntry
{
Date = d.Date,
ReliabilityPerc = d.ReliabilityPerc,
HoursDetails = d.HoursDetails
});

Expand Down
5 changes: 5 additions & 0 deletions Weather4Agents.Domain/Entities/DayWeather.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,10 @@ public class DayWeather
/// Weather provider where date come from
/// </summary>
public WeatherProvider Provider { get; set; } = new WeatherProvider("NotSet");
/// <summary>
/// Overall forecast reliability percentage (0-100) as reported by the provider.
/// Defaults to 100 when the provider does not expose a reliability indicator.
/// </summary>
public int ReliabilityPerc { get; set; } = 100;
}
}
7 changes: 3 additions & 4 deletions Weather4Agents.Domain/Entities/HoursWeatherDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,9 @@ public class HoursWeatherDetails
/// </summary>
public string WindDirection { get; set; } = "";
/// <summary>
/// Forecast reliability percentage (0-100) extracted from provider page.
/// Defaults to 20 for probabilistic pages where the indicator is absent.
/// Defaults to 100 for standard hourly pages without an explicit reliability indicator.
/// Precipitation probability for this time slot (0-100), if provided by the source.
/// Null when the provider does not expose per-slot precipitation probability.
/// </summary>
public int ReliabilityPerc { get; set; } = 100;
public int? PrecipitationProbabilityPerc { get; set; }
}
}
67 changes: 40 additions & 27 deletions Weather4Agents.Infrastructure/Scrapers/3bMeteoScraper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,20 +66,28 @@ private DayWeather ParseDayPage(string html, DateOnly date)
Provider = new WeatherProvider(ProviderName)
};

if (doc.GetElementbyId("table_orario_ita") is not null)
// The table layout is used for near-future days. It appears either as:
// - #table_orario_ita (days 1-2), or
// - .table-previsioni-ora without the wrapper id (today/day-0).
var hasTableLayout =
doc.GetElementbyId("table_orario_ita") is not null ||
doc.DocumentNode.SelectSingleNode(
"//div[contains(@class,'table-previsioni-ora')]") is not null;

if (hasTableLayout)
{
// Table layout (complete1/2/today-style): flat row-table rows
var reliability = ParseTableReliability(doc);
dayWeather.HoursDetails = ParseTableHourlyItems(doc, reliability);
dayWeather.ReliabilityPerc = ParseTableReliability(doc);
dayWeather.HoursDetails = ParseTableHourlyItems(doc);
}
else
{
// Accordion layout (complete4-style and legacy): fc-accordion-item elements
var isComplicated = IsComplicatedPage(doc);
var reliability = isComplicated ? 20 : ParseReliability(doc);
dayWeather.ReliabilityPerc = isComplicated ? 20 : ParseReliability(doc);
dayWeather.HoursDetails = isComplicated
? ParseEsaSlots(doc, reliability)
: ParseHourlyItems(doc, reliability);
? ParseEsaSlots(doc)
: ParseHourlyItems(doc);
}

return dayWeather;
Expand Down Expand Up @@ -115,7 +123,7 @@ private static int ParseReliability(HtmlDocument doc)
: 100;
}

private static List<HoursWeatherDetails> ParseHourlyItems(HtmlDocument doc, int reliability)
private static List<HoursWeatherDetails> ParseHourlyItems(HtmlDocument doc)
{
var items = doc.DocumentNode.SelectNodes(
"//div[@id='content-orario']//li[contains(@class,'fc-accordion-item')]");
Expand All @@ -131,7 +139,7 @@ private static List<HoursWeatherDetails> ParseHourlyItems(HtmlDocument doc, int
if (!int.TryParse(timeText, out var hour) || hour is < 0 or > 23)
continue;

var details = ParseAccordionItem(item, new TimeOnly(hour, 0), new TimeOnly(hour, 0).AddHours(1), reliability);
var details = ParseAccordionItem(item, new TimeOnly(hour, 0), new TimeOnly(hour, 0).AddHours(1));
if (details is not null)
results.Add(details);
}
Expand All @@ -147,7 +155,7 @@ private static readonly (TimeOnly From, TimeOnly To)[] EsaSlotDefs =
(new TimeOnly(18, 0), new TimeOnly(0, 0)), // Ser. - Sera
];

private static List<HoursWeatherDetails> ParseEsaSlots(HtmlDocument doc, int reliability)
private static List<HoursWeatherDetails> ParseEsaSlots(HtmlDocument doc)
{
var items = doc.DocumentNode.SelectNodes(
"//div[@id='content-esaorario']//li[contains(@class,'fc-accordion-item')]");
Expand All @@ -158,23 +166,25 @@ private static List<HoursWeatherDetails> ParseEsaSlots(HtmlDocument doc, int rel
for (var i = 0; i < 4; i++)
{
var (timeFrom, timeTo) = EsaSlotDefs[i];
var details = ParseAccordionItem(items[i], timeFrom, timeTo, reliability);
var details = ParseAccordionItem(items[i], timeFrom, timeTo);
if (details is not null)
results.Add(details);
}
return results;
}

private static HoursWeatherDetails? ParseAccordionItem(HtmlNode item, TimeOnly timeFrom, TimeOnly timeTo, int reliability)
private static HoursWeatherDetails? ParseAccordionItem(HtmlNode item, TimeOnly timeFrom, TimeOnly timeTo)
{
// Prefer the detailed summary text; fall back to the short header label (new design) or condition span (legacy)
var descNode = item.SelectSingleNode(".//div[contains(@class,'fc-accordion-summary-mobile')]")
?? item.SelectSingleNode(".//span[contains(@class,'unit-tempo')]")
?? item.SelectSingleNode(".//div[contains(@class,'fc-accordion-condition')]//span[contains(@class,'ds-body-medium')]");
var description = HtmlEntity.DeEntitize(descNode?.InnerText ?? string.Empty).Trim();

// Temperature: data-temp-c attribute on span.unit-temp (class name varies across designs)
var tempSpan = item.SelectSingleNode(".//span[contains(@class,'unit-temp')]");
// Temperature: data-temp-c attribute on span.unit-temp.
// Use word-boundary match to avoid matching span.unit-tempo (weather description).
var tempSpan = item.SelectSingleNode(
".//span[contains(concat(' ', normalize-space(@class), ' '), ' unit-temp ')]");
double.TryParse(
tempSpan?.GetAttributeValue("data-temp-c", "0").Trim(),
NumberStyles.Any, CultureInfo.InvariantCulture, out var tempC);
Expand All @@ -193,19 +203,22 @@ private static List<HoursWeatherDetails> ParseEsaSlots(HtmlDocument doc, int rel

var pressureMbar = (int)ParseFirstDouble(GetParamValue(item, "pressione"));

var probText = GetParamValue(item, "probabilita").Replace("%", "").Trim();
int? precipProb = int.TryParse(probText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p) ? p : null;

return new HoursWeatherDetails
{
TimeFrom = timeFrom,
TimeTo = timeTo,
WeatherType = MapWeatherType(description),
WeatherTypeDescription = description,
TemperatureC = tempC,
PrecipitationMm = precipMm,
HumidityPerc = humidity,
PressionMbar = pressureMbar,
WindKmh = windKmh,
WindDirection = windDir,
ReliabilityPerc = reliability,
TimeFrom = timeFrom,
TimeTo = timeTo,
WeatherType = MapWeatherType(description),
WeatherTypeDescription = description,
TemperatureC = tempC,
PrecipitationMm = precipMm,
HumidityPerc = humidity,
PressionMbar = pressureMbar,
WindKmh = windKmh,
WindDirection = windDir,
PrecipitationProbabilityPerc = precipProb,
};
}

Expand Down Expand Up @@ -240,10 +253,11 @@ private static int ParseTableReliability(HtmlDocument doc)
: 100;
}

private static List<HoursWeatherDetails> ParseTableHourlyItems(HtmlDocument doc, int reliability)
private static List<HoursWeatherDetails> ParseTableHourlyItems(HtmlDocument doc)
{
var rows = doc.DocumentNode.SelectNodes(
"//div[@id='table_orario_ita']//div[contains(@class,'row-table') and contains(@class,'noPad')]");
"//div[@id='table_orario_ita' or contains(@class,'table-previsioni-ora')]" +
"//div[contains(@class,'row-table') and contains(@class,'noPad')]");
if (rows is null)
return [];

Expand Down Expand Up @@ -305,7 +319,6 @@ private static List<HoursWeatherDetails> ParseTableHourlyItems(HtmlDocument doc,
PressionMbar = pressureMbar,
WindKmh = windKmh,
WindDirection = windDir,
ReliabilityPerc = reliability,
});
}

Expand Down
Loading