browse-x
Reads public X (formerly Twitter) posts, threads, X Articles, profiles, followers, following, and search results as Markdown or JSON, with no X account and no API key. Use when a URL points at x.com, twitter.com, or t.co; when the user mentions a tweet, an X post, a thread, or an
Install
npx skills add https://github.com/pc-style/x-md/tree/main/skills/browse-x
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install pc-style-x-md@llmmart
git clone https://github.com/pc-style/x-md.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole pc-style/x-md collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
browse x
public content through x.pcstyle.dev. no X login needed. run the helper from this skill's directory:
when to use this
reach for it whenever the job is reading one piece of public X content right now:
- you need the text of a public X post, thread, or X Article and can't run a browser or log into X.
- you hit an
x.com,twitter.com, ort.colink in a document, issue, changelog, or chat log and need its content inline. - you want a public profile's bio and latest original posts (replies and reposts filtered out).
- you're checking who a public account follows, or who follows it.
- you want X search results as data rather than a rendered timeline.
- you're saving a post into notes or a vault —
--format obsidianemits YAML frontmatter.
when not to use this
- anything that writes. it never posts, replies, follows, likes, bookmarks, or sends DMs, and takes no X credentials.
- private, protected, suspended, or deleted accounts and posts. never available; no flag unlocks them.
- X Lists, DMs, notifications, the personalized home timeline, account analytics. not supported.
- bulk collection, backfills, or dataset building. it's rate limited and cached for one question at a time.
- anything needing guaranteed completeness or an SLA. results come from public upstream providers and can lag or truncate — read
warningsinstead of assuming.
bun scripts/browse-x.ts "https://x.com/handle/status/123"
bun scripts/browse-x.ts profile handle
bun scripts/browse-x.ts search "from:handle release"
bun scripts/browse-x.ts followers handle
bun scripts/browse-x.ts following handle
options
markdown by default. --json for structured output, --full for metadata, --compact for less. use --cursor with the returned cursor to continue. run bun scripts/browse-x.ts --help for thread, feed, and other options.
optional: X_MD_API_KEY sends a bearer key; X_API_BASE changes the host. never print the key. invalid or disabled keys return 401.
limits
public and keyed requests have different quotas. on 429 (exit 3), wait for the printed Retry-After; don't loop retries. exit 2 means bad arguments, exit 1 means another error. private or deleted content may be unavailable. check warnings; don't invent missing replies, media, or pinned posts.
Files (x-md)
-
scripts
-
browse-x.ts 9.4 KB
type Resource = 'status' | 'profile' | 'search' | 'list' type Output = { write: (value: string) => void } class CliError extends Error { readonly code: 0 | 1 | 2 | 3 constructor( message: string, code: 0 | 1 | 2 | 3, ) { super(message) this.code = code } } const usage = `Usage: browse-x.ts <x-status-or-profile-url> [options] browse-x.ts status <x-status-url> [options] browse-x.ts profile <handle> [options] browse-x.ts search <query> [options] browse-x.ts followers <handle> [options] browse-x.ts following <handle> [options] Output: --json, --full, --compact, --format markdown|obsidian|json, --headers Lists: --page 1-10, --limit 1-20, --cursor <cursor>, --feed latest|top|photos|videos|users|media Status: --thread off|full|conversation|2-100, --userinfo off|author|all, --context full|thread, --replies top|recent|off Other: --nocache, --help X_API_BASE (or X_MD_API_BASE) overrides https://x.pcstyle.dev. X_MD_API_KEY optionally sends a bearer key for browse requests. ` const fail = (message: string): never => { throw new CliError(`browse-x: ${message}`, 2) } const setOption = (options: Map<string, string>, name: string, value: string, option: string) => { const old = options.get(name) if (old !== undefined && old !== value) { fail(`${option} was supplied with conflicting values ('${old}' and '${value}')`) } options.set(name, value) } const requireValue = (args: string[], index: number, option: string) => { const value = args[index + 1] if (!value) fail(`${option} requires a value`) return value } const validate = (options: Map<string, string>) => { const value = (name: string) => options.get(name) if (value('format') && !/^(markdown|obsidian|json)$/.test(value('format') as string)) { fail('--format must be markdown, obsidian, or json') } if (value('page') && !/^(?:[1-9]|10)$/.test(value('page') as string)) { fail('--page must be an integer from 1 to 10') } if (value('limit') && !/^(?:[1-9]|1[0-9]|20)$/.test(value('limit') as string)) { fail('--limit must be an integer from 1 to 20') } if (value('feed') && !/^(latest|top|photos|videos|users|media)$/.test(value('feed') as string)) { fail('--feed must be latest, top, photos, videos, users, or media') } if (value('thread') && !/^(off|full|conversation|(?:[2-9]|[1-9][0-9]|100))$/.test(value('thread') as string)) { fail('--thread must be off, full, conversation, or an integer from 2 to 100') } if (value('userinfo') && !/^(off|author|all)$/.test(value('userinfo') as string)) { fail('--userinfo must be off, author, or all') } if (value('context') && !/^(full|thread)$/.test(value('context') as string)) fail('--context must be full or thread') if (value('replies') && !/^(top|recent|off)$/.test(value('replies') as string)) fail('--replies must be top, recent, or off') if (options.has('json') && value('format') && value('format') !== 'json') fail(`--json conflicts with --format ${value('format')}`) } const parse = (args: string[]) => { if (args.length === 0) throw new CliError(usage, 2) if (args[0] === '-h' || args[0] === '--help') throw new CliError(usage, 0) let command = '' let target = '' const first = args[0] if (/^(status|profile|search|followers|following)$/.test(first)) { command = first if (args[1] === '-h' || args[1] === '--help') throw new CliError(usage, 0) target = args[1] ?? fail(`${first} requires a target`) args = args.slice(2) } else if (/^https?:\/\//.test(first)) { target = first args = args.slice(1) } else fail(`expected a command or public X URL (got '${first}')`) const options = new Map<string, string>() let headers = false for (let index = 0; index < args.length; index += 1) { const arg = args[index] if (arg === '--json' || arg === '--nocache') options.set(arg.slice(2), 'true') else if (arg === '--full') setOption(options, 'full', 'true', '--full/--compact') else if (arg === '--compact') setOption(options, 'full', 'false', '--full/--compact') else if (arg === '--headers') headers = true else if (/^--(format|page|limit|cursor|feed|thread|userinfo|context|replies)$/.test(arg)) { const value = requireValue(args, index, arg) setOption(options, arg.slice(2), value, arg) index += 1 } else if (arg === '-h' || arg === '--help') throw new CliError(usage, 0) else fail(`unknown option '${arg}'`) } validate(options) if (options.has('json')) options.set('format', 'json') return { command, target, options, headers } } const publicHosts = new Set(['x.com', 'www.x.com', 'twitter.com', 'www.twitter.com', 'mobile.twitter.com']) const publicUrl = (target: string) => { try { const url = new URL(target) return /^https?:$/.test(url.protocol) && publicHosts.has(url.hostname.toLowerCase()) ? url : undefined } catch { return undefined } } const isStatusUrl = (url: URL | undefined) => Boolean(url && /^\/[^/]+\/status\/[0-9]+\/?$/.test(url.pathname)) const request = (parsed: ReturnType<typeof parse>, base: string) => { const { command, target, options } = parsed const handle = encodeURIComponent(target.replace(/^@/, '')) const targetUrl = publicUrl(target) let resource: Resource = 'status' let endpoint = '' let targetParam: [string, string] | undefined if (command === 'status') { if (!isStatusUrl(targetUrl)) fail('status requires a public x.com or twitter.com status URL') resource = 'status'; endpoint = `${base}/api/convert`; targetParam = ['url', target] } else if (command === 'profile') { resource = 'profile'; endpoint = `${base}/${handle}` } else if (command === 'search') { resource = 'search'; endpoint = `${base}/search`; targetParam = ['q', target] } else if (command === 'followers' || command === 'following') { resource = 'list'; endpoint = `${base}/${handle}/${command}` } else if (isStatusUrl(targetUrl)) { resource = 'status'; endpoint = `${base}/api/convert`; targetParam = ['url', target] } else if (targetUrl) { if (!/^\/[^/]+\/?$/.test(targetUrl.pathname)) fail('only public x.com or twitter.com status/profile URLs are supported') const handleFromUrl = targetUrl.pathname.split('/').filter(Boolean)[0] if (!handleFromUrl) fail('profile URL must contain a handle') resource = 'profile'; endpoint = `${base}/${encodeURIComponent(handleFromUrl)}` } else fail('only public x.com or twitter.com status/profile URLs are supported') const statusOptions = ['thread', 'userinfo', 'context', 'replies'] if (resource !== 'status' && statusOptions.some((name) => options.has(name))) fail('status options are only valid for status requests') if (resource !== 'status' && options.get('format') === 'obsidian') fail('--format obsidian is only valid for status requests') if (resource !== 'search' && options.has('feed')) fail('--feed is only valid for search') if (resource === 'status' && ['page', 'limit', 'cursor', 'feed'].some((name) => options.has(name))) fail('list options are not valid for status requests') const url = new URL(endpoint) if (targetParam) url.searchParams.set(targetParam[0], targetParam[1]) for (const name of ['format', 'full', 'page', 'limit', 'cursor', 'feed', 'thread', 'userinfo', 'context', 'replies']) { const value = options.get(name) if (value !== undefined) url.searchParams.set(name, value) } if (options.has('nocache')) url.searchParams.set('nocache', 'true') return { url, accept: options.get('format') === 'json' ? 'application/json' : 'text/markdown' } } export const run = async ( args: string[], env: NodeJS.ProcessEnv = process.env, fetcher: typeof fetch = fetch, output: Output = { write: (value) => process.stdout.write(value) }, ) => { const parsed = parse(args) const { url, accept } = request(parsed, env.X_API_BASE || env.X_MD_API_BASE || 'https://x.pcstyle.dev') const headers: Record<string, string> = { Accept: accept } if (env.X_MD_API_KEY) headers.Authorization = `Bearer ${env.X_MD_API_KEY}` let response: Response try { response = await fetcher(url, { headers }) } catch (error) { const reason = error instanceof Error ? error.message : String(error) throw new CliError(`browse-x: request to ${url.origin} failed: ${reason}\n`, 1) } const body = await response.text() if (!response.ok) { const signals = ['retry-after', 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset', 'x-api-key-status'] .flatMap((name) => response.headers.has(name) ? [`${name}: ${response.headers.get(name)}`] : []) .join('\n') const details = [signals, body].filter(Boolean).join('\n') throw new CliError( `browse-x: HTTP ${response.status} from ${url.origin}${url.pathname}${details ? `\n${details}` : ''}\n`, response.status === 429 ? 3 : 1, ) } if (parsed.headers) { output.write(`HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}\r\n`) response.headers.forEach((value, key) => output.write(`${key}: ${value}\r\n`)) output.write('\r\n') } output.write(body) if (body.length > 0 && !body.endsWith('\n')) output.write('\n') } if (process.argv[1]?.replaceAll('\\', '/').endsWith('/browse-x.ts')) { run(process.argv.slice(2)).catch((error: unknown) => { const cliError = error instanceof CliError ? error : new CliError(`browse-x: ${String(error)}\n`, 1) process.stderr.write(cliError.message.endsWith('\n') ? cliError.message : `${cliError.message}\n`) process.exitCode = cliError.code }) }
-
-
tests
-
browse-x.test.ts 4.8 KB
import { describe, expect, test } from 'vitest' import { run } from '../scripts/browse-x.ts' const response = (body = 'ok', status = 200, headers: Record<string, string> = {}) => new Response(body, { status, headers: { 'x-source': 'stub', ...headers } }) const invoke = async (args: string[]) => { const output: string[] = [] await run(args, { X_API_BASE: 'https://example.test' }, async (input, init) => { const url = String(input) expect(init?.headers).toEqual({ Accept: url.includes('format=json') ? 'application/json' : 'text/markdown' }) return response() }, { write: (value) => output.push(value) }) return output.join('') } describe('browse-x CLI', () => { test('builds a status request and prints headers', async () => { const output = await invoke(['status', 'https://x.com/a/status/1', '--thread', 'full', '--headers']) expect(output).toContain('HTTP 200') expect(output).toContain('x-source: stub') expect(output).toMatch(/ok\n$/) }) test('encodes search and list options', async () => { let requested: URL | undefined const output: string[] = [] await run( ['search', 'from:test release', '--feed', 'media', '--page', '3', '--limit', '10', '--compact'], { X_API_BASE: 'https://example.test' }, async (input) => { requested = new URL(String(input)) return response() }, { write: (value) => output.push(value) }, ) expect(requested?.pathname).toBe('/search') expect(Object.fromEntries(requested?.searchParams ?? [])).toEqual({ q: 'from:test release', full: 'false', page: '3', limit: '10', feed: 'media', }) expect(output.join('')).toBe('ok\n') }) test('accepts every supported feed and rejects limits above 20', async () => { for (const feed of ['latest', 'top', 'photos', 'videos', 'users', 'media']) { await expect(invoke(['search', 'test', '--feed', feed, '--limit', '20'])).resolves.toBe('ok\n') } await expect(invoke(['search', 'test', '--limit', '21'])).rejects.toMatchObject({ code: 2 }) }) test('rejects invalid option combinations', async () => { await expect(invoke(['status', 'https://x.com/a/status/1', '--json', '--format', 'markdown'])).rejects.toThrow( '--json conflicts with --format markdown', ) await expect(invoke(['profile', 'test', '--thread', 'full'])).rejects.toThrow('status options are only valid') await expect(invoke(['status', '--help'])).rejects.toMatchObject({ code: 0 }) await expect(invoke(['status', 'https://example.test/a/status/1'])).rejects.toMatchObject({ code: 2 }) await expect(invoke(['https://example.test/a/status/1'])).rejects.toMatchObject({ code: 2 }) await expect(invoke(['status', 'https://x.com/a/status/1/photo/1'])).rejects.toMatchObject({ code: 2 }) await expect(invoke(['https://x.com/a/status/not-a-number'])).rejects.toMatchObject({ code: 2 }) }) test('supports mobile Twitter status URLs and encodes handles', async () => { let requested = '' const fetcher = async (input: RequestInfo | URL) => { requested = String(input) return response() } await run(['status', 'https://mobile.twitter.com/a/status/1'], { X_API_BASE: 'https://example.test' }, fetcher) expect(requested).toContain('url=https%3A%2F%2Fmobile.twitter.com%2Fa%2Fstatus%2F1') await run(['profile', 'name/with?delimiters'], { X_API_BASE: 'https://example.test' }, fetcher) expect(new URL(requested).pathname).toBe('/name%2Fwith%3Fdelimiters') }) test('falls back from an empty base and sends configured API authentication', async () => { let requested = '' let requestHeaders: HeadersInit | undefined await run( ['profile', 'test'], { X_API_BASE: '', X_MD_API_BASE: 'https://fallback.test', X_MD_API_KEY: 'secret' }, async (input, init) => { requested = String(input) requestHeaders = init?.headers return response() }, ) expect(requested).toBe('https://fallback.test/test') expect(requestHeaders).toEqual({ Accept: 'text/markdown', Authorization: 'Bearer secret' }) }) test('preserves network and rate-limit diagnostics', async () => { await expect( run(['profile', 'test'], { X_API_BASE: 'https://example.test' }, async () => { throw new Error('connection refused') }), ).rejects.toThrow('connection refused') await expect( run( ['search', 'test'], { X_API_BASE: 'https://example.test' }, async () => response('slow down', 429, { 'retry-after': '30' }), ), ).rejects.toMatchObject({ code: 3, message: expect.stringContaining('retry-after: 30') }) }) test('preserves API errors', async () => { await expect(run(['profile', 'test'], { X_API_BASE: 'https://example.test' }, async () => response('not found', 404))).rejects.toThrow( 'HTTP 404', ) }) })
-
-
SKILL.md 2.9 KB
--- name: browse-x description: "Reads public X (formerly Twitter) posts, threads, X Articles, profiles, followers, following, and search results as Markdown or JSON, with no X account and no API key. Use when a URL points at x.com, twitter.com, or t.co; when the user mentions a tweet, an X post, a thread, or an X profile; when you need an account's recent posts or connections; or when you need X search results as structured data. Read-only: it never posts, replies, follows, or reads protected accounts, direct messages, or Lists." allowed-tools: - Bash(bun scripts/browse-x.ts *) - Bash(curl *x.pcstyle.dev*) --- # browse x public content through x.pcstyle.dev. no X login needed. run the helper from this skill's directory: ## when to use this reach for it whenever the job is reading one piece of public X content right now: - you need the text of a public X post, thread, or X Article and can't run a browser or log into X. - you hit an `x.com`, `twitter.com`, or `t.co` link in a document, issue, changelog, or chat log and need its content inline. - you want a public profile's bio and latest original posts (replies and reposts filtered out). - you're checking who a public account follows, or who follows it. - you want X search results as data rather than a rendered timeline. - you're saving a post into notes or a vault — `--format obsidian` emits YAML frontmatter. ## when not to use this - anything that writes. it never posts, replies, follows, likes, bookmarks, or sends DMs, and takes no X credentials. - private, protected, suspended, or deleted accounts and posts. never available; no flag unlocks them. - X Lists, DMs, notifications, the personalized home timeline, account analytics. not supported. - bulk collection, backfills, or dataset building. it's rate limited and cached for one question at a time. - anything needing guaranteed completeness or an SLA. results come from public upstream providers and can lag or truncate — read `warnings` instead of assuming. ```bash bun scripts/browse-x.ts "https://x.com/handle/status/123" bun scripts/browse-x.ts profile handle bun scripts/browse-x.ts search "from:handle release" bun scripts/browse-x.ts followers handle bun scripts/browse-x.ts following handle ``` ## options markdown by default. `--json` for structured output, `--full` for metadata, `--compact` for less. use `--cursor` with the returned cursor to continue. run `bun scripts/browse-x.ts --help` for thread, feed, and other options. optional: `X_MD_API_KEY` sends a bearer key; `X_API_BASE` changes the host. never print the key. invalid or disabled keys return 401. ## limits public and keyed requests have different quotas. on 429 (exit 3), wait for the printed `Retry-After`; don't loop retries. exit 2 means bad arguments, exit 1 means another error. private or deleted content may be unavailable. check `warnings`; don't invent missing replies, media, or pinned posts.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.