Compare commits

...

4 Commits

Author SHA1 Message Date
jackwener fa521b133f docs: sync bilibili adapter contract for feed-detail 2026-04-12 17:12:50 +08:00
harveyyue e0545a9895 docs: add feed-detail command to bilibili docs 2026-04-12 11:46:15 +08:00
harveyyue ade9d8e419 docs: add binance adapter documentation 2026-04-12 10:47:54 +08:00
harveyyue cb9daf7c50 feat(bilibili): add feed-detail and enhance feed command 2026-04-12 10:23:51 +08:00
3 changed files with 223 additions and 50 deletions
+202 -48
View File
@@ -1,64 +1,218 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { apiGet, payloadData, stripHtml } from './utils.js';
import { apiGet, payloadData, resolveUid, stripHtml } from './utils.js';
/** Map bilibili dynamic type to readable short name */
const TYPE_MAP = {
DYNAMIC_TYPE_AV: 'video',
DYNAMIC_TYPE_DRAW: 'draw',
DYNAMIC_TYPE_ARTICLE: 'article',
DYNAMIC_TYPE_FORWARD: 'forward',
DYNAMIC_TYPE_WORD: 'text',
DYNAMIC_TYPE_LIVE_RCMD: 'live',
DYNAMIC_TYPE_PGC: 'bangumi',
};
function parseItem(item) {
const modules = item.modules ?? {};
const authorModule = modules.module_author ?? {};
const dynamicModule = modules.module_dynamic ?? {};
const major = dynamicModule.major ?? {};
const stat = modules.module_stat ?? {};
let title = '';
let url = item.id_str ? `https://t.bilibili.com/${item.id_str}` : '';
const itemType = TYPE_MAP[item.type] ?? item.type ?? '';
// video
if (major.archive) {
title = major.archive.title ?? '';
url = major.archive.jump_url ? `https:${major.archive.jump_url}` : url;
}
// article
if (!title && major.article) {
title = major.article.title ?? '';
url = major.article.jump_url ? `https:${major.article.jump_url}` : url;
}
// text content in desc
if (!title && dynamicModule.desc?.text) {
title = stripHtml(dynamicModule.desc.text).slice(0, 60);
}
// draw (图文) — use opus or draw items count as hint
if (!title && major.draw) {
const imgCount = major.draw.items?.length ?? 0;
title = imgCount > 0 ? `[图片x${imgCount}]` : '[图文动态]';
}
// VIP only content
if (!title && item.basic?.is_only_fans) {
title = '[充电专属]';
}
// forward
if (!title && item.type === 'DYNAMIC_TYPE_FORWARD') {
title = '[转发动态]';
}
// final fallback
if (!title) {
title = `[${itemType || '动态'}]`;
}
const time = authorModule.pub_time ?? '';
const likes = stat.like?.count ?? 0;
const comments = stat.comment?.count ?? 0;
return { title, url, itemType, author: authorModule.name ?? '', time, likes, comments };
}
cli({
site: 'bilibili',
name: 'feed',
description: '关注的人的动态时间线',
description: '动态时间线(不传 uid 查关注时间线,传 uid 查指定用户动态)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
{ name: 'type', default: 'all', help: 'Filter: all, video, article' },
{ name: 'uid', positional: true, required: false, help: '用户 UID 或用户名(不传则显示关注时间线)' },
{ name: 'limit', type: 'int', default: 20, help: 'Max results to return' },
{ name: 'type', default: 'all', help: 'Filter: all, video, article, draw, text' },
{ name: 'pages', type: 'int', default: 1, help: 'Number of pages to fetch (each ~20 items)' },
],
columns: ['rank', 'author', 'title', 'type', 'url'],
columns: ['rank', 'time', 'author', 'title', 'type', 'likes', 'url'],
func: async (page, kwargs) => {
const { limit = 20, type = 'all' } = kwargs;
const typeMap = { all: 'all', video: 'video', article: 'article' };
const updateBaseline = '';
const payload = await apiGet(page, '/x/polymer/web-dynamic/v1/feed/all', {
params: {
timezone_offset: -480,
type: typeMap[type] ?? 'all',
page: 1,
...(updateBaseline ? { update_baseline: updateBaseline } : {}),
},
});
const items = payloadData(payload)?.items ?? [];
const maxResults = Number(kwargs.limit) || 20;
const maxPages = Number(kwargs.pages) || 1;
const filterType = kwargs.type === 'all' ? '' : (kwargs.type ?? '');
const isUserFeed = !!kwargs.uid;
const uid = isUserFeed ? await resolveUid(page, String(kwargs.uid)) : null;
const rows = [];
for (let i = 0; i < Math.min(items.length, Number(limit)); i++) {
const item = items[i];
const modules = item.modules ?? {};
const authorModule = modules.module_author ?? {};
const dynamicModule = modules.module_dynamic ?? {};
const major = dynamicModule.major ?? {};
let title = '';
let url = '';
let itemType = item.type ?? '';
if (major.archive) {
title = major.archive.title ?? '';
url = major.archive.jump_url ? `https:${major.archive.jump_url}` : '';
itemType = 'video';
let offset = '';
for (let p = 0; p < maxPages; p++) {
if (rows.length >= maxResults) break;
let payload;
if (isUserFeed) {
const params = { host_mid: uid, timezone_offset: -480 };
if (offset) params.offset = offset;
payload = await apiGet(page, '/x/polymer/web-dynamic/v1/feed/space', { params });
} else {
const params = {
timezone_offset: -480,
type: filterType || 'all',
page: p + 1,
};
if (offset) params.offset = offset;
payload = await apiGet(page, '/x/polymer/web-dynamic/v1/feed/all', { params });
}
else if (major.article) {
title = major.article.title ?? '';
url = major.article.jump_url ? `https:${major.article.jump_url}` : '';
itemType = 'article';
const data = payloadData(payload) ?? {};
const items = data.items ?? [];
if (items.length === 0) break;
for (const item of items) {
if (rows.length >= maxResults) break;
const parsed = parseItem(item);
if (filterType && parsed.itemType !== filterType) continue;
rows.push({
rank: rows.length + 1,
time: parsed.time,
author: parsed.author,
title: parsed.title,
type: parsed.itemType,
likes: parsed.likes,
url: parsed.url,
});
}
else if (dynamicModule.desc) {
title = stripHtml(dynamicModule.desc.text ?? '').slice(0, 60);
url = item.id_str ? `https://t.bilibili.com/${item.id_str}` : '';
itemType = 'dynamic';
}
if (!title)
continue;
rows.push({
rank: rows.length + 1,
author: authorModule.name ?? '',
title,
type: itemType,
url,
});
offset = data.offset ?? items[items.length - 1]?.id_str ?? '';
if (!offset || !data.has_more) break;
}
return rows;
},
});
cli({
site: 'bilibili',
name: 'feed-detail',
description: '查看 Bilibili 动态详情(支持充电专属内容)',
domain: 'www.bilibili.com',
strategy: Strategy.COOKIE,
args: [
{ name: 'id', positional: true, required: true, help: '动态 ID(从 feed 命令的 url 中获取)' },
],
columns: ['field', 'value'],
func: async (page, kwargs) => {
const id = String(kwargs.id);
const payload = await apiGet(page, '/x/polymer/web-dynamic/v1/detail', {
params: { id, timezone_offset: -480 },
});
const rows = [];
const data = payloadData(payload);
const item = data?.item;
if (!item) {
rows.push({ field: 'error', value: '动态不存在或无权查看'});
return rows;
}
const modules = item.modules ?? {};
const author = modules.module_author ?? {};
const dynamicModule = modules.module_dynamic ?? {};
const major = dynamicModule.major ?? {};
const stat = modules.module_stat ?? {};
rows.push({ field: 'id', value: item.id_str ?? id });
rows.push({ field: 'author', value: author.name ?? '' });
rows.push({ field: 'time', value: author.pub_time ?? '' });
rows.push({ field: 'type', value: TYPE_MAP[item.type] ?? item.type ?? '' });
// text content
if (dynamicModule.desc?.text) {
rows.push({ field: 'text', value: stripHtml(dynamicModule.desc.text) });
}
// video
if (major.archive) {
rows.push({ field: 'video_title', value: major.archive.title ?? '' });
rows.push({ field: 'video_desc', value: major.archive.desc ?? '' });
rows.push({ field: 'video_url', value: major.archive.jump_url ? `https:${major.archive.jump_url}` : '' });
rows.push({ field: 'play', value: String(major.archive.stat?.play ?? '') });
rows.push({ field: 'danmaku', value: String(major.archive.stat?.danmaku ?? '') });
}
// article
if (major.article) {
rows.push({ field: 'article_title', value: major.article.title ?? '' });
rows.push({ field: 'article_url', value: major.article.jump_url ? `https:${major.article.jump_url}` : '' });
}
// draw (images)
if (major.draw?.items?.length) {
rows.push({ field: 'images', value: major.draw.items.map((img) => img.src).join('\n') });
}
// opus (rich text, some dynamics use this)
if (major.opus?.summary?.text) {
rows.push({ field: 'opus_text', value: stripHtml(major.opus.summary.text) });
}
if (major.opus?.title) {
rows.push({ field: 'opus_title', value: major.opus.title });
}
// forward - show original dynamic info
if (item.orig) {
const origAuthor = item.orig.modules?.module_author?.name ?? '';
const origDesc = item.orig.modules?.module_dynamic?.desc?.text ?? '';
rows.push({ field: 'forward_from', value: origAuthor });
if (origDesc) rows.push({ field: 'forward_text', value: stripHtml(origDesc).slice(0, 200) });
}
// stats
rows.push({ field: 'likes', value: String(stat.like?.count ?? 0) });
rows.push({ field: 'comments', value: String(stat.comment?.count ?? 0) });
rows.push({ field: 'forwards', value: String(stat.forward?.count ?? 0) });
rows.push({ field: 'url', value: `https://t.bilibili.com/${item.id_str ?? id}` });
return rows;
},
});
+20 -1
View File
@@ -11,7 +11,8 @@
| `opencli bilibili me` | |
| `opencli bilibili favorite` | |
| `opencli bilibili history` | |
| `opencli bilibili feed` | |
| `opencli bilibili feed` | Read the following feed, or a specific user's dynamics by uid/name |
| `opencli bilibili feed-detail` | Read one dynamic in detail, including exclusive content |
| `opencli bilibili subtitle` | |
| `opencli bilibili dynamic` | |
| `opencli bilibili ranking` | |
@@ -31,6 +32,18 @@ opencli bilibili search 黑神话 --limit 10
# Read one creator's videos
opencli bilibili user-videos 2 --limit 10
# Read following feed
opencli bilibili feed --limit 10
# Read one user's dynamics by UID
opencli bilibili feed 2 --limit 10
# Read one user's dynamics by username and paginate
opencli bilibili feed 老番茄 --pages 2 --type video
# Read one dynamic in detail
opencli bilibili feed-detail 1234567890123456789
# Fetch subtitles
opencli bilibili subtitle BV1xx411c7mD --lang zh-CN
@@ -45,3 +58,9 @@ opencli bilibili hot -v
- Chrome running and **logged into** bilibili.com
- [Browser Bridge extension](/guide/browser-bridge) installed
## Notes
- `opencli bilibili feed` without `uid` reads your following feed
- `opencli bilibili feed <uid-or-name>` reads a specific user's dynamics
- `feed-detail` expects the dynamic ID from a `https://t.bilibili.com/<id>` URL
+1 -1
View File
@@ -10,7 +10,7 @@ Run `opencli list` for the live registry.
| **[reddit](./browser/reddit.md)** | `hot` `frontpage` `popular` `search` `subreddit` `read` `user` `user-posts` `user-comments` `upvote` `save` `comment` `subscribe` `saved` `upvoted` | 🔐 Browser |
| **[tieba](./browser/tieba.md)** | `hot` `posts` `search` `read` | 🔐 Browser |
| **[hupu](./browser/hupu.md)** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 🌐 / 🔐 |
| **[bilibili](./browser/bilibili.md)** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
| **[bilibili](./browser/bilibili.md)** | `hot` `search` `me` `favorite` `history` `feed` `feed-detail` `subtitle` `dynamic` `ranking` `following` `user-videos` `download` | 🔐 Browser |
| **[zhihu](./browser/zhihu.md)** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | 🔐 Browser |
| **[xiaohongshu](./browser/xiaohongshu.md)** | `search` `notifications` `feed` `user` `note` `comments` `download` `publish` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser |
| **[xiaoe](./browser/xiaoe.md)** | `courses` `detail` `catalog` `play-url` `content` | 🔐 Browser |