Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
29 changes: 29 additions & 0 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owned-bvid> -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",
Expand Down
197 changes: 197 additions & 0 deletions clis/bilibili/creator-stats.js
Original file line number Diff line number Diff line change
@@ -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 <owned-bvid> -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;
},
});
Loading