From 74487d685610164e0076976a861f7a7b1ccd9d9a Mon Sep 17 00:00:00 2001 From: yufo Date: Mon, 3 Aug 2026 17:03:02 +0800 Subject: [PATCH] feat(bilibili): add creator analytics adapter --- README.md | 2 +- README.zh-CN.md | 2 +- cli-manifest.json | 29 ++++ clis/bilibili/creator-stats.js | 197 ++++++++++++++++++++++++++++ clis/bilibili/creator-stats.test.js | 161 +++++++++++++++++++++++ docs/adapters/browser/bilibili.md | 6 + docs/adapters/index.md | 2 +- 7 files changed, 396 insertions(+), 3 deletions(-) create mode 100644 clis/bilibili/creator-stats.js create mode 100644 clis/bilibili/creator-stats.test.js diff --git a/README.md b/README.md index 21a2e0878..028734fe1 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ When the site you need is not yet covered, use the `opencli-adapter-author` skil | Site | Commands | |------|----------| | **xiaohongshu** | `search` `ask` `note` `comments` `feed` `user` `download` `publish` `follow` `unfollow` `notifications` `creator-notes` `creator-notes-summary` `creator-note-detail` `creator-profile` `creator-stats` | -| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `follow` `unfollow` `me` `subtitle` `summary` `video` `user-videos` | +| **bilibili** | `hot` `search` `history` `feed` `ranking` `download` `comments` `dynamic` `favorite` `following` `follow` `unfollow` `me` `subtitle` `summary` `video` `user-videos` `creator-stats` | | **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | | **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | | **hltv** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 2ee6986b8..af8033445 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -174,7 +174,7 @@ Browser Bridge daemon 与扩展的通信端口固定为 `localhost:19825`,不 | 站点 | 命令 | |------|------| | **xiaohongshu** | `search` `ask` `note` `comments` `notifications` `feed` `user` `saved` `liked` `download` `publish` `follow` `unfollow` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | -| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `follow` `unfollow` `user-videos` `download` | +| **bilibili** | `hot` `search` `me` `favorite` `history` `feed` `subtitle` `summary` `video` `comments` `dynamic` `ranking` `following` `follow` `unfollow` `user-videos` `download` `creator-stats` | | **zhihu** | `hot` `search` `question` `download` `follow` `like` `favorite` `comment` `answer` | | **hackernews** | `top` `new` `best` `ask` `show` `jobs` `search` `user` | | **hltv** | `search` `player-summary` `player-matches` `player-form` `player-map-pool` `player-vs-team` `player-teammate-impact` `player-duel` `match-map` `match-series` `team-matches` `team-map-pool` `event-matches` | diff --git a/cli-manifest.json b/cli-manifest.json index 41a3d1c95..cf8a5e214 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -3574,6 +3574,35 @@ "sourceFile": "bilibili/comments.js", "navigateBefore": "https://www.bilibili.com" }, + { + "site": "bilibili", + "name": "creator-stats", + "description": "读取本人稿件的创作诊断、转粉和留存原始指标(需登录创作中心)", + "access": "read", + "example": "opencli bilibili creator-stats -f json", + "domain": "member.bilibili.com", + "strategy": "cookie", + "browser": true, + "args": [ + { + "name": "bvid", + "type": "string", + "required": true, + "positional": true, + "help": "本人稿件 BV ID" + } + ], + "columns": [ + "source", + "metric", + "value", + "unit" + ], + "type": "js", + "modulePath": "bilibili/creator-stats.js", + "sourceFile": "bilibili/creator-stats.js", + "navigateBefore": "https://member.bilibili.com/platform/home" + }, { "site": "bilibili", "name": "download", diff --git a/clis/bilibili/creator-stats.js b/clis/bilibili/creator-stats.js new file mode 100644 index 000000000..1d3bc2f5d --- /dev/null +++ b/clis/bilibili/creator-stats.js @@ -0,0 +1,197 @@ +/** + * Bilibili creator-only manuscript analytics. + * + * Contract note: these are undocumented creator-center endpoints. The registry + * uses Strategy.COOKIE to acquire a logged-in browser session; requests use the + * supported page.fetchJson() primitive and preserve metric paths plus Bilibili's + * raw platform scale so downstream consumers do not mistake basis points or + * internal scores for percentages. + */ +import { cli, Strategy } from '@jackwener/opencli/registry'; +import { + ArgumentError, + AuthRequiredError, + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; + +const MEMBER_ORIGIN = 'https://member.bilibili.com'; +const API_ORIGIN = 'https://api.bilibili.com'; + +function isRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function parseBvid(value) { + const raw = String(value ?? '').trim(); + if (!/^BV[0-9A-Za-z]{10}$/i.test(raw)) { + throw new ArgumentError('bvid must be a 12-character BV ID, for example BV1xx411c7mD'); + } + return `BV${raw.slice(2)}`; +} + +function isAuthLike(code, message) { + return code === -101 + || code === -111 + || code === -403 + || /登录|账号|权限|forbidden|permission|login|auth/i.test(String(message ?? '')); +} + +function requirePayload(payload, label) { + if (!isRecord(payload) || !Object.hasOwn(payload, 'code')) { + throw new CommandExecutionError(`Bilibili ${label} API returned a malformed envelope`); + } + const message = String(payload.message ?? payload.msg ?? 'unknown error'); + if (payload.code !== 0) { + if (isAuthLike(payload.code, message)) { + throw new AuthRequiredError( + 'member.bilibili.com', + `Bilibili ${label} requires a logged-in creator account with access: ${message} (${payload.code})`, + ); + } + if (payload.code === -404) { + throw new EmptyResultError(`bilibili creator-stats ${label}`, message); + } + throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`); + } + return payload.data; +} + +async function fetchPayload(page, url, label) { + try { + const payload = await page.fetchJson(url); + return requirePayload(payload, label); + } catch (error) { + if ( + error instanceof ArgumentError + || error instanceof AuthRequiredError + || error instanceof EmptyResultError + || error instanceof CommandExecutionError + ) { + throw error; + } + throw new CommandExecutionError( + `Bilibili ${label} request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function normalizePlatformValue(value) { + if (typeof value === 'string' && (value.trim() === '' || value.trim() === '-')) return null; + return value; +} + +function addScalars(rows, source, value, prefix = '') { + if (value === undefined) return; + if (value === null) { + if (prefix) rows.push({ source, metric: prefix, value: null, unit: 'platform_raw' }); + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => addScalars(rows, source, item, `${prefix}[${index}]`)); + return; + } + if (isRecord(value)) { + Object.entries(value).forEach(([key, item]) => { + addScalars(rows, source, item, prefix ? `${prefix}.${key}` : key); + }); + return; + } + if (!prefix) { + throw new CommandExecutionError(`Bilibili ${source} returned a scalar root instead of an object`); + } + rows.push({ source, metric: prefix, value: normalizePlatformValue(value), unit: 'platform_raw' }); +} + +cli({ + site: 'bilibili', + name: 'creator-stats', + description: '读取本人稿件的创作诊断、转粉和留存原始指标(需登录创作中心)', + access: 'read', + example: 'opencli bilibili creator-stats -f json', + domain: 'member.bilibili.com', + strategy: Strategy.COOKIE, + browser: true, + navigateBefore: `${MEMBER_ORIGIN}/platform/home`, + args: [ + { + name: 'bvid', + type: 'string', + required: true, + positional: true, + help: '本人稿件 BV ID', + }, + ], + columns: ['source', 'metric', 'value', 'unit'], + func: async (page, args) => { + const bvid = parseBvid(args.bvid); + + const publicData = await fetchPayload( + page, + `${API_ORIGIN}/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`, + 'video view', + ); + if (!isRecord(publicData)) { + throw new CommandExecutionError('Bilibili video view API returned malformed data'); + } + const cid = publicData.pages?.[0]?.cid; + if (!Number.isSafeInteger(cid) || cid <= 0) { + throw new CommandExecutionError(`Bilibili video view API did not return a valid cid for ${bvid}`); + } + + const compare = await fetchPayload( + page, + `${MEMBER_ORIGIN}/x/web/data/archive_diagnose/compare?bvid=${encodeURIComponent(bvid)}&size=100&tmid=`, + 'creator comparison', + ); + if (!isRecord(compare) || !Array.isArray(compare.list)) { + throw new CommandExecutionError('Bilibili creator comparison API returned malformed list data'); + } + const target = compare.list.find( + (item) => String(item?.bvid ?? '').toUpperCase() === bvid.toUpperCase(), + ); + if (!target) { + throw new AuthRequiredError( + 'member.bilibili.com', + `The logged-in Bilibili creator account does not own or cannot access ${bvid}`, + ); + } + if (!isRecord(target.stat)) { + throw new CommandExecutionError(`Bilibili creator comparison returned malformed stat data for ${bvid}`); + } + if (target.hour_stat != null && !isRecord(target.hour_stat)) { + throw new CommandExecutionError(`Bilibili creator comparison returned malformed hour_stat data for ${bvid}`); + } + + const play = await fetchPayload( + page, + `${MEMBER_ORIGIN}/x/web/data/archive_diagnose/play_analyze?bvid=${encodeURIComponent(bvid)}&tmid=`, + 'play analysis', + ); + if (!isRecord(play)) { + throw new CommandExecutionError('Bilibili play analysis API returned malformed data'); + } + + const graph = await fetchPayload( + page, + `${MEMBER_ORIGIN}/x/web/data/v2/archive/analyze/graph?cid=${encodeURIComponent(cid)}&tmid=`, + 'retention graph', + ); + if (!isRecord(graph)) { + throw new CommandExecutionError('Bilibili retention graph API returned malformed data'); + } + + const rows = []; + addScalars(rows, 'compare.stat', target.stat); + if (target.hour_stat) addScalars(rows, 'compare.hour_stat', target.hour_stat); + addScalars(rows, 'play_analyze', play); + addScalars(rows, 'retention_graph', graph); + if (rows.length === 0) { + throw new EmptyResultError( + `bilibili creator-stats ${bvid}`, + 'Creator analytics are not available yet for this manuscript', + ); + } + return rows; + }, +}); diff --git a/clis/bilibili/creator-stats.test.js b/clis/bilibili/creator-stats.test.js new file mode 100644 index 000000000..ffdfd3266 --- /dev/null +++ b/clis/bilibili/creator-stats.test.js @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ArgumentError, + AuthRequiredError, + CommandExecutionError, + EmptyResultError, +} from '@jackwener/opencli/errors'; +import { getRegistry, Strategy } from '@jackwener/opencli/registry'; +import './creator-stats.js'; + +describe('bilibili creator-stats', () => { + const command = getRegistry().get('bilibili/creator-stats'); + let page; + + beforeEach(() => { + page = { fetchJson: vi.fn() }; + }); + + it('registers as a browser-cookie read command with raw metric columns', () => { + expect(command).toMatchObject({ + strategy: Strategy.COOKIE, + browser: true, + access: 'read', + navigateBefore: 'https://member.bilibili.com/platform/home', + columns: ['source', 'metric', 'value', 'unit'], + }); + }); + + it('fetches creator endpoints and preserves metric paths on the platform raw scale', async () => { + page.fetchJson + .mockResolvedValueOnce({ code: 0, data: { pages: [{ cid: 12345 }] } }) + .mockResolvedValueOnce({ + code: 0, + data: { + list: [{ + bvid: 'BV1xx411c7mD', + stat: { play: 1200, full_play_ratio: 2345 }, + hour_stat: { play: 600 }, + }], + }, + }) + .mockResolvedValueOnce({ + code: 0, + data: { arc_audience: { play_fan_rate: 2500 }, available: true }, + }) + .mockResolvedValueOnce({ + code: 0, + data: { + duration_info: { avg_play_time: '-' }, + viewer_quit: [{ duration_key: 30, num: 1000 }], + }, + }); + + const rows = await command.func(page, { bvid: 'BV1xx411c7mD' }); + + expect(page.fetchJson).toHaveBeenCalledTimes(4); + expect(page.fetchJson).toHaveBeenNthCalledWith( + 1, + 'https://api.bilibili.com/x/web-interface/view?bvid=BV1xx411c7mD', + ); + expect(page.fetchJson).toHaveBeenNthCalledWith( + 2, + 'https://member.bilibili.com/x/web/data/archive_diagnose/compare?bvid=BV1xx411c7mD&size=100&tmid=', + ); + expect(page.fetchJson).toHaveBeenNthCalledWith( + 3, + 'https://member.bilibili.com/x/web/data/archive_diagnose/play_analyze?bvid=BV1xx411c7mD&tmid=', + ); + expect(page.fetchJson).toHaveBeenNthCalledWith( + 4, + 'https://member.bilibili.com/x/web/data/v2/archive/analyze/graph?cid=12345&tmid=', + ); + expect(rows).toContainEqual({ + source: 'compare.stat', + metric: 'full_play_ratio', + value: 2345, + unit: 'platform_raw', + }); + expect(rows).toContainEqual({ + source: 'retention_graph', + metric: 'viewer_quit[0].duration_key', + value: 30, + unit: 'platform_raw', + }); + expect(rows).toContainEqual({ + source: 'retention_graph', + metric: 'duration_info.avg_play_time', + value: null, + unit: 'platform_raw', + }); + expect(rows).toContainEqual({ + source: 'play_analyze', + metric: 'available', + value: true, + unit: 'platform_raw', + }); + }); + + it('rejects malformed BVIDs before any browser request', async () => { + await expect(command.func(page, { bvid: 'not-a-bvid' })).rejects.toBeInstanceOf(ArgumentError); + expect(page.fetchJson).not.toHaveBeenCalled(); + }); + + it('maps login and permission API failures to AuthRequiredError', async () => { + page.fetchJson + .mockResolvedValueOnce({ code: 0, data: { pages: [{ cid: 12345 }] } }) + .mockResolvedValueOnce({ code: -101, message: '账号未登录', data: null }); + + await expect(command.func(page, { bvid: 'BV1xx411c7mD' })) + .rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('fails with AuthRequiredError when the logged-in account does not own the BVID', async () => { + page.fetchJson + .mockResolvedValueOnce({ code: 0, data: { pages: [{ cid: 12345 }] } }) + .mockResolvedValueOnce({ + code: 0, + data: { list: [{ bvid: 'BV1yy411c7mD', stat: {}, hour_stat: {} }] }, + }); + + await expect(command.func(page, { bvid: 'BV1xx411c7mD' })) + .rejects.toBeInstanceOf(AuthRequiredError); + }); + + it('maps a missing public video to EmptyResultError', async () => { + page.fetchJson.mockResolvedValueOnce({ code: -404, message: '啥都木有', data: null }); + + await expect(command.func(page, { bvid: 'BV1xx411c7mD' })) + .rejects.toBeInstanceOf(EmptyResultError); + }); + + it('fails closed on malformed response shapes', async () => { + page.fetchJson + .mockResolvedValueOnce({ code: 0, data: { pages: [{ cid: 12345 }] } }) + .mockResolvedValueOnce({ code: 0, data: { items: [] } }); + + await expect(command.func(page, { bvid: 'BV1xx411c7mD' })) + .rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('wraps fetchJson transport errors as CommandExecutionError', async () => { + page.fetchJson.mockRejectedValueOnce(new Error('browser fetch failed')); + + await expect(command.func(page, { bvid: 'BV1xx411c7mD' })) + .rejects.toBeInstanceOf(CommandExecutionError); + }); + + it('uses EmptyResultError when all creator metric objects are empty', async () => { + page.fetchJson + .mockResolvedValueOnce({ code: 0, data: { pages: [{ cid: 12345 }] } }) + .mockResolvedValueOnce({ + code: 0, + data: { list: [{ bvid: 'BV1xx411c7mD', stat: {}, hour_stat: {} }] }, + }) + .mockResolvedValueOnce({ code: 0, data: {} }) + .mockResolvedValueOnce({ code: 0, data: {} }); + + await expect(command.func(page, { bvid: 'BV1xx411c7mD' })) + .rejects.toBeInstanceOf(EmptyResultError); + }); +}); diff --git a/docs/adapters/browser/bilibili.md b/docs/adapters/browser/bilibili.md index 402167b4a..680f56cdc 100644 --- a/docs/adapters/browser/bilibili.md +++ b/docs/adapters/browser/bilibili.md @@ -25,6 +25,7 @@ | `opencli bilibili unfollow` | Unfollow a user by UID, profile URL, or resolvable name; verifies the relation after modify | | `opencli bilibili user-videos` | | | `opencli bilibili download` | | +| `opencli bilibili creator-stats ` | Read creator-only manuscript diagnostics, conversion, and retention metrics on Bilibili's raw platform scale | ## Usage Examples @@ -67,6 +68,9 @@ opencli bilibili subtitle BV1xx411c7mD --lang zh-CN opencli bilibili video BV1xx411c7mD opencli bilibili video https://www.bilibili.com/video/BV1xx411c7mD/ +# Read raw creator-only analytics for one of your own manuscripts +opencli bilibili creator-stats "$OWNED_BVID" -f json + # Fetch the official AI summary for a video opencli bilibili summary BV1xx411c7mD opencli bilibili summary https://www.bilibili.com/video/BV1xx411c7mD/ @@ -103,3 +107,5 @@ opencli bilibili hot -v - `comment --parent` expects the top-level/root `rpid`; nested reply-to-reply targeting is not inferred - `follow` and `unfollow` are write commands; they no-op when the current relation already matches the requested state and otherwise re-read `/x/relation` after modify before reporting success - `follow` and `unfollow` accept numeric UID, exact `space.bilibili.com/` profile URL, or a name that resolves through Bilibili search +- `creator-stats` only accepts manuscripts owned by the logged-in creator account +- `creator-stats` uses undocumented creator-center endpoints; metric paths and values are intentionally returned on Bilibili's raw platform scale diff --git a/docs/adapters/index.md b/docs/adapters/index.md index 997d676b9..b8d268779 100644 --- a/docs/adapters/index.md +++ b/docs/adapters/index.md @@ -11,7 +11,7 @@ Run `opencli list` for the live registry. | **[tieba](./browser/tieba.md)** | `hot` `posts` `search` `read` | 🔐 Browser | | **[hupu](./browser/hupu.md)** | `hot` `search` `detail` `mentions` `reply` `like` `unlike` | 🌐 / 🔐 | | **[huodongxing](./browser/huodongxing.md)** | `events` | 🌐 Public / Browser | -| **[bilibili](./browser/bilibili.md)** | `hot` `search` `me` `favorite` `history` `feed` `feed-detail` `subtitle` `summary` `video` `dynamic` `ranking` `following` `follow` `unfollow` `user-videos` `download` | 🔐 Browser | +| **[bilibili](./browser/bilibili.md)** | `hot` `search` `me` `favorite` `history` `feed` `feed-detail` `subtitle` `summary` `video` `dynamic` `ranking` `following` `follow` `unfollow` `user-videos` `download` `creator-stats` | 🔐 Browser | | **[zhihu](./browser/zhihu.md)** | `hot` `recommend` `search` `question` `answer-detail` `answer-comments` `download` `follow` `like` `favorite` `comment` `answer` | 🔐 Browser | | **[xiaohongshu](./browser/xiaohongshu.md)** | `search` `ask` `notifications` `feed` `user` `note` `comments` `download` `publish` `follow` `unfollow` `creator-notes` `creator-note-detail` `creator-notes-summary` `creator-profile` `creator-stats` | 🔐 Browser | | **[rednote](./browser/rednote.md)** | `search` `note` `comments` `user` `download` `feed` `notifications` | 🔐 Browser |