feat: QR login, AcFun settings panel, and 动态 (moment) tools
- scan.acfun.cn QR flow (start -> long-poll scanResult -> acceptResult) with rotated-signature handling, background poller, durable session in the Host credential service, and cookie handoff to the provider - Client half: Settings -> AcFun page (status, QR code, poll, logout) over a same-origin /acfun-panel JSON API with cross-origin rejection - acfun_moment / acfun_moment_comments / acfun_topic read tools, moment_like / moment_unlike / moment_banana actions, and comment posting on moments - acfun_account status tool; docs for the QR contract, the 广场/动态 research verdict, and the client-composition restart requirement
This commit is contained in:
+464
-40
@@ -36,6 +36,17 @@ export const DEFAULT_USER_AGENT =
|
||||
/** The `sourceType` AcFun uses for video (douga) comment threads. */
|
||||
export const COMMENT_SOURCE_TYPE = 3
|
||||
|
||||
/** The `sourceType` AcFun uses for moment (动态) comment threads. */
|
||||
export const MOMENT_COMMENT_SOURCE_TYPE = 4
|
||||
|
||||
/** The `resourceType` AcFun uses for moment likes / bananas. */
|
||||
export const MOMENT_RESOURCE_TYPE = 10
|
||||
|
||||
/** Canonical public URL of one AcFun moment (user-facing share page). */
|
||||
export function momentShareUrl(momentId) {
|
||||
return `https://m.acfun.cn/communityCircle/moment/${momentId}`
|
||||
}
|
||||
|
||||
/** The `resourceType` AcFun uses for video favorites. */
|
||||
export const FAVORITE_RESOURCE_TYPE = 9
|
||||
|
||||
@@ -133,7 +144,12 @@ export function formatDuration(milliseconds) {
|
||||
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
|
||||
}
|
||||
|
||||
/** Format an epoch-millisecond timestamp as an ISO date-time, or ''. */
|
||||
/**
|
||||
* Format an epoch-millisecond timestamp as an ISO date-time, or ''.
|
||||
*
|
||||
* @param {unknown} milliseconds
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatTimestamp(milliseconds) {
|
||||
const value = Number(milliseconds)
|
||||
if (!Number.isFinite(value) || value <= 0) return ''
|
||||
@@ -141,11 +157,62 @@ export function formatTimestamp(milliseconds) {
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a banana (投蕉) amount.
|
||||
*
|
||||
* @param {unknown} count
|
||||
* @returns {number} an integer between 1 and 10.
|
||||
*/
|
||||
function validateBananaCount(count) {
|
||||
const amount = Number(count ?? 1)
|
||||
if (!Number.isInteger(amount) || amount < 1 || amount > 10) {
|
||||
throw new AcfunError('count must be an integer between 1 and 10.', { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
/** The canonical web URL of one AcFun video. */
|
||||
export function acfunVideoUrl(acId) {
|
||||
return `https://www.acfun.cn/v/ac${acId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a `comment/listByFloor` payload (videos and moments share it).
|
||||
*
|
||||
* @param {Record<string, any>} data - the API body.
|
||||
* @param {number} fallbackPage - page used when the body omits `curPage`.
|
||||
* @returns {{items: object[], currentPage: number, totalPages: number, totalCount: number, pageSize: number}}
|
||||
*/
|
||||
export function mapCommentPage(data, fallbackPage = 1) {
|
||||
const map = data.commentsMap !== null && typeof data.commentsMap === 'object' ? data.commentsMap : {}
|
||||
const orderedIds = Array.isArray(data.commentIds)
|
||||
? data.commentIds.map((id) => String(id))
|
||||
: Object.keys(map).map((key) => key.replace(/^c/, ''))
|
||||
const items = []
|
||||
for (const id of orderedIds) {
|
||||
const entry = map[`c${id}`] ?? map[id]
|
||||
if (entry === null || typeof entry !== 'object') continue
|
||||
items.push({
|
||||
commentId: String(entry.cid ?? id),
|
||||
userId: String(entry.userId ?? ''),
|
||||
userName: String(entry.userName ?? ''),
|
||||
content: normalizeUbb(entry.content),
|
||||
floor: Number(entry.floor) || 0,
|
||||
postedAt: String(entry.postDate ?? ''),
|
||||
timestamp: Number(entry.timestamp) || 0,
|
||||
isUp: entry.isUp === true,
|
||||
isDeleted: entry.isDelete === true,
|
||||
})
|
||||
}
|
||||
return {
|
||||
items,
|
||||
currentPage: Number(data.curPage) || fallbackPage,
|
||||
totalPages: Number(data.totalPage) || 1,
|
||||
totalCount: Number(data.totalCount) || items.length,
|
||||
pageSize: Number(data.pageSize) || items.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept `42588725`, `ac42588725`, `AC42588725`, or any acfun.cn URL that
|
||||
* contains `/v/ac<digits>`, and return the bare numeric ac id.
|
||||
@@ -216,6 +283,29 @@ export function extractAssignedJson(html, marker) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a numeric moment id, a `momentId=` query fragment, or a
|
||||
* `m.acfun.cn/communityCircle/moment/<id>` share URL.
|
||||
*
|
||||
* @param {unknown} input - model-supplied moment identifier.
|
||||
* @returns {string} bare numeric moment id.
|
||||
*/
|
||||
export function normalizeMomentId(input) {
|
||||
if (typeof input !== 'string') {
|
||||
throw new AcfunError('momentId must be a string such as "5093831" or a communityCircle/moment URL.', {
|
||||
code: 'INVALID_ARGUMENT',
|
||||
})
|
||||
}
|
||||
const value = input.trim()
|
||||
if (/^\d+$/.test(value)) return value
|
||||
const fromUrl = /\/moment\/(\d+)/i.exec(value) ?? /[?&]momentId=(\d+)/i.exec(value)
|
||||
if (fromUrl !== null) return fromUrl[1]
|
||||
throw new AcfunError(
|
||||
`Cannot read an AcFun moment id from "${value}". Pass the numeric momentId or a https://m.acfun.cn/communityCircle/moment/... URL.`,
|
||||
{ code: 'INVALID_ARGUMENT' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the H.264 representation ladder out of a `ksPlayJson` string.
|
||||
*
|
||||
@@ -255,6 +345,80 @@ export function readPlayQualities(playJson) {
|
||||
return qualities
|
||||
}
|
||||
|
||||
/** Strip HTML tags/scripts and collapse whitespace into one readable line. */
|
||||
function stripHtml(value) {
|
||||
return decodeEntities(
|
||||
String(value ?? '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]*>/g, ' '),
|
||||
)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** First capture group of `pattern` in `html`, HTML-stripped, or ''. */
|
||||
function firstGroup(html, pattern) {
|
||||
const match = pattern.exec(html)
|
||||
return match === null ? '' : stripHtml(match[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one server-rendered `m.acfun.cn/communityCircle/{tagId}` page.
|
||||
*
|
||||
* @param {string} html - full mobile topic page.
|
||||
* @param {string} tagId - requested topic id (fallback for the link column).
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
export function parseTopicPage(html, tagId) {
|
||||
const pageText = stripHtml(html)
|
||||
const tagName =
|
||||
/【(.+?)】/.exec(firstGroup(html, /<title[^>]*>([\s\S]*?)<\/title>/i))?.[1] ??
|
||||
(firstGroup(html, /class="tag-name"[^>]*>([\s\S]*?)<\//i) || `话题 ${tagId}`)
|
||||
const followersText = /(\d[\d,.]*\s*万?)\s*关注/.exec(pageText)?.[1] ?? ''
|
||||
const contentsText = /(\d[\d,.]*\s*万?)\s*内容/.exec(pageText)?.[1] ?? ''
|
||||
const items = []
|
||||
const chunkPattern = /<li[^>]*class="[^"]*resources-item[^"]*"([\s\S]*?)(?=<li[^>]*class="[^"]*resources-item|<\/ul>|$)/gi
|
||||
let match
|
||||
while ((match = chunkPattern.exec(html)) !== null) {
|
||||
const chunk = match[1]
|
||||
const momentId = /moment-id="(\d*)"/.exec(chunk)?.[1] ?? ''
|
||||
const resourceType = Number(/resource-type="(\d*)"/.exec(chunk)?.[1] ?? 0) || 0
|
||||
const resourceId = /resource-id="(\d*)"/.exec(chunk)?.[1] ?? ''
|
||||
if (momentId.length === 0 && resourceId.length === 0) continue
|
||||
const url =
|
||||
momentId.length > 0
|
||||
? momentShareUrl(momentId)
|
||||
: resourceType === 2 && resourceId.length > 0
|
||||
? acfunVideoUrl(resourceId)
|
||||
: resourceId.length > 0
|
||||
? `https://www.acfun.cn/a/ac${resourceId}`
|
||||
: `https://m.acfun.cn/communityCircle/${tagId}`
|
||||
items.push({
|
||||
momentId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
title: firstGroup(chunk, /class="article-title"[^>]*>([\s\S]*?)<\/p>/i),
|
||||
body: firstGroup(chunk, /class="article-body"[^>]*>([\s\S]*?)<\/p>/i),
|
||||
authorName: firstGroup(chunk, /class="user-name"[^>]*>([\s\S]*?)<\/(?:p|span)>/i),
|
||||
publishedAt: firstGroup(chunk, /class="release-time"[^>]*>([\s\S]*?)<\/(?:p|span)>/i),
|
||||
tags: [...chunk.matchAll(/class="tag-item"[^>]*>([\s\S]*?)<\/a>/gi)].map((entry) => stripHtml(entry[1])).filter(Boolean),
|
||||
coverUrl: /<img[^>]+src="([^"]+)"/i.exec(chunk)?.[1] ?? '',
|
||||
statTexts: [...chunk.matchAll(/class="base-text"[^>]*>([\s\S]*?)<\//gi)].map((entry) => stripHtml(entry[1])).filter(Boolean),
|
||||
url,
|
||||
})
|
||||
}
|
||||
return {
|
||||
tagId,
|
||||
tagName,
|
||||
followersText,
|
||||
contentsText,
|
||||
returned: items.length,
|
||||
items,
|
||||
note: 'HTML topic page: at most 15 newest items, no pagination; only resourceType 3 items carry a momentId.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One AcFun API client bound to a cookie and timeout budget.
|
||||
*/
|
||||
@@ -284,6 +448,30 @@ export class AcfunProvider {
|
||||
return this.cookie.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the session cookie (used after QR login and logout).
|
||||
*
|
||||
* @param {string} cookie - full Cookie header value; empty clears it.
|
||||
*/
|
||||
setCookie(cookie) {
|
||||
this.cookie = typeof cookie === 'string' ? cookie.trim() : ''
|
||||
this.cookies = parseCookieHeader(this.cookie)
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the configured session without exposing it.
|
||||
*
|
||||
* @returns {{configured: boolean, length: number, hasAuthKey: boolean, hasDid: boolean}}
|
||||
*/
|
||||
describeSession() {
|
||||
return {
|
||||
configured: this.cookie.length > 0,
|
||||
length: this.cookie.length,
|
||||
hasAuthKey: typeof this.cookies.auth_key === 'string' && this.cookies.auth_key.length > 0,
|
||||
hasDid: typeof this.cookies._did === 'string' && this.cookies._did.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether mutating calls are permitted by configuration and login state. */
|
||||
get writable() {
|
||||
return this.allowWriteActions && this.loggedIn
|
||||
@@ -417,7 +605,8 @@ export class AcfunProvider {
|
||||
})
|
||||
}
|
||||
if (payload !== null && typeof payload === 'object' && typeof payload.result === 'number' && payload.result !== 0) {
|
||||
throw this.#apiError(path, payload, response.status)
|
||||
const accepted = Array.isArray(options.okResults) ? options.okResults : []
|
||||
if (!accepted.includes(payload.result)) throw this.#apiError(path, payload, response.status)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -441,6 +630,12 @@ export class AcfunProvider {
|
||||
{ code: 'CAPTCHA_REQUIRED', route: path, status, detail: serverMessage },
|
||||
)
|
||||
}
|
||||
if (code === 140_002 || code === 140_005) {
|
||||
return new AcfunError(
|
||||
`${base}. ${code === 140_002 ? 'The moment id is outside the current id range.' : 'The moment does not exist or was deleted.'}`,
|
||||
{ code: 'NOT_FOUND', route: path, status, detail: serverMessage },
|
||||
)
|
||||
}
|
||||
return new AcfunError(base, { code: 'API_ERROR', route: path, status, detail: serverMessage })
|
||||
}
|
||||
|
||||
@@ -674,41 +869,165 @@ export class AcfunProvider {
|
||||
},
|
||||
signal: options.signal,
|
||||
})
|
||||
const map = data.commentsMap !== null && typeof data.commentsMap === 'object' ? data.commentsMap : {}
|
||||
const orderedIds = Array.isArray(data.commentIds)
|
||||
? data.commentIds.map((id) => String(id))
|
||||
: Object.keys(map).map((key) => key.replace(/^c/, ''))
|
||||
const items = []
|
||||
for (const id of orderedIds) {
|
||||
const entry = map[`c${id}`] ?? map[id]
|
||||
if (entry === null || typeof entry !== 'object') continue
|
||||
items.push({
|
||||
commentId: String(entry.cid ?? id),
|
||||
userId: String(entry.userId ?? ''),
|
||||
userName: String(entry.userName ?? ''),
|
||||
content: normalizeUbb(entry.content),
|
||||
floor: Number(entry.floor) || 0,
|
||||
postedAt: String(entry.postDate ?? ''),
|
||||
timestamp: Number(entry.timestamp) || 0,
|
||||
isUp: entry.isUp === true,
|
||||
isDeleted: entry.isDelete === true,
|
||||
})
|
||||
}
|
||||
const totalPages = Number(data.totalPage) || 1
|
||||
const currentPage = Number(data.curPage) || page
|
||||
const pageData = mapCommentPage(data, page)
|
||||
return {
|
||||
acId,
|
||||
page: currentPage,
|
||||
totalPages,
|
||||
totalCount: Number(data.totalCount) || items.length,
|
||||
pageSize: Number(data.pageSize) || items.length,
|
||||
returned: items.length,
|
||||
hasMore: currentPage < totalPages,
|
||||
page: pageData.currentPage,
|
||||
totalPages: pageData.totalPages,
|
||||
totalCount: pageData.totalCount,
|
||||
pageSize: pageData.pageSize,
|
||||
returned: pageData.items.length,
|
||||
hasMore: pageData.currentPage < pageData.totalPages,
|
||||
url: acfunVideoUrl(acId),
|
||||
items,
|
||||
items: pageData.items,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one AcFun moment (动态) by id.
|
||||
*
|
||||
* `moment/detail` works anonymously for both the legacy (≤7525) and current
|
||||
* (7-digit) id spaces; ids can only be discovered from a topic page or a
|
||||
* following feed.
|
||||
*
|
||||
* @param {string|number} input - numeric moment id or communityCircle/moment URL.
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<Record<string, any>>}
|
||||
*/
|
||||
async moment(input, options = {}) {
|
||||
const momentId = normalizeMomentId(String(input))
|
||||
const data = await this.#api('/rest/pc-direct/moment/detail', {
|
||||
query: { momentId },
|
||||
signal: options.signal,
|
||||
})
|
||||
const raw = data.moment
|
||||
if (raw === null || typeof raw !== 'object') {
|
||||
throw new AcfunError(`AcFun did not return a moment for id ${momentId}.`, {
|
||||
code: 'NOT_FOUND',
|
||||
route: '/rest/pc-direct/moment/detail',
|
||||
})
|
||||
}
|
||||
const user = raw.user !== null && typeof raw.user === 'object' ? raw.user : {}
|
||||
const images = []
|
||||
if (Array.isArray(raw.imgs)) {
|
||||
for (const image of raw.imgs) {
|
||||
if (image === null || typeof image !== 'object') continue
|
||||
const url = image.originUrl || image.expandedUrl || image.url
|
||||
if (typeof url === 'string' && url.length > 0) {
|
||||
images.push({ url, width: Number(image.w ?? image.width) || 0, height: Number(image.h ?? image.height) || 0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (images.length === 0 && Array.isArray(raw.imgInfos)) {
|
||||
for (const info of raw.imgInfos) {
|
||||
const urls = info?.originImage?.cdnUrls
|
||||
const url = Array.isArray(urls) ? urls.map((cdn) => cdn?.url).find((candidate) => typeof candidate === 'string' && candidate.length > 0) : undefined
|
||||
if (typeof url === 'string') images.push({ url, width: 0, height: 0 })
|
||||
}
|
||||
}
|
||||
const tags = Array.isArray(raw.tagList)
|
||||
? raw.tagList
|
||||
.filter((tag) => tag !== null && typeof tag === 'object' && typeof tag.tagName === 'string')
|
||||
.map((tag) => ({ id: String(tag.tagId ?? ''), name: tag.tagName }))
|
||||
: []
|
||||
const shareUrl = typeof raw.shareUrl === 'string' && raw.shareUrl.length > 0 ? raw.shareUrl : momentShareUrl(momentId)
|
||||
const repost = data.repostSource ?? raw.repostSource
|
||||
const repostedFrom = repost !== null && typeof repost === 'object' ? String(repost.text ?? '').slice(0, 300) : ''
|
||||
return {
|
||||
momentId: String(raw.momentId ?? momentId),
|
||||
momentType: Number(raw.momentType) || 0,
|
||||
text: String(raw.text ?? raw.replaceUbbText ?? '').trim(),
|
||||
createTime: String(raw.createTime ?? ''),
|
||||
authorId: String(user.id ?? ''),
|
||||
authorName: String(user.name ?? ''),
|
||||
authorAvatar: typeof user.headUrl === 'string' ? user.headUrl : '',
|
||||
authorSignature: typeof user.signature === 'string' ? user.signature : '',
|
||||
authorFollowers: Number(user.fanCountValue ?? user.fanCount) || 0,
|
||||
images,
|
||||
tags,
|
||||
likeCount: Number(raw.likeCount) || 0,
|
||||
commentCount: Number(raw.commentCount) || 0,
|
||||
bananaCount: Number(raw.bananaCount) || 0,
|
||||
shareCount: Number(raw.shareCount) || 0,
|
||||
isLike: raw.isLike === true,
|
||||
isThrowBanana: raw.isThrowBanana === true,
|
||||
visibleForFans: raw.visibleForFans === true,
|
||||
repostedFrom,
|
||||
shareUrl,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one page of a moment's comment thread.
|
||||
*
|
||||
* Moments use the same `comment/listByFloor` route as videos, with
|
||||
* `sourceType: 4` (the moment enum — likes/bananas use `resourceType: 10`).
|
||||
*
|
||||
* @param {string|number} input - numeric moment id or communityCircle/moment URL.
|
||||
* @param {{page?: number, signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<Record<string, any>>}
|
||||
*/
|
||||
async momentComments(input, options = {}) {
|
||||
const momentId = normalizeMomentId(String(input))
|
||||
const page = Number(options.page ?? 1)
|
||||
const data = await this.#api('/rest/pc-direct/comment/listByFloor', {
|
||||
query: {
|
||||
sourceId: momentId,
|
||||
sourceType: MOMENT_COMMENT_SOURCE_TYPE,
|
||||
page: Number.isInteger(page) && page > 0 ? page : 1,
|
||||
pivotCommentId: '',
|
||||
newPivotCommentId: '',
|
||||
t: '',
|
||||
supportZtEmot: 'true',
|
||||
},
|
||||
signal: options.signal,
|
||||
})
|
||||
const pageData = mapCommentPage(data, page)
|
||||
return {
|
||||
momentId,
|
||||
page: pageData.currentPage,
|
||||
totalPages: pageData.totalPages,
|
||||
totalCount: pageData.totalCount,
|
||||
pageSize: pageData.pageSize,
|
||||
returned: pageData.items.length,
|
||||
hasMore: pageData.currentPage < pageData.totalPages,
|
||||
url: momentShareUrl(momentId),
|
||||
items: pageData.items,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read of one AcFun topic ("社区圈") page: the only public feed
|
||||
* that lists current moment ids for anonymous clients.
|
||||
*
|
||||
* The page is server-rendered HTML (15 newest items, no pagination) and only
|
||||
* resource-type 3 items carry a `moment-id`.
|
||||
*
|
||||
* @param {string|number} tagId - numeric topic id, for example 488 (教育).
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<Record<string, any>>}
|
||||
*/
|
||||
async topicMoments(tagId, options = {}) {
|
||||
const id = String(tagId ?? '').trim()
|
||||
if (!/^\d+$/.test(id)) {
|
||||
throw new AcfunError('tagId must be a numeric topic id, for example "488".', { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
const response = await this.#http(`https://m.acfun.cn/communityCircle/${id}`, {
|
||||
// The mobile page switches to a JS-only shell when a navigation Accept is
|
||||
// sent; the default wildcard keeps the server-rendered list.
|
||||
accept: '*/*',
|
||||
signal: options.signal,
|
||||
})
|
||||
if (response.status !== 200) {
|
||||
throw new AcfunError(`AcFun topic ${id} returned HTTP ${response.status}.`, {
|
||||
code: response.status === 404 ? 'NOT_FOUND' : 'HTTP_ERROR',
|
||||
route: `/communityCircle/${id}`,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
return parseTopicPage(response.text, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one page of a video's danmaku (弹幕) list.
|
||||
*
|
||||
@@ -863,24 +1182,47 @@ export class AcfunProvider {
|
||||
async throwBanana(input, count, options = {}) {
|
||||
this.assertWritable('Tipping bananas (投蕉)')
|
||||
const acId = normalizeAcId(String(input))
|
||||
const amount = Number(count ?? 1)
|
||||
if (!Number.isInteger(amount) || amount < 1 || amount > 10) {
|
||||
throw new AcfunError('count must be an integer between 1 and 10.', { code: 'INVALID_ARGUMENT' })
|
||||
const amount = validateBananaCount(count)
|
||||
const total = await this.#tipBanana(acId, amount, 2, options)
|
||||
return {
|
||||
ok: true,
|
||||
message: `Tipped ${amount} banana(s) to ac${acId}.`,
|
||||
...total === undefined ? {} : { bananaCount: total },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tip bananas to one moment (动态). Moments use `resourceType: 10`.
|
||||
*
|
||||
* @param {string|number} input - numeric moment id or communityCircle URL.
|
||||
* @param {number} count - bananas to tip, 1-10.
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{ok: true, message: string, bananaCount?: number}>}
|
||||
*/
|
||||
async momentBanana(input, count, options = {}) {
|
||||
this.assertWritable('Tipping bananas to a moment')
|
||||
const momentId = normalizeMomentId(String(input))
|
||||
const amount = validateBananaCount(count)
|
||||
const total = await this.#tipBanana(momentId, amount, MOMENT_RESOURCE_TYPE, options)
|
||||
return {
|
||||
ok: true,
|
||||
message: `Tipped ${amount} banana(s) to moment ${momentId}.`,
|
||||
...total === undefined ? {} : { bananaCount: total },
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared banana request for videos (resourceType 2) and moments (10). */
|
||||
async #tipBanana(resourceId, amount, resourceType, options) {
|
||||
const udid = this.cookies._did
|
||||
const headers = udid === undefined ? {} : { udid }
|
||||
const data = await this.#api('/rest/pc-direct/banana/throwBanana', {
|
||||
method: 'POST',
|
||||
form: { resourceId: acId, count: amount, resourceType: 2 },
|
||||
form: { resourceId, count: amount, resourceType },
|
||||
headers,
|
||||
signal: options.signal,
|
||||
})
|
||||
const total = Number(data.extData?.bananaRealCount)
|
||||
return {
|
||||
ok: true,
|
||||
message: `Tipped ${amount} banana(s) to ac${acId}.`,
|
||||
...Number.isFinite(total) ? { bananaCount: total } : {},
|
||||
}
|
||||
return Number.isFinite(total) ? total : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -943,6 +1285,42 @@ export class AcfunProvider {
|
||||
return { ok: true, message: like ? `Liked ac${acId}.` : `Removed the like from ac${acId}.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Like or unlike one moment (动态).
|
||||
*
|
||||
* Moments are addressed by `objectType: 10` on the same signed-token
|
||||
* interaction endpoint as videos. The contract is derived from the site's own
|
||||
* store (`resourceType = 10`); the success envelope is `result: 1`, not 0, so
|
||||
* both codes are accepted.
|
||||
*
|
||||
* @param {string|number} input - numeric moment id or communityCircle URL.
|
||||
* @param {boolean} like - true to like, false to remove the like.
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{ok: true, message: string}>}
|
||||
*/
|
||||
async setMomentLike(input, like, options = {}) {
|
||||
this.assertWritable(like ? 'Liking a moment' : 'Unliking a moment')
|
||||
const momentId = normalizeMomentId(String(input))
|
||||
const token = await this.midgroundToken({ signal: options.signal })
|
||||
const form = {
|
||||
kpn: 'ACFUN_APP',
|
||||
kpf: 'PC_WEB',
|
||||
subBiz: 'mainApp',
|
||||
interactType: 1,
|
||||
objectType: MOMENT_RESOURCE_TYPE,
|
||||
objectId: momentId,
|
||||
userId: token.userId,
|
||||
[token.key]: token.value,
|
||||
}
|
||||
await this.#api(`${this.interactBaseUrl}${like ? '/rest/zt/interact/add' : '/rest/zt/interact/delete'}`, {
|
||||
method: 'POST',
|
||||
form,
|
||||
okResults: [0, 1],
|
||||
signal: options.signal,
|
||||
})
|
||||
return { ok: true, message: like ? `Liked moment ${momentId}.` : `Removed the like from moment ${momentId}.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* List the signed-in user's favorite folders for one resource.
|
||||
*
|
||||
@@ -1073,4 +1451,50 @@ export class AcfunProvider {
|
||||
message: replyTo.length > 0 ? `Replied to comment ${replyTo} on ac${acId}.` : `Commented on ac${acId}.`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a comment (or reply) on one moment (动态).
|
||||
*
|
||||
* The mobile comment widget builds itself with `resourceType: 4`, which is the
|
||||
* same enum the verified read path uses as `sourceType: 4`. Both `objectId`
|
||||
* and `sourceId` are sent because the two client variants disagree; the
|
||||
* request is documented as UNVERIFIED until an account can exercise it.
|
||||
*
|
||||
* @param {string|number} input - numeric moment id or communityCircle URL.
|
||||
* @param {string} content - plain text or AcFun UBB content.
|
||||
* @param {{replyToCommentId?: string, signal?: AbortSignal}} [options]
|
||||
* @returns {Promise<{ok: true, commentId: string, message: string, url: string}>}
|
||||
*/
|
||||
async postMomentComment(input, content, options = {}) {
|
||||
this.assertWritable('Commenting on a moment')
|
||||
const momentId = normalizeMomentId(String(input))
|
||||
const body = typeof content === 'string' ? content.trim() : ''
|
||||
if (body.length === 0) {
|
||||
throw new AcfunError('content must be a non-empty string.', { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
if (body.length > 2000) {
|
||||
throw new AcfunError('content is longer than 2000 characters.', { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
const replyTo = String(options.replyToCommentId ?? '').trim()
|
||||
if (replyTo.length > 0 && !/^\d+$/.test(replyTo)) {
|
||||
throw new AcfunError('replyToCommentId must be a numeric comment id.', { code: 'INVALID_ARGUMENT' })
|
||||
}
|
||||
const data = await this.#api('/rest/pc-direct/comment/add', {
|
||||
method: 'POST',
|
||||
jsonBody: {
|
||||
objectId: momentId,
|
||||
sourceId: momentId,
|
||||
sourceType: MOMENT_COMMENT_SOURCE_TYPE,
|
||||
content: body,
|
||||
replyToCommentId: replyTo.length > 0 ? Number(replyTo) : 0,
|
||||
},
|
||||
signal: options.signal,
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
commentId: String(data.commentId ?? data.cid ?? ''),
|
||||
message: replyTo.length > 0 ? `Replied to comment ${replyTo} on moment ${momentId}.` : `Commented on moment ${momentId}.`,
|
||||
url: momentShareUrl(momentId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user