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:
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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}` : '',
|
||||
};
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user