diff --git a/.github/workflows/generate-builder-feeds.yml b/.github/workflows/generate-builder-feeds.yml new file mode 100644 index 0000000..ff22048 --- /dev/null +++ b/.github/workflows/generate-builder-feeds.yml @@ -0,0 +1,75 @@ +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 + uses: actions/checkout@v4 + with: + # Use the triggering ref so workflow_dispatch from a feature branch + # can publish with that branch's generator/sources. + ref: ${{ github.sha }} + 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/feed-videos.json "$PUBLISH_DIR/feed-videos.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 feed-videos.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..2c18742 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ Before doing anything technical, tell the user what they're about to get: > - **Save for later** bookmark individual tabs to a checklist before closing them > - **Modular workspace cards** can be collapsed or hidden, with preferences stored locally > - **Daily horoscope** generates a private, deterministic reading from the local date and selected zodiac sign -> - **AI Builder daily brief** opens as an independent full-height drawer with public updates from builders, podcasts, and engineering blogs +> - **AI Builder daily brief** opens as an independent full-height drawer with public updates from builders, podcasts, engineering blogs, and conference videos > - **Local-first** tab and saved-item data never leaves the browser > > It's just a Chrome extension. Setup takes about 1 minute. @@ -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..48e389b 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`, `feed-podcasts.json`, and `feed-videos.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..0a57fe1 --- /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), conference videos (YouTube 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..215cc3a --- /dev/null +++ b/builder-feeds/sources.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "x": [ + { "name": "Boris Cherny", "handle": "bcherny" }, + { "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" + } + ], + "videos": [ + { + "name": "AI Engineer", + "rssUrl": "https://www.youtube.com/feeds/videos.xml?channel_id=UCLKPca3kwwd-B59HNr-_lvA" + }, + { + "name": "Cursor Compile", + "rssUrl": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLuI2ZfvGpzwDCn0njJpjZ3ZiEpCqhN7BK" + }, + { + "name": "Figma Config", + "rssUrl": "https://www.youtube.com/feeds/videos.xml?playlist_id=PLXDU_eVOJTx6erPKfFHtCNbyCmcCn4zrp" + } + ] +} 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 @@
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.
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.
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/app.js b/extension/app.js index dcb220c..0cfe70f 100644 --- a/extension/app.js +++ b/extension/app.js @@ -1313,6 +1313,7 @@ function formatDigestDate(dateStr) { function digestKindLabel(kind) { if (kind === 'blog') return 'Blog'; if (kind === 'podcast') return 'Podcast'; + if (kind === 'video') return 'Video'; return 'X'; } @@ -1372,7 +1373,7 @@ function renderDigestItemCard(item, state, stale, targetLanguage) { const safeExcerpt = escapeHtml(localized?.excerpt || item.excerpt); const safeSource = escapeHtml(item.source); const safeItemId = escapeHtml(item.id); - const kind = ['x', 'blog', 'podcast'].includes(item.kind) ? item.kind : 'x'; + const kind = ['x', 'blog', 'podcast', 'video'].includes(item.kind) ? item.kind : 'x'; const dateLabel = escapeHtml(formatDigestDate(item.publishedAt)); const isRead = state.readIds?.includes(item.id); diff --git a/extension/builder-digest.js b/extension/builder-digest.js index 28f4680..c773c57 100644 --- a/extension/builder-digest.js +++ b/extension/builder-digest.js @@ -8,12 +8,14 @@ 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', + videos: 'https://raw.githubusercontent.com/beforeload/zero-tab/feeds/feed-videos.json', }; const MAX_RESPONSE_CHARS = 1_500_000; const CACHE_RETENTION_MS = 48 * 60 * 60 * 1000; + const VIDEO_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; const RETRY_COOLDOWN_MS = 15 * 60 * 1000; const KEYWORDS = /\b(launch|launched|release|released|ship|shipped|announce|model|agent|coding|code|api|open[\s-]?source|research|benchmark|security|product|tool|framework|developer|build|robot|autonom)/i; @@ -43,6 +45,53 @@ return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; } + function cleanTweetText(value) { + let text = String(value || ''); + text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, ' '); + text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1'); + text = text.replace(/\[[^\]]*\]\([^)]*$/g, ' '); + text = text.replace(/\[[^\]]*\]\(/g, ' '); + text = text.replace(/^#{1,6}\s+/gm, ''); + text = text.replace(/^\*\s+/gm, ''); + text = text.replace(/\*\*|__/g, ''); + text = text.replace(/<[^>]+>/g, ' '); + return normalizeText(text); + } + + function isJunkTweetText(value) { + const raw = String(value || ''); + if (/pbs\.twimg\.com\/profile_images/i.test(raw)) return true; + if (/\buser avatar\b/i.test(raw)) return true; + if (/!\[[^\]]*\]\([^)]*profile_images/i.test(raw)) return true; + if (/is_blue_verified|entry_id|conversation_id_str|withheld_in_countries|"sort_index"/i.test(raw)) { + return true; + } + + const text = cleanTweetText(value); + if (!text || text.length < 20 || text.length > 400) return true; + if (/^(log in or sign up|sign up for x|create an account)\b/i.test(text)) return true; + if (/^joined (jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i.test(text)) return true; + if (/\bfollowing\b/i.test(text) && /\bfollowers?\b/i.test(text)) return true; + if (/^image\s+\d+\b/i.test(text)) return true; + if (/^(posts?|replies|highlights|media|likes|articles|subscriptions)\b/i.test(text)) return true; + if (/^(san francisco|singapore|new york|london|seattle|remote)\b/i.test(text) && text.length < 48) { + return true; + } + if (/^(?:[a-z0-9-]+\.)+[a-z]{2,}(?:\/\S*)?$/i.test(text)) return true; + if (/^@?[A-Za-z0-9_]{2,40}$/.test(text)) return true; + if ( + /^.{2,60}\s@\w{1,40}\s+(?:\[)?(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i.test( + text, + ) + ) { + return true; + } + if (/^[\p{L}\p{N}.''\-\s]{2,60}\s@\w{1,40}$/u.test(text)) return true; + if (/\[[^\]]*$/.test(text) || /\]\($/.test(text) || /\[[^\]]*\]\($/.test(text)) return true; + if (/[{}=]|\\u00|"type":/.test(text)) return true; + return false; + } + function safeHttpsUrl(value) { try { const url = new URL(value); @@ -79,26 +128,29 @@ const xFeed = feeds?.x; const podcastFeed = feeds?.podcasts; const blogFeed = feeds?.blogs; + const videoFeed = feeds?.videos; for (const builder of Array.isArray(xFeed?.x) ? xFeed.x : []) { for (const tweet of Array.isArray(builder?.tweets) ? builder.tweets : []) { const url = safeHttpsUrl(tweet?.url); const id = normalizeText(tweet?.id); - const excerpt = truncate(tweet?.text, 300); - if (!id || !url || !excerpt) continue; + const text = cleanTweetText(tweet?.text); + if (!id || !url || !text || isJunkTweetText(tweet?.text) || isJunkTweetText(text)) continue; const name = truncate(builder?.name || builder?.handle || 'AI Builder', 80); const handle = truncate(builder?.handle, 40); const publishedAt = new Date(timestamp(tweet?.createdAt, nowMs)).toISOString(); + const title = truncate(text, 180); + const excerpt = text.length > 180 ? truncate(text, 320) : ''; items.push({ id: `x:${id}`, kind: 'x', - source: name, - title: handle ? `${name} (@${handle})` : name, + source: handle ? `${name} (@${handle})` : name, + title, excerpt, url, publishedAt, - score: 40 + engagementScore(tweet) + keywordScore(excerpt) + recencyScore(publishedAt, nowMs), + score: 40 + engagementScore(tweet) + keywordScore(text) + recencyScore(publishedAt, nowMs), }); } } @@ -144,16 +196,41 @@ }); } + for (const video of Array.isArray(videoFeed?.videos) ? videoFeed.videos : []) { + const url = safeHttpsUrl(video?.url); + const title = truncate(video?.title, 180); + if (!url || !title) continue; + + const fallbackTime = timestamp(videoFeed?.generatedAt, nowMs); + const publishedAt = new Date(timestamp(video?.publishedAt, fallbackTime)).toISOString(); + const excerpt = truncate(video?.transcript, 340); + const guid = normalizeText(video?.guid) || url; + items.push({ + id: `video:${guid}`, + kind: 'video', + source: truncate(video?.name || 'Conference talk', 80), + title, + excerpt, + url, + publishedAt, + score: 64 + keywordScore(`${title} ${excerpt}`) + recencyScore(publishedAt, nowMs), + }); + } + return items.sort((a, b) => b.score - a.score || b.publishedAt.localeCompare(a.publishedAt)); } + function retentionMsFor(item) { + return item?.kind === 'video' ? VIDEO_RETENTION_MS : CACHE_RETENTION_MS; + } + function mergeItems(previous, incoming, now = new Date(), limit = Infinity) { - const cutoff = now.getTime() - CACHE_RETENTION_MS; + const nowMs = now.getTime(); const merged = new Map(); for (const item of [...(Array.isArray(previous) ? previous : []), ...(Array.isArray(incoming) ? incoming : [])]) { if (!item?.id || !safeHttpsUrl(item.url)) continue; - if (timestamp(item.publishedAt, now.getTime()) < cutoff) continue; + if (timestamp(item.publishedAt, nowMs) < nowMs - retentionMsFor(item)) continue; merged.set(item.id, item); } @@ -168,7 +245,7 @@ const selected = []; const selectedIds = new Set(); - for (const kind of ['x', 'blog', 'podcast']) { + for (const kind of ['x', 'blog', 'podcast', 'video']) { const item = sorted.find(candidate => candidate.kind === kind); if (item && !selectedIds.has(item.id)) { selected.push(item); 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 @@