/ clis / bilibili / feed.js
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  });