feat(juejin): add Juejin (掘金) read-only adapter (#2007)

* feat(juejin): add Juejin (掘金) read-only adapter

Two PUBLIC commands for the Juejin developer community: `recommend` (homepage feed) and `hot` (article ranking by category). Native fetch against api.juejin.cn; no browser, no auth. Category aliases (`backend`, `frontend`, `android`, `ios`, `ai`) resolve to Juejin's stable numeric ids.

Closes #1711

* fix(juejin): fail closed on API shape drift

* fix(juejin): expose recommendation cursor

* fix(juejin): classify response cursor drift

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
This commit is contained in:
Bo Liu
2026-06-24 07:18:24 +09:00
committed by GitHub
parent ee4820ef59
commit 1dd712d609
8 changed files with 676 additions and 0 deletions
+81
View File
@@ -18839,6 +18839,87 @@
"modulePath": "jira/search.js",
"sourceFile": "jira/search.js"
},
{
"site": "juejin",
"name": "hot",
"description": "Juejin (掘金) hot article ranking, optionally scoped to a category",
"access": "read",
"domain": "api.juejin.cn",
"strategy": "public",
"browser": false,
"args": [
{
"name": "category",
"type": "string",
"required": false,
"help": "Category slug or numeric id. Slugs: backend, frontend, android, ios, ai. Defaults to \"backend\"."
},
{
"name": "limit",
"type": "int",
"default": 20,
"required": false,
"help": "Max articles (1-50)."
}
],
"columns": [
"rank",
"article_id",
"title",
"brief",
"views",
"likes",
"comments",
"hot_rank",
"author",
"url"
],
"type": "js",
"modulePath": "juejin/hot.js",
"sourceFile": "juejin/hot.js"
},
{
"site": "juejin",
"name": "recommend",
"description": "Juejin (掘金) homepage recommended article feed",
"access": "read",
"domain": "api.juejin.cn",
"strategy": "public",
"browser": false,
"args": [
{
"name": "limit",
"type": "int",
"default": 20,
"required": false,
"help": "Max articles (1-100, single page)."
},
{
"name": "cursor",
"type": "string",
"default": "0",
"required": false,
"help": "Pagination cursor; pass back the previous response's next-page cursor to keep scrolling."
}
],
"columns": [
"rank",
"article_id",
"title",
"brief",
"views",
"likes",
"comments",
"author",
"tags",
"url",
"next_cursor",
"has_more"
],
"type": "js",
"modulePath": "juejin/recommend.js",
"sourceFile": "juejin/recommend.js"
},
{
"site": "ke",
"name": "chengjiao",
+37
View File
@@ -0,0 +1,37 @@
// juejin hot: Juejin's article hot ranking, optionally scoped to a category.
//
// Hits the `article_rank` endpoint that backs the "hot" board on the Juejin
// web UI. Returns a different envelope from the recommend feed
// (`content` / `content_counter` / `author`), so the mapping lives in utils.
import { cli, Strategy } from '@jackwener/opencli/registry';
import {
CATEGORY_ALIASES,
juejinFetch,
mapHotItem,
readDataArray,
requireBoundedInt,
resolveCategory,
} from './utils.js';
cli({
site: 'juejin',
name: 'hot',
access: 'read',
description: 'Juejin (掘金) hot article ranking, optionally scoped to a category',
domain: 'api.juejin.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'category', type: 'string', required: false, help: `Category slug or numeric id. Slugs: ${Object.keys(CATEGORY_ALIASES).join(', ')}. Defaults to "backend".` },
{ name: 'limit', type: 'int', default: 20, help: 'Max articles (1-50).' },
],
columns: ['rank', 'article_id', 'title', 'brief', 'views', 'likes', 'comments', 'hot_rank', 'author', 'url'],
func: async (args) => {
const categoryId = resolveCategory(args.category) || CATEGORY_ALIASES.backend;
const limit = requireBoundedInt(args.limit, 20, 50);
const path = `/content_api/v1/content/article_rank?category_id=${encodeURIComponent(categoryId)}&type=hot`;
const payload = await juejinFetch(path, null, 'juejin hot', 'GET');
const data = readDataArray(payload, 'juejin hot');
return data.slice(0, limit).map((row, i) => mapHotItem(row, i + 1));
},
});
+241
View File
@@ -0,0 +1,241 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './recommend.js';
import './hot.js';
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('juejin adapter registry contracts', () => {
it('declares overlapping article_id columns so recommend and hot rows share a shape', () => {
const recommend = getRegistry().get('juejin/recommend');
const hot = getRegistry().get('juejin/hot');
expect(recommend).toBeDefined();
expect(hot).toBeDefined();
expect(recommend.columns).toContain('article_id');
expect(hot.columns).toContain('article_id');
expect(recommend.columns).toContain('url');
expect(hot.columns).toContain('url');
});
it('marks every command as read access on the api.juejin.cn domain', () => {
for (const name of ['recommend', 'hot']) {
const cmd = getRegistry().get(`juejin/${name}`);
expect(cmd, name).toBeDefined();
expect(cmd.access, name).toBe('read');
expect(cmd.domain, name).toBe('api.juejin.cn');
expect(cmd.browser, name).toBe(false);
}
});
});
describe('juejin recommend command', () => {
const command = getRegistry().get('juejin/recommend');
it('returns feed rows whose article_id round-trips into a juejin.cn post URL', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
err_no: 0,
cursor: '20',
has_more: true,
data: [{
item_info: {
article_info: {
article_id: '7650882103059939337',
title: 'Sample Feed Article',
brief_content: 'A short blurb',
view_count: 4236,
digg_count: 7,
comment_count: 3,
},
author_user_info: { user_name: '神奇小汤圆' },
tags: [{ tag_name: '后端' }, { tag_name: 'AI' }],
},
}],
}));
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ limit: 1, cursor: '0' })).resolves.toEqual([{
rank: 1,
article_id: '7650882103059939337',
title: 'Sample Feed Article',
brief: 'A short blurb',
views: 4236,
likes: 7,
comments: 3,
author: '神奇小汤圆',
tags: '后端, AI',
url: 'https://juejin.cn/post/7650882103059939337',
next_cursor: '20',
has_more: 'true',
}]);
const init = fetchMock.mock.calls[0][1];
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toMatchObject({ limit: 1, cursor: '0' });
});
it('rejects invalid arguments before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ limit: 101 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ limit: '1e2' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ limit: ' 1 ' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ limit: '01' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ limit: 1, cursor: '1e2' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ limit: 1, cursor: ' 0 ' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('maps empty feed responses to EmptyResultError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ err_no: 0, data: [] })));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
});
it('fails closed when recommend payload lacks a data array', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ err_no: 0 })));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('fails closed on malformed pagination metadata', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({
err_no: 0,
cursor: '1e2',
data: [{ item_info: { article_info: { article_id: '7650882103059939337', title: 'ok' } } }],
})));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({
err_no: 0,
has_more: 'yes',
data: [{ item_info: { article_info: { article_id: '7650882103059939337', title: 'ok' } } }],
})));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({
err_no: 0,
has_more: true,
data: [{ item_info: { article_info: { article_id: '7650882103059939337', title: 'ok' } } }],
})));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('surfaces Juejin err_no envelopes as CommandExecutionError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ err_no: 2, err_msg: '参数错误' })));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('surfaces HTTP and JSON parser failures as CommandExecutionError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('nope', { status: 502 })));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not-json', {
status: 200,
headers: { 'content-type': 'application/json' },
})));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('fails closed when the API envelope is missing err_no', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ data: [] })));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('fails closed when a recommend row lacks a round-trippable article id', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
err_no: 0,
data: [{ item_info: { article_info: { article_id: 'bad-id', title: 'Bad' } } }],
})));
await expect(command.func({ limit: 1 })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
describe('juejin hot command', () => {
const command = getRegistry().get('juejin/hot');
it('returns ranked rows using the content / content_counter / author envelope', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
err_no: 0,
data: [{
content: {
content_id: '7653666093677314057',
title: 'Hot Article One',
brief: 'Hot blurb',
},
content_counter: { view: 12000, like: 340, comment_count: 50, hot_rank: 9876 },
author: { name: 'SimonKing' },
}],
}));
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ limit: 1 })).resolves.toEqual([{
rank: 1,
article_id: '7653666093677314057',
title: 'Hot Article One',
brief: 'Hot blurb',
views: 12000,
likes: 340,
comments: 50,
hot_rank: 9876,
author: 'SimonKing',
url: 'https://juejin.cn/post/7653666093677314057',
}]);
const url = fetchMock.mock.calls[0][0];
expect(url).toContain('category_id=6809637769959178254');
});
it('resolves a friendly category slug to the matching numeric id', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ err_no: 0, data: [] }));
vi.stubGlobal('fetch', fetchMock);
await command.func({ category: 'ai', limit: 1 }).catch(() => { /* EmptyResult check is not the point here */ });
expect(fetchMock.mock.calls[0][0]).toContain('category_id=6809637773935378440');
});
it('rejects unknown category slugs before fetching', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(command.func({ category: 'nonsense', limit: 1 })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ category: 'backend', limit: '1e2' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ category: 'backend', limit: ' 1 ' })).rejects.toBeInstanceOf(ArgumentError);
await expect(command.func({ category: 'backend', limit: '01' })).rejects.toBeInstanceOf(ArgumentError);
expect(fetchMock).not.toHaveBeenCalled();
});
it('maps empty hot responses to EmptyResultError', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ err_no: 0, data: [] })));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
});
it('fails closed when hot payload lacks a data array', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ err_no: 0, data: null })));
await expect(command.func({ limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
});
it('fails closed when a hot row lacks a round-trippable article id', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
err_no: 0,
data: [{ content: { content_id: '' }, content_counter: {}, author: {} }],
})));
await expect(command.func({ limit: 1 })).rejects.toBeInstanceOf(CommandExecutionError);
});
});
+61
View File
@@ -0,0 +1,61 @@
// juejin recommend: Juejin homepage recommendation feed.
//
// Hits the `recommend_all_feed` endpoint, which mirrors what the Juejin web UI
// renders on the front page; `sort_type` 200 is the default "recommended" mix.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';
import {
juejinFetch,
mapFeedItem,
readDataArray,
requireBoundedInt,
requireCursor,
} from './utils.js';
function readResponseCursor(value) {
if (value == null || value === '') return '';
try {
return requireCursor(value);
}
catch {
throw new CommandExecutionError('juejin recommend returned a malformed cursor');
}
}
cli({
site: 'juejin',
name: 'recommend',
access: 'read',
description: 'Juejin (掘金) homepage recommended article feed',
domain: 'api.juejin.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Max articles (1-100, single page).' },
{ name: 'cursor', type: 'string', default: '0', help: 'Pagination cursor; pass back the previous response\'s next-page cursor to keep scrolling.' },
],
columns: ['rank', 'article_id', 'title', 'brief', 'views', 'likes', 'comments', 'author', 'tags', 'url', 'next_cursor', 'has_more'],
func: async (args) => {
const limit = requireBoundedInt(args.limit, 20, 100);
const cursor = requireCursor(args.cursor);
const payload = await juejinFetch(
'/recommend_api/v1/article/recommend_all_feed',
{ id_type: 2, client_type: 2608, sort_type: 200, limit, cursor },
'juejin recommend',
);
const data = readDataArray(payload, 'juejin recommend');
const nextCursor = readResponseCursor(payload.cursor);
if (payload.has_more != null && typeof payload.has_more !== 'boolean') {
throw new CommandExecutionError('juejin recommend returned a malformed has_more flag');
}
const hasMore = payload.has_more == null ? '' : String(payload.has_more);
if (payload.has_more === true && !nextCursor) {
throw new CommandExecutionError('juejin recommend returned has_more without a next cursor');
}
return data.slice(0, limit).map((row, i) => ({
...mapFeedItem(row, i + 1),
next_cursor: nextCursor,
has_more: hasMore,
}));
},
});
+199
View File
@@ -0,0 +1,199 @@
// Shared helpers for the Juejin (`api.juejin.cn`) adapter.
//
// Juejin is a Chinese developer community (similar to Dev.to). The public
// REST API is unauthenticated; all read endpoints are reachable without a
// browser session. Article URLs round-trip as `https://juejin.cn/post/<id>`.
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
export const JUEJIN_API_BASE = 'https://api.juejin.cn';
export const JUEJIN_POST_URL = 'https://juejin.cn/post';
export const JUEJIN_USER_URL = 'https://juejin.cn/user';
const UA = 'opencli-juejin-adapter (+https://github.com/jackwener/opencli)';
// Juejin content / article IDs are 19-digit numeric strings.
const JUEJIN_ID = /^\d{16,20}$/;
// Top-level categories surfaced by `query_category_briefs`. The slugs are
// stable so the adapter accepts a friendly name in addition to the raw id.
export const CATEGORY_ALIASES = {
backend: '6809637769959178254',
frontend: '6809637767543259144',
android: '6809635626879549454',
ios: '6809635626661445640',
ai: '6809637773935378440',
};
export function requireString(value, label) {
const s = String(value ?? '').trim();
if (!s) {
throw new ArgumentError(`juejin ${label} cannot be empty`);
}
return s;
}
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
const raw = value ?? defaultValue;
let n;
if (typeof raw === 'number') {
n = raw;
}
else if (typeof raw === 'string' && /^[1-9]\d*$/.test(raw)) {
n = Number(raw);
}
else {
throw new ArgumentError(`juejin ${label} must be a positive decimal integer`);
}
if (!Number.isSafeInteger(n) || n <= 0) {
throw new ArgumentError(`juejin ${label} must be a positive integer`);
}
if (n > maxValue) {
throw new ArgumentError(`juejin ${label} must be <= ${maxValue}`);
}
return n;
}
export function requireCursor(value) {
const raw = value ?? '0';
if (typeof raw === 'number') {
if (Number.isSafeInteger(raw) && raw >= 0) return String(raw);
throw new ArgumentError('juejin cursor must be a non-negative decimal integer');
}
if (typeof raw === 'string' && /^(0|[1-9]\d*)$/.test(raw)) {
return raw;
}
throw new ArgumentError('juejin cursor must be a non-negative decimal integer');
}
/** Resolve a `--category` arg to the underlying numeric category id. */
export function resolveCategory(value) {
if (value == null) return '';
const raw = String(value).trim();
if (!raw) return '';
if (JUEJIN_ID.test(raw)) return raw;
const slug = raw.toLowerCase();
if (CATEGORY_ALIASES[slug]) return CATEGORY_ALIASES[slug];
throw new ArgumentError(
`juejin category "${value}" is not recognised`,
`Use a category id (e.g. "${CATEGORY_ALIASES.backend}") or one of: ${Object.keys(CATEGORY_ALIASES).join(', ')}.`,
);
}
/**
* POST JSON to a Juejin endpoint. The API returns `{ err_no, err_msg, data }`;
* a non-zero `err_no` is surfaced as a typed `CommandExecutionError`.
*/
export async function juejinFetch(path, body, label, method = 'POST') {
const url = `${JUEJIN_API_BASE}${path}`;
let resp;
try {
const init = {
method,
headers: { 'user-agent': UA, accept: 'application/json' },
};
if (method === 'POST') {
init.headers['content-type'] = 'application/json';
init.body = JSON.stringify(body ?? {});
}
resp = await fetch(url, init);
} catch (err) {
throw new CommandExecutionError(
`${label} request failed: ${err?.message ?? err}`,
'Check that api.juejin.cn is reachable from this network.',
);
}
if (resp.status === 429) {
throw new CommandExecutionError(
`${label} returned HTTP 429 (rate limited)`,
'Juejin throttles bursty traffic; wait a few seconds and retry.',
);
}
if (!resp.ok) {
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
}
let payload;
try {
payload = await resp.json();
} catch (err) {
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Object.hasOwn(payload, 'err_no')) {
throw new CommandExecutionError(`${label} returned a malformed API envelope`);
}
if (payload.err_no !== 0) {
throw new CommandExecutionError(`${label} returned err_no ${payload.err_no}: ${payload.err_msg ?? ''}`);
}
return payload;
}
export function readDataArray(payload, label) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Object.hasOwn(payload, 'data')) {
throw new CommandExecutionError(`${label} returned a malformed payload`);
}
if (!Array.isArray(payload.data)) {
throw new CommandExecutionError(`${label} returned a non-array data field`);
}
if (payload.data.length === 0) {
throw new EmptyResultError(label, `${label} returned no articles.`);
}
return payload.data;
}
function readArticleId(value, label) {
const id = String(value ?? '').trim();
if (!JUEJIN_ID.test(id)) {
throw new CommandExecutionError(`${label} returned a malformed article id`);
}
return id;
}
function readOptionalNumber(value, label) {
if (value == null) return null;
const n = Number(value);
if (!Number.isFinite(n)) {
throw new CommandExecutionError(`${label} returned a malformed numeric field`);
}
return n;
}
/** Map a recommend-feed row (`item_info.article_info` / `author_user_info`) to a flat shape. */
export function mapFeedItem(row, rank) {
const info = row?.item_info ?? {};
const article = info.article_info ?? {};
const author = info.author_user_info ?? {};
const tags = Array.isArray(info.tags)
? info.tags.map(t => t?.tag_name).filter(Boolean).slice(0, 6).join(', ')
: '';
const articleId = readArticleId(article.article_id, 'juejin recommend');
return {
rank,
article_id: articleId,
title: String(article.title ?? '').trim(),
brief: String(article.brief_content ?? '').trim(),
views: readOptionalNumber(article.view_count, 'juejin recommend'),
likes: readOptionalNumber(article.digg_count, 'juejin recommend'),
comments: readOptionalNumber(article.comment_count, 'juejin recommend'),
author: String(author.user_name ?? '').trim(),
tags,
url: articleId ? `${JUEJIN_POST_URL}/${articleId}` : '',
};
}
/** Map a hot-list row (different envelope from the feed endpoint) to the same flat shape. */
export function mapHotItem(row, rank) {
const content = row?.content ?? {};
const counter = row?.content_counter ?? {};
const author = row?.author ?? {};
const articleId = readArticleId(content.content_id, 'juejin hot');
return {
rank,
article_id: articleId,
title: String(content.title ?? '').trim(),
brief: String(content.brief ?? '').trim(),
views: readOptionalNumber(counter.view, 'juejin hot'),
likes: readOptionalNumber(counter.like, 'juejin hot'),
comments: readOptionalNumber(counter.comment_count, 'juejin hot'),
hot_rank: readOptionalNumber(counter.hot_rank, 'juejin hot'),
author: String(author.name ?? '').trim(),
url: articleId ? `${JUEJIN_POST_URL}/${articleId}` : '',
};
}
+1
View File
@@ -125,6 +125,7 @@ export default defineConfig({
items: [
{ text: 'HackerNews', link: '/adapters/browser/hackernews' },
{ text: 'Dev.to', link: '/adapters/browser/devto' },
{ text: 'Juejin', link: '/adapters/browser/juejin' },
{ text: 'Dictionary', link: '/adapters/browser/dictionary' },
{ text: 'BBC', link: '/adapters/browser/bbc' },
{ text: 'Apple Podcasts', link: '/adapters/browser/apple-podcasts' },
+55
View File
@@ -0,0 +1,55 @@
# Juejin
**Mode**: 🌐 Public · **Domain**: `api.juejin.cn`
## Commands
| Command | Description |
|---------|-------------|
| `opencli juejin recommend` | Juejin (掘金) homepage recommended article feed |
| `opencli juejin hot` | Juejin (掘金) hot article ranking, optionally scoped to a category |
## Usage Examples
```bash
# Front-page recommendation feed
opencli juejin recommend --limit 10
# Paginate the feed with the next-page cursor returned in the previous batch
opencli juejin recommend --cursor "1718900000000000000" --limit 10
# Hot ranking, default backend category
opencli juejin hot --limit 20
# Hot ranking scoped to AI / frontend
opencli juejin hot --category ai --limit 10
opencli juejin hot --category frontend --limit 10
# Hot ranking by raw category id (the API returned id)
opencli juejin hot --category 6809637773935378440 --limit 5
# JSON output
opencli juejin hot -f json
```
### `recommend` Options
| Option | Description |
|--------|-------------|
| `--limit` | Max articles (1-100, default 20) |
| `--cursor` | Pagination cursor; pass back the previous response's cursor to keep scrolling (default "0") |
Returns rows with `rank, article_id, title, brief, views, likes, comments, author, tags, url, next_cursor, has_more`. The `article_id` round-trips into `https://juejin.cn/post/<id>`. Use `next_cursor` as the next `--cursor` value when `has_more` is `true`.
### `hot` Options
| Option | Description |
|--------|-------------|
| `--category` | Category slug or numeric id. Slugs: `backend`, `frontend`, `android`, `ios`, `ai` (default backend) |
| `--limit` | Max articles (1-50, default 20) |
Returns rows with `rank, article_id, title, brief, views, likes, comments, hot_rank, author, url`.
## Prerequisites
- No browser required; uses public Juejin API endpoints.
+1
View File
@@ -101,6 +101,7 @@ Run `opencli list` for the live registry.
| **[hackernews](./browser/hackernews.md)** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | 🌐 Public |
| **[bbc](./browser/bbc.md)** | `news` | 🌐 Public |
| **[devto](./browser/devto.md)** | `top` `tag` `user` | 🌐 Public |
| **[juejin](./browser/juejin.md)** | `recommend` `hot` | 🌐 Public |
| **[dictionary](./browser/dictionary.md)** | `search` `synonyms` `examples` | 🌐 Public |
| **[apple-podcasts](./browser/apple-podcasts.md)** | `search` `episodes` `top` | 🌐 Public |
| **[xiaoyuzhou](./browser/xiaoyuzhou.md)** | `podcast` `podcast-episodes` `episode` `download` `transcript` (local credentials required) | 🔑 Local API |