From 12c6f42fa90b3bf14867225dd4a6c8a25e0f467e Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 12 Aug 2026 14:29:05 +0800 Subject: [PATCH] Add self-hosted AI Builder feed generation Generate X, blog, and podcast JSON via GitHub Actions and publish them on the feeds branch so Zero Tab no longer depends on Follow Builders. Co-authored-by: Cursor --- .github/workflows/generate-builder-feeds.yml | 72 +++ .gitignore | 4 + AGENTS.md | 2 +- README.md | 14 +- builder-feeds/README.md | 9 + builder-feeds/sources.json | 56 +++ docs/privacy.html | 2 +- extension/builder-digest.js | 6 +- extension/index.html | 2 +- package.json | 5 +- scripts/generate-builder-feeds.mjs | 17 + scripts/lib/feed-generator.mjs | 459 +++++++++++++++++++ src/components/BuilderDigestDrawer.tsx | 2 +- store/PUBLISHING_CHECKLIST.md | 4 +- store/SUBMISSION_GUIDE.md | 6 +- store/privacy-policy.md | 2 +- store/review-notes.md | 8 +- tests/builder-digest.test.js | 16 + tests/feed-generator.test.mjs | 98 ++++ tests/fixtures/blog.html | 10 + tests/fixtures/blog.rss.xml | 20 + tests/fixtures/podcast.atom.xml | 11 + tests/fixtures/sources.json | 17 + tests/fixtures/x-syndication.html | 12 + 24 files changed, 833 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/generate-builder-feeds.yml create mode 100644 builder-feeds/README.md create mode 100644 builder-feeds/sources.json create mode 100644 scripts/generate-builder-feeds.mjs create mode 100644 scripts/lib/feed-generator.mjs create mode 100644 tests/feed-generator.test.mjs create mode 100644 tests/fixtures/blog.html create mode 100644 tests/fixtures/blog.rss.xml create mode 100644 tests/fixtures/podcast.atom.xml create mode 100644 tests/fixtures/sources.json create mode 100644 tests/fixtures/x-syndication.html diff --git a/.github/workflows/generate-builder-feeds.yml b/.github/workflows/generate-builder-feeds.yml new file mode 100644 index 0000000..3a0f5b4 --- /dev/null +++ b/.github/workflows/generate-builder-feeds.yml @@ -0,0 +1,72 @@ +name: Generate builder feeds + +on: + schedule: + # Daily at 01:15 UTC + - cron: '15 1 * * *' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: generate-builder-feeds + cancel-in-progress: false + +jobs: + generate: + name: generate · publish feeds + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate public feeds + run: npm run generate:builder-feeds + + - name: Publish to feeds branch + run: | + set -euo pipefail + PUBLISH_DIR="${RUNNER_TEMP}/zero-tab-feeds" + rm -rf "$PUBLISH_DIR" + mkdir -p "$PUBLISH_DIR" + + if git ls-remote --exit-code --heads origin feeds >/dev/null 2>&1; then + git fetch origin feeds + git worktree add "$PUBLISH_DIR" origin/feeds + git -C "$PUBLISH_DIR" checkout -B feeds + else + git worktree add --detach "$PUBLISH_DIR" + git -C "$PUBLISH_DIR" checkout --orphan feeds + git -C "$PUBLISH_DIR" rm -rf . >/dev/null 2>&1 || true + fi + + cp builder-feeds/generated/feed-x.json "$PUBLISH_DIR/feed-x.json" + cp builder-feeds/generated/feed-blogs.json "$PUBLISH_DIR/feed-blogs.json" + cp builder-feeds/generated/feed-podcasts.json "$PUBLISH_DIR/feed-podcasts.json" + cp builder-feeds/generated/generation-report.json "$PUBLISH_DIR/generation-report.json" + + git -C "$PUBLISH_DIR" config user.name "github-actions[bot]" + git -C "$PUBLISH_DIR" config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C "$PUBLISH_DIR" add feed-x.json feed-blogs.json feed-podcasts.json generation-report.json + + if git -C "$PUBLISH_DIR" diff --cached --quiet; then + echo "No feed changes to publish." + else + git -C "$PUBLISH_DIR" commit -m "Update builder feeds $(date -u +%Y-%m-%dT%H:%M:%SZ)" + git -C "$PUBLISH_DIR" push origin HEAD:feeds + fi diff --git a/.gitignore b/.gitignore index d344ec9..9a2994b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ dist/ # Personal config (landing page patterns, etc.) — never push to GitHub extension/config.local.js + +# Local feed generation output; published only on the feeds branch +builder-feeds/generated/ +tests/fixtures/out/ diff --git a/AGENTS.md b/AGENTS.md index d7ac442..ccbc478 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,5 +102,5 @@ Once the extension is loaded: - Run `npm run build` and load `dist/extension/` in Chrome. - Saved tabs are stored in `chrome.storage.local` (persists across sessions). - Tab management is fully local. Open-tab and saved-tab data is never uploaded. -- The optional AI Builder digest requests access only to `raw.githubusercontent.com`, fetches public feeds at most once per local day, and stores a compact cache locally. +- The optional AI Builder digest requests access only to `raw.githubusercontent.com`, fetches public feeds from this repository's `feeds` branch at most once per local day, and stores a compact cache locally. - To update: `cd zero-tab && git pull && npm install && npm run build`, then reload the extension in `chrome://extensions`. diff --git a/README.md b/README.md index 5ce46c1..b8162d4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ No server and no account are required. Open-tab URLs, titles, and saved links st - Local, deterministic daily horoscope by zodiac sign - Localhost port labels - macOS-inspired light and dark themes -- Full-height AI Builder Daily Report drawer sourced from public Follow Builders feeds +- Full-height AI Builder Daily Report drawer sourced from public Zero Tab feeds - Optional on-device translation through Chrome's built-in Translator API ## Install locally @@ -46,7 +46,7 @@ No server and no account are required. Open-tab URLs, titles, and saved links st Core tab management makes no external requests. Tab URLs, titles, Saved for later items, read state, and preferences are stored locally using Chrome extension storage. -AI Builder Daily Report is disabled until the user enables it. When enabled, Zero Tab requests optional access to `raw.githubusercontent.com` and downloads three public Follow Builders JSON feeds at most once per local calendar day. No tab, browsing, saved-link, identifier, or API-key data is included in those requests. +AI Builder Daily Report is disabled until the user enables it. When enabled, Zero Tab requests optional access to `raw.githubusercontent.com` and downloads three public JSON feeds published by this repository's `feeds` branch at most once per local calendar day. No tab, browsing, saved-link, identifier, or API-key data is included in those requests. Translation uses Chrome's built-in on-device Translator API when available. The language model or language pack may be downloaded by Chrome, but report text is not sent to a third-party translation service by Zero Tab. @@ -87,6 +87,16 @@ The previous Vanilla implementation remains in `extension/` as a migration reference while the remaining publishing assets are moved to the new source tree. +## Builder feed generation + +Zero Tab publishes AI Builder JSON feeds from this repository's `feeds` branch. + +```bash +npm run generate:builder-feeds +``` + +Sources live in [`builder-feeds/sources.json`](builder-feeds/sources.json). GitHub Actions runs [`.github/workflows/generate-builder-feeds.yml`](.github/workflows/generate-builder-feeds.yml) daily and on `workflow_dispatch`, then commits `feed-x.json`, `feed-blogs.json`, and `feed-podcasts.json` to the `feeds` branch root. The extension reads those files from `raw.githubusercontent.com`. + ## Attribution and license Zero Tab began as a fork of Zara Zhang's MIT-licensed original project. diff --git a/builder-feeds/README.md b/builder-feeds/README.md new file mode 100644 index 0000000..d127c76 --- /dev/null +++ b/builder-feeds/README.md @@ -0,0 +1,9 @@ +# Builder feeds + +Curated source list and local generation output for the AI Builder Daily Report. + +- [`sources.json`](sources.json) — X handles, blogs (RSS and/or HTML), podcasts (RSS) +- `generated/` — local output of `npm run generate:builder-feeds` (gitignored) +- Published artifacts live on the repository `feeds` branch root + +Generation is owned by Zero Tab. It does not call Follow Builders services or APIs. diff --git a/builder-feeds/sources.json b/builder-feeds/sources.json new file mode 100644 index 0000000..53d9b1b --- /dev/null +++ b/builder-feeds/sources.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "x": [ + { "name": "Boris Cherny", "handle": "boris_cherny" }, + { "name": "Anthropic", "handle": "AnthropicAI" }, + { "name": "Guillermo Rauch", "handle": "rauchg" }, + { "name": "Simon Willison", "handle": "simonw" }, + { "name": "Addy Osmani", "handle": "addyosmani" }, + { "name": "Swyx", "handle": "swyx" }, + { "name": "Andrej Karpathy", "handle": "karpathy" } + ], + "blogs": [ + { + "name": "Anthropic Engineering", + "url": "https://www.anthropic.com/engineering" + }, + { + "name": "Anthropic News", + "url": "https://www.anthropic.com/news" + }, + { + "name": "OpenAI Blog", + "url": "https://openai.com/blog", + "rssUrl": "https://openai.com/blog/rss.xml" + }, + { + "name": "Simon Willison", + "url": "https://simonwillison.net/", + "rssUrl": "https://simonwillison.net/atom/everything/" + }, + { + "name": "Julia Evans", + "url": "https://jvns.ca/", + "rssUrl": "https://jvns.ca/atom.xml" + }, + { + "name": "Latent Space", + "url": "https://www.latent.space/", + "rssUrl": "https://www.latent.space/feed" + }, + { + "name": "Cursor Blog", + "url": "https://cursor.com/blog" + } + ], + "podcasts": [ + { + "name": "Latent Space", + "rssUrl": "https://api.substack.com/feed/podcast/1084089.rss" + }, + { + "name": "Practical AI", + "rssUrl": "https://changelog.com/practicalai/feed" + } + ] +} diff --git a/docs/privacy.html b/docs/privacy.html index 4925fbc..d953d38 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -29,7 +29,7 @@

