[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build an RSS Feed Aggregation Service

Build an RSS Feed Aggregation Service

Build an RSS feed aggregation service with multi-format support, content deduplication, smart categorization, digest generation, and OPML import/export.

Development#typescript#tsconfig#type-checking#type-safety#javascript-migration
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 5 skills

Avg quality 96/100·All SAFE
>

typescript

v1.0.0

Sets up and repairs TypeScript projects: picks tsconfig.json settings for a Node.js service, an npm library or a bundled web app, runs type-checking in CI, and replaces `any` and unsafe casts with types the compiler can verify. Use when someone asks to "set up TypeScript", "fix this tsconfig", "fix a TypeScript error", "upgrade to TypeScript 6 or 7", "migrate JavaScript to TypeScript", "publish types for a package", "run .ts files in Node" or "make this type-safe". Covers the changed defaults in TypeScript 6.0 and 7.0, module and moduleResolution, strict mode, project references, declaration output, narrowing, satisfies and common compiler errors.

93/100 quality
7.30× impact
SAFE· stale score
View skill
>

redis

v1.1.0

Redis is an in-memory data store used as a cache, session store, message broker and counter service. Use when adding a cache layer, storing sessions, rate limiting an API, running pub/sub or Streams with consumer groups, taking a distributed lock, building a leaderboard, or configuring Redis for production (persistence, maxmemory, eviction).

93/100 quality
1.79× impact
SAFE· stale score
View skill
>

postgresql

v1.1.0

Assists with designing schemas, writing performant queries, managing indexes, and operating PostgreSQL databases. Use when working with JSONB, full-text search, window functions, CTEs, row-level security, replication, or performance tuning. Trigger words: postgresql, postgres, sql, database, jsonb, rls, window functions, cte.

100/100 quality
5.15× impact
SAFE· stale score
View skill
>

hono

v1.1.0

Hono is a small web framework built on Web Standards (Request/Response) that runs unchanged on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda and Vercel. Use it when the user asks to build a Hono API, add Zod validation, JWT auth, CORS or middleware, use the type-safe RPC client (hc), render JSX, stream responses, or deploy one codebase to several runtimes.

100/100 quality
6.20× impact
SAFE· stale score
View skill
>

zod

v1.1.0

Zod is a TypeScript-first schema library that validates data at runtime and infers static types from the same schema. Use when validating API request bodies, form input, environment variables or config files, parsing untrusted JSON, or migrating from Zod 3 to Zod 4. Covers z.object, safeParse, z.infer, coercion, discriminated unions, refinements, JSON Schema output and the v4 API changes.

93/100 quality
3.87× impact
SAFE· stale score
View skill
$

The Problem

Elena manages content curation at a 15-person media company. They monitor 200 RSS feeds across tech, business, and design. Google Reader is long gone and alternatives are either expensive ($20/user/month) or missing features. Content drowns them — 500 new items daily, 80% irrelevant. Nobody reads the full firehose. They need a smart aggregator: import OPML, poll feeds, deduplicate across sources, categorize by topic, score by relevance, and generate a daily digest with only the most important items.

Step 1: Build the Feed Aggregation Engine

typescript
// src/feeds/aggregator.ts — RSS/Atom feed aggregation with dedup and smart digest
import { pool } from "../db";
import { Redis } from "ioredis";
import { parseStringPromise } from "xml2js";
import { createHash, randomBytes } from "node:crypto";

const redis = new Redis(process.env.REDIS_URL!);

interface Feed {
  id: string;
  title: string;
  url: string;
  siteUrl: string;
  format: "rss" | "atom" | "json";
  category: string;
  tags: string[];
  refreshInterval: number;     // minutes
  lastFetchedAt: string | null;
  lastItemAt: string | null;
  errorCount: number;
  status: "active" | "error" | "paused";
}

interface FeedItem {
  id: string;
  feedId: string;
  title: string;
  url: string;
  content: string;
  summary: string;
  author: string;
  publishedAt: string;
  categories: string[];
  relevanceScore: number;
  contentHash: string;
  isDuplicate: boolean;
  isRead: boolean;
  isStarred: boolean;
}

// Import feeds from OPML file
export async function importOPML(opmlXml: string, userId: string): Promise<Feed[]> {
  const parsed = await parseStringPromise(opmlXml, { explicitArray: false });
  const outlines = extractOutlines(parsed.opml.body.outline);
  const feeds: Feed[] = [];

  for (const outline of outlines) {
    if (!outline.xmlUrl) continue;
    const id = `feed-${randomBytes(4).toString("hex")}`;
    const feed: Feed = {
      id, title: outline.title || outline.text || "Untitled",
      url: outline.xmlUrl, siteUrl: outline.htmlUrl || "",
      format: "rss", category: outline.category || "Uncategorized",
      tags: [], refreshInterval: 30,
      lastFetchedAt: null, lastItemAt: null,
      errorCount: 0, status: "active",
    };

    await pool.query(
      `INSERT INTO feeds (id, title, url, site_url, category, refresh_interval, status, user_id, created_at)
       VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, NOW()) ON CONFLICT (url, user_id) DO NOTHING`,
      [id, feed.title, feed.url, feed.siteUrl, feed.category, feed.refreshInterval, userId]
    );
    feeds.push(feed);
  }

  return feeds;
}

// Fetch and process a single feed
export async function fetchFeed(feed: Feed): Promise<FeedItem[]> {
  try {
    const response = await fetch(feed.url, {
      headers: { "User-Agent": "FeedAggregator/1.0", "Accept": "application/rss+xml, application/atom+xml, application/xml" },
      signal: AbortSignal.timeout(10000),
    });

    const text = await response.text();
    const items = await parseFeed(text);
    const newItems: FeedItem[] = [];

    for (const item of items) {
      const contentHash = createHash("sha256")
        .update((item.title || "") + (item.link || ""))
        .digest("hex").slice(0, 16);

      // Skip if already processed
      const exists = await redis.get(`feed:item:${contentHash}`);
      if (exists) continue;
      await redis.setex(`feed:item:${contentHash}`, 86400 * 7, "1");

      // Deduplication across feeds
      const isDuplicate = await checkDuplicate(item.title || "");

      // Relevance scoring
      const relevanceScore = scoreRelevance(item, feed);

      const feedItem: FeedItem = {
        id: `fi-${randomBytes(6).toString("hex")}`,
        feedId: feed.id,
        title: item.title || "Untitled",
        url: item.link || "",
        content: item["content:encoded"] || item.description || "",
        summary: (item.description || "").replace(/<[^>]+>/g, "").slice(0, 300),
        author: item.author || item["dc:creator"] || "",
        publishedAt: item.pubDate ? new Date(item.pubDate).toISOString() : new Date().toISOString(),
        categories: extractCategories(item),
        relevanceScore,
        contentHash,
        isDuplicate,
        isRead: false,
        isStarred: false,
      };

      await pool.query(
        `INSERT INTO feed_items (id, feed_id, title, url, content, summary, author, published_at, categories, relevance_score, content_hash, is_duplicate, created_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW())`,
        [feedItem.id, feed.id, feedItem.title, feedItem.url, feedItem.content,
         feedItem.summary, feedItem.author, feedItem.publishedAt,
         JSON.stringify(feedItem.categories), feedItem.relevanceScore,
         feedItem.contentHash, feedItem.isDuplicate]
      );

      newItems.push(feedItem);
    }

    // Update feed status
    await pool.query(
      "UPDATE feeds SET last_fetched_at = NOW(), error_count = 0, status = 'active' WHERE id = $1",
      [feed.id]
    );

    return newItems;
  } catch (error: any) {
    await pool.query(
      "UPDATE feeds SET error_count = error_count + 1, status = CASE WHEN error_count >= 5 THEN 'error' ELSE status END WHERE id = $1",
      [feed.id]
    );
    return [];
  }
}

// Generate daily digest — top N most relevant items
export async function generateDigest(userId: string, maxItems: number = 20): Promise<{
  date: string;
  items: FeedItem[];
  byCategory: Record<string, FeedItem[]>;
}> {
  const { rows } = await pool.query(
    `SELECT fi.* FROM feed_items fi
     JOIN feeds f ON fi.feed_id = f.id
     WHERE f.user_id = $1
       AND fi.is_duplicate = false
       AND fi.published_at > NOW() - INTERVAL '24 hours'
     ORDER BY fi.relevance_score DESC
     LIMIT $2`,
    [userId, maxItems]
  );

  const byCategory: Record<string, FeedItem[]> = {};
  for (const item of rows) {
    const cats = JSON.parse(item.categories);
    const cat = cats[0] || "General";
    if (!byCategory[cat]) byCategory[cat] = [];
    byCategory[cat].push(item);
  }

  return { date: new Date().toISOString().slice(0, 10), items: rows, byCategory };
}

function scoreRelevance(item: any, feed: Feed): number {
  let score = 0.5;
  const title = (item.title || "").toLowerCase();
  // Boost for trending keywords
  const trending = ["ai", "llm", "agent", "startup", "funding", "launch"];
  for (const kw of trending) {
    if (title.includes(kw)) score += 0.1;
  }
  score = Math.min(1, score);
  return score;
}

async function checkDuplicate(title: string): Promise<boolean> {
  const { rows } = await pool.query(
    `SELECT id FROM feed_items WHERE similarity(title, $1) > 0.6 AND created_at > NOW() - INTERVAL '48 hours' LIMIT 1`,
    [title]
  );
  return rows.length > 0;
}

async function parseFeed(xml: string): Promise<any[]> {
  const result = await parseStringPromise(xml, { explicitArray: false });
  if (result.rss) return [].concat(result.rss.channel.item || []);
  if (result.feed) return [].concat(result.feed.entry || []).map((e: any) => ({
    title: e.title?.["_"] || e.title,
    link: e.link?.$?.href || e.link,
    description: e.summary?.["_"] || e.summary || "",
    pubDate: e.published || e.updated,
    author: e.author?.name || "",
  }));
  return [];
}

function extractOutlines(outline: any): any[] {
  if (!outline) return [];
  const items = Array.isArray(outline) ? outline : [outline];
  const results: any[] = [];
  for (const item of items) {
    if (item.xmlUrl) results.push(item);
    if (item.outline) results.push(...extractOutlines(item.outline));
  }
  return results;
}

function extractCategories(item: any): string[] {
  if (!item.category) return [];
  const cats = Array.isArray(item.category) ? item.category : [item.category];
  return cats.map((c: any) => (typeof c === "string" ? c : c._ || c.$ || "")).filter(Boolean);
}

Results

  • 200 feeds imported in one OPML upload — all feeds polling automatically; no manual entry; existing subscriptions migrated in seconds
  • 500 items/day → 20 item digest — relevance scoring surfaces what matters; daily digest email at 8 AM with top stories; team reads in 10 minutes instead of drowning
  • Deduplication saves 40% reading time — same story from TechCrunch, The Verge, and Ars Technica shows once with all source links; no redundant reading
  • Feed health tracking — broken feeds auto-paused after 5 failures; dashboard shows which feeds are active/errored; no silent data loss
  • Cost: $20/user/month → $0 — self-hosted; unlimited feeds and users; OPML export ensures no lock-in

Related use cases