From 850c53ed754776d315ecfc3cd3b0713a95428608 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 23 Mar 2026 14:53:36 +0800 Subject: [PATCH] feat(hackernews): add new, best, ask, show, jobs, search, user commands Expand HackerNews from 1 command to 8, covering all major HN use cases. All YAML adapters, strategy: public, browser: false. - new/best/ask/show/jobs: Firebase API list endpoints with deleted/dead filtering - search: Algolia API with query + sort (relevance/date) - user: Firebase user profile with date formatting - top.yaml: add filter for deleted/dead items + dynamic pre-fetch limit - E2E tests for all 7 new commands - Update README, README.zh-CN, adapter docs --- README.md | 2 +- README.zh-CN.md | 2 +- docs/adapters/browser/hackernews.md | 24 ++++- docs/adapters/index.md | 2 +- src/clis/hackernews/ask.yaml | 38 ++++++++ src/clis/hackernews/best.yaml | 38 ++++++++ src/clis/hackernews/jobs.yaml | 36 ++++++++ src/clis/hackernews/new.yaml | 38 ++++++++ src/clis/hackernews/search.yaml | 44 +++++++++ src/clis/hackernews/show.yaml | 38 ++++++++ src/clis/hackernews/top.yaml | 4 +- src/clis/hackernews/user.yaml | 25 +++++ tests/e2e/public-commands.test.ts | 137 ++++++++++++++++++++++++---- 13 files changed, 400 insertions(+), 28 deletions(-) create mode 100644 src/clis/hackernews/ask.yaml create mode 100644 src/clis/hackernews/best.yaml create mode 100644 src/clis/hackernews/jobs.yaml create mode 100644 src/clis/hackernews/new.yaml create mode 100644 src/clis/hackernews/search.yaml create mode 100644 src/clis/hackernews/show.yaml create mode 100644 src/clis/hackernews/user.yaml diff --git a/README.md b/README.md index ac4c44ff..67b1a76b 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Run `opencli list` for the live registry. | **devto** | `top` `tag` `user` | Public | | **arxiv** | `search` `paper` | Public | | **wikipedia** | `search` `summary` | Public | -| **hackernews** | `top` | Public | +| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | Public | | **linkedin** | `search` | Browser | | **reuters** | `search` | Browser | | **smzdm** | `search` | Browser | diff --git a/README.zh-CN.md b/README.zh-CN.md index b2d7d504..88ea951f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -126,7 +126,7 @@ npm install -g @jackwener/opencli@latest | **devto** | `top` `tag` `user` | 公开 | | **arxiv** | `search` `paper` | 公开 | | **wikipedia** | `search` `summary` | 公开 | -| **hackernews** | `top` | 公共 API | +| **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 公共 API | | **linkedin** | `search` | 浏览器 | | **reuters** | `search` | 浏览器 | | **smzdm** | `search` | 浏览器 | diff --git a/docs/adapters/browser/hackernews.md b/docs/adapters/browser/hackernews.md index f504a58f..8ca6e000 100644 --- a/docs/adapters/browser/hackernews.md +++ b/docs/adapters/browser/hackernews.md @@ -6,19 +6,35 @@ | Command | Description | |---------|-------------| -| `opencli hackernews top` | | +| `opencli hackernews top` | Hacker News top stories | +| `opencli hackernews new` | Hacker News newest stories | +| `opencli hackernews best` | Hacker News best stories | +| `opencli hackernews ask` | Hacker News Ask HN posts | +| `opencli hackernews show` | Hacker News Show HN posts | +| `opencli hackernews jobs` | Hacker News job postings | +| `opencli hackernews search ` | Search Hacker News stories | +| `opencli hackernews user ` | Hacker News user profile | ## Usage Examples ```bash -# Quick start +# Top stories opencli hackernews top --limit 5 +# Newest stories +opencli hackernews new --limit 10 + +# Search stories +opencli hackernews search "machine learning" --limit 5 + +# User profile +opencli hackernews user pg + # JSON output opencli hackernews top -f json -# Verbose mode -opencli hackernews top -v +# Sort search by date +opencli hackernews search "rust" --sort date ``` ## Prerequisites diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 247ca5d2..b4400451 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -33,7 +33,7 @@ Run `opencli list` for the live registry. | Site | Commands | Mode | |------|----------|------| -| **[hackernews](/adapters/browser/hackernews)** | `top` | 🌐 Public | +| **[hackernews](/adapters/browser/hackernews)** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public | | **[bbc](/adapters/browser/bbc)** | `news` | 🌐 Public | | **[devto](/adapters/browser/devto)** | `top` `tag` `user` | 🌐 Public | | **[apple-podcasts](/adapters/browser/apple-podcasts)** | `search` `episodes` `top` | 🌐 Public | diff --git a/src/clis/hackernews/ask.yaml b/src/clis/hackernews/ask.yaml new file mode 100644 index 00000000..040e2deb --- /dev/null +++ b/src/clis/hackernews/ask.yaml @@ -0,0 +1,38 @@ +site: hackernews +name: ask +description: Hacker News Ask HN posts +domain: news.ycombinator.com +strategy: public +browser: false + +args: + limit: + type: int + default: 20 + description: Number of stories + +pipeline: + - fetch: + url: https://hacker-news.firebaseio.com/v0/askstories.json + + - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + + - map: + id: ${{ item }} + + - fetch: + url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json + + - filter: item.title && !item.deleted && !item.dead + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + score: ${{ item.score }} + author: ${{ item.by }} + comments: ${{ item.descendants }} + url: ${{ item.url }} + + - limit: ${{ args.limit }} + +columns: [rank, title, score, author, comments] diff --git a/src/clis/hackernews/best.yaml b/src/clis/hackernews/best.yaml new file mode 100644 index 00000000..fc1167c4 --- /dev/null +++ b/src/clis/hackernews/best.yaml @@ -0,0 +1,38 @@ +site: hackernews +name: best +description: Hacker News best stories +domain: news.ycombinator.com +strategy: public +browser: false + +args: + limit: + type: int + default: 20 + description: Number of stories + +pipeline: + - fetch: + url: https://hacker-news.firebaseio.com/v0/beststories.json + + - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + + - map: + id: ${{ item }} + + - fetch: + url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json + + - filter: item.title && !item.deleted && !item.dead + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + score: ${{ item.score }} + author: ${{ item.by }} + comments: ${{ item.descendants }} + url: ${{ item.url }} + + - limit: ${{ args.limit }} + +columns: [rank, title, score, author, comments] diff --git a/src/clis/hackernews/jobs.yaml b/src/clis/hackernews/jobs.yaml new file mode 100644 index 00000000..48b488bc --- /dev/null +++ b/src/clis/hackernews/jobs.yaml @@ -0,0 +1,36 @@ +site: hackernews +name: jobs +description: Hacker News job postings +domain: news.ycombinator.com +strategy: public +browser: false + +args: + limit: + type: int + default: 20 + description: Number of job postings + +pipeline: + - fetch: + url: https://hacker-news.firebaseio.com/v0/jobstories.json + + - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + + - map: + id: ${{ item }} + + - fetch: + url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json + + - filter: item.title && !item.deleted && !item.dead + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + author: ${{ item.by }} + url: ${{ item.url }} + + - limit: ${{ args.limit }} + +columns: [rank, title, author, url] diff --git a/src/clis/hackernews/new.yaml b/src/clis/hackernews/new.yaml new file mode 100644 index 00000000..23c5fed4 --- /dev/null +++ b/src/clis/hackernews/new.yaml @@ -0,0 +1,38 @@ +site: hackernews +name: new +description: Hacker News newest stories +domain: news.ycombinator.com +strategy: public +browser: false + +args: + limit: + type: int + default: 20 + description: Number of stories + +pipeline: + - fetch: + url: https://hacker-news.firebaseio.com/v0/newstories.json + + - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + + - map: + id: ${{ item }} + + - fetch: + url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json + + - filter: item.title && !item.deleted && !item.dead + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + score: ${{ item.score }} + author: ${{ item.by }} + comments: ${{ item.descendants }} + url: ${{ item.url }} + + - limit: ${{ args.limit }} + +columns: [rank, title, score, author, comments] diff --git a/src/clis/hackernews/search.yaml b/src/clis/hackernews/search.yaml new file mode 100644 index 00000000..8629bae3 --- /dev/null +++ b/src/clis/hackernews/search.yaml @@ -0,0 +1,44 @@ +site: hackernews +name: search +description: Search Hacker News stories +domain: news.ycombinator.com +strategy: public +browser: false + +args: + query: + type: str + required: true + positional: true + description: Search query + limit: + type: int + default: 20 + description: Number of results + sort: + type: str + default: relevance + choices: [relevance, date] + description: Sort by relevance or date + +pipeline: + - fetch: + url: "https://hn.algolia.com/api/v1/${{ args.sort === 'date' ? 'search_by_date' : 'search' }}" + params: + query: ${{ args.query }} + tags: story + hitsPerPage: ${{ args.limit }} + + - select: hits + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + score: ${{ item.points }} + author: ${{ item.author }} + comments: ${{ item.num_comments }} + url: ${{ item.url }} + + - limit: ${{ args.limit }} + +columns: [rank, title, score, author, comments] diff --git a/src/clis/hackernews/show.yaml b/src/clis/hackernews/show.yaml new file mode 100644 index 00000000..7266298d --- /dev/null +++ b/src/clis/hackernews/show.yaml @@ -0,0 +1,38 @@ +site: hackernews +name: show +description: Hacker News Show HN posts +domain: news.ycombinator.com +strategy: public +browser: false + +args: + limit: + type: int + default: 20 + description: Number of stories + +pipeline: + - fetch: + url: https://hacker-news.firebaseio.com/v0/showstories.json + + - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" + + - map: + id: ${{ item }} + + - fetch: + url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json + + - filter: item.title && !item.deleted && !item.dead + + - map: + rank: ${{ index + 1 }} + title: ${{ item.title }} + score: ${{ item.score }} + author: ${{ item.by }} + comments: ${{ item.descendants }} + url: ${{ item.url }} + + - limit: ${{ args.limit }} + +columns: [rank, title, score, author, comments] diff --git a/src/clis/hackernews/top.yaml b/src/clis/hackernews/top.yaml index cf756012..59c6f17d 100644 --- a/src/clis/hackernews/top.yaml +++ b/src/clis/hackernews/top.yaml @@ -15,7 +15,7 @@ pipeline: - fetch: url: https://hacker-news.firebaseio.com/v0/topstories.json - - limit: 30 + - limit: "${{ Math.min((args.limit ? args.limit : 20) + 10, 50) }}" - map: id: ${{ item }} @@ -23,6 +23,8 @@ pipeline: - fetch: url: https://hacker-news.firebaseio.com/v0/item/${{ item.id }}.json + - filter: item.title && !item.deleted && !item.dead + - map: rank: ${{ index + 1 }} title: ${{ item.title }} diff --git a/src/clis/hackernews/user.yaml b/src/clis/hackernews/user.yaml new file mode 100644 index 00000000..7c1628c6 --- /dev/null +++ b/src/clis/hackernews/user.yaml @@ -0,0 +1,25 @@ +site: hackernews +name: user +description: Hacker News user profile +domain: news.ycombinator.com +strategy: public +browser: false + +args: + username: + type: str + required: true + positional: true + description: HN username + +pipeline: + - fetch: + url: https://hacker-news.firebaseio.com/v0/user/${{ args.username }}.json + + - map: + username: ${{ item.id }} + karma: ${{ item.karma }} + created: "${{ item.created ? new Date(item.created * 1000).toISOString().slice(0, 10) : '' }}" + about: ${{ item.about }} + +columns: [username, karma, created, about] diff --git a/tests/e2e/public-commands.test.ts b/tests/e2e/public-commands.test.ts index 9975d9d0..2790cc16 100644 --- a/tests/e2e/public-commands.test.ts +++ b/tests/e2e/public-commands.test.ts @@ -3,8 +3,8 @@ * These commands use Node.js fetch directly — no browser needed. */ -import { describe, it, expect } from 'vitest'; -import { runCli, parseJsonOutput } from './helpers.js'; +import { describe, expect, it } from 'vitest'; +import { parseJsonOutput, runCli } from './helpers.js'; function isExpectedChineseSiteRestriction(code: number, stderr: string): boolean { if (code === 0) return false; @@ -40,21 +40,25 @@ describe('public commands E2E', () => { expect(Array.isArray(data[0].mediaLinks)).toBe(true); }, 30_000); - it.each(['markets', 'economics', 'industries', 'tech', 'politics', 'businessweek', 'opinions'])( - 'bloomberg %s returns structured RSS items', - async (section) => { - const { stdout, code } = await runCli(['bloomberg', section, '--limit', '1', '-f', 'json']); - expect(code).toBe(0); - const data = parseJsonOutput(stdout); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(1); - expect(data[0]).toHaveProperty('title'); - expect(data[0]).toHaveProperty('summary'); - expect(data[0]).toHaveProperty('link'); - expect(data[0]).toHaveProperty('mediaLinks'); - }, - 30_000, - ); + it.each([ + 'markets', + 'economics', + 'industries', + 'tech', + 'politics', + 'businessweek', + 'opinions', + ])('bloomberg %s returns structured RSS items', async (section) => { + const { stdout, code } = await runCli(['bloomberg', section, '--limit', '1', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(1); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('summary'); + expect(data[0]).toHaveProperty('link'); + expect(data[0]).toHaveProperty('mediaLinks'); + }, 30_000); it('bloomberg feeds lists the supported RSS aliases', async () => { const { stdout, code } = await runCli(['bloomberg', 'feeds', '-f', 'json']); @@ -95,7 +99,16 @@ describe('public commands E2E', () => { }, 30_000); it('apple-podcasts top returns ranked podcasts', async () => { - const { stdout, stderr, code } = await runCli(['apple-podcasts', 'top', '--limit', '3', '--country', 'us', '-f', 'json']); + const { stdout, stderr, code } = await runCli([ + 'apple-podcasts', + 'top', + '--limit', + '3', + '--country', + 'us', + '-f', + 'json', + ]); if (isExpectedApplePodcastsRestriction(code, stderr)) { console.warn(`apple-podcasts top skipped: ${stderr.trim()}`); return; @@ -128,6 +141,76 @@ describe('public commands E2E', () => { expect(data.length).toBe(1); }, 30_000); + it('hackernews new returns newest stories', async () => { + const { stdout, code } = await runCli(['hackernews', 'new', '--limit', '3', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('score'); + expect(data[0]).toHaveProperty('rank'); + }, 30_000); + + it('hackernews best returns best stories', async () => { + const { stdout, code } = await runCli(['hackernews', 'best', '--limit', '3', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('score'); + }, 30_000); + + it('hackernews ask returns Ask HN posts', async () => { + const { stdout, code } = await runCli(['hackernews', 'ask', '--limit', '3', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('title'); + }, 30_000); + + it('hackernews show returns Show HN posts', async () => { + const { stdout, code } = await runCli(['hackernews', 'show', '--limit', '3', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('title'); + }, 30_000); + + it('hackernews jobs returns job postings', async () => { + const { stdout, code } = await runCli(['hackernews', 'jobs', '--limit', '3', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThanOrEqual(1); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('url'); + }, 30_000); + + it('hackernews search returns results for query', async () => { + const { stdout, code } = await runCli(['hackernews', 'search', 'typescript', '--limit', '3', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(3); + expect(data[0]).toHaveProperty('title'); + expect(data[0]).toHaveProperty('score'); + expect(data[0]).toHaveProperty('author'); + }, 30_000); + + it('hackernews user returns user profile', async () => { + const { stdout, code } = await runCli(['hackernews', 'user', 'pg', '-f', 'json']); + expect(code).toBe(0); + const data = parseJsonOutput(stdout); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(1); + expect(data[0]).toHaveProperty('username', 'pg'); + expect(data[0]).toHaveProperty('karma'); + }, 30_000); + // ── v2ex (public API, browser: false) ── it('v2ex hot returns topics', async () => { const { stdout, code } = await runCli(['v2ex', 'hot', '--limit', '3', '-f', 'json']); @@ -173,7 +256,13 @@ describe('public commands E2E', () => { }, 30_000); it('xiaoyuzhou podcast-episodes returns episode list', async () => { - const { stdout, stderr, code } = await runCli(['xiaoyuzhou', 'podcast-episodes', '6013f9f58e2f7ee375cf4216', '-f', 'json']); + const { stdout, stderr, code } = await runCli([ + 'xiaoyuzhou', + 'podcast-episodes', + '6013f9f58e2f7ee375cf4216', + '-f', + 'json', + ]); if (isExpectedXiaoyuzhouRestriction(code, stderr)) { console.warn(`xiaoyuzhou podcast-episodes skipped: ${stderr.trim()}`); return; @@ -204,7 +293,15 @@ describe('public commands E2E', () => { }, 30_000); it('xiaoyuzhou podcast-episodes rejects invalid limit', async () => { - const { stderr, code } = await runCli(['xiaoyuzhou', 'podcast-episodes', '6013f9f58e2f7ee375cf4216', '--limit', 'abc', '-f', 'json']); + const { stderr, code } = await runCli([ + 'xiaoyuzhou', + 'podcast-episodes', + '6013f9f58e2f7ee375cf4216', + '--limit', + 'abc', + '-f', + 'json', + ]); if (isExpectedXiaoyuzhouRestriction(code, stderr)) { console.warn(`xiaoyuzhou invalid-limit skipped: ${stderr.trim()}`); return;