feed.js
1 import { cli, Strategy } from '@jackwener/opencli/registry'; 2 import { apiGet, payloadData, resolveUid, stripHtml } from './utils.js'; 3 4 /** Map bilibili dynamic type to readable short name */ 5 const TYPE_MAP = { 6 DYNAMIC_TYPE_AV: 'video', 7 DYNAMIC_TYPE_DRAW: 'draw', 8 DYNAMIC_TYPE_ARTICLE: 'article', 9 DYNAMIC_TYPE_FORWARD: 'forward', 10 DYNAMIC_TYPE_WORD: 'text', 11 DYNAMIC_TYPE_LIVE_RCMD: 'live', 12 DYNAMIC_TYPE_PGC: 'bangumi', 13 }; 14 15 function parseItem(item) { 16 const modules = item.modules ?? {}; 17 const authorModule = modules.module_author ?? {}; 18 const dynamicModule = modules.module_dynamic ?? {}; 19 const major = dynamicModule.major ?? {}; 20 const stat = modules.module_stat ?? {}; 21 22 let title = ''; 23 let url = item.id_str ? `https://t.bilibili.com/${item.id_str}` : ''; 24 const itemType = TYPE_MAP[item.type] ?? item.type ?? ''; 25 26 // video 27 if (major.archive) { 28 title = major.archive.title ?? ''; 29 url = major.archive.jump_url ? `https:${major.archive.jump_url}` : url; 30 } 31 // article 32 if (!title && major.article) { 33 title = major.article.title ?? ''; 34 url = major.article.jump_url ? `https:${major.article.jump_url}` : url; 35 } 36 // text content in desc 37 if (!title && dynamicModule.desc?.text) { 38 title = stripHtml(dynamicModule.desc.text).slice(0, 60); 39 } 40 // draw (图文) — use opus or draw items count as hint 41 if (!title && major.draw) { 42 const imgCount = major.draw.items?.length ?? 0; 43 title = imgCount > 0 ? `[图片x${imgCount}]` : '[图文动态]'; 44 } 45 // VIP only content 46 if (!title && item.basic?.is_only_fans) { 47 title = '[充电专属]'; 48 } 49 // forward 50 if (!title && item.type === 'DYNAMIC_TYPE_FORWARD') { 51 title = '[转发动态]'; 52 } 53 // final fallback 54 if (!title) { 55 title = `[${itemType || '动态'}]`; 56 } 57 58 const time = authorModule.pub_time ?? ''; 59 const likes = stat.like?.count ?? 0; 60 const comments = stat.comment?.count ?? 0; 61 62 return { title, url, itemType, author: authorModule.name ?? '', time, likes, comments }; 63 } 64 65 cli({ 66 site: 'bilibili', 67 name: 'feed', 68 description: '动态时间线(不传 uid 查关注时间线,传 uid 查指定用户动态)', 69 domain: 'www.bilibili.com', 70 strategy: Strategy.COOKIE, 71 args: [ 72 { name: 'uid', positional: true, required: false, help: '用户 UID 或用户名(不传则显示关注时间线)' }, 73 { name: 'limit', type: 'int', default: 20, help: 'Max results to return' }, 74 { name: 'type', default: 'all', help: 'Filter: all, video, article, draw, text' }, 75 { name: 'pages', type: 'int', default: 1, help: 'Number of pages to fetch (each ~20 items)' }, 76 ], 77 columns: ['rank', 'time', 'author', 'title', 'type', 'likes', 'url'], 78 func: async (page, kwargs) => { 79 const maxResults = Number(kwargs.limit) || 20; 80 const maxPages = Number(kwargs.pages) || 1; 81 const filterType = kwargs.type === 'all' ? '' : (kwargs.type ?? ''); 82 83 const isUserFeed = !!kwargs.uid; 84 const uid = isUserFeed ? await resolveUid(page, String(kwargs.uid)) : null; 85 86 const rows = []; 87 let offset = ''; 88 89 for (let p = 0; p < maxPages; p++) { 90 if (rows.length >= maxResults) break; 91 92 let payload; 93 if (isUserFeed) { 94 const params = { host_mid: uid, timezone_offset: -480 }; 95 if (offset) params.offset = offset; 96 payload = await apiGet(page, '/x/polymer/web-dynamic/v1/feed/space', { params }); 97 } else { 98 const params = { 99 timezone_offset: -480, 100 type: filterType || 'all', 101 page: p + 1, 102 }; 103 if (offset) params.offset = offset; 104 payload = await apiGet(page, '/x/polymer/web-dynamic/v1/feed/all', { params }); 105 } 106 107 const data = payloadData(payload) ?? {}; 108 const items = data.items ?? []; 109 if (items.length === 0) break; 110 111 for (const item of items) { 112 if (rows.length >= maxResults) break; 113 const parsed = parseItem(item); 114 if (filterType && parsed.itemType !== filterType) continue; 115 rows.push({ 116 rank: rows.length + 1, 117 time: parsed.time, 118 author: parsed.author, 119 title: parsed.title, 120 type: parsed.itemType, 121 likes: parsed.likes, 122 url: parsed.url, 123 }); 124 } 125 126 offset = data.offset ?? items[items.length - 1]?.id_str ?? ''; 127 if (!offset || !data.has_more) break; 128 } 129 130 return rows; 131 }, 132 }); 133 134 cli({ 135 site: 'bilibili', 136 name: 'feed-detail', 137 description: '查看 Bilibili 动态详情(支持充电专属内容)', 138 domain: 'www.bilibili.com', 139 strategy: Strategy.COOKIE, 140 args: [ 141 { name: 'id', positional: true, required: true, help: '动态 ID(从 feed 命令的 url 中获取)' }, 142 ], 143 columns: ['field', 'value'], 144 func: async (page, kwargs) => { 145 const id = String(kwargs.id); 146 const payload = await apiGet(page, '/x/polymer/web-dynamic/v1/detail', { 147 params: { id, timezone_offset: -480 }, 148 }); 149 150 const rows = []; 151 const data = payloadData(payload); 152 const item = data?.item; 153 if (!item) { 154 rows.push({ field: 'error', value: '动态不存在或无权查看'}); 155 return rows; 156 } 157 158 const modules = item.modules ?? {}; 159 const author = modules.module_author ?? {}; 160 const dynamicModule = modules.module_dynamic ?? {}; 161 const major = dynamicModule.major ?? {}; 162 const stat = modules.module_stat ?? {}; 163 164 rows.push({ field: 'id', value: item.id_str ?? id }); 165 rows.push({ field: 'author', value: author.name ?? '' }); 166 rows.push({ field: 'time', value: author.pub_time ?? '' }); 167 rows.push({ field: 'type', value: TYPE_MAP[item.type] ?? item.type ?? '' }); 168 169 // text content 170 if (dynamicModule.desc?.text) { 171 rows.push({ field: 'text', value: stripHtml(dynamicModule.desc.text) }); 172 } 173 174 // video 175 if (major.archive) { 176 rows.push({ field: 'video_title', value: major.archive.title ?? '' }); 177 rows.push({ field: 'video_desc', value: major.archive.desc ?? '' }); 178 rows.push({ field: 'video_url', value: major.archive.jump_url ? `https:${major.archive.jump_url}` : '' }); 179 rows.push({ field: 'play', value: String(major.archive.stat?.play ?? '') }); 180 rows.push({ field: 'danmaku', value: String(major.archive.stat?.danmaku ?? '') }); 181 } 182 183 // article 184 if (major.article) { 185 rows.push({ field: 'article_title', value: major.article.title ?? '' }); 186 rows.push({ field: 'article_url', value: major.article.jump_url ? `https:${major.article.jump_url}` : '' }); 187 } 188 189 // draw (images) 190 if (major.draw?.items?.length) { 191 rows.push({ field: 'images', value: major.draw.items.map((img) => img.src).join('\n') }); 192 } 193 194 // opus (rich text, some dynamics use this) 195 if (major.opus?.summary?.text) { 196 rows.push({ field: 'opus_text', value: stripHtml(major.opus.summary.text) }); 197 } 198 if (major.opus?.title) { 199 rows.push({ field: 'opus_title', value: major.opus.title }); 200 } 201 202 // forward - show original dynamic info 203 if (item.orig) { 204 const origAuthor = item.orig.modules?.module_author?.name ?? ''; 205 const origDesc = item.orig.modules?.module_dynamic?.desc?.text ?? ''; 206 rows.push({ field: 'forward_from', value: origAuthor }); 207 if (origDesc) rows.push({ field: 'forward_text', value: stripHtml(origDesc).slice(0, 200) }); 208 } 209 210 // stats 211 rows.push({ field: 'likes', value: String(stat.like?.count ?? 0) }); 212 rows.push({ field: 'comments', value: String(stat.comment?.count ?? 0) }); 213 rows.push({ field: 'forwards', value: String(stat.forward?.count ?? 0) }); 214 rows.push({ field: 'url', value: `https://t.bilibili.com/${item.id_str ?? id}` }); 215 216 return rows; 217 }, 218 });