dsh-shlr-bilitoolkit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -3
- package/cordis.patch.yml +13 -0
- package/dist/apis/article.d.ts +12 -0
- package/dist/apis/article.js +49 -0
- package/dist/apis/auth.d.ts +20 -0
- package/dist/apis/auth.js +83 -0
- package/dist/apis/comment.d.ts +37 -0
- package/dist/apis/comment.js +164 -0
- package/dist/apis/download.d.ts +8 -0
- package/dist/apis/download.js +60 -0
- package/dist/apis/dynamic.d.ts +23 -0
- package/dist/apis/dynamic.js +154 -0
- package/dist/apis/interact.d.ts +29 -0
- package/dist/apis/interact.js +224 -0
- package/dist/apis/message.d.ts +18 -0
- package/dist/apis/message.js +109 -0
- package/dist/apis/notify.d.ts +17 -0
- package/dist/apis/notify.js +54 -0
- package/dist/apis/profile.d.ts +15 -0
- package/dist/apis/profile.js +59 -0
- package/dist/apis/relation.d.ts +13 -0
- package/dist/apis/relation.js +47 -0
- package/dist/apis/search.d.ts +17 -0
- package/dist/apis/search.js +81 -0
- package/dist/apis/space.d.ts +17 -0
- package/dist/apis/space.js +89 -0
- package/dist/apis/video.d.ts +18 -0
- package/dist/apis/video.js +153 -0
- package/dist/cache.d.ts +18 -0
- package/dist/cache.js +75 -0
- package/dist/client.d.ts +85 -0
- package/dist/client.js +325 -0
- package/dist/dsh.d.ts +25 -0
- package/dist/dsh.js +484 -0
- package/dist/endpoints.d.ts +55 -0
- package/dist/endpoints.js +69 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +50 -0
- package/dist/formatting.d.ts +7 -0
- package/dist/formatting.js +69 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +21 -0
- package/dist/models.d.ts +215 -0
- package/dist/models.js +876 -0
- package/dist/pagination.d.ts +7 -0
- package/dist/pagination.js +20 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.js +4 -0
- package/dist/wbi.d.ts +21 -0
- package/dist/wbi.js +105 -0
- package/package.json +52 -6
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** 关注列表、粉丝列表与关系统计。 */
|
|
2
|
+
import type { BiliClient } from "../client";
|
|
3
|
+
import { RelationUser } from "../models";
|
|
4
|
+
export declare class RelationAPI {
|
|
5
|
+
private client;
|
|
6
|
+
constructor(client: BiliClient);
|
|
7
|
+
stat(uid: number): Promise<Record<string, unknown>>;
|
|
8
|
+
followings(uid: number, page?: number, pageSize?: number, order?: string, orderType?: string): Promise<[RelationUser[], number]>;
|
|
9
|
+
followers(uid: number, page?: number, pageSize?: number, order?: string): Promise<[RelationUser[], number]>;
|
|
10
|
+
iterFollowings(uid: number, pageSize?: number, order?: string, maxPages?: number): AsyncGenerator<RelationUser, any, any>;
|
|
11
|
+
iterFollowers(uid: number, pageSize?: number, order?: string, maxPages?: number): AsyncGenerator<RelationUser, any, any>;
|
|
12
|
+
private static parse;
|
|
13
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** 关注列表、粉丝列表与关系统计。 */
|
|
2
|
+
import * as endpoints from "../endpoints";
|
|
3
|
+
import { RelationUser } from "../models";
|
|
4
|
+
import { iterPages } from "../pagination";
|
|
5
|
+
export class RelationAPI {
|
|
6
|
+
client;
|
|
7
|
+
constructor(client) {
|
|
8
|
+
this.client = client;
|
|
9
|
+
}
|
|
10
|
+
async stat(uid) {
|
|
11
|
+
const payload = await this.client.get(endpoints.RELATION_STAT, {
|
|
12
|
+
vmid: Number(uid),
|
|
13
|
+
});
|
|
14
|
+
return payload?.data ?? {};
|
|
15
|
+
}
|
|
16
|
+
async followings(uid, page = 1, pageSize = 20, order = "desc", orderType = "attention") {
|
|
17
|
+
const payload = await this.client.get(endpoints.RELATION_FOLLOWINGS, {
|
|
18
|
+
vmid: Number(uid),
|
|
19
|
+
pn: page,
|
|
20
|
+
ps: pageSize,
|
|
21
|
+
order,
|
|
22
|
+
order_type: orderType,
|
|
23
|
+
}, { Referer: `https://space.bilibili.com/${Number(uid)}/relation/follow` });
|
|
24
|
+
return RelationAPI.parse(payload?.data ?? {});
|
|
25
|
+
}
|
|
26
|
+
async followers(uid, page = 1, pageSize = 20, order = "desc") {
|
|
27
|
+
const payload = await this.client.get(endpoints.RELATION_FOLLOWERS, { vmid: Number(uid), pn: page, ps: pageSize, order }, { Referer: `https://space.bilibili.com/${Number(uid)}/relation/fans` });
|
|
28
|
+
return RelationAPI.parse(payload?.data ?? {});
|
|
29
|
+
}
|
|
30
|
+
iterFollowings(uid, pageSize = 50, order = "desc", maxPages) {
|
|
31
|
+
const fetch = (page) => this.followings(uid, page, pageSize, order);
|
|
32
|
+
return iterPages(fetch, maxPages);
|
|
33
|
+
}
|
|
34
|
+
iterFollowers(uid, pageSize = 50, order = "desc", maxPages) {
|
|
35
|
+
const fetch = (page) => this.followers(uid, page, pageSize, order);
|
|
36
|
+
return iterPages(fetch, maxPages);
|
|
37
|
+
}
|
|
38
|
+
static parse(data) {
|
|
39
|
+
const users = [];
|
|
40
|
+
for (const item of data?.list ?? []) {
|
|
41
|
+
const u = RelationUser.fromApi(item);
|
|
42
|
+
if (u)
|
|
43
|
+
users.push(u);
|
|
44
|
+
}
|
|
45
|
+
return [users, Number(data.total ?? users.length)];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** 搜索接口:视频、用户。
|
|
2
|
+
|
|
3
|
+
搜索走 WBI 签名接口,并带搜索页 Referer。B 站会对搜索做风控,
|
|
4
|
+
这里把网页端常见的 dm_img_* 环境参数一并带上,降低被 -412 拦截的概率。
|
|
5
|
+
*/
|
|
6
|
+
import type { BiliClient } from "../client";
|
|
7
|
+
import { User, Video } from "../models";
|
|
8
|
+
export declare const VIDEO_ORDERS: readonly ["totalrank", "click", "pubdate", "dm", "stow", "scores"];
|
|
9
|
+
export declare class SearchAPI {
|
|
10
|
+
private client;
|
|
11
|
+
constructor(client: BiliClient);
|
|
12
|
+
private _search;
|
|
13
|
+
videos(keyword: string, page?: number, pageSize?: number, order?: string): Promise<[Video[], number]>;
|
|
14
|
+
users(keyword: string, page?: number, pageSize?: number): Promise<[User[], number]>;
|
|
15
|
+
iterVideos(keyword: string, pageSize?: number, order?: string, maxPages?: number): AsyncGenerator<Video, any, any>;
|
|
16
|
+
iterUsers(keyword: string, pageSize?: number, maxPages?: number): AsyncGenerator<User, any, any>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** 搜索接口:视频、用户。
|
|
2
|
+
|
|
3
|
+
搜索走 WBI 签名接口,并带搜索页 Referer。B 站会对搜索做风控,
|
|
4
|
+
这里把网页端常见的 dm_img_* 环境参数一并带上,降低被 -412 拦截的概率。
|
|
5
|
+
*/
|
|
6
|
+
import * as endpoints from "../endpoints";
|
|
7
|
+
import { User, Video } from "../models";
|
|
8
|
+
import { iterPages } from "../pagination";
|
|
9
|
+
// 视频搜索支持的排序
|
|
10
|
+
export const VIDEO_ORDERS = [
|
|
11
|
+
"totalrank",
|
|
12
|
+
"click",
|
|
13
|
+
"pubdate",
|
|
14
|
+
"dm",
|
|
15
|
+
"stow",
|
|
16
|
+
"scores",
|
|
17
|
+
];
|
|
18
|
+
// 搜索接口会校验的浏览器环境参数,值为网页端常见形态
|
|
19
|
+
const FINGERPRINT = {
|
|
20
|
+
dm_img_list: "[]",
|
|
21
|
+
dm_img_str: "V2ViR0wgMS4wIChPcGVuR0wgRVMgMi4wIENocm9taXVtKQ",
|
|
22
|
+
dm_cover_img_str: "QU5HTEUgKEludGVsLCBJbnRlbChSKSBVSEQgR3JhcGhpY3MgNjMwICgweDAwMDAzRTlCKSBEaXJlY3QzRDExIHZzXzVfMCBwc181XzApLCBvciBzaW1pbGFy",
|
|
23
|
+
dm_img_inter: '{"ds":[],"wh":[0,0,0],"of":[0,0,0]}',
|
|
24
|
+
};
|
|
25
|
+
function totalOf(data, fallback) {
|
|
26
|
+
const n = parseInt(data?.numResults ?? "", 10);
|
|
27
|
+
return Number.isNaN(n) ? fallback : n;
|
|
28
|
+
}
|
|
29
|
+
export class SearchAPI {
|
|
30
|
+
client;
|
|
31
|
+
constructor(client) {
|
|
32
|
+
this.client = client;
|
|
33
|
+
}
|
|
34
|
+
async _search(keyword, searchType, page, pageSize, order) {
|
|
35
|
+
keyword = (keyword || "").trim();
|
|
36
|
+
if (!keyword)
|
|
37
|
+
throw new Error("搜索关键词不能为空");
|
|
38
|
+
const params = {
|
|
39
|
+
search_type: searchType,
|
|
40
|
+
keyword,
|
|
41
|
+
page,
|
|
42
|
+
page_size: pageSize,
|
|
43
|
+
web_location: 1430654,
|
|
44
|
+
...FINGERPRINT,
|
|
45
|
+
};
|
|
46
|
+
if (order)
|
|
47
|
+
params["order"] = order;
|
|
48
|
+
const headers = {
|
|
49
|
+
Referer: `https://search.bilibili.com/all?keyword=${encodeURIComponent(keyword)}`,
|
|
50
|
+
};
|
|
51
|
+
const payload = await this.client.getSigned(endpoints.SEARCH_TYPE, params, headers);
|
|
52
|
+
return payload?.data ?? {};
|
|
53
|
+
}
|
|
54
|
+
async videos(keyword, page = 1, pageSize = 20, order = "totalrank") {
|
|
55
|
+
if (!VIDEO_ORDERS.includes(order)) {
|
|
56
|
+
throw new Error(`order 只支持 ${VIDEO_ORDERS.join(", ")}`);
|
|
57
|
+
}
|
|
58
|
+
const data = await this._search(keyword, "video", page, pageSize, order);
|
|
59
|
+
const items = data.result ?? [];
|
|
60
|
+
const videos = items
|
|
61
|
+
.map((i) => Video.fromSearchItem(i))
|
|
62
|
+
.filter((v) => v !== null);
|
|
63
|
+
return [videos, totalOf(data, videos.length)];
|
|
64
|
+
}
|
|
65
|
+
async users(keyword, page = 1, pageSize = 20) {
|
|
66
|
+
const data = await this._search(keyword, "bili_user", page, pageSize);
|
|
67
|
+
const items = data.result ?? [];
|
|
68
|
+
const users = items
|
|
69
|
+
.map((i) => User.fromSearchItem(i))
|
|
70
|
+
.filter((u) => u !== null);
|
|
71
|
+
return [users, totalOf(data, users.length)];
|
|
72
|
+
}
|
|
73
|
+
iterVideos(keyword, pageSize = 20, order = "totalrank", maxPages) {
|
|
74
|
+
const fetch = (page) => this.videos(keyword, page, pageSize, order);
|
|
75
|
+
return iterPages(fetch, maxPages);
|
|
76
|
+
}
|
|
77
|
+
iterUsers(keyword, pageSize = 20, maxPages) {
|
|
78
|
+
const fetch = (page) => this.users(keyword, page, pageSize);
|
|
79
|
+
return iterPages(fetch, maxPages);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** 用户空间接口:个人信息、投稿视频列表、合集。
|
|
2
|
+
|
|
3
|
+
这些接口都要求 WBI 签名,统一走 client.getSigned(),并带上空间页 Referer。
|
|
4
|
+
*/
|
|
5
|
+
import type { BiliClient } from "../client";
|
|
6
|
+
import { Collection, Video } from "../models";
|
|
7
|
+
export declare class SpaceAPI {
|
|
8
|
+
private client;
|
|
9
|
+
constructor(client: BiliClient);
|
|
10
|
+
private static referer;
|
|
11
|
+
info(uid: number): Promise<any>;
|
|
12
|
+
videos(uid: number, page?: number, pageSize?: number, order?: string): Promise<[Video[], number]>;
|
|
13
|
+
iterVideos(uid: number, pageSize?: number, order?: string, maxPages?: number): AsyncGenerator<Video, any, any>;
|
|
14
|
+
collections(uid: number, page?: number, pageSize?: number): Promise<[Collection[], number]>;
|
|
15
|
+
collection(seasonId: number, page?: number, pageSize?: number, mid?: number, author?: string): Promise<[Collection, Video[], number]>;
|
|
16
|
+
iterCollection(seasonId: number, pageSize?: number, mid?: number, maxPages?: number, author?: string): AsyncGenerator<Video, any, any>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** 用户空间接口:个人信息、投稿视频列表、合集。
|
|
2
|
+
|
|
3
|
+
这些接口都要求 WBI 签名,统一走 client.getSigned(),并带上空间页 Referer。
|
|
4
|
+
*/
|
|
5
|
+
import * as endpoints from "../endpoints";
|
|
6
|
+
import { Collection, Video } from "../models";
|
|
7
|
+
import { iterPages } from "../pagination";
|
|
8
|
+
export class SpaceAPI {
|
|
9
|
+
client;
|
|
10
|
+
constructor(client) {
|
|
11
|
+
this.client = client;
|
|
12
|
+
}
|
|
13
|
+
static referer(uid) {
|
|
14
|
+
return { Referer: `https://space.bilibili.com/${uid}/` };
|
|
15
|
+
}
|
|
16
|
+
async info(uid) {
|
|
17
|
+
const payload = await this.client.getSigned(endpoints.SPACE_INFO, { mid: uid }, SpaceAPI.referer(uid));
|
|
18
|
+
return payload?.data ?? {};
|
|
19
|
+
}
|
|
20
|
+
async videos(uid, page = 1, pageSize = 20, order = "pubdate") {
|
|
21
|
+
if (!["pubdate", "click", "stow"].includes(order)) {
|
|
22
|
+
throw new Error("order 只支持 pubdate / click / stow");
|
|
23
|
+
}
|
|
24
|
+
const payload = await this.client.getSigned(endpoints.SPACE_ARC_SEARCH, {
|
|
25
|
+
mid: uid,
|
|
26
|
+
pn: page,
|
|
27
|
+
ps: pageSize,
|
|
28
|
+
order,
|
|
29
|
+
platform: "web",
|
|
30
|
+
web_location: 1550101,
|
|
31
|
+
}, SpaceAPI.referer(uid));
|
|
32
|
+
const data = payload?.data ?? {};
|
|
33
|
+
const videoList = data.list ?? {};
|
|
34
|
+
const vlist = videoList.vlist ?? [];
|
|
35
|
+
const author = videoList.name ?? (data.card ?? {}).name ?? "";
|
|
36
|
+
const videos = vlist
|
|
37
|
+
.map((i) => Video.fromSpaceItem(i, author, uid))
|
|
38
|
+
.filter((v) => v !== null);
|
|
39
|
+
const total = (data.page ?? {}).count ?? videos.length;
|
|
40
|
+
return [videos, total];
|
|
41
|
+
}
|
|
42
|
+
iterVideos(uid, pageSize = 20, order = "pubdate", maxPages) {
|
|
43
|
+
const fetch = (page) => this.videos(uid, page, pageSize, order);
|
|
44
|
+
return iterPages(fetch, maxPages);
|
|
45
|
+
}
|
|
46
|
+
async collections(uid, page = 1, pageSize = 20) {
|
|
47
|
+
const payload = await this.client.get(endpoints.SEASONS_SERIES_LIST, { mid: uid, page_num: page, page_size: pageSize }, SpaceAPI.referer(uid));
|
|
48
|
+
const data = payload?.data ?? {};
|
|
49
|
+
const seasons = (data.items_lists ?? {}).seasons_list ?? [];
|
|
50
|
+
const collections = seasons
|
|
51
|
+
.map((i) => Collection.fromApi(i))
|
|
52
|
+
.filter((c) => c.seasonId);
|
|
53
|
+
// 接口的 page.total 把「系列」也算进去了,这里只返回合集,所以用实际条数
|
|
54
|
+
return [collections, collections.length];
|
|
55
|
+
}
|
|
56
|
+
async collection(seasonId, page = 1, pageSize = 30, mid, author = "") {
|
|
57
|
+
const params = {
|
|
58
|
+
season_id: seasonId,
|
|
59
|
+
sort_reverse: "false",
|
|
60
|
+
page_num: page,
|
|
61
|
+
page_size: pageSize,
|
|
62
|
+
};
|
|
63
|
+
let headers;
|
|
64
|
+
if (mid) {
|
|
65
|
+
mid = Number(mid);
|
|
66
|
+
params["mid"] = mid;
|
|
67
|
+
headers = {
|
|
68
|
+
Referer: `https://space.bilibili.com/${mid}/channel/collectiondetail` +
|
|
69
|
+
`?sid=${seasonId}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const payload = await this.client.get(endpoints.SEASONS_ARCHIVES, params, headers);
|
|
73
|
+
const data = payload?.data ?? {};
|
|
74
|
+
const meta = data.meta ?? {};
|
|
75
|
+
const collection = Collection.fromApi(meta);
|
|
76
|
+
const archives = data.archives ?? [];
|
|
77
|
+
const videos = archives
|
|
78
|
+
.map((i) => Video.fromArchiveItem(i, author, meta.mid))
|
|
79
|
+
.filter((v) => v !== null);
|
|
80
|
+
const total = (data.page ?? {}).total ?? videos.length;
|
|
81
|
+
return [collection, videos, total];
|
|
82
|
+
}
|
|
83
|
+
iterCollection(seasonId, pageSize = 30, mid, maxPages, author = "") {
|
|
84
|
+
const fetch = (page) => {
|
|
85
|
+
return this.collection(seasonId, page, pageSize, mid, author).then(([, videos, total]) => [videos, total]);
|
|
86
|
+
};
|
|
87
|
+
return iterPages(fetch, maxPages);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** 视频相关接口:首页推荐流、视频详情、分P、播放地址、字幕。 */
|
|
2
|
+
import type { BiliClient } from "../client";
|
|
3
|
+
import { Page, PlayInfo, Subtitle, Video } from "../models";
|
|
4
|
+
export declare class VideoAPI {
|
|
5
|
+
private client;
|
|
6
|
+
private pageInterval;
|
|
7
|
+
constructor(client: BiliClient, pageInterval?: number);
|
|
8
|
+
iterRecommend(pages?: number, pageSize?: number): AsyncGenerator<Video>;
|
|
9
|
+
recommend(pages?: number, pageSize?: number): Promise<Video[]>;
|
|
10
|
+
detail(bvid: string): Promise<Video>;
|
|
11
|
+
resolveAid(bvid: string): Promise<number>;
|
|
12
|
+
private referer;
|
|
13
|
+
pagelist(bvid: string): Promise<Page[]>;
|
|
14
|
+
playurl(bvid: string, page?: number, cid?: number, qn?: number): Promise<PlayInfo>;
|
|
15
|
+
subtitles(bvid: string, cid?: number): Promise<Subtitle[]>;
|
|
16
|
+
static pickSubtitle(subtitles: Subtitle[], lang?: string): Subtitle | null;
|
|
17
|
+
subtitleText(bvid: string, lang?: string, subtitle?: Subtitle | null, cid?: number): Promise<string>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/** 视频相关接口:首页推荐流、视频详情、分P、播放地址、字幕。 */
|
|
2
|
+
import { BiliApiError } from "../errors";
|
|
3
|
+
import * as endpoints from "../endpoints";
|
|
4
|
+
import { Page, PlayInfo, Subtitle, Video } from "../models";
|
|
5
|
+
import { sleep } from "../util";
|
|
6
|
+
export class VideoAPI {
|
|
7
|
+
client;
|
|
8
|
+
pageInterval;
|
|
9
|
+
constructor(client, pageInterval = 0.6) {
|
|
10
|
+
this.client = client;
|
|
11
|
+
this.pageInterval = pageInterval;
|
|
12
|
+
}
|
|
13
|
+
async *iterRecommend(pages = 1, pageSize = 10) {
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
for (let page = 1; page <= pages; page++) {
|
|
16
|
+
const payload = await this.client.get(endpoints.RCMD_FEED, {
|
|
17
|
+
fresh_idx: page,
|
|
18
|
+
fresh_idx_1h: page,
|
|
19
|
+
feed_version: 1,
|
|
20
|
+
ps: pageSize,
|
|
21
|
+
});
|
|
22
|
+
const items = payload?.data?.item ?? [];
|
|
23
|
+
let added = 0;
|
|
24
|
+
for (const item of items) {
|
|
25
|
+
const video = Video.fromFeedItem(item);
|
|
26
|
+
if (video && !seen.has(video.bvid)) {
|
|
27
|
+
seen.add(video.bvid);
|
|
28
|
+
added += 1;
|
|
29
|
+
yield video;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!added)
|
|
33
|
+
break;
|
|
34
|
+
if (page < pages)
|
|
35
|
+
await sleep(this.pageInterval * 1000);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async recommend(pages = 1, pageSize = 10) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for await (const video of this.iterRecommend(pages, pageSize))
|
|
41
|
+
out.push(video);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
async detail(bvid) {
|
|
45
|
+
const payload = await this.client.get(endpoints.VIDEO_VIEW, { bvid });
|
|
46
|
+
const video = Video.fromView(payload?.data);
|
|
47
|
+
if (!video.bvid)
|
|
48
|
+
video.bvid = bvid;
|
|
49
|
+
return video;
|
|
50
|
+
}
|
|
51
|
+
async resolveAid(bvid) {
|
|
52
|
+
const cacheKey = `aid:${bvid}`;
|
|
53
|
+
const cached = this.client.cache?.get(cacheKey);
|
|
54
|
+
if (cached)
|
|
55
|
+
return cached;
|
|
56
|
+
const video = await this.detail(bvid);
|
|
57
|
+
if (!video.aid)
|
|
58
|
+
throw new BiliApiError(`无法解析 ${bvid} 对应的 aid`);
|
|
59
|
+
this.client.cache?.set(cacheKey, video.aid);
|
|
60
|
+
return video.aid;
|
|
61
|
+
}
|
|
62
|
+
referer(bvid) {
|
|
63
|
+
return { Referer: `https://www.bilibili.com/video/${bvid}/` };
|
|
64
|
+
}
|
|
65
|
+
async pagelist(bvid) {
|
|
66
|
+
const payload = await this.client.get(endpoints.PLAYER_PAGELIST, { bvid }, this.referer(bvid));
|
|
67
|
+
const data = payload?.data ?? [];
|
|
68
|
+
const pages = data
|
|
69
|
+
.filter((i) => typeof i === "object")
|
|
70
|
+
.map((i) => Page.fromApi(i))
|
|
71
|
+
.filter((pg) => pg.cid);
|
|
72
|
+
if (!pages.length)
|
|
73
|
+
throw new BiliApiError(`${bvid} 没有可用的分P信息`);
|
|
74
|
+
return pages;
|
|
75
|
+
}
|
|
76
|
+
async playurl(bvid, page = 1, cid, qn = 80) {
|
|
77
|
+
if (cid == null) {
|
|
78
|
+
const pages = await this.pagelist(bvid);
|
|
79
|
+
if (!(1 <= page && page <= pages.length)) {
|
|
80
|
+
throw new Error(`page 超出范围:${bvid} 只有 ${pages.length} 个分P`);
|
|
81
|
+
}
|
|
82
|
+
cid = pages[page - 1].cid;
|
|
83
|
+
}
|
|
84
|
+
const payload = await this.client.get(endpoints.PLAYURL, {
|
|
85
|
+
bvid,
|
|
86
|
+
cid,
|
|
87
|
+
qn,
|
|
88
|
+
fnver: 0,
|
|
89
|
+
fnval: 4048,
|
|
90
|
+
fourk: 1,
|
|
91
|
+
platform: "pc",
|
|
92
|
+
high_quality: 1,
|
|
93
|
+
}, this.referer(bvid));
|
|
94
|
+
return PlayInfo.fromApi(payload?.data);
|
|
95
|
+
}
|
|
96
|
+
async subtitles(bvid, cid) {
|
|
97
|
+
if (cid == null)
|
|
98
|
+
cid = (await this.pagelist(bvid))[0].cid;
|
|
99
|
+
const payload = await this.client.getSigned(endpoints.PLAYER_V2, { bvid, cid }, this.referer(bvid));
|
|
100
|
+
const items = payload?.data?.subtitle?.subtitles ?? [];
|
|
101
|
+
return items
|
|
102
|
+
.map((i) => Subtitle.fromApi(i))
|
|
103
|
+
.filter((s) => s !== null);
|
|
104
|
+
}
|
|
105
|
+
static pickSubtitle(subtitles, lang = "zh") {
|
|
106
|
+
if (!subtitles.length)
|
|
107
|
+
return null;
|
|
108
|
+
lang = (lang || "zh").toLowerCase();
|
|
109
|
+
for (const subtitle of subtitles) {
|
|
110
|
+
if (subtitle.lan.toLowerCase() === lang)
|
|
111
|
+
return subtitle;
|
|
112
|
+
}
|
|
113
|
+
if (["zh", "zh-cn", "zh-hans"].includes(lang)) {
|
|
114
|
+
for (const subtitle of subtitles) {
|
|
115
|
+
const code = subtitle.lan.toLowerCase();
|
|
116
|
+
if (code.startsWith("ai-zh") ||
|
|
117
|
+
code.startsWith("zh") ||
|
|
118
|
+
subtitle.lanDoc.includes("中文")) {
|
|
119
|
+
return subtitle;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const subtitle of subtitles) {
|
|
124
|
+
if (subtitle.lan.toLowerCase().startsWith(lang))
|
|
125
|
+
return subtitle;
|
|
126
|
+
}
|
|
127
|
+
return subtitles[0];
|
|
128
|
+
}
|
|
129
|
+
async subtitleText(bvid, lang = "zh", subtitle, cid) {
|
|
130
|
+
if (!subtitle) {
|
|
131
|
+
subtitle = VideoAPI.pickSubtitle(await this.subtitles(bvid, cid), lang);
|
|
132
|
+
}
|
|
133
|
+
if (!subtitle || !subtitle.url)
|
|
134
|
+
return "";
|
|
135
|
+
let url = subtitle.url;
|
|
136
|
+
if (url.startsWith("//"))
|
|
137
|
+
url = "https:" + url;
|
|
138
|
+
try {
|
|
139
|
+
const response = await this.client.http.get(url, {
|
|
140
|
+
timeout: this.client.timeout,
|
|
141
|
+
});
|
|
142
|
+
const data = response.data;
|
|
143
|
+
const body = data?.body ?? [];
|
|
144
|
+
return body
|
|
145
|
+
.filter((i) => i && i.content)
|
|
146
|
+
.map((i) => String(i.content))
|
|
147
|
+
.join("\n");
|
|
148
|
+
}
|
|
149
|
+
catch (exc) {
|
|
150
|
+
throw new BiliApiError(`字幕下载失败: ${exc.message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** 本地 JSON 缓存:跨进程复用 WBI 密钥、bvid→aid 这类稳定或带 TTL 的数据。
|
|
2
|
+
*
|
|
3
|
+
* 对齐 Python 版 bili.cache.JsonCache:写文件用临时文件 + 原子替换,
|
|
4
|
+
* 失败时静默降级(缓存不是关键路径)。读 / 写在构造与调用时同步完成,
|
|
5
|
+
* 避免每次请求都 IO,也避免「刚写入又被新实例读到旧内容」的竞态。
|
|
6
|
+
*/
|
|
7
|
+
export declare class JsonCache {
|
|
8
|
+
readonly path: string;
|
|
9
|
+
private defaultTtl?;
|
|
10
|
+
private data;
|
|
11
|
+
constructor(cachePath: string, ttl?: number);
|
|
12
|
+
private load;
|
|
13
|
+
get<T = unknown>(key: string, defaultValue?: T): T | undefined;
|
|
14
|
+
set(key: string, value: unknown, ttlSec?: number): void;
|
|
15
|
+
delete(key: string): void;
|
|
16
|
+
clear(): void;
|
|
17
|
+
private save;
|
|
18
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** 本地 JSON 缓存:跨进程复用 WBI 密钥、bvid→aid 这类稳定或带 TTL 的数据。
|
|
2
|
+
*
|
|
3
|
+
* 对齐 Python 版 bili.cache.JsonCache:写文件用临时文件 + 原子替换,
|
|
4
|
+
* 失败时静默降级(缓存不是关键路径)。读 / 写在构造与调用时同步完成,
|
|
5
|
+
* 避免每次请求都 IO,也避免「刚写入又被新实例读到旧内容」的竞态。
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
export class JsonCache {
|
|
10
|
+
path;
|
|
11
|
+
defaultTtl;
|
|
12
|
+
data;
|
|
13
|
+
constructor(cachePath, ttl) {
|
|
14
|
+
this.path = cachePath;
|
|
15
|
+
this.defaultTtl = ttl;
|
|
16
|
+
this.data = this.load();
|
|
17
|
+
}
|
|
18
|
+
load() {
|
|
19
|
+
try {
|
|
20
|
+
if (!existsSync(this.path))
|
|
21
|
+
return {};
|
|
22
|
+
const text = readFileSync(this.path, "utf-8");
|
|
23
|
+
const parsed = JSON.parse(text);
|
|
24
|
+
return parsed && typeof parsed === "object"
|
|
25
|
+
? parsed
|
|
26
|
+
: {};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {}; // 文件损坏 / 不存在 → 空缓存(自愈)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
get(key, defaultValue) {
|
|
33
|
+
const entry = this.data[key];
|
|
34
|
+
if (!entry || typeof entry !== "object")
|
|
35
|
+
return defaultValue;
|
|
36
|
+
if (entry.expires !== undefined && Date.now() > entry.expires) {
|
|
37
|
+
delete this.data[key];
|
|
38
|
+
this.save();
|
|
39
|
+
return defaultValue;
|
|
40
|
+
}
|
|
41
|
+
if (entry.value === null || entry.value === undefined)
|
|
42
|
+
return defaultValue;
|
|
43
|
+
return entry.value;
|
|
44
|
+
}
|
|
45
|
+
set(key, value, ttlSec) {
|
|
46
|
+
const ttl = ttlSec ?? this.defaultTtl;
|
|
47
|
+
const entry = { value };
|
|
48
|
+
if (ttl !== undefined && ttl !== null) {
|
|
49
|
+
entry.expires = Date.now() + ttl * 1000;
|
|
50
|
+
}
|
|
51
|
+
this.data[key] = entry;
|
|
52
|
+
this.save();
|
|
53
|
+
}
|
|
54
|
+
delete(key) {
|
|
55
|
+
if (key in this.data) {
|
|
56
|
+
delete this.data[key];
|
|
57
|
+
this.save();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
clear() {
|
|
61
|
+
this.data = {};
|
|
62
|
+
this.save();
|
|
63
|
+
}
|
|
64
|
+
save() {
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(path.dirname(this.path), { recursive: true });
|
|
67
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
68
|
+
writeFileSync(tmp, JSON.stringify(this.data, null, 2), "utf-8");
|
|
69
|
+
renameSync(tmp, this.path);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// 只读 / 无权限环境下静默降级:缓存不是关键路径
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** BiliClient:统一维护 Session、Cookie、请求细节和账号状态。
|
|
2
|
+
|
|
3
|
+
内置基础节流与退避,目标是让请求节奏接近正常浏览,降低触发 B 站风控的概率:
|
|
4
|
+
|
|
5
|
+
- 请求之间保持最小间隔(默认 0.6 秒,带随机抖动)
|
|
6
|
+
- 写操作之间额外保持间隔(默认 1 秒)
|
|
7
|
+
- GET 遇到网络错误或 -799「请求过于频繁」时指数退避重试
|
|
8
|
+
- 写操作绝不自动重试,避免重复评论 / 重复私信
|
|
9
|
+
- HTTP 412 与业务码 -412 视为风控信号,立即停止,不做重试
|
|
10
|
+
*/
|
|
11
|
+
import { AxiosInstance } from "axios";
|
|
12
|
+
import { VideoAPI } from "./apis/video";
|
|
13
|
+
import { SearchAPI } from "./apis/search";
|
|
14
|
+
import { SpaceAPI } from "./apis/space";
|
|
15
|
+
import { CommentAPI } from "./apis/comment";
|
|
16
|
+
import { MessageAPI } from "./apis/message";
|
|
17
|
+
import { NotifyAPI } from "./apis/notify";
|
|
18
|
+
import { InteractAPI } from "./apis/interact";
|
|
19
|
+
import { RelationAPI } from "./apis/relation";
|
|
20
|
+
import { DynamicAPI } from "./apis/dynamic";
|
|
21
|
+
import { ArticleAPI } from "./apis/article";
|
|
22
|
+
import { ProfileAPI } from "./apis/profile";
|
|
23
|
+
import { QrCodeLogin } from "./apis/auth";
|
|
24
|
+
import { WbiSigner } from "./wbi";
|
|
25
|
+
import { JsonCache } from "./cache";
|
|
26
|
+
export declare const USER_AGENT: string;
|
|
27
|
+
export declare const DEFAULT_HEADERS: Record<string, string>;
|
|
28
|
+
export interface BiliClientOptions {
|
|
29
|
+
cookieFile?: string;
|
|
30
|
+
cacheFile?: string;
|
|
31
|
+
timeout?: number;
|
|
32
|
+
minInterval?: number;
|
|
33
|
+
jitter?: number;
|
|
34
|
+
maxRetries?: number;
|
|
35
|
+
writeInterval?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare class BiliClient {
|
|
38
|
+
readonly http: AxiosInstance;
|
|
39
|
+
readonly timeout: number;
|
|
40
|
+
cookieFile: string;
|
|
41
|
+
minInterval: number;
|
|
42
|
+
jitter: number;
|
|
43
|
+
maxRetries: number;
|
|
44
|
+
writeInterval: number;
|
|
45
|
+
readonly video: VideoAPI;
|
|
46
|
+
readonly search: SearchAPI;
|
|
47
|
+
readonly space: SpaceAPI;
|
|
48
|
+
readonly comment: CommentAPI;
|
|
49
|
+
readonly message: MessageAPI;
|
|
50
|
+
readonly notify: NotifyAPI;
|
|
51
|
+
readonly interact: InteractAPI;
|
|
52
|
+
readonly relation: RelationAPI;
|
|
53
|
+
readonly dynamic: DynamicAPI;
|
|
54
|
+
readonly article: ArticleAPI;
|
|
55
|
+
readonly profile: ProfileAPI;
|
|
56
|
+
readonly auth: QrCodeLogin;
|
|
57
|
+
readonly wbi: WbiSigner;
|
|
58
|
+
readonly cache: JsonCache;
|
|
59
|
+
private lastRequestAt;
|
|
60
|
+
private lastWriteAt;
|
|
61
|
+
private cookies;
|
|
62
|
+
private _account;
|
|
63
|
+
constructor(opts?: BiliClientOptions);
|
|
64
|
+
get(url: string, params?: Record<string, unknown>, headers?: Record<string, string>, wbi?: boolean): Promise<any>;
|
|
65
|
+
post(url: string, data?: Record<string, unknown> | string, headers?: Record<string, string>, wbi?: boolean, params?: Record<string, unknown>, jsonBody?: boolean): Promise<any>;
|
|
66
|
+
getSigned(url: string, params?: Record<string, unknown>, headers?: Record<string, string>): Promise<any>;
|
|
67
|
+
private request;
|
|
68
|
+
private throttle;
|
|
69
|
+
private rememberRequest;
|
|
70
|
+
private backoff;
|
|
71
|
+
private applyCookieHeader;
|
|
72
|
+
loadCookies(): Promise<string>;
|
|
73
|
+
saveCookies(): Promise<string>;
|
|
74
|
+
/** 清空本地缓存(WBI 密钥、bvid→aid 等),下次请求会自动重新拉取。 */
|
|
75
|
+
clearCache(): void;
|
|
76
|
+
/** 供登录流程(未来实现)把 Set-Cookie 写入本地状态。 */
|
|
77
|
+
updateCookiesFromHeader(setCookie: string[]): void;
|
|
78
|
+
get isLoggedIn(): boolean;
|
|
79
|
+
get csrf(): string;
|
|
80
|
+
fetchAccount(): Promise<any | null>;
|
|
81
|
+
getAccount(): Promise<any | null>;
|
|
82
|
+
get username(): string | null;
|
|
83
|
+
checkLogin(): Promise<boolean>;
|
|
84
|
+
requireLogin(): Promise<any>;
|
|
85
|
+
}
|