Files
go-extractor/sites/duckduckgo/weather.go
Steve Dudenhoeffer 469171da9c
All checks were successful
CI / build (pull_request) Successful in 30s
CI / vet (pull_request) Successful in 46s
CI / test (pull_request) Successful in 49s
feature: add hourly forecast, precipitation, and icon hints to weather extractor
Add HourlyForecast struct and Hourly field to WeatherData for hourly
temperature/condition data. Add Precipitation (int, -1 if unavailable)
and IconHint (from aria-label/title/alt attributes) to both DayForecast
and HourlyForecast. This enables downstream consumers like mort to
replace inline DuckDuckGo scraping with a single GetWeather() call.

Closes #51

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:22:04 +00:00

207 lines
5.1 KiB
Go

package duckduckgo
import (
"context"
"fmt"
"log/slog"
"time"
"gitea.stevedudenhoeffer.com/steve/go-extractor"
"gitea.stevedudenhoeffer.com/steve/go-extractor/sites/internal/parse"
)
// WeatherData holds structured weather information extracted from DuckDuckGo.
type WeatherData struct {
Location string
CurrentTemp float64
Condition string
HighTemp float64
LowTemp float64
Humidity string
Wind string
Forecast []DayForecast
Hourly []HourlyForecast
}
// DayForecast holds a single day's forecast.
type DayForecast struct {
Day string
HighTemp float64
LowTemp float64
Condition string
Precipitation int // percentage 0-100, -1 if unavailable
IconHint string // icon type from element attributes (e.g. "PartlyCloudy", "Snow")
}
// HourlyForecast holds a single hour's forecast.
type HourlyForecast struct {
Time string
Temp float64
Condition string
Precipitation int // percentage 0-100, -1 if unavailable
IconHint string // icon type from element attributes (e.g. "MostlyCloudy", "Rain")
}
// GetWeather extracts weather data from DuckDuckGo's weather widget.
func (c Config) GetWeather(ctx context.Context, b extractor.Browser, city string) (*WeatherData, error) {
c = c.validate()
u := c.ToSearchURL("weather " + city)
slog.Info("fetching weather", "url", u, "city", city)
doc, err := b.Open(ctx, u.String(), extractor.OpenPageOptions{})
if err != nil {
return nil, fmt.Errorf("failed to open weather page: %w", err)
}
defer extractor.DeferClose(doc)
timeout := 10 * time.Second
if err := doc.WaitForNetworkIdle(&timeout); err != nil {
slog.Warn("WaitForNetworkIdle failed", "err", err)
}
return extractWeather(doc)
}
// GetWeather is a convenience function using DefaultConfig.
func GetWeather(ctx context.Context, b extractor.Browser, city string) (*WeatherData, error) {
return DefaultConfig.GetWeather(ctx, b, city)
}
func extractWeather(doc extractor.Node) (*WeatherData, error) {
var data WeatherData
// Location
locs := doc.Select("div.module--weather span.module__title__link")
if len(locs) > 0 {
data.Location, _ = locs[0].Text()
}
// Current temperature
temps := doc.Select("div.module--weather .module__current-temp")
if len(temps) > 0 {
txt, _ := temps[0].Text()
data.CurrentTemp = parse.NumericOnly(txt)
}
// Condition
conds := doc.Select("div.module--weather .module__weather-summary")
if len(conds) > 0 {
data.Condition, _ = conds[0].Text()
}
// High/low
highs := doc.Select("div.module--weather .module__high-temp")
if len(highs) > 0 {
txt, _ := highs[0].Text()
data.HighTemp = parse.NumericOnly(txt)
}
lows := doc.Select("div.module--weather .module__low-temp")
if len(lows) > 0 {
txt, _ := lows[0].Text()
data.LowTemp = parse.NumericOnly(txt)
}
// Humidity
humids := doc.Select("div.module--weather .module__humidity")
if len(humids) > 0 {
data.Humidity, _ = humids[0].Text()
}
// Wind
winds := doc.Select("div.module--weather .module__wind")
if len(winds) > 0 {
data.Wind, _ = winds[0].Text()
}
// Daily forecast
_ = doc.ForEach("div.module--weather .module__forecast-day", func(n extractor.Node) error {
var day DayForecast
day.Precipitation = -1
days := n.Select(".forecast-day__name")
if len(days) > 0 {
day.Day, _ = days[0].Text()
}
dayHighs := n.Select(".forecast-day__high")
if len(dayHighs) > 0 {
txt, _ := dayHighs[0].Text()
day.HighTemp = parse.NumericOnly(txt)
}
dayLows := n.Select(".forecast-day__low")
if len(dayLows) > 0 {
txt, _ := dayLows[0].Text()
day.LowTemp = parse.NumericOnly(txt)
}
dayConds := n.Select(".forecast-day__condition")
if len(dayConds) > 0 {
day.Condition, _ = dayConds[0].Text()
}
precips := n.Select(".forecast-day__precip")
if len(precips) > 0 {
txt, _ := precips[0].Text()
day.Precipitation = int(parse.NumericOnly(txt))
}
day.IconHint = extractIconHint(n.Select(".forecast-day__icon"))
data.Forecast = append(data.Forecast, day)
return nil
})
// Hourly forecast
_ = doc.ForEach("div.module--weather .module__hourly-item", func(n extractor.Node) error {
var hour HourlyForecast
hour.Precipitation = -1
times := n.Select(".hourly-item__time")
if len(times) > 0 {
hour.Time, _ = times[0].Text()
}
temps := n.Select(".hourly-item__temp")
if len(temps) > 0 {
txt, _ := temps[0].Text()
hour.Temp = parse.NumericOnly(txt)
}
conds := n.Select(".hourly-item__condition")
if len(conds) > 0 {
hour.Condition, _ = conds[0].Text()
}
precips := n.Select(".hourly-item__precip")
if len(precips) > 0 {
txt, _ := precips[0].Text()
hour.Precipitation = int(parse.NumericOnly(txt))
}
hour.IconHint = extractIconHint(n.Select(".hourly-item__icon"))
data.Hourly = append(data.Hourly, hour)
return nil
})
return &data, nil
}
// extractIconHint reads the icon type from an element's aria-label, title, or alt attribute.
func extractIconHint(nodes extractor.Nodes) string {
if len(nodes) == 0 {
return ""
}
n := nodes[0]
for _, attr := range []string{"aria-label", "title", "alt"} {
v, _ := n.Attr(attr)
if v != "" {
return v
}
}
return ""
}