Local storage

Saved links, preferences, AI Builder Daily Report cache, read state, and translations are stored in chrome.storage.local. Zero Tab does not operate a server and does not synchronize this data to the publisher.

AI Builder Daily Report

-

This optional feature is disabled until the user enables it. When enabled, Zero Tab downloads public JSON feeds from https://raw.githubusercontent.com/. These requests do not contain tab data, saved links, browsing history, identifiers, credentials, or API keys.

+

This optional feature is disabled until the user enables it. When enabled, Zero Tab downloads public JSON feeds from https://raw.githubusercontent.com/beforeload/zero-tab/feeds/. These requests do not contain tab data, saved links, browsing history, identifiers, credentials, or API keys.

On-device translation

When supported and explicitly requested, Zero Tab uses Chrome's built-in Translator API. Chrome may download a language pack or model. Zero Tab does not send report text to a third-party translation API.

diff --git a/extension/builder-digest.js b/extension/builder-digest.js index 28f4680..b43ad44 100644 --- a/extension/builder-digest.js +++ b/extension/builder-digest.js @@ -8,9 +8,9 @@ const CACHE_KEY = 'builderDigestState'; const OPTIONAL_ORIGIN = 'https://raw.githubusercontent.com/'; const FEED_URLS = { - x: 'https://raw.githubusercontent.com/zarazhangrui/follow-builders/main/feed-x.json', - podcasts: 'https://raw.githubusercontent.com/zarazhangrui/follow-builders/main/feed-podcasts.json', - blogs: 'https://raw.githubusercontent.com/zarazhangrui/follow-builders/main/feed-blogs.json', + x: 'https://raw.githubusercontent.com/beforeload/zero-tab/feeds/feed-x.json', + podcasts: 'https://raw.githubusercontent.com/beforeload/zero-tab/feeds/feed-podcasts.json', + blogs: 'https://raw.githubusercontent.com/beforeload/zero-tab/feeds/feed-blogs.json', }; const MAX_RESPONSE_CHARS = 1_500_000; const CACHE_RETENTION_MS = 48 * 60 * 60 * 1000; diff --git a/extension/index.html b/extension/index.html index 6405e48..115232b 100644 --- a/extension/index.html +++ b/extension/index.html @@ -58,7 +58,7 @@

AI Builder Daily Report

-
Public updates from Follow Builders
+
Public updates from Zero Tab feeds
diff --git a/package.json b/package.json index ec1d152..09b7da2 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "test": "TZ=UTC node --test tests/*.test.js && TZ=UTC vitest run", - "typecheck": "tsc -b" + "test": "TZ=UTC node --test tests/*.test.js tests/*.test.mjs && TZ=UTC vitest run", + "typecheck": "tsc -b", + "generate:builder-feeds": "node scripts/generate-builder-feeds.mjs" }, "repository": { "type": "git", diff --git a/scripts/generate-builder-feeds.mjs b/scripts/generate-builder-feeds.mjs new file mode 100644 index 0000000..4fb8175 --- /dev/null +++ b/scripts/generate-builder-feeds.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +import { generateBuilderFeeds } from './lib/feed-generator.mjs'; + +const result = await generateBuilderFeeds(); +if (!result.hasData) { + console.error('Feed generation produced no items.'); + for (const error of result.errors) console.error(`- ${error}`); + process.exit(1); +} + +console.log( + `Generated feeds: x=${result.feedX.x.length} blogs=${result.feedBlogs.blogs.length} podcasts=${result.feedPodcasts.podcasts.length}`, +); +if (result.errors.length) { + console.warn(`Completed with ${result.errors.length} source warning(s).`); + for (const error of result.errors) console.warn(`- ${error}`); +} diff --git a/scripts/lib/feed-generator.mjs b/scripts/lib/feed-generator.mjs new file mode 100644 index 0000000..99184aa --- /dev/null +++ b/scripts/lib/feed-generator.mjs @@ -0,0 +1,459 @@ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const DEFAULT_SOURCES = join(ROOT, 'builder-feeds', 'sources.json'); +const DEFAULT_OUT_DIR = join(ROOT, 'builder-feeds', 'generated'); +const USER_AGENT = + 'ZeroTabFeedBot/1.4 (+https://github.com/beforeload/zero-tab; public-feed-aggregator)'; +const FETCH_TIMEOUT_MS = 20_000; +const MAX_TWEETS_PER_HANDLE = 8; +const MAX_BLOG_ITEMS_PER_SOURCE = 6; +const MAX_PODCAST_ITEMS_PER_SOURCE = 4; + +export function normalizeText(value) { + return String(value || '') + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function decodeEntities(value) { + return normalizeText(value) + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/'/gi, "'") + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, code) => + String.fromCharCode(Number.parseInt(code, 16)), + ); +} + +export function truncate(value, maxLength) { + const text = normalizeText(value); + if (text.length <= maxLength) return text; + return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; +} + +export function safeHttpsUrl(value, base) { + if (!value || !String(value).trim()) return ''; + try { + const url = new URL(value, base); + return url.protocol === 'https:' ? url.href : ''; + } catch { + return ''; + } +} + +export function stripTags(value) { + return decodeEntities( + String(value || '') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' '), + ); +} + +function tagValue(block, tag) { + const cdata = block.match( + new RegExp(`<${tag}[^>]*>\\s*\\s*`, 'i'), + ); + if (cdata) return decodeEntities(cdata[1]); + const plain = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)`, 'i')); + return plain ? stripTags(plain[1]) : ''; +} + +function tagAttr(block, tag, attr) { + const match = block.match( + new RegExp(`<${tag}[^>]*\\s${attr}=["']([^"']+)["'][^>]*/?>`, 'i'), + ); + return match ? decodeEntities(match[1]) : ''; +} + +export function parseRssOrAtom(xml, { sourceName, baseUrl, limit = 6 } = {}) { + const text = String(xml || ''); + const items = []; + const itemBlocks = [...text.matchAll(//gi)].map((m) => m[0]); + const entryBlocks = [...text.matchAll(//gi)].map((m) => m[0]); + const blocks = itemBlocks.length ? itemBlocks : entryBlocks; + + for (const block of blocks) { + const title = truncate(tagValue(block, 'title'), 180); + const link = + safeHttpsUrl(tagValue(block, 'link'), baseUrl) || + safeHttpsUrl(tagAttr(block, 'link', 'href'), baseUrl) || + safeHttpsUrl(tagValue(block, 'guid'), baseUrl) || + safeHttpsUrl(tagValue(block, 'id'), baseUrl); + if (!title || !link) continue; + + const publishedAt = + tagValue(block, 'pubDate') || + tagValue(block, 'published') || + tagValue(block, 'updated') || + tagValue(block, 'dc:date') || + null; + const description = + tagValue(block, 'description') || + tagValue(block, 'summary') || + tagValue(block, 'content:encoded') || + tagValue(block, 'content') || + ''; + const guid = tagValue(block, 'guid') || tagValue(block, 'id') || link; + + items.push({ + name: truncate(sourceName || 'Source', 80), + title, + url: link, + guid, + description: truncate(description, 500), + content: truncate(description, 500), + transcript: truncate(description, 500), + publishedAt: publishedAt ? new Date(Date.parse(publishedAt) || Date.now()).toISOString() : undefined, + }); + if (items.length >= limit) break; + } + + return items; +} + +export function parseBlogHtml(html, { sourceName, baseUrl, limit = 6 } = {}) { + const text = String(html || ''); + const found = new Map(); + + const patterns = [ + /]+href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, + ]; + + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + const href = match[1]; + const title = truncate(stripTags(match[2]), 180); + const url = safeHttpsUrl(href, baseUrl); + if (!url || !title || title.length < 12) continue; + if (!/\/(blog|news|engineering|posts|articles|research|changelog)\b/i.test(url) && + !/blog|news|engineering|post|article/i.test(href)) { + // Keep homepage-relative article-looking paths with dates or long slugs. + if (!/\/\d{4}\/\d{2}\//.test(url) && !/\/[a-z0-9-]{16,}\/?$/i.test(url)) continue; + } + if (/#(respond|comments)|\/tag\/|\/category\/|\/author\//i.test(url)) continue; + if (!found.has(url)) found.set(url, title); + if (found.size >= limit * 3) break; + } + } + + return [...found.entries()].slice(0, limit).map(([url, title]) => ({ + name: truncate(sourceName || 'Official blog', 80), + title, + url, + description: '', + content: '', + })); +} + +export function parseXSyndicationHtml(html, { name, handle } = {}) { + const text = String(html || ''); + const tweets = []; + const seen = new Set(); + const expectedHandle = String(handle || '').replace(/^@/, '').toLowerCase(); + + const permalinks = [ + ...text.matchAll( + /https?:\/\/(?:twitter\.com|x\.com)\/([A-Za-z0-9_]+)\/status\/(\d+)/gi, + ), + ]; + + for (const match of permalinks) { + const tweetHandle = match[1]; + const id = match[2]; + if (expectedHandle && tweetHandle.toLowerCase() !== expectedHandle) continue; + if (seen.has(id)) continue; + seen.add(id); + + const around = text.slice(Math.max(0, match.index - 800), match.index + 1200); + const textMatch = + around.match(/data-tweet-text=["']([^"']+)["']/i) || + around.match(/]*class=["'][^"']*tweet-text[^"']*["'][^>]*>([\s\S]*?)<\/p>/i) || + around.match(/"full_text"\s*:\s*"((?:\\.|[^"\\])*)"/) || + around.match(/"text"\s*:\s*"((?:\\.|[^"\\])*)"/) || + around.match(/\n([^\n]{20,280})\n/); + let body = ''; + if (textMatch) { + body = textMatch[1] + .replace(/\\n/g, ' ') + .replace(/\\"/g, '"') + .replace(/\\u([0-9a-f]{4})/gi, (_, hex) => + String.fromCharCode(Number.parseInt(hex, 16)), + ); + body = stripTags(body); + } + if (!body) { + // jina.ai markdown often has the tweet body on the previous lines. + const before = text.slice(Math.max(0, match.index - 400), match.index); + const line = before + .split('\n') + .map((part) => normalizeText(part)) + .filter((part) => part.length >= 24 && !/^https?:\/\//i.test(part) && !/^@/.test(part)) + .at(-1); + body = line || ''; + } + if (!body) continue; + + const created = + around.match(/datetime=["']([^"']+)["']/i)?.[1] || + around.match(/"created_at"\s*:\s*"([^"]+)"/)?.[1] || + null; + + tweets.push({ + id, + text: truncate(body, 400), + createdAt: created + ? new Date(Date.parse(created) || Date.now()).toISOString() + : new Date().toISOString(), + url: `https://x.com/${tweetHandle}/status/${id}`, + likes: Number(around.match(/"favorite_count"\s*:\s*(\d+)/)?.[1] || 0) || undefined, + retweets: Number(around.match(/"retweet_count"\s*:\s*(\d+)/)?.[1] || 0) || undefined, + replies: Number(around.match(/"reply_count"\s*:\s*(\d+)/)?.[1] || 0) || undefined, + }); + if (tweets.length >= MAX_TWEETS_PER_HANDLE) break; + } + + if (!tweets.length) { + try { + const jsonMatch = text.match(/\{[\s\S]*"tweets"[\s\S]*\}/); + if (jsonMatch) { + const payload = JSON.parse(jsonMatch[0]); + for (const tweet of Array.isArray(payload.tweets) ? payload.tweets : []) { + const id = String(tweet.id_str || tweet.id || ''); + const body = normalizeText(tweet.full_text || tweet.text || ''); + const tweetHandle = tweet.user?.screen_name || handle; + if (!id || !body || !tweetHandle) continue; + tweets.push({ + id, + text: truncate(body, 400), + createdAt: new Date(Date.parse(tweet.created_at) || Date.now()).toISOString(), + url: `https://x.com/${tweetHandle}/status/${id}`, + likes: Number(tweet.favorite_count || 0) || undefined, + retweets: Number(tweet.retweet_count || 0) || undefined, + replies: Number(tweet.reply_count || 0) || undefined, + }); + if (tweets.length >= MAX_TWEETS_PER_HANDLE) break; + } + } + } catch { + // Ignore malformed JSON blobs. + } + } + + return { + name: truncate(name || handle || 'AI Builder', 80), + handle: truncate(String(handle || '').replace(/^@/, ''), 40), + tweets, + }; +} + +export async function fetchText(url, { fetchImpl = fetch, timeoutMs = FETCH_TIMEOUT_MS } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(url, { + signal: controller.signal, + headers: { + 'user-agent': USER_AGENT, + accept: 'text/html,application/xhtml+xml,application/xml,application/json;q=0.9,*/*;q=0.8', + }, + redirect: 'follow', + }); + if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`); + return await response.text(); + } finally { + clearTimeout(timer); + } +} + +export async function collectXFeed(sources, options = {}) { + const builders = []; + const errors = []; + for (const source of sources || []) { + const handle = String(source.handle || '').replace(/^@/, '').trim(); + if (!handle) continue; + const urls = [ + `https://cdn.syndication.twimg.com/timeline/profile?screen_name=${encodeURIComponent(handle)}`, + `https://syndication.twitter.com/srv/timeline-profile/screen-name/${encodeURIComponent(handle)}`, + `https://r.jina.ai/https://x.com/${encodeURIComponent(handle)}`, + ]; + let parsed = null; + let lastError = null; + for (const url of urls) { + try { + const html = await fetchText(url, { + ...options, + timeoutMs: options.timeoutMs || 45_000, + }); + parsed = parseXSyndicationHtml(html, { name: source.name, handle }); + if (parsed.tweets.length) break; + } catch (error) { + lastError = error; + } + } + if (parsed?.tweets?.length) builders.push(parsed); + else errors.push(`x:@${handle}: ${lastError?.message || 'no public tweets parsed'}`); + } + return { builders, errors }; +} + +export async function collectBlogFeed(sources, options = {}) { + const blogs = []; + const errors = []; + for (const source of sources || []) { + try { + if (source.rssUrl) { + const xml = await fetchText(source.rssUrl, options); + const items = parseRssOrAtom(xml, { + sourceName: source.name, + baseUrl: source.rssUrl, + limit: MAX_BLOG_ITEMS_PER_SOURCE, + }); + for (const item of items) { + blogs.push({ + name: item.name, + title: item.title, + url: item.url, + description: item.description, + content: item.content, + publishedAt: item.publishedAt, + }); + } + if (items.length) continue; + } + + if (!source.url) { + errors.push(`blog:${source.name || 'unknown'}: missing url/rssUrl`); + continue; + } + const html = await fetchText(source.url, options); + const items = parseBlogHtml(html, { + sourceName: source.name, + baseUrl: source.url, + limit: MAX_BLOG_ITEMS_PER_SOURCE, + }); + if (!items.length) { + errors.push(`blog:${source.name}: no articles parsed from HTML`); + continue; + } + blogs.push(...items); + } catch (error) { + errors.push(`blog:${source.name || source.url}: ${error.message}`); + } + } + return { blogs, errors }; +} + +export async function collectPodcastFeed(sources, options = {}) { + const podcasts = []; + const errors = []; + for (const source of sources || []) { + try { + if (!source.rssUrl) { + errors.push(`podcast:${source.name || 'unknown'}: missing rssUrl`); + continue; + } + const xml = await fetchText(source.rssUrl, options); + const items = parseRssOrAtom(xml, { + sourceName: source.name, + baseUrl: source.rssUrl, + limit: MAX_PODCAST_ITEMS_PER_SOURCE, + }); + if (!items.length) { + errors.push(`podcast:${source.name}: empty RSS`); + continue; + } + for (const item of items) { + podcasts.push({ + name: item.name, + title: item.title, + url: item.url, + guid: item.guid, + transcript: item.transcript, + publishedAt: item.publishedAt, + }); + } + } catch (error) { + errors.push(`podcast:${source.name || source.rssUrl}: ${error.message}`); + } + } + return { podcasts, errors }; +} + +export function atomicWriteJson(filePath, value) { + mkdirSync(dirname(filePath), { recursive: true }); + const tempPath = `${filePath}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + renameSync(tempPath, filePath); +} + +export async function generateBuilderFeeds({ + sourcesPath = DEFAULT_SOURCES, + outDir = DEFAULT_OUT_DIR, + now = new Date(), + fetchImpl, +} = {}) { + const sources = JSON.parse(readFileSync(sourcesPath, 'utf8')); + const generatedAt = now.toISOString(); + const options = { fetchImpl }; + + const [xResult, blogResult, podcastResult] = await Promise.all([ + collectXFeed(sources.x, options), + collectBlogFeed(sources.blogs, options), + collectPodcastFeed(sources.podcasts, options), + ]); + + const feedX = { generatedAt, x: xResult.builders }; + const feedBlogs = { generatedAt, blogs: blogResult.blogs }; + const feedPodcasts = { generatedAt, podcasts: podcastResult.podcasts }; + const errors = [...xResult.errors, ...blogResult.errors, ...podcastResult.errors]; + + const hasData = + feedX.x.some((builder) => builder.tweets?.length) || + feedBlogs.blogs.length > 0 || + feedPodcasts.podcasts.length > 0; + + mkdirSync(outDir, { recursive: true }); + atomicWriteJson(join(outDir, 'feed-x.json'), feedX); + atomicWriteJson(join(outDir, 'feed-blogs.json'), feedBlogs); + atomicWriteJson(join(outDir, 'feed-podcasts.json'), feedPodcasts); + atomicWriteJson(join(outDir, 'generation-report.json'), { + generatedAt, + hasData, + counts: { + xBuilders: feedX.x.length, + xTweets: feedX.x.reduce((sum, builder) => sum + (builder.tweets?.length || 0), 0), + blogs: feedBlogs.blogs.length, + podcasts: feedPodcasts.podcasts.length, + }, + errors, + }); + + return { hasData, errors, feedX, feedBlogs, feedPodcasts }; +} + +const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]); + +if (isMain) { + const result = await generateBuilderFeeds(); + if (!result.hasData) { + console.error('Feed generation produced no items.'); + for (const error of result.errors) console.error(`- ${error}`); + process.exit(1); + } + console.log( + `Generated feeds: x=${result.feedX.x.length} blogs=${result.feedBlogs.blogs.length} podcasts=${result.feedPodcasts.podcasts.length}`, + ); + if (result.errors.length) { + console.warn(`Completed with ${result.errors.length} source warning(s).`); + for (const error of result.errors) console.warn(`- ${error}`); + } +} diff --git a/src/components/BuilderDigestDrawer.tsx b/src/components/BuilderDigestDrawer.tsx index e83f251..9c09a0a 100644 --- a/src/components/BuilderDigestDrawer.tsx +++ b/src/components/BuilderDigestDrawer.tsx @@ -237,7 +237,7 @@ export function BuilderDigestDrawer({ open, onClose, onToast }: Props) {
Follow the people actually building AI products

- Downloads public Follow Builders updates from GitHub at most + Downloads public Zero Tab builder feeds from GitHub at most once per day. Your tabs and saved links never leave Chrome.

diff --git a/store/PUBLISHING_CHECKLIST.md b/store/PUBLISHING_CHECKLIST.md index 92942a8..09113b9 100644 --- a/store/PUBLISHING_CHECKLIST.md +++ b/store/PUBLISHING_CHECKLIST.md @@ -71,8 +71,8 @@ Generate package: - [ ] Submit for review - [ ] Monitor the publisher email for review questions -## Known publication decision +## Builder feed publication -The first release includes AI Builder Daily Report. Its listing and review notes position it as part of one developer-focused new-tab workspace. The Follow Builders data source currently lacks a repository-level license; obtain written permission for public feed display before final publication. +AI Builder Daily Report reads public JSON from `beforeload/zero-tab` on the `feeds` branch. Generate those files with the scheduled GitHub Action (or `workflow_dispatch`) before submitting listing updates that mention the report. See [`SUBMISSION_GUIDE.md`](SUBMISSION_GUIDE.md) for the full submission walkthrough. diff --git a/store/SUBMISSION_GUIDE.md b/store/SUBMISSION_GUIDE.md index cb78dee..2bcda5b 100644 --- a/store/SUBMISSION_GUIDE.md +++ b/store/SUBMISSION_GUIDE.md @@ -90,7 +90,7 @@ Paste the contents of [`store/test-instructions.md`](test-instructions.md) into | `tabs` | Read, focus, and close user-selected tabs | | `storage` | Saved for later, preferences, report cache | | `favicon` | Show local favicons without third-party requests | -| `https://raw.githubusercontent.com/*` (optional) | Fetch public Follow Builders JSON feeds after user opt-in | +| `https://raw.githubusercontent.com/*` (optional) | Fetch public Zero Tab JSON feeds after user opt-in | ## 8. Distribution settings @@ -100,9 +100,9 @@ Recommended first release: - Regions: all supported countries - Publishing: manual review, then publish when approved -## 9. Known blocker before final approval +## 9. Feed readiness before final approval -AI Builder Daily Report displays public content from the Follow Builders repository. That repository does not currently include a repository-level license for public feed display. Before the final public release, obtain written permission from the feed owner or remove/replace the feed source. +AI Builder Daily Report reads public JSON from this repository's `feeds` branch. Before relying on the report in production listings, run `.github/workflows/generate-builder-feeds.yml` at least once and confirm the three feed files are present on `feeds`. ## 10. After approval diff --git a/store/privacy-policy.md b/store/privacy-policy.md index 4424f93..513d6f8 100644 --- a/store/privacy-policy.md +++ b/store/privacy-policy.md @@ -26,7 +26,7 @@ Zero Tab does not operate a server and does not synchronize this local data to t AI Builder Daily Report is optional and disabled until the user enables it. -When enabled, Zero Tab requests access only to `https://raw.githubusercontent.com/` and downloads public JSON feeds published by the Follow Builders project. Requests do not include open-tab data, saved links, browsing history, identifiers, credentials, or API keys. As with any HTTPS request, the remote host may receive standard network metadata such as the user's IP address and user agent under its own privacy policy. +When enabled, Zero Tab requests access only to `https://raw.githubusercontent.com/` and downloads public JSON feeds published by the Zero Tab repository on its `feeds` branch. Those feeds are generated by a scheduled GitHub Action that aggregates public page and RSS summaries. Requests do not include open-tab data, saved links, browsing history, identifiers, credentials, or API keys. As with any HTTPS request, the remote host may receive standard network metadata such as the user's IP address and user agent under its own privacy policy. Feed content is cached locally and filtered in the browser. Original report text is not sent by Zero Tab to a cloud AI service. diff --git a/store/review-notes.md b/store/review-notes.md index 640fd0b..04cdf37 100644 --- a/store/review-notes.md +++ b/store/review-notes.md @@ -27,9 +27,9 @@ Required to display Chrome's local favicon representation without sending tab ho Requested only after the user clicks **Enable AI Builder Daily Report**. Used only to fetch these public data files: -- `zarazhangrui/follow-builders/main/feed-x.json` -- `zarazhangrui/follow-builders/main/feed-podcasts.json` -- `zarazhangrui/follow-builders/main/feed-blogs.json` +- `beforeload/zero-tab/feeds/feed-x.json` +- `beforeload/zero-tab/feeds/feed-podcasts.json` +- `beforeload/zero-tab/feeds/feed-blogs.json` The files contain data, not executable logic. All parsing, ranking, rendering, and interaction logic is packaged in the extension. @@ -37,7 +37,7 @@ No tab data, saved links, identifiers, credentials, or API keys are transmitted ## Remote code -Zero Tab does not download or execute remotely hosted code. The optional GitHub resources are JSON content feeds only. +Zero Tab does not download or execute remotely hosted code. The optional GitHub resources are JSON content feeds only. Feed generation runs in GitHub Actions and aggregates public web/RSS summaries into static JSON; the extension does not scrape third-party sites itself. ## Built-in AI diff --git a/tests/builder-digest.test.js b/tests/builder-digest.test.js index 4265c08..d3a2f5d 100644 --- a/tests/builder-digest.test.js +++ b/tests/builder-digest.test.js @@ -142,6 +142,22 @@ test('returns partial feed results when one source fails', async () => { assert.match(result.errors[0], /podcasts/); }); +test('points AI Builder feeds at this repository feeds branch', () => { + assert.equal(digest.OPTIONAL_ORIGIN, 'https://raw.githubusercontent.com/'); + assert.equal( + digest.FEED_URLS.x, + 'https://raw.githubusercontent.com/beforeload/zero-tab/feeds/feed-x.json', + ); + assert.equal( + digest.FEED_URLS.blogs, + 'https://raw.githubusercontent.com/beforeload/zero-tab/feeds/feed-blogs.json', + ); + assert.equal( + digest.FEED_URLS.podcasts, + 'https://raw.githubusercontent.com/beforeload/zero-tab/feeds/feed-podcasts.json', + ); +}); + test('requests the declared raw GitHub origin without a wildcard path', async () => { const storage = {}; let requestedOrigins; diff --git a/tests/feed-generator.test.mjs b/tests/feed-generator.test.mjs new file mode 100644 index 0000000..c60ef9e --- /dev/null +++ b/tests/feed-generator.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { + generateBuilderFeeds, + parseBlogHtml, + parseRssOrAtom, + parseXSyndicationHtml, +} from '../scripts/lib/feed-generator.mjs'; + +const fixtures = join(dirname(fileURLToPath(import.meta.url)), 'fixtures'); + +describe('feed generator parsers', () => { + it('parses RSS blog entries', () => { + const xml = readFileSync(join(fixtures, 'blog.rss.xml'), 'utf8'); + const items = parseRssOrAtom(xml, { + sourceName: 'Example Blog', + baseUrl: 'https://example.com/feed.xml', + }); + assert.equal(items.length, 2); + assert.equal(items[0].title, 'Shipping smaller context windows'); + assert.equal(items[0].url, 'https://example.com/posts/context-windows'); + assert.match(items[0].description, /prompts/i); + }); + + it('parses Atom podcast entries', () => { + const xml = readFileSync(join(fixtures, 'podcast.atom.xml'), 'utf8'); + const items = parseRssOrAtom(xml, { + sourceName: 'Builders Podcast', + baseUrl: 'https://example.com/podcast/atom.xml', + limit: 1, + }); + assert.equal(items.length, 1); + assert.equal(items[0].title, 'Evaluating coding agents'); + assert.equal(items[0].url, 'https://example.com/episodes/agents'); + }); + + it('parses blog HTML article links', () => { + const html = readFileSync(join(fixtures, 'blog.html'), 'utf8'); + const items = parseBlogHtml(html, { + sourceName: 'Cursor Blog', + baseUrl: 'https://cursor.com/blog', + }); + assert.ok(items.length >= 1); + assert.equal(items[0].url, 'https://cursor.com/blog/agent-harness'); + assert.match(items[0].title, /agent harness/i); + }); + + it('parses X syndication markup into tweets', () => { + const html = readFileSync(join(fixtures, 'x-syndication.html'), 'utf8'); + const builder = parseXSyndicationHtml(html, { + name: 'Simon Willison', + handle: 'simonw', + }); + assert.equal(builder.handle, 'simonw'); + assert.equal(builder.tweets.length, 1); + assert.equal(builder.tweets[0].id, '1234567890'); + assert.match(builder.tweets[0].text, /Translator API/i); + assert.equal(builder.tweets[0].url, 'https://x.com/simonw/status/1234567890'); + }); + + it('writes feed JSON through a mocked fetch layer', async () => { + const fixturesByUrl = { + 'https://cdn.syndication.twimg.com/timeline/profile?screen_name=simonw': + readFileSync(join(fixtures, 'x-syndication.html'), 'utf8'), + 'https://example.com/feed.xml': readFileSync(join(fixtures, 'blog.rss.xml'), 'utf8'), + 'https://example.com/podcast.xml': readFileSync(join(fixtures, 'podcast.atom.xml'), 'utf8'), + }; + const fetchImpl = async (url) => { + const body = fixturesByUrl[url]; + if (!body) throw new Error(`unexpected url ${url}`); + return { + ok: true, + status: 200, + text: async () => body, + }; + }; + + const sourcesPath = join(fixtures, 'sources.json'); + const outDir = join(fixtures, 'out'); + const result = await generateBuilderFeeds({ + sourcesPath, + outDir, + now: new Date('2026-08-11T12:00:00.000Z'), + fetchImpl, + }); + + assert.equal(result.hasData, true); + assert.equal(result.feedX.x[0].tweets.length, 1); + assert.ok(result.feedBlogs.blogs.length >= 1); + assert.ok(result.feedPodcasts.podcasts.length >= 1); + + const writtenX = JSON.parse(readFileSync(join(outDir, 'feed-x.json'), 'utf8')); + assert.equal(writtenX.generatedAt, '2026-08-11T12:00:00.000Z'); + }); +}); diff --git a/tests/fixtures/blog.html b/tests/fixtures/blog.html new file mode 100644 index 0000000..3c55612 --- /dev/null +++ b/tests/fixtures/blog.html @@ -0,0 +1,10 @@ + + + +
+ Building a reliable agent harness + Designing a personal tab workstation + Pricing +
+ + diff --git a/tests/fixtures/blog.rss.xml b/tests/fixtures/blog.rss.xml new file mode 100644 index 0000000..5c88789 --- /dev/null +++ b/tests/fixtures/blog.rss.xml @@ -0,0 +1,20 @@ + + + + Example Blog + + Shipping smaller context windows + https://example.com/posts/context-windows + https://example.com/posts/context-windows + Mon, 10 Aug 2026 09:00:00 GMT + Practical notes on trimming prompts and keeping the edit loop fast. + + + Local-first browser tools + https://example.com/posts/local-first + https://example.com/posts/local-first + Sun, 09 Aug 2026 11:00:00 GMT + Why tab metadata should stay on the device. + + + diff --git a/tests/fixtures/podcast.atom.xml b/tests/fixtures/podcast.atom.xml new file mode 100644 index 0000000..c0eda70 --- /dev/null +++ b/tests/fixtures/podcast.atom.xml @@ -0,0 +1,11 @@ + + + Builders Podcast + + Evaluating coding agents + episode-agents + + 2026-08-09T19:40:00Z + Rollout guardrails and evaluation harnesses. + + diff --git a/tests/fixtures/sources.json b/tests/fixtures/sources.json new file mode 100644 index 0000000..0f6da74 --- /dev/null +++ b/tests/fixtures/sources.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "x": [{ "name": "Simon Willison", "handle": "simonw" }], + "blogs": [ + { + "name": "Example Blog", + "url": "https://example.com/", + "rssUrl": "https://example.com/feed.xml" + } + ], + "podcasts": [ + { + "name": "Builders Podcast", + "rssUrl": "https://example.com/podcast.xml" + } + ] +} diff --git a/tests/fixtures/x-syndication.html b/tests/fixtures/x-syndication.html new file mode 100644 index 0000000..2faa880 --- /dev/null +++ b/tests/fixtures/x-syndication.html @@ -0,0 +1,12 @@ + + + +
+ +

+ Chrome on-device Translator API notes for local coding agents +

+ +
+ +