-
- );
-}
-
-export const column = defineColumnUI({
- ...meta,
- ConfigForm,
- ItemRenderer,
-});
diff --git a/lib/columns/plugins/bluesky/plugin.ts b/lib/columns/plugins/bluesky/plugin.ts
deleted file mode 100644
index 6c8b80e..0000000
--- a/lib/columns/plugins/bluesky/plugin.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { z } from "zod";
-import { Cloud } from "lucide-react";
-import type { PluginMeta } from "@/lib/columns/types";
-
-export const schema = z.object({
- mode: z.enum(["search", "author"]).default("search"),
- query: z.string().default(""),
- handle: z.string().default(""),
-});
-
-export type BlueskyConfig = z.infer;
-
-export interface BlueskyMeta {
- likes: number;
- reposts: number;
- replies: number;
- postUrl: string;
-}
-
-export const meta: PluginMeta = {
- id: "bluesky",
- label: "Bluesky",
- description:
- "Latest posts from Bluesky — keyword search or by author handle. Keyless via the public AppView.",
- icon: Cloud,
- accent: "#0085ff",
- category: "social",
- schema,
- defaultConfig: schema.parse({}),
- defaultTitle: (c) => {
- if (c.mode === "author") {
- const h = c.handle.trim().replace(/^@/, "");
- return h ? `Bluesky · @${h}` : "Bluesky · Author";
- }
- const q = c.query.trim();
- return q ? `Bluesky · ${q}` : "Bluesky · Search";
- },
- capabilities: { paginated: true },
-};
diff --git a/lib/columns/plugins/bluesky/server.ts b/lib/columns/plugins/bluesky/server.ts
deleted file mode 100644
index 2e5e7a2..0000000
--- a/lib/columns/plugins/bluesky/server.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import "server-only";
-
-import {
- defineColumnServer,
- type FeedItem,
- type ServerFetcher,
-} from "@/lib/columns/types";
-import { fetchBlueskyPage } from "@/lib/integrations/bluesky";
-import { PAGE_SIZE } from "@/lib/columns/constants";
-import { meta, type BlueskyConfig, type BlueskyMeta } from "./plugin";
-
-const fetch: ServerFetcher = async (
- config,
- cursor,
-) => {
- if (config.mode === "author") {
- if (!config.handle.trim()) throw new Error("Author handle is required.");
- } else if (!config.query.trim()) {
- throw new Error("Search query is required.");
- }
-
- const r = await fetchBlueskyPage(
- config.mode,
- config.query,
- config.handle,
- PAGE_SIZE,
- cursor,
- );
- return {
- items: r.items as FeedItem[],
- nextCursor: r.nextCursor,
- };
-};
-
-export const server = defineColumnServer({
- meta,
- fetch,
-});
diff --git a/lib/integrations/bluesky.ts b/lib/integrations/bluesky.ts
deleted file mode 100644
index d1a8a28..0000000
--- a/lib/integrations/bluesky.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import { fetchUpstream } from "@/lib/integrations/fetch";
-import type { FeedItem } from "@/lib/columns/types";
-
-// Bluesky public AppView — keyless, no auth, generous quota for read endpoints.
-// https://docs.bsky.app/docs/api
-// Both endpoints used here are documented as public.
-const BLUESKY = "https://public.api.bsky.app/xrpc";
-
-export type BlueskyMode = "search" | "author";
-
-interface BlueskyAuthor {
- did?: string;
- handle?: string;
- displayName?: string;
- avatar?: string;
-}
-
-interface BlueskyRecord {
- text?: string;
- createdAt?: string;
-}
-
-interface BlueskyPost {
- uri?: string;
- cid?: string;
- author?: BlueskyAuthor;
- record?: BlueskyRecord;
- indexedAt?: string;
- likeCount?: number;
- replyCount?: number;
- repostCount?: number;
- quoteCount?: number;
-}
-
-interface SearchResponse {
- posts?: BlueskyPost[];
- cursor?: string;
-}
-
-interface AuthorFeedItem {
- post?: BlueskyPost;
- reason?: { $type?: string };
-}
-
-interface AuthorFeedResponse {
- feed?: AuthorFeedItem[];
- cursor?: string;
-}
-
-// at://did:plc:abc/app.bsky.feed.post/3kxyz → 3kxyz (the rkey).
-// Falls back to the full uri if the format doesn't match — better a
-// non-clickable id than throwing on a future record-type change.
-function rkeyFromUri(uri: string): string {
- const m = /\/([^/]+)$/.exec(uri);
- return m ? m[1] : uri;
-}
-
-function postUrl(post: BlueskyPost): string {
- const handle = post.author?.handle;
- const uri = post.uri ?? "";
- if (!handle || !uri) return "https://bsky.app";
- return `https://bsky.app/profile/${handle}/post/${rkeyFromUri(uri)}`;
-}
-
-function mapPost(post: BlueskyPost): FeedItem | null {
- const handle = post.author?.handle;
- const uri = post.uri;
- // Skip posts that lack either an author handle or a uri — the renderer
- // can't link to them and there's no useful id, so they'd just render as
- // dead content. Bluesky's API normally returns both, this guards against
- // schema drift from future record types.
- if (!handle || !uri) return null;
-
- const text = post.record?.text ?? "";
- const createdAt =
- post.record?.createdAt ?? post.indexedAt ?? new Date().toISOString();
-
- return {
- id: post.cid ?? uri,
- author: {
- name: post.author?.displayName?.trim() || handle,
- handle,
- avatarUrl: post.author?.avatar,
- },
- content: text,
- url: postUrl(post),
- createdAt,
- meta: {
- likes: post.likeCount ?? 0,
- reposts: (post.repostCount ?? 0) + (post.quoteCount ?? 0),
- replies: post.replyCount ?? 0,
- postUrl: postUrl(post),
- },
- };
-}
-
-async function fetchSearch(
- query: string,
- limit: number,
- cursor?: string,
-): Promise<{ items: FeedItem[]; nextCursor?: string }> {
- const params = new URLSearchParams({
- q: query,
- limit: String(limit),
- sort: "latest",
- });
- if (cursor) params.set("cursor", cursor);
- const res = await fetchUpstream(`${BLUESKY}/app.bsky.feed.searchPosts?${params}`, {
- headers: { accept: "application/json" },
- cache: "no-store",
- });
- if (!res.ok) {
- throw new Error(
- `Bluesky search ${res.status}: ${(await res.text()).slice(0, 200)}`,
- );
- }
- const json = (await res.json()) as SearchResponse;
- const items = (json.posts ?? [])
- .map(mapPost)
- .filter((x): x is FeedItem => x !== null);
- return { items, nextCursor: json.cursor };
-}
-
-async function fetchAuthor(
- actor: string,
- limit: number,
- cursor?: string,
-): Promise<{ items: FeedItem[]; nextCursor?: string }> {
- const params = new URLSearchParams({
- actor,
- limit: String(limit),
- filter: "posts_no_replies",
- });
- if (cursor) params.set("cursor", cursor);
- const res = await fetchUpstream(`${BLUESKY}/app.bsky.feed.getAuthorFeed?${params}`, {
- headers: { accept: "application/json" },
- cache: "no-store",
- });
- if (!res.ok) {
- throw new Error(
- `Bluesky author ${res.status}: ${(await res.text()).slice(0, 200)}`,
- );
- }
- const json = (await res.json()) as AuthorFeedResponse;
- // Skip reposts — Bluesky author feeds include them but the resulting card
- // would attribute the original poster, which is confusing in a column titled
- // "by @actor". `posts_no_replies` removes replies but not reposts.
- const items = (json.feed ?? [])
- .filter((entry) => !entry.reason)
- .map((entry) => entry.post)
- .filter((p): p is BlueskyPost => Boolean(p))
- .map(mapPost)
- .filter((x): x is FeedItem => x !== null);
- return { items, nextCursor: json.cursor };
-}
-
-function normalizeHandle(input: string): string {
- // Accept "@example.bsky.social", "example.bsky.social", or a bare username
- // ("example"). The bare username gets ".bsky.social" appended — the canonical
- // suffix for users who haven't set a custom handle.
- const trimmed = input.trim().replace(/^@/, "");
- if (!trimmed) return trimmed;
- if (trimmed.includes(".")) return trimmed;
- return `${trimmed}.bsky.social`;
-}
-
-export async function fetchBlueskyPage(
- mode: BlueskyMode,
- query: string,
- handle: string,
- limit: number,
- cursor?: string,
-): Promise<{ items: FeedItem[]; nextCursor?: string }> {
- if (mode === "author") {
- const actor = normalizeHandle(handle);
- if (!actor) return { items: [] };
- return fetchAuthor(actor, limit, cursor);
- }
- const q = query.trim();
- if (!q) return { items: [] };
- return fetchSearch(q, limit, cursor);
-}
diff --git a/lib/integrations/reddit.ts b/lib/integrations/reddit.ts
index f748807..af1a3d8 100644
--- a/lib/integrations/reddit.ts
+++ b/lib/integrations/reddit.ts
@@ -1,47 +1,87 @@
import { fetchUpstream } from "@/lib/integrations/fetch";
import type { FeedItem } from "@/lib/columns/types";
import { identiconUrl } from "@/lib/utils";
+import { decodeEntities, stripHtml } from "@/lib/integrations/text";
+// Reddit's JSON API (`/r//.json`, `/search.json`) now returns HTTP
+// 403 with an HTML anti-bot page to unauthenticated clients from most IPs —
+// datacenter and residential alike. Reddit's Atom feeds (`/.rss`) are still
+// served keyless to a descriptive User-Agent, so we read those instead.
+//
+// The trade-off vs. the old JSON path: the feed carries no score / comment
+// count and no `after` cursor. Score and comments degrade to 0 (the UI renders
+// them with a `?? 0` fallback), and each column shows a single feed page
+// (~25 items) instead of paginating. Everything else — title, author,
+// permalink, outbound link, timestamp — comes through.
const UA = "minitor/0.1 (https://github.com/anthropics/claude-code dashboard)";
+const ACCEPT = "application/atom+xml, application/xml;q=0.9, */*;q=0.8";
-interface RedditChild {
- data: {
- id: string;
- name?: string;
- author?: string;
- title?: string;
- selftext?: string;
- permalink?: string;
- url?: string;
- created_utc?: number;
- score?: number;
- num_comments?: number;
- subreddit?: string;
- thumbnail?: string;
- is_self?: boolean;
- over_18?: boolean;
- stickied?: boolean;
- };
-}
+const SORTS = new Set(["hot", "new", "top", "rising"]);
-interface RedditListing {
- data?: { children?: RedditChild[] };
- message?: string;
- error?: number;
+// --- tiny Atom helpers (regex, matching the lib/integrations/rss.ts style) ---
+
+function getTag(xml: string, tag: string): string {
+ const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)${tag}>`, "i");
+ return xml.match(re)?.[1] ?? "";
}
-const SORTS = new Set(["hot", "new", "top", "rising"]);
+// The first `` in an entry with no `rel` (or rel="alternate") is the
+// submission's permalink (the Reddit comments page).
+function permalinkOf(entry: string): string {
+ const re = /]*?)\/?>/gi;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(entry))) {
+ const href = m[1].match(/\bhref=["']([^"']+)["']/)?.[1];
+ const rel = m[1].match(/\brel=["']([^"']+)["']/)?.[1];
+ if (href && (!rel || rel === "alternate")) return href;
+ }
+ return "";
+}
-interface RedditListingDataExt {
- children?: RedditChild[];
- after?: string | null;
- before?: string | null;
+// Each entry body embeds `[link]` (the submission's outbound
+// URL) alongside `[comments]`. Pull the `[link]`
+// href so we can tell a link post from a self post.
+function outboundLink(contentHtml: string): string | undefined {
+ const html = decodeEntities(contentHtml);
+ return html.match(/href=["']([^"']+)["'][^>]*>\s*\[link\]/i)?.[1];
}
-interface RedditListingExt {
- data?: RedditListingDataExt;
- message?: string;
- error?: number;
+function parseRedditFeed(xml: string, fallbackSub: string): FeedItem[] {
+ const items: FeedItem[] = [];
+ const entryRe = /]*>([\s\S]*?)<\/entry>/gi;
+ let m: RegExpExecArray | null;
+ while ((m = entryRe.exec(xml))) {
+ const entry = m[1];
+ const id = getTag(entry, "id").trim().replace(/^t3_/, "") || permalinkOf(entry);
+ const title = decodeEntities(stripHtml(getTag(entry, "title"))).trim();
+ const permalink = permalinkOf(entry);
+ const author =
+ stripHtml(getTag(getTag(entry, "author"), "name")).replace(/^\/u\//, "").trim() ||
+ "unknown";
+ const subreddit =
+ entry.match(/]*\bterm=["']([^"']+)["']/i)?.[1] || fallbackSub;
+ const published = getTag(entry, "published") || getTag(entry, "updated");
+ const outbound = outboundLink(getTag(entry, "content"));
+ // A self post's `[link]` points back at its own comments page.
+ const isSelf = !outbound || /reddit\.com\/r\/[^/]+\/comments\//i.test(outbound);
+
+ items.push({
+ id,
+ author: { name: author, handle: author, avatarUrl: identiconUrl(author) },
+ content: title,
+ url: permalink || outbound,
+ createdAt: new Date(published || Date.now()).toISOString(),
+ meta: {
+ score: 0,
+ comments: 0,
+ subreddit,
+ isSelf,
+ externalUrl: isSelf ? undefined : outbound,
+ nsfw: false,
+ },
+ });
+ }
+ return items;
}
export async function fetchSubredditPage(
@@ -50,62 +90,24 @@ export async function fetchSubredditPage(
limit = 12,
after?: string,
): Promise<{ items: FeedItem[]; nextAfter?: string }> {
+ // The Atom feed is a single fixed page with no cursor, so a "load more"
+ // (non-empty `after`) has nothing further to return.
+ if (after) return { items: [], nextAfter: undefined };
+
const sub = subreddit.trim().replace(/^r\//, "") || "popular";
const sort = SORTS.has(sortBy) ? sortBy : "hot";
- const params = new URLSearchParams({
- limit: String(limit),
- raw_json: "1",
- });
- if (after) params.set("after", after);
- const url = `https://www.reddit.com/r/${encodeURIComponent(sub)}/${sort}.json?${params}`;
+ const url = `https://www.reddit.com/r/${encodeURIComponent(sub)}/${sort}/.rss?limit=${limit}`;
const res = await fetchUpstream(url, {
- headers: { "user-agent": UA, accept: "application/json" },
+ headers: { "user-agent": UA, accept: ACCEPT },
cache: "no-store",
});
-
if (!res.ok) {
throw new Error(`Reddit ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
- const json = (await res.json()) as RedditListingExt;
- if (json.error || !json.data?.children) {
- throw new Error(`Reddit error: ${json.message ?? "no listing data"}`);
- }
-
- const items = json.data.children
- .filter((c) => !c.data.stickied)
- .slice(0, limit)
- .map((c) => toFeedItem(c, sub));
- const nextAfter = json.data.after ?? undefined;
- return { items, nextAfter: nextAfter || undefined };
-}
-
-function toFeedItem(c: RedditChild, fallbackSub: string): FeedItem {
- const d = c.data;
- const author = d.author ?? "unknown";
- const permalink = d.permalink
- ? `https://reddit.com${d.permalink}`
- : d.url;
- return {
- id: d.id,
- author: {
- name: author,
- handle: author,
- avatarUrl: identiconUrl(author),
- },
- content: d.title ?? "",
- url: permalink,
- createdAt: new Date(((d.created_utc ?? 0) * 1000) || Date.now()).toISOString(),
- meta: {
- score: d.score ?? 0,
- comments: d.num_comments ?? 0,
- subreddit: d.subreddit ?? fallbackSub,
- isSelf: !!d.is_self,
- externalUrl: !d.is_self ? d.url : undefined,
- nsfw: !!d.over_18,
- },
- };
+ const items = parseRedditFeed(await res.text(), sub).slice(0, limit);
+ return { items, nextAfter: undefined };
}
export async function searchReddit(
@@ -114,17 +116,13 @@ export async function searchReddit(
): Promise {
const q = query.trim();
if (!q) return [];
- const url = `https://www.reddit.com/search.json?q=${encodeURIComponent(q)}&sort=new&limit=${limit}&raw_json=1`;
+ const url = `https://www.reddit.com/search.rss?q=${encodeURIComponent(q)}&sort=new&limit=${limit}`;
const res = await fetchUpstream(url, {
- headers: { "user-agent": UA, accept: "application/json" },
+ headers: { "user-agent": UA, accept: ACCEPT },
cache: "no-store",
});
if (!res.ok) {
throw new Error(`Reddit ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
- const json = (await res.json()) as RedditListing;
- if (!json.data?.children) return [];
- return json.data.children
- .slice(0, limit)
- .map((c) => toFeedItem(c, c.data.subreddit ?? "all"));
+ return parseRedditFeed(await res.text(), "all").slice(0, limit);
}
From 243b703bf1c8f8b6dde4ef28fecb7d5bb591c73d Mon Sep 17 00:00:00 2001
From: Aaron Elijah Mars <61592645+aaronjmars@users.noreply.github.com>
Date: Fri, 10 Jul 2026 15:55:47 -0400
Subject: [PATCH 2/3] fix(github): require a token for stars & discussions with
a clear error
GitHub now auth-gates the endpoints these two columns depend on:
- REST /repos//stargazers returns 401 for unauthenticated requests (the
plain /repos/ endpoint still works keyless). There is no keyless way to
list recent stargazers, so fetchStargazers throws a clear "requires a token"
message instead of a raw 401, and uses GraphQL when a token is present.
Removes the now-dead REST stargazer helpers (fetchStargazersREST,
ghFetchStargazersPageREST, parseLastPage, GHStargazerEdgeREST).
- The GraphQL API gives unauthenticated requests ~0 quota (immediate 403), and
Discussions have no REST surface, so fetchDiscussions now fails fast with a
clear message (the old "never fails without a token" comment was false).
Updates both plugins' rateLimitHints and the GITHUB_TOKEN env description to
list search / stars / discussions as token-required.
---
.../plugins/github-discussions/plugin.ts | 3 +-
lib/columns/plugins/github-stars/plugin.ts | 2 +-
lib/env-keys.ts | 2 +-
lib/integrations/github-discussions.ts | 15 ++-
lib/integrations/github.ts | 122 ++----------------
5 files changed, 28 insertions(+), 116 deletions(-)
diff --git a/lib/columns/plugins/github-discussions/plugin.ts b/lib/columns/plugins/github-discussions/plugin.ts
index 43ebf98..c712118 100644
--- a/lib/columns/plugins/github-discussions/plugin.ts
+++ b/lib/columns/plugins/github-discussions/plugin.ts
@@ -58,6 +58,7 @@ export const meta: PluginMeta = {
},
capabilities: {
paginated: true,
- rateLimitHint: "60 req/hr without GITHUB_TOKEN, 5000 with.",
+ rateLimitHint:
+ "Requires GITHUB_TOKEN — the GraphQL API gives unauthenticated requests ~0 quota (403). 5000 req/hr with one.",
},
};
diff --git a/lib/columns/plugins/github-stars/plugin.ts b/lib/columns/plugins/github-stars/plugin.ts
index d5c1d73..14cacb8 100644
--- a/lib/columns/plugins/github-stars/plugin.ts
+++ b/lib/columns/plugins/github-stars/plugin.ts
@@ -27,6 +27,6 @@ export const meta: PluginMeta = {
capabilities: {
paginated: true,
rateLimitHint:
- "60 req/hr without GITHUB_TOKEN, 5000 with. Token unlocks GraphQL for newest-first ordering.",
+ "Requires GITHUB_TOKEN — GitHub auth-gates the stargazers list (401 without a token). 5000 req/hr with one.",
},
};
diff --git a/lib/env-keys.ts b/lib/env-keys.ts
index 7545208..88e042b 100644
--- a/lib/env-keys.ts
+++ b/lib/env-keys.ts
@@ -40,7 +40,7 @@ export const ENV_KEYS: EnvKeySpec[] = [
key: "GITHUB_TOKEN",
label: "GitHub",
description:
- "Optional — raises every GitHub plugin's rate limit from 60 → 5000 req/hr. GitHub code search additionally requires a token.",
+ "Optional — raises every GitHub plugin's rate limit from 60 → 5000 req/hr. GitHub code search, stars, and discussions additionally require a token (GitHub auth-gates those endpoints).",
signupUrl: "https://github.com/settings/tokens",
required: false,
},
diff --git a/lib/integrations/github-discussions.ts b/lib/integrations/github-discussions.ts
index 8213f18..38d16ca 100644
--- a/lib/integrations/github-discussions.ts
+++ b/lib/integrations/github-discussions.ts
@@ -8,11 +8,11 @@ import { identiconUrl } from "@/lib/utils";
// `github.ts` rather than inside it. The REST module is intentionally left
// untouched so its API surface (legacy callers) stays stable.
//
-// Auth: optional `GITHUB_TOKEN`. With a token we get the standard 5000 req/hr
-// authenticated GraphQL quota; without one we degrade to the 60 req/hr
-// unauthenticated public quota (same as the rest of the github-* plugins).
-// The column never fails because of a missing token — that's a deliberate
-// product decision: keyless dashboards are a first-class use case.
+// Auth: `GITHUB_TOKEN` is REQUIRED. Unlike GitHub's REST API (which serves the
+// other github-* plugins keyless at 60 req/hr), the GraphQL API gives
+// unauthenticated requests ~0 quota — it returns HTTP 403 "API rate limit
+// exceeded" immediately. Discussions have no REST surface, so there is no
+// keyless path; we fail fast with a clear message instead of a raw 403.
const GRAPHQL_ENDPOINT = "https://api.github.com/graphql";
@@ -133,6 +133,11 @@ export async function fetchDiscussions(
mode: GHDiscussionMode,
first: number,
): Promise[]> {
+ if (!process.env.GITHUB_TOKEN) {
+ throw new Error(
+ "GitHub Discussions requires a token. Set GITHUB_TOKEN in your env (read-only public scope is enough).",
+ );
+ }
const { owner, name } = parseRepo(repo);
const fullRepo = `${owner}/${name}`;
// We always pull a generous batch (caller controls `first`, usually 50) so
diff --git a/lib/integrations/github.ts b/lib/integrations/github.ts
index a76f01e..eec7087 100644
--- a/lib/integrations/github.ts
+++ b/lib/integrations/github.ts
@@ -686,78 +686,6 @@ export function normalizeGitHubRepo(input: string): string {
return clean;
}
-function parseLastPage(linkHeader: string | null): number | undefined {
- if (!linkHeader) return undefined;
- // Link: <...&page=42>; rel="last", <...&page=2>; rel="next"
- for (const part of linkHeader.split(",")) {
- const m = /<([^>]+)>;\s*rel="last"/.exec(part.trim());
- if (m) {
- try {
- const u = new URL(m[1]);
- const p = u.searchParams.get("page");
- if (p) return Number(p);
- } catch {
- // The Link-header URL is matched by regex; a malformed match shouldn't
- // be fatal — fall through and let pagination return undefined.
- }
- }
- }
- return undefined;
-}
-
-interface GHStargazerEdgeREST {
- starred_at: string;
- user: { login: string; avatar_url?: string; html_url?: string };
-}
-
-async function ghFetchStargazersPageREST(
- fullRepo: string,
- page: number,
- perPage: number,
-): Promise<{ items: GHWatcherItem[]; lastPage?: number }> {
- const params = new URLSearchParams({
- per_page: String(perPage),
- page: String(page),
- });
- const url = `${API}/repos/${fullRepo}/stargazers?${params}`;
- const res = await fetchUpstream(url, {
- headers: {
- ...headers(),
- // star+json is required to receive `starred_at` timestamps.
- accept: "application/vnd.github.star+json",
- },
- cache: "no-store",
- });
- if (!res.ok) {
- const body = await res.text().catch(() => "");
- throw new Error(`GitHub ${res.status}: ${body.slice(0, 200)}`);
- }
- const lastPage = parseLastPage(res.headers.get("link"));
- const json = (await res.json()) as GHStargazerEdgeREST[];
- const items = json.map((edge) => {
- const u = edge.user;
- return {
- id: `gh-star-${fullRepo}-${u.login}`,
- author: {
- name: u.login,
- handle: u.login,
- avatarUrl:
- u.avatar_url ??
- identiconUrl(u.login),
- },
- content: `${u.login} starred ${fullRepo}`,
- url: u.html_url ?? `https://github.com/${u.login}`,
- createdAt: edge.starred_at,
- meta: {
- kind: "star",
- repo: fullRepo,
- starredAt: edge.starred_at,
- },
- } satisfies GHWatcherItem;
- });
- return { items, lastPage };
-}
-
interface GHGraphQLStargazersResponse {
data?: {
repository: {
@@ -841,49 +769,27 @@ async function fetchStargazersGraphQL(
};
}
-async function fetchStargazersREST(
- fullRepo: string,
- limit: number,
- cursor?: string,
-): Promise {
- // REST sorts oldest-first. To surface newest-first we must walk the Link
- // header to find the last page, then page backwards from there.
- let pageToFetch: number;
- if (cursor) {
- pageToFetch = Number(cursor);
- if (!Number.isFinite(pageToFetch) || pageToFetch < 1) {
- return { items: [] };
- }
- } else {
- const probe = await ghFetchStargazersPageREST(fullRepo, 1, limit);
- pageToFetch = probe.lastPage ?? 1;
- }
- const { items } = await ghFetchStargazersPageREST(
- fullRepo,
- pageToFetch,
- limit,
- );
- items.reverse();
- return {
- items,
- nextCursor: pageToFetch > 1 ? String(pageToFetch - 1) : undefined,
- };
-}
-
export async function fetchStargazers(
repo: string,
limit = 12,
cursor?: string,
): Promise {
- const fullRepo = normalizeGitHubRepo(repo);
- if (process.env.GITHUB_TOKEN) {
- return fetchStargazersGraphQL(
- fullRepo,
- limit,
- cursor?.startsWith("gql:") ? cursor.slice(4) : undefined,
+ // GitHub now returns 401 on the REST `/stargazers` endpoint for
+ // unauthenticated requests (the plain repo endpoint still works keyless, but
+ // the stargazer list is auth-gated), so this column requires a token — there
+ // is no keyless way to list recent stargazers. With a token we use GraphQL,
+ // which orders by STARRED_AT and gives real newest-first pagination.
+ if (!process.env.GITHUB_TOKEN) {
+ throw new Error(
+ "GitHub stargazers requires a token. Set GITHUB_TOKEN in your env (read-only public scope is enough).",
);
}
- return fetchStargazersREST(fullRepo, limit, cursor);
+ const fullRepo = normalizeGitHubRepo(repo);
+ return fetchStargazersGraphQL(
+ fullRepo,
+ limit,
+ cursor?.startsWith("gql:") ? cursor.slice(4) : undefined,
+ );
}
interface GHForkREST {
From 1bf3e01f34507ee9f4086efda1f85778a0cffb9e Mon Sep 17 00:00:00 2001
From: Aaron Elijah Mars <61592645+aaronjmars@users.noreply.github.com>
Date: Fri, 10 Jul 2026 15:55:57 -0400
Subject: [PATCH 3/3] refactor(columns): remove Bluesky and Bing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Both are unreliable to fetch keyless:
- Bluesky search mode 403s (the public AppView's searchPosts needs auth).
- Bing News RSS 307-redirects ~half of server-side requests to a consent
interstitial regardless of headers (undici follows the redirect → HTML →
0 items); it also overlaps the reliable Google News column.
Removes both plugins plus Bluesky's integration, and drops them from the
manifest, client + server registries, and docs (49 → 47 columns).
github-backlinks keeps Bing News as one of its four fan-out backlink sources,
where it degrades gracefully when Bing fails.
---
README.md | 12 ++++++------
lib/columns/README.md | 2 +-
lib/columns/plugins/manifest.ts | 4 ----
lib/columns/registry.ts | 4 ----
lib/columns/server-registry.ts | 4 ----
5 files changed, 7 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 3ace8f9..8d919b3 100644
--- a/README.md
+++ b/README.md
@@ -18,12 +18,12 @@
> **Monitor the current thing. Your dashboard for the internet.**
-> Build a deck, pack it with columns, refresh on demand. Each column is a plugin: X, Bluesky, Reddit, Hacker News, Lobsters, Stack Overflow, DEV.to, npm + PyPI + crates.io packages, Hugging Face (models / datasets / spaces), arXiv (CS / stat / math.OC papers), GitHub (trending / issues / PRs / stars / forks / backlinks / search / releases / commits / Actions / Discussions), Farcaster, Mastodon, YouTube, RSS, Google News, Bing, Substack, LinkedIn, Facebook, Instagram, Apple + Google Play reviews, on-chain wallet activity, Polymarket prediction markets, CoinGecko crypto trending + prices, DeFiLlama TVL leaderboard, Dexscreener DEX pair search + watchlist, and the six biggest Chinese platforms (Weibo / Zhihu / Douyin / Bilibili / Toutiao / Baidu).
+> Build a deck, pack it with columns, refresh on demand. Each column is a plugin: X, Reddit, Hacker News, Lobsters, Stack Overflow, DEV.to, npm + PyPI + crates.io packages, Hugging Face (models / datasets / spaces), arXiv (CS / stat / math.OC papers), GitHub (trending / issues / PRs / stars / forks / backlinks / search / releases / commits / Actions / Discussions), Farcaster, Mastodon, YouTube, RSS, Google News, Substack, LinkedIn, Facebook, Instagram, Apple + Google Play reviews, on-chain wallet activity, Polymarket prediction markets, CoinGecko crypto trending + prices, DeFiLlama TVL leaderboard, Dexscreener DEX pair search + watchlist, and the six biggest Chinese platforms (Weibo / Zhihu / Douyin / Bilibili / Toutiao / Baidu).
### What it does
- You name a deck. Minitor packs it with whatever you're watching.
-- 49 column types out of the box — social feeds, news, GitHub (including commits, CI runs, and Discussions), Hugging Face, arXiv, DEV.to, Product Hunt, npm + PyPI + crates.io packages, app reviews, on-chain transactions, prediction markets, CoinGecko prices, DeFiLlama TVL, Dexscreener DEX pairs, Chinese hot boards.
+- 47 column types out of the box — social feeds, news, GitHub (including commits, CI runs, and Discussions), Hugging Face, arXiv, DEV.to, Product Hunt, npm + PyPI + crates.io packages, app reviews, on-chain transactions, prediction markets, CoinGecko prices, DeFiLlama TVL, Dexscreener DEX pairs, Chinese hot boards.
- Refresh per column or auto-fetch on creation. Load more pages 10 at a time.
- Shape the signal per column — highlight items with alert keywords, or filter the feed to "show only" / "hide" by keyword so a firehose column shows just what matters.
- ⌘K command palette over every deck, column, and action. Drag to reorder.
@@ -42,7 +42,7 @@ git clone https://github.com/aaronjmars/minitor.git && cd minitor
The launcher checks Node, picks the right package manager (npm / pnpm / yarn / bun based on lockfile), installs deps, copies `.env.example` → `.env.local` if missing, runs DB migrations against PGlite, and starts the dev server at `http://localhost:3000`. Re-running it just starts the server.
-For Grok / X / News / Web / Farcaster columns, paste your **[xAI API key](https://console.x.ai/)** into `XAI_API_KEY` in `.env.local`. Keyless columns (Reddit, HN, Lobsters, Stack Overflow, DEV.to, Product Hunt, npm, PyPI, crates.io, Hugging Face, arXiv, Bluesky, Mastodon, RSS, Google News, Bing, GitHub, China Hot, YouTube channel/playlist, app reviews, wallet transactions, Polymarket, CoinGecko, DeFiLlama, Dexscreener) work out of the box with no keys.
+For Grok / X / News / Web / Farcaster columns, paste your **[xAI API key](https://console.x.ai/)** into `XAI_API_KEY` in `.env.local`. Keyless columns (Reddit, HN, Lobsters, Stack Overflow, DEV.to, Product Hunt, npm, PyPI, crates.io, Hugging Face, arXiv, Mastodon, RSS, Google News, GitHub, China Hot, YouTube channel/playlist, app reviews, wallet transactions, Polymarket, CoinGecko, DeFiLlama, Dexscreener) work out of the box with no keys.
**Other launcher subcommands:**
@@ -59,9 +59,9 @@ For Grok / X / News / Web / Farcaster columns, paste your **[xAI API key](https:
| Category | Columns |
|----------|---------|
-| **Social — X / Bluesky / Reddit / HN / Farcaster / Mastodon** (7) | `x-search`, `x-trending`, `bluesky`, `reddit`, `hacker-news`, `farcaster`, `mastodon` |
+| **Social — X / Reddit / HN / Farcaster / Mastodon** (6) | `x-search`, `x-trending`, `reddit`, `hacker-news`, `farcaster`, `mastodon` |
| **GitHub** (11) | `github-trending`, `github-releases`, `github-issues`, `github-prs`, `github-commits`, `github-stars`, `github-forks`, `github-search`, `github-backlinks`, `github-actions`, `github-discussions` |
-| **News & web** (11) | `bing` (Web search), `google-news`, `news-search`, `rss`, `lobsters`, `stack-overflow`, `devto`, `npm`, `pypi`, `crates`, `producthunt` |
+| **News & web** (10) | `google-news`, `news-search`, `rss`, `lobsters`, `stack-overflow`, `devto`, `npm`, `pypi`, `crates`, `producthunt` |
| **AI / ML** (2) | `huggingface` (trending models, datasets, spaces), `arxiv` (CS / stat / math.OC papers) |
| **Long-form & video** (3) | `substack`, `youtube`, `linkedin` |
| **Mention monitors** (2) | `facebook`, `instagram` |
@@ -89,7 +89,7 @@ Full plugin manifest: [`lib/columns/plugins/manifest.ts`](lib/columns/plugins/ma
- **Founder dashboard** — your X mentions, Hacker News, Product Hunt RSS, GitHub stars on your repo, App Store reviews, Substack analytics, all on one screen
- **Crypto desk** — wallet activity across 9 chains, X trending in crypto, Reddit r/cryptocurrency, news search for protocol names
- **Open-source maintainer** — GitHub trending in your language, new issues / PRs across watched repos, new stargazers + forks, backlinks from HN / Reddit / news for your repo URL
-- **Journalist** — Google News + Bing + xAI news search on a topic, paired with X trending and Reddit threads, plus Substack publications you trust
+- **Journalist** — Google News + xAI news search on a topic, paired with X trending and Reddit threads, plus Substack publications you trust
- **PM / marketing** — Apple + Google Play reviews on your app, Twitter mentions, LinkedIn / Facebook / Instagram mentions, Substack write-ups
- **China-watcher** — Weibo, Zhihu, Douyin, Bilibili, Toutiao, and Baidu hot boards in one column stack
- **Personal radar** — RSS for the blogs that matter, Farcaster for the casts that matter, no algorithmic feed in sight
diff --git a/lib/columns/README.md b/lib/columns/README.md
index ca4578a..779502c 100644
--- a/lib/columns/README.md
+++ b/lib/columns/README.md
@@ -70,7 +70,7 @@ client bundle and vice versa.
if (!q) throw new Error("Search query is required.");
```
- Most input-driven columns follow this (`linkedin`, `youtube`, `bluesky`,
+ Most input-driven columns follow this (`linkedin`, `youtube`, `reddit`,
`mastodon`, and the `repo`-based `github-*` columns). Throwing here is caught
by the shared API route and surfaced to the user as a fetch-error toast.
- Keep upstream HTTP clients in `lib/integrations/.ts` and import
diff --git a/lib/columns/plugins/manifest.ts b/lib/columns/plugins/manifest.ts
index 00decc5..9125bd8 100644
--- a/lib/columns/plugins/manifest.ts
+++ b/lib/columns/plugins/manifest.ts
@@ -13,7 +13,6 @@ import { meta as githubTrending } from "./github-trending/plugin";
import { meta as githubIssues } from "./github-issues/plugin";
import { meta as rss } from "./rss/plugin";
import { meta as googleNews } from "./google-news/plugin";
-import { meta as bing } from "./bing/plugin";
import { meta as farcaster } from "./farcaster/plugin";
import { meta as mastodon } from "./mastodon/plugin";
import { meta as youtube } from "./youtube/plugin";
@@ -36,7 +35,6 @@ import { meta as playReviews } from "./play-reviews/plugin";
import { meta as githubStars } from "./github-stars/plugin";
import { meta as githubForks } from "./github-forks/plugin";
import { meta as githubReleases } from "./github-releases/plugin";
-import { meta as bluesky } from "./bluesky/plugin";
import { meta as lobsters } from "./lobsters/plugin";
import { meta as polymarket } from "./polymarket/plugin";
import { meta as stackOverflow } from "./stack-overflow/plugin";
@@ -64,7 +62,6 @@ export const PLUGIN_METAS = [
githubIssues,
rss,
googleNews,
- bing,
farcaster,
mastodon,
youtube,
@@ -87,7 +84,6 @@ export const PLUGIN_METAS = [
githubStars,
githubForks,
githubReleases,
- bluesky,
lobsters,
polymarket,
stackOverflow,
diff --git a/lib/columns/registry.ts b/lib/columns/registry.ts
index e7af0bf..14165f9 100644
--- a/lib/columns/registry.ts
+++ b/lib/columns/registry.ts
@@ -21,7 +21,6 @@ import { column as githubTrending } from "@/lib/columns/plugins/github-trending/
import { column as githubIssues } from "@/lib/columns/plugins/github-issues/client";
import { column as rss } from "@/lib/columns/plugins/rss/client";
import { column as googleNews } from "@/lib/columns/plugins/google-news/client";
-import { column as bing } from "@/lib/columns/plugins/bing/client";
import { column as farcaster } from "@/lib/columns/plugins/farcaster/client";
import { column as mastodon } from "@/lib/columns/plugins/mastodon/client";
import { column as youtube } from "@/lib/columns/plugins/youtube/client";
@@ -44,7 +43,6 @@ import { column as playReviews } from "@/lib/columns/plugins/play-reviews/client
import { column as githubStars } from "@/lib/columns/plugins/github-stars/client";
import { column as githubForks } from "@/lib/columns/plugins/github-forks/client";
import { column as githubReleases } from "@/lib/columns/plugins/github-releases/client";
-import { column as bluesky } from "@/lib/columns/plugins/bluesky/client";
import { column as lobsters } from "@/lib/columns/plugins/lobsters/client";
import { column as polymarket } from "@/lib/columns/plugins/polymarket/client";
import { column as stackOverflow } from "@/lib/columns/plugins/stack-overflow/client";
@@ -75,7 +73,6 @@ const COLUMNS_BY_ID: Record = {
"github-issues": githubIssues,
rss,
"google-news": googleNews,
- bing,
farcaster,
mastodon,
youtube,
@@ -98,7 +95,6 @@ const COLUMNS_BY_ID: Record = {
"github-stars": githubStars,
"github-forks": githubForks,
"github-releases": githubReleases,
- bluesky,
lobsters,
polymarket,
"stack-overflow": stackOverflow,
diff --git a/lib/columns/server-registry.ts b/lib/columns/server-registry.ts
index 99b1141..1086983 100644
--- a/lib/columns/server-registry.ts
+++ b/lib/columns/server-registry.ts
@@ -17,7 +17,6 @@ import { server as githubTrending } from "@/lib/columns/plugins/github-trending/
import { server as githubIssues } from "@/lib/columns/plugins/github-issues/server";
import { server as rss } from "@/lib/columns/plugins/rss/server";
import { server as googleNews } from "@/lib/columns/plugins/google-news/server";
-import { server as bing } from "@/lib/columns/plugins/bing/server";
import { server as farcaster } from "@/lib/columns/plugins/farcaster/server";
import { server as mastodon } from "@/lib/columns/plugins/mastodon/server";
import { server as youtube } from "@/lib/columns/plugins/youtube/server";
@@ -40,7 +39,6 @@ import { server as playReviews } from "@/lib/columns/plugins/play-reviews/server
import { server as githubStars } from "@/lib/columns/plugins/github-stars/server";
import { server as githubForks } from "@/lib/columns/plugins/github-forks/server";
import { server as githubReleases } from "@/lib/columns/plugins/github-releases/server";
-import { server as bluesky } from "@/lib/columns/plugins/bluesky/server";
import { server as lobsters } from "@/lib/columns/plugins/lobsters/server";
import { server as polymarket } from "@/lib/columns/plugins/polymarket/server";
import { server as stackOverflow } from "@/lib/columns/plugins/stack-overflow/server";
@@ -68,7 +66,6 @@ const SERVERS_BY_ID: Record = {
"github-issues": githubIssues,
rss,
"google-news": googleNews,
- bing,
farcaster,
mastodon,
youtube,
@@ -91,7 +88,6 @@ const SERVERS_BY_ID: Record = {
"github-stars": githubStars,
"github-forks": githubForks,
"github-releases": githubReleases,
- bluesky,
lobsters,
polymarket,
"stack-overflow": stackOverflow,