SLOPSHOPPER

news-ticker

Scrolling RSS headlines from the last 6 hours above the prompt

newbandnetworktimer
v0.1.0no licenseupdated 2026-10-05GitVex/Recipeat/.claude/skills/news-ticker
A shopper browsing a rack in a slop shop
README

Recipeat

A little inspiration. A lot of good food.

Recipeat turns recipes from photos, websites, and scraps of text into your own collection. It is a Nuxt 4 app with Zitadel login, a hosted model for reading recipes and one small Python service behind an authenticated extraction API.

Run

npm install
npm run dev

Needs Node.js 22.19+. Login and extraction both require a running server; nuxt generate cannot serve them. Storage needs a database — see Database — and without NUXT_DATABASE_URL the server starts and answers 503.

npm run build
node .output/server/index.mjs

Test

npm run test:database                # node:test, no database needed
npm run test:recipes                 # node:test, the validator on the way in
NUXT_DATABASE_URL=... npm run test:database:live   # the migration runner, against a real Postgres
npm run test:extraction              # node:test, no browser or model needed
npm run build && npm run test:auth   # Playwright against a mock OIDC issuer
cd services/recipeat-fetcher && uv run pytest   # the fetcher service

What works today

  • Landing page and recipe shelf. The shelf shows sample recipes, and its collection still lives in browser local storage.
  • The import dialog calls the three extraction routes below and opens the recipe that comes back, which can then be added to the collection. Signed out, it asks for a sign-in first.
  • Zitadel login through nuxt-oidc-auth, with GET /api/me as the worked example of a server-enforced private endpoint.
  • POST /api/extract/text sends text to the model and returns a normalized recipe. It is authenticated, and it stores nothing yet.
  • POST /api/extract/website turns a recipe URL into the same shape through the fetcher service, with no model involved. Also authenticated, also stores nothing.
  • POST /api/extract/photo sends an uploaded photo to the same model the text path uses, which reads the page itself. The image is read and discarded — nothing stores it yet.
  • POST /api/recipes writes an extracted recipe to Postgres, owned by the subject in the session. GET /api/recipes lists a user's collection and GET /api/recipes/{id} reads one; nobody reaches another user's rows.
  • Save, progression, variant. PUT /api/recipes/{id} corrects a version in place; POST /api/recipes/{id}/progressions adds a version to its line and moves the pin to it; POST /api/recipes/{id}/variants branches into a line of its own. Planning has what those words mean.

The browser calls the extraction routes and POST /api/recipes: an imported recipe can be added to the collection. The shelf does not read that collection yet, and still shows samples saved in browser local storage. Planning covers what is left.

Docs

AuthenticationZitadel application, secrets, deployment
FetcherThe Python service behind website import
DatabasePostgres, migrations, and the third container
ExtractionThe API, the pipeline, the recipe shape
API mapEvery route, arranged for testing by hand in Postman
PlanningWhat is next, and what is still undecided
DesignColour, type, shape, and how the app moves between places
ArchitectureThe two containers, their networks, and every request path

Photography comes from Unsplash and fonts from Google Fonts, so both need internet access.

Source 2 files
hooks/register.tsx 160 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Item } from '../types'
5
6const FEEDS = [
7  ['BBC', 'https://feeds.bbci.co.uk/news/world/rss.xml'],
8  ['Guardian', 'https://www.theguardian.com/world/rss'],
9  ['NPR', 'https://feeds.npr.org/1001/rss.xml'],
10  ['tagesschau', 'https://www.tagesschau.de/index~rss2.xml'],
11  ['Al Jazeera', 'https://www.aljazeera.com/xml/rss/all.xml'],
12  ['DW', 'https://rss.dw.com/rdf/rss-en-all'],
13  ['Euronews', 'https://www.euronews.com/rss'],
14  ['Sky News', 'https://feeds.skynews.com/feeds/rss/world.xml'],
15  ['CBC', 'https://www.cbc.ca/webfeed/rss/rss-topstories'],
16  ['Global News', 'https://globalnews.ca/feed/'],
17] as const
18const SEP = '  •  '
19const TICK_MS = 350
20const REFRESH_MS = 10 * 60 * 1000
21const MAX_AGE_MS = 6 * 60 * 60 * 1000
22const PER_FEED = 4
23
24// Every headline in ticker order; empty until the first fetch lands.
25const items = atom({ plugin: 'news-ticker', key: 'items' } as const, [])
26const offset = atom({ plugin: 'news-ticker', key: 'offset' } as const, 0)
27
28const decode = (s: string) =>
29  s
30    .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
31    .replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n: string) =>
32      String.fromCodePoint(/^x/i.test(n) ? parseInt(n.slice(1), 16) : Number(n)),
33    )
34    .replace(/&quot;/g, '"').replace(/&apos;/g, "'")
35    .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
36    .replace(/\s+/g, ' ')
37    .trim()
38
39// ponytail: regex RSS parsing (<pubDate>, or <dc:date> for DW's RDF); swap for a real parser if Atom feeds are added
40export const itemsOf = (xml: string) =>
41  [...xml.matchAll(/<item[\s>]([\s\S]*?)<\/item>/g)].map(([, body = '']) => ({
42    title: decode(body.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? ''),
43    published: Date.parse(body.match(/<(?:pubDate|dc:date)>([\s\S]*?)<\//)?.[1] ?? ''),
44  }))
45
46export const ago = (ms: number) => {
47  const minutes = Math.max(0, Math.round(ms / 60_000))
48  if (minutes < 60) return `${minutes} min ago`
49  const hours = Math.round(minutes / 60)
50  if (hours < 48) return hours === 1 ? '1 hour ago' : `${hours} hours ago`
51  return `${Math.round(hours / 24)} days ago`
52}
53
54// One topic per line, `#` starts a comment. Matches whole words or phrases, any case.
55export const topicsOf = (text: string) =>
56  text
57    .split('\n')
58    .map(line => line.replace(/#.*/, '').trim())
59    .filter(Boolean)
60    .map(t => new RegExp(`(?<![\\p{L}\\p{N}])${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\p{L}\\p{N}])`, 'iu'))
61
62export const isWanted = (title: string, allow: RegExp[], deny: RegExp[]) =>
63  (allow.length === 0 || allow.some(t => t.test(title))) && !deny.some(t => t.test(title))
64
65// An undated headline can't be judged, so it stays.
66export const isRecent = (published: number, now: number) => Number.isNaN(published) || now - published <= MAX_AGE_MS
67
68// Every headline joined into one loop of text, its age worked out now rather than at fetch time.
69export const stripOf = (list: readonly Item[], now: number) =>
70  list
71    .map(i => `${i.title} [${i.outlet}${i.published === null ? '' : `, ${ago(now - i.published)}`}]${SEP}`)
72    .join('')
73
74let timers: Timer[] = []
75
76// Reads the lists, then starts the scroll and the feed refresh, replacing any earlier run.
77async function start($: EngineInterface) {
78  timers.forEach(t => t.cancel())
79  // Editing either list reloads the plugin, so they're read once per load.
80  const dir = $.plugin.root.replace(/[\\/]\.claude-plugin$/, '')
81  const list = (name: string) => $.fs.read(`${dir}/${name}`).then(topicsOf, () => [])
82  const [allow, deny] = await Promise.all([list('whitelist.txt'), list('blacklist.txt')])
83
84  const refresh = async () => {
85    const now = await $.clock.now()
86    const results = await Promise.all(
87      FEEDS.map(([outlet, url]) =>
88        $.http.fetch(url).then(
89          r =>
90            r.ok
91              ? itemsOf(r.text)
92                  .filter(i => i.title && isWanted(i.title, allow, deny) && isRecent(i.published, now))
93                  .slice(0, PER_FEED)
94                  .map(i => ({
95                    title: i.title,
96                    outlet,
97                    published: Number.isNaN(i.published) ? null : i.published,
98                  }))
99              : [],
100          () => [],
101        ),
102      ),
103    )
104    // Interleave feeds so one source doesn't hog a stretch of the ticker.
105    const fresh: Item[] = []
106    for (let i = 0; i < PER_FEED; i++) for (const r of results) {
107      const item = r[i]
108      if (item) fresh.push(item)
109    }
110    if (fresh.length === 0) return
111    await update($, items, () => fresh)
112  }
113
114  const tick = async () => {
115    const list = await read($, items)
116    if (list.length === 0) return
117    const length = stripOf(list, await $.clock.now()).length
118    await update($, offset, o => (o + 1) % length)
119  }
120
121  timers = [
122    $.clock.every(TICK_MS, tick),
123    $.clock.every(REFRESH_MS, refresh),
124    // A timer, not a direct call: session.end aborts its own in-flight fetches after 1.5 s.
125    $.clock.after(0, refresh),
126  ]
127}
128
129export const register: Register = on => {
130  on('session.start', async ($, e, next) => {
131    await start($)
132    return next(e)
133  })
134
135  // A /clear ends the session and starts no new session.start, so start over here.
136  on('session.end', async ($, e, next) => {
137    const result = await next(e)
138    if (e.reason === 'clear') await start($)
139    return result
140  })
141
142  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143    const list = await read($, items)
144    if (e.props.hasSurvey || list.length === 0) return next(e)
145
146    const { Text } = $.ui.resolve(e)
147    const width = Math.max(20, (e.viewport?.columns ?? 80) - 2)
148    const text = stripOf(list, await $.clock.now())
149    const from = (await read($, offset)) % text.length
150    const loop = text.repeat(Math.ceil((from + width) / text.length) + 1)
151
152    // The theme's "subtle" key: a faint gray that sits close to the background in every theme.
153    return (
154      <Text color="subtle" wrap="truncate-end">
155        {loop.slice(from, from + width)}
156      </Text>
157    )
158  })
159}
160
types/index.d.ts 9 lines
1// `published` is epoch ms, or null when the feed gave no date.
2export type Item = { title: string; outlet: string; published: number | null }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'news-ticker': { items: Item[]; offset: number }
7  }
8}
9