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 "" }