File size: 2,983 Bytes
ee888e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/**
 * #4920 (a): pure feed-health payload builder.
 *
 * Consumed by scripts/validate-rss-feeds.mjs after its validation run to
 * turn per-feed results into the published `news:feed-health:v1` payload,
 * carrying cross-run state (consecutive-empty streaks) so "silent zero"
 * Google News wrappers — feeds that respond 200 with zero items when
 * Google's ranking shifts — become visible instead of rotting quietly.
 *
 * Pure module: no I/O. The caller reads the previous payload from Redis
 * and passes it in.
 */

/** A Google News search wrapper — the class of feed that silently zeroes. */
export function isGoogleNewsWrapper(url) {
  return typeof url === 'string' && url.includes('news.google.com/rss/');
}

/**
 * Consecutive EMPTY runs (this run included) before a wrapper feed is
 * flagged as a silent zero. One empty run can be Google jitter; two daily
 * runs of zero items on a search wrapper is a real coverage hole.
 */
export const SILENT_ZERO_THRESHOLD = 2;

/**
 * @param {Array<{ name: string; url: string; status: 'OK'|'STALE'|'DEAD'|'EMPTY'|'SKIP'; detail?: string; catalog?: string }>} results
 * @param {{ feeds?: Record<string, { consecutiveEmpty?: number }> } | null} previousPayload
 * @param {number} nowMs
 */
export function buildFeedHealthPayload(results, previousPayload, nowMs) {
  const prevFeeds = previousPayload && typeof previousPayload === 'object' && previousPayload.feeds
    ? previousPayload.feeds
    : {};

  const feeds = {};
  const summary = { ok: 0, stale: 0, dead: 0, empty: 0, skipped: 0 };
  const silentZeros = [];

  for (const result of results) {
    const key = result.url;
    const status = result.status;
    if (status === 'OK') summary.ok++;
    else if (status === 'STALE') summary.stale++;
    else if (status === 'DEAD') summary.dead++;
    else if (status === 'EMPTY') summary.empty++;
    else summary.skipped++;

    const wrapper = isGoogleNewsWrapper(result.url);
    const prevStreak = Number.isFinite(prevFeeds[key]?.consecutiveEmpty)
      ? prevFeeds[key].consecutiveEmpty
      : 0;
    // DEAD counts toward the streak too — a wrapper that flips between
    // "200 with zero items" and timeouts is still delivering nothing.
    const deliveredNothing = status === 'EMPTY' || status === 'DEAD';
    const consecutiveEmpty = deliveredNothing ? prevStreak + 1 : 0;

    const entry = {
      name: result.name,
      status,
      catalog: result.catalog ?? 'client',
      wrapper,
      consecutiveEmpty,
    };
    if (result.detail) entry.detail = String(result.detail).slice(0, 120);
    feeds[key] = entry;

    if (wrapper && consecutiveEmpty >= SILENT_ZERO_THRESHOLD) {
      silentZeros.push({ name: result.name, url: result.url, consecutiveEmpty });
    }
  }

  silentZeros.sort((a, b) => b.consecutiveEmpty - a.consecutiveEmpty || a.name.localeCompare(b.name));

  return {
    v: 1,
    checkedAt: nowMs,
    summary,
    feedCount: results.length,
    silentZeros,
    feeds,
  };
}