chore: improved feed discovery and parsing
e0c3935e
1 file(s) · +65 −28
| 284 | 284 | return nil, fmt.Errorf("invalid URL: %w", err) |
|
| 285 | 285 | } |
|
| 286 | 286 | client := buildHTTPClient() |
|
| 287 | - | req, err := newRequest(ctx, http.MethodGet, baseURL) |
|
| 288 | - | if err != nil { |
|
| 289 | - | return nil, fmt.Errorf("invalid URL: %w", err) |
|
| 287 | + | ||
| 288 | + | // Pages to scan for <link rel="alternate"> feed hints. Always include the |
|
| 289 | + | // origin root: a user often pastes a deep or dead feed URL (e.g. an |
|
| 290 | + | // advertised /rss.xml that 404s) while the real feed is advertised on the |
|
| 291 | + | // homepage. |
|
| 292 | + | scanPages := []string{baseURL} |
|
| 293 | + | if root := originRoot(parsed); root != "" && root != baseURL { |
|
| 294 | + | scanPages = append(scanPages, root) |
|
| 290 | 295 | } |
|
| 291 | - | feeds := []string{} |
|
| 292 | - | resp, err := client.Do(req) |
|
| 293 | - | if err == nil { |
|
| 294 | - | defer resp.Body.Close() |
|
| 296 | + | ||
| 297 | + | candidates := []string{} |
|
| 298 | + | addCandidate := func(u string) { |
|
| 299 | + | if u != "" && !slices.Contains(candidates, u) { |
|
| 300 | + | candidates = append(candidates, u) |
|
| 301 | + | } |
|
| 302 | + | } |
|
| 303 | + | for _, page := range scanPages { |
|
| 304 | + | req, err := newRequest(ctx, http.MethodGet, page) |
|
| 305 | + | if err != nil { |
|
| 306 | + | continue |
|
| 307 | + | } |
|
| 308 | + | resp, err := client.Do(req) |
|
| 309 | + | if err != nil { |
|
| 310 | + | continue |
|
| 311 | + | } |
|
| 295 | 312 | body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) |
|
| 296 | - | links := findAlternateFeedLinks(string(body)) |
|
| 297 | - | for _, href := range links { |
|
| 313 | + | _ = resp.Body.Close() |
|
| 314 | + | if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
|
| 315 | + | continue |
|
| 316 | + | } |
|
| 317 | + | for _, href := range findAlternateFeedLinks(string(body)) { |
|
| 298 | 318 | resolved := href |
|
| 299 | 319 | if u, err := parsed.Parse(href); err == nil { |
|
| 300 | 320 | resolved = u.String() |
|
| 301 | 321 | } |
|
| 302 | - | if !slices.Contains(feeds, resolved) { |
|
| 303 | - | feeds = append(feeds, resolved) |
|
| 304 | - | } |
|
| 322 | + | addCandidate(resolved) |
|
| 305 | 323 | } |
|
| 306 | 324 | } |
|
| 307 | - | if len(feeds) == 0 { |
|
| 325 | + | ||
| 326 | + | // Fall back to well-known feed paths only when the pages advertised none. |
|
| 327 | + | if len(candidates) == 0 { |
|
| 308 | 328 | paths := []string{"/feed", "/feed.xml", "/rss", "/rss.xml", "/atom.xml", "/index.xml", "/feed/rss", "/blog/feed", "/blog/rss"} |
|
| 309 | 329 | for _, path := range paths { |
|
| 310 | - | probe, err := parsed.Parse(path) |
|
| 311 | - | if err != nil { |
|
| 312 | - | continue |
|
| 330 | + | if probe, err := parsed.Parse(path); err == nil { |
|
| 331 | + | addCandidate(probe.String()) |
|
| 313 | 332 | } |
|
| 314 | - | req, err := newRequest(ctx, http.MethodHead, probe.String()) |
|
| 315 | - | if err != nil { |
|
| 316 | - | continue |
|
| 317 | - | } |
|
| 318 | - | resp, err := client.Do(req) |
|
| 319 | - | if err != nil { |
|
| 320 | - | continue |
|
| 321 | - | } |
|
| 322 | - | _ = resp.Body.Close() |
|
| 323 | - | ct := strings.ToLower(resp.Header.Get("Content-Type")) |
|
| 324 | - | if resp.StatusCode >= 200 && resp.StatusCode < 300 && (strings.Contains(ct, "xml") || strings.Contains(ct, "rss") || strings.Contains(ct, "atom")) { |
|
| 325 | - | feeds = append(feeds, probe.String()) |
|
| 333 | + | } |
|
| 334 | + | } |
|
| 335 | + | ||
| 336 | + | // A candidate is only a feed if it actually parses. Content-type is |
|
| 337 | + | // unreliable — many valid feeds serve text/html or send no type at all. |
|
| 338 | + | // Validate concurrently to keep discovery fast. |
|
| 339 | + | valid := make([]bool, len(candidates)) |
|
| 340 | + | var wg sync.WaitGroup |
|
| 341 | + | for i, c := range candidates { |
|
| 342 | + | wg.Add(1) |
|
| 343 | + | go func() { |
|
| 344 | + | defer wg.Done() |
|
| 345 | + | if _, err := fetchFeed(ctx, c, "", ""); err == nil { |
|
| 346 | + | valid[i] = true |
|
| 326 | 347 | } |
|
| 348 | + | }() |
|
| 349 | + | } |
|
| 350 | + | wg.Wait() |
|
| 351 | + | ||
| 352 | + | feeds := []string{} |
|
| 353 | + | for i, c := range candidates { |
|
| 354 | + | if valid[i] { |
|
| 355 | + | feeds = append(feeds, c) |
|
| 327 | 356 | } |
|
| 328 | 357 | } |
|
| 329 | 358 | if len(feeds) == 0 { |
|
| 330 | 359 | return nil, errors.New("no feeds found at this URL") |
|
| 331 | 360 | } |
|
| 332 | 361 | return feeds, nil |
|
| 362 | + | } |
|
| 363 | + | ||
| 364 | + | // originRoot returns the scheme://host/ root for a parsed URL. |
|
| 365 | + | func originRoot(u *url.URL) string { |
|
| 366 | + | if u == nil || u.Scheme == "" || u.Host == "" { |
|
| 367 | + | return "" |
|
| 368 | + | } |
|
| 369 | + | return u.Scheme + "://" + u.Host + "/" |
|
| 333 | 370 | } |
|
| 334 | 371 | ||
| 335 | 372 | func findAlternateFeedLinks(doc string) []string { |