import { getUID, pascalCase, raiseEvent } from '@/core/utils' import { getJsonWithCredentials } from '@/core/ajax' import { formatCount, formatDuration, parseCount, parseDuration } from '@/core/utils/formatters' import { watchlaterList } from '@/components/video/watchlater' import { getData, registerData } from '@/plugins/data' import { descendingStringSort } from '@/core/utils/sort' import { VideoCard } from '../video-card' import { FeedsCard, FeedsCardType, feedsCardTypes } from './types' import { childList } from '@/core/observer' import { select } from '@/core/spin-query' export * from './types' export * from './manager' /** * 搜索视频卡片中重复的 aid, 合并为联合投稿 * @param cards 视频卡片 */ export const groupVideoFeeds = (cards: VideoCard[]) => { const groups = lodash.groupBy(cards, c => c.aid) const cardToCooperationItem = (card: VideoCard) => ({ id: card.upID, name: card.upName, faceUrl: card.upFaceUrl, }) const results = Object.values(groups) .map(groupCards => { if (groupCards.length === 1) { return groupCards[0] } const [firstCard, ...restCards] = groupCards firstCard.cooperation = [ cardToCooperationItem(firstCard), ...restCards.map(cardToCooperationItem), ] console.log([...firstCard.cooperation]) return firstCard }) .sort(descendingStringSort(it => it.id)) return results } /** 判断动态卡片是否包含预约功能. * > 在预约功能刚上线时, 无论使用什么参数请求动态流, 预约过的东西都会插入到其中. * > 比如传参番剧类型结果依然混入的预约视频, 导致数据混乱. 不知道之后有没有修复, 目前是使用此方法过滤掉预约类卡片. */ export const isPreOrderedVideo = (card: any) => lodash.get(card, 'extra.is_reserve_recall', 0) === 1 export interface FeedsContentFilter { filter: (items: T[]) => T[] } const contentFiltersKey = 'feeds.contentFilters' registerData(contentFiltersKey, [] as FeedsContentFilter[]) /** 对动态内容进行过滤 */ export const applyContentFilter = (items: T[]) => { const [contentFilters] = getData(contentFiltersKey) as [FeedsContentFilter[]] const result = contentFilters.reduce((acc, it) => (acc = it.filter(acc)), items) return result } /** 对异步获取动态内容的函数进行包装, 将返回值套用 `applyContentFilter` */ export const withContentFilter = (func: (...args: Args) => Promise) => (...args: Args) => func(...args).then(items => applyContentFilter(items)) /** * 获取动态 API 地址 * @param type 动态类型, 或传入类型ID列表返回最新动态 * @param afterID 返回指定ID之前的动态历史, 省略则返回最新的动态 */ export const getFeedsUrl = (type: FeedsCardType | string, afterID?: string | number) => { const params = new URLSearchParams() if (typeof type === 'string') { params.set('type', type) } else if (type.apiType) { params.set('type', type.apiType) } else { console.warn(`unknown apiType for ${type.name}`) params.set('type', 'all') } if (afterID) { params.set('offset', afterID.toString()) } return `https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all?${params.toString()}` } /** * 获取动态 * @param type 动态类型, 或传入类型ID列表返回最新动态 * @param afterID 返回指定ID之前的动态历史, 省略则返回最新的动态 */ export const getFeeds = async (type: FeedsCardType | string, afterID?: string | number) => getJsonWithCredentials(getFeedsUrl(type, afterID)) /** * 获取视频或番剧动态 * @param type 指定视频 (video) 或番剧 (bangumi) 类型, 默认是视频 * @param afterID 返回指定ID之前的动态历史, 省略则返回最新的动态 */ export const getVideoFeeds = withContentFilter( async (type: 'video' | 'bangumi' = 'video', afterID?: string | number): Promise => { if (!getUID()) { return [] } const json = await getJsonWithCredentials( getFeedsUrl(type === 'video' ? feedsCardTypes.video : feedsCardTypes.bangumi, afterID), ) if (json.code !== 0) { throw new Error(json.message) } const dataCards = json.data.items as any[] if (type === 'video') { return groupVideoFeeds( dataCards.map((card: any): VideoCard => { const archive = lodash.get(card, 'modules.module_dynamic.major.archive') const author = lodash.get(card, 'modules.module_author') const dynamic = lodash.get(card, 'modules.module_dynamic.desc.text', '') const stat = lodash.get(card, 'modules.module_stat') const topics = ( lodash.get(card, 'modules.module_dynamic.desc.rich_text_nodes', []) as any[] ) .filter(node => node.type === 'RICH_TEXT_NODE_TYPE_TOPIC') .map(node => { return { id: node.text, name: node.text, url: node.jump_url, } }) return { id: card.id_str, aid: parseInt(archive.aid), bvid: archive.bvid, title: archive.title, upFaceUrl: author.face, upName: author.name, upID: author.mid, coverUrl: archive.cover, description: archive.desc, timestamp: author.pub_ts * 1000, time: new Date(author.pub_ts * 1000), topics, dynamic, like: formatCount(stat.like.count), duration: parseDuration(archive.duration_text), durationText: formatDuration(parseDuration(archive.duration_text)), playCount: formatCount(parseCount(archive.stat.play)), danmakuCount: formatCount(parseCount(archive.stat.danmaku)), watchlater: watchlaterList.includes(archive.aid), } }), ) } if (type === 'bangumi') { return dataCards.map((card: any): VideoCard => { const pgc = lodash.get(card, 'modules.module_dynamic.major.pgc') const author = lodash.get(card, 'modules.module_author') const stat = lodash.get(card, 'modules.module_stat') return { id: card.id_str, epID: pgc.epid, title: pgc.title.replace(new RegExp(`^${author.name}:`), ''), upName: author.name, upFaceUrl: author.face, coverUrl: pgc.cover, description: '', timestamp: author.pub_ts * 1000, time: new Date(author.pub_ts * 1000), like: formatCount(stat.like.count), durationText: '', playCount: formatCount(parseCount(pgc.stat.play)), danmakuCount: formatCount(parseCount(pgc.stat.danmaku)), watchlater: false, } }) } return [] }, ) /** * 向动态卡片的菜单中添加菜单项 * @param card 动态卡片 * @param config 菜单项配置 */ export const addMenuItem = ( card: FeedsCard, config: { className: string text: string action: (e: MouseEvent) => void }, ) => { const morePanel = dq( card.element, '.more-panel, .bili-dyn-more__menu, .opus-more__menu, .bili-dyn-item__more, .opus-more', ) as HTMLElement const { className, text, action } = config if (!morePanel || dq(morePanel, `.${className}`)) { console.warn('more panel not found', card.element) return } const isV2 = !morePanel.classList.contains('more-panel') const isOpus = morePanel.classList.contains('opus-more__menu') const isCascader = morePanel.classList.contains('bili-dyn-item__more') || morePanel.classList.contains('opus-more') const createMenuItem = (): HTMLElement => { if (isCascader) { const menuItem = document.createElement('div') menuItem.innerHTML = /* html */ `
${text}
` return menuItem } const menuItem = document.createElement(isV2 ? 'div' : 'p') if (isOpus) { menuItem.classList.add('opus-more__menu__item', className) const styleReferenceElement = morePanel.children[0] as HTMLElement if (styleReferenceElement) { menuItem.setAttribute('style', styleReferenceElement.getAttribute('style')) } menuItem.dataset.type = 'more' menuItem.dataset.style = lodash.snakeCase(`ThreePoint${pascalCase(className)}`).toUpperCase() menuItem.dataset.params = '{}' } else if (isV2) { menuItem.classList.add('bili-dyn-more__menu__item', className) const styleReferenceElement = morePanel.children[0] as HTMLElement if (styleReferenceElement) { menuItem.setAttribute('style', styleReferenceElement.getAttribute('style')) } else { menuItem.style.height = '25px' menuItem.style.padding = '2px 0' menuItem.style.textAlign = 'center' } menuItem.dataset.module = 'more' menuItem.dataset.type = lodash.snakeCase(`ThreePoint${pascalCase(className)}`).toUpperCase() menuItem.dataset.params = '{}' } else { menuItem.classList.add('child-button', 'c-pointer', className) } menuItem.textContent = text const vueScopeAttributes = [ ...new Set( [...morePanel.children] .map((element: HTMLElement) => element.getAttributeNames().filter(it => it.startsWith('data-v-')), ) .flat(), ), ] vueScopeAttributes.forEach(attr => menuItem.setAttribute(attr, '')) return menuItem } if (isCascader) { ;(async () => { const cascader = await select(() => dq(morePanel, '.bili-cascader')) childList(cascader, (_, observer) => { const cascaderOptions = dq(cascader, '.bili-cascader-options') if (cascaderOptions === null) { return } observer.disconnect() const menuItem = createMenuItem() menuItem.addEventListener('click', e => { action(e) const triggerButton = dq(morePanel, '.bili-dyn-more__btn') as HTMLElement | null if (triggerButton !== null) { raiseEvent(triggerButton, 'mouseleave') } else { raiseEvent(morePanel, 'mouseleave') } }) cascaderOptions.appendChild(menuItem) }) })() return } const menuItem = createMenuItem() menuItem.addEventListener('click', e => { action(e) card.element.click() }) morePanel.appendChild(menuItem) }