cortico-world-mosey 2.2.0 → 2.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # 更新日志
2
2
 
3
+ ## 2.3.0
4
+
5
+ **B站也能抽帧了——用播放器进度条的预览图,不用登录、不开播放页、不抢你的浏览器。**
6
+
7
+ 以前设置里的「看视频抽几帧」只对抖音有效:抖音没有字幕,只能靠截图看画面;B站是纯接口读取(字幕/弹幕/评论),从头到尾没打开过播放页,所以那个数字对 B站一直是空的。
8
+
9
+ ### 怎么做的
10
+
11
+ - 走 B站 自己的进度条预览图接口(`player/videoshot`):一次请求返回一张 10×10 的精灵图 + 每一帧的时间,按时间挑最近的一格裁出来。
12
+ - 裁剪没有引入图片库,用的是已经依赖的无头浏览器:把精灵图铺上去,截视口那么大一块。浏览器懒启动、闲置 3 分钟自己关,不常驻。
13
+ - 不用登录态,也不影响「B站不登录也能用」这个定位。
14
+
15
+ ### 要知道的局限
16
+
17
+ - 分辨率只有 **160×90**:看得出「在室内 / 在做饭 / 画面很暗」,**看不清字和细节**。
18
+ - 每一帧的间隔约 6 秒,做不到精确到秒。
19
+ - 有些视频/番剧不提供预览图,这时会如实返回「没拿到预览图」,不会硬凑一张。
20
+
21
+ 顺手把「找 playwright-core」的逻辑抽成了共享模块 `src/browser.js`,抖音和 B站 用的是同一份,不再各写一遍。
22
+
3
23
  ## 2.2.0
4
24
 
5
25
  **B站视频不再「只看了开头」——字幕改成按时间分段抽样,还能定点读某一段。**
package/describe.json CHANGED
@@ -87,7 +87,7 @@
87
87
  "type": "integer",
88
88
  "minimum": 0,
89
89
  "maximum": 3,
90
- "description": "抖音抽几帧;默认 0,最省 token。"
90
+ "description": "open 用:抽几帧画面。B站用进度条缩略图(160×90,不用登录),抖音用播放页截图。默认 0,最省 token。"
91
91
  },
92
92
  "at": {
93
93
  "type": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-mosey",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "description": "摸鱼(Mosey):让 Coopanion 的桌宠自己去抖音和 B站逛一圈。评论和私信都能收能回;能一起看视频(B站带字幕弹幕,抖音可自动连刷);关注流、周报、本地书库;按额度点赞、评论、关注、发动态。默认人审,三把工具、少 token。\n\nQQ 交流群:745879632",
6
6
  "license": "MIT",
package/src/bilibili.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  truncate,
16
16
  uuid,
17
17
  } from './util.js';
18
+ import { launchHeadless } from './browser.js';
18
19
 
19
20
  const UA =
20
21
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
@@ -725,6 +726,119 @@ export class Bilibili {
725
726
  return out2;
726
727
  }
727
728
 
729
+ /**
730
+ * B站抽帧:走播放器的「进度条预览图」接口(videoshot)。
731
+ * 不用登录、不用开播放页:一次接口给一张 10×10 的精灵图 + 每格的秒数,
732
+ * 按时间挑最近的一格裁出来。分辨率只有 160×90,够看「画面大概在干嘛」,
733
+ * 看不清字和细节——要更清楚只能开播放页截图,那样要登录态还慢,先不做。
734
+ */
735
+ async framesOf(idOrUrl, n = 1) {
736
+ const v = await this.view(idOrUrl);
737
+ if (!v.cid) throw new BiliError('NO_CID', '这条视频拿不到 cid,取不了画面');
738
+ const sheet = await this.storyboard(v.aid, v.cid);
739
+ if (!sheet) throw new BiliError('NO_FRAME', '这条视频没有进度条预览图');
740
+ const count = Math.min(3, Math.max(1, Math.round(Number(n) || 1)));
741
+ const out = [];
742
+ for (let i = 0; i < count; i += 1) {
743
+ // 均匀取:1 帧取中间,2 帧取 1/3、2/3,3 帧取 1/4、2/4、3/4
744
+ const ratio = (i + 1) / (count + 1);
745
+ const slot = Math.min(sheet.index.length - 1, Math.round(ratio * (sheet.index.length - 1)));
746
+ const cell = sheet.cellAt(sheet.index[slot] ?? 0);
747
+ const bytes = await this.cropSprite(cell).catch(() => null);
748
+ if (bytes) out.push({ at: cell.at, bytes });
749
+ }
750
+ return out;
751
+ }
752
+
753
+ /** 预览图索引(按 cid 缓存 10 分钟)。index 里的值就是每一帧的时间(秒)。 */
754
+ async storyboard(aid, cid) {
755
+ const key = `sb:${cid}`;
756
+ const hit = this.mediaCache.get(key);
757
+ if (hit?.sheet && Date.now() - hit.at < 10 * 60 * 1000) return hit.sheet;
758
+ const data = await this.request('/x/player/videoshot', { aid, cid, index: 1 }, { cacheMs: 0 }).catch(() => null);
759
+ const images = (data?.image ?? []).map((u) => (String(u).startsWith('//') ? `https:${String(u)}` : String(u)));
760
+ const index = (data?.index ?? []).map((x) => Number(x) || 0);
761
+ if (!images.length || !index.length) return null;
762
+ const cols = Math.max(1, Number(data.img_x_len) || 10);
763
+ const w = Math.max(1, Number(data.img_x_size) || 160);
764
+ const h = Math.max(1, Number(data.img_y_size) || 90);
765
+ const per = cols * Math.max(1, Number(data.img_y_len) || 10);
766
+ const sheet = {
767
+ images,
768
+ cols,
769
+ w,
770
+ h,
771
+ index,
772
+ /** 第 sec 秒那一帧:落在第几张图、哪一格。长视频会有多张图,index 是跨图连着的。 */
773
+ cellAt: (sec) => {
774
+ let best = 0;
775
+ for (let i = 1; i < index.length; i += 1) {
776
+ if (Math.abs(index[i] - sec) < Math.abs(index[best] - sec)) best = i;
777
+ }
778
+ const page = Math.floor(best / per);
779
+ const local = best % per;
780
+ return {
781
+ at: index[best] ?? sec,
782
+ url: images[page] ?? images[0],
783
+ x: (local % cols) * w,
784
+ y: Math.floor(local / cols) * h,
785
+ w,
786
+ h,
787
+ };
788
+ },
789
+ };
790
+ this.mediaCache.set(key, { at: Date.now(), sheet });
791
+ return sheet;
792
+ }
793
+
794
+ /**
795
+ * 裁一格。没有图片库,就借无头浏览器:把精灵图铺上去,截视口那么大一块。
796
+ * 浏览器懒启动、闲置 3 分钟自己关,不常驻。
797
+ */
798
+ async cropSprite(cell) {
799
+ const page = await this.spritePage();
800
+ const raw = await this.fetchRaw(cell.url, { binary: true });
801
+ if (!raw?.length) return null;
802
+ const b64 = Buffer.from(raw).toString('base64');
803
+ await page.setViewportSize({ width: cell.w, height: cell.h });
804
+ await page.setContent(
805
+ `<style>html,body{margin:0;padding:0;background:#000;overflow:hidden}` +
806
+ `img{position:absolute;left:${-cell.x}px;top:${-cell.y}px}</style>` +
807
+ `<img src="data:image/jpeg;base64,${b64}">`,
808
+ { waitUntil: 'load' },
809
+ );
810
+ return await page.screenshot({ type: 'jpeg', quality: 62, clip: { x: 0, y: 0, width: cell.w, height: cell.h } });
811
+ }
812
+
813
+ async spritePage() {
814
+ if (this._spritePage && !this._spritePage.isClosed()) {
815
+ clearTimeout(this._spriteIdle);
816
+ this._spriteIdle = this.spriteIdleTimer();
817
+ return this._spritePage;
818
+ }
819
+ const browser = await launchHeadless(this.dataDir);
820
+ const page = await browser.newPage({ viewport: { width: 160, height: 90 }, locale: 'zh-CN' });
821
+ this._spriteBrowser = browser;
822
+ this._spritePage = page;
823
+ this._spriteIdle = this.spriteIdleTimer();
824
+ return page;
825
+ }
826
+
827
+ spriteIdleTimer() {
828
+ const timer = setTimeout(() => this.closeSprite(), 3 * 60 * 1000);
829
+ timer.unref?.();
830
+ return timer;
831
+ }
832
+
833
+ async closeSprite() {
834
+ clearTimeout(this._spriteIdle);
835
+ this._spriteIdle = null;
836
+ const browser = this._spriteBrowser;
837
+ this._spriteBrowser = null;
838
+ this._spritePage = null;
839
+ await browser?.close().catch(() => {});
840
+ }
841
+
728
842
  /** 弹幕:comment.bilibili.com/{cid}.xml 是 raw-deflate 的 XML,解压后按 <d p="..."> 读。 */
729
843
  async danmakuOf(cid) {
730
844
  const buf = await this.fetchRaw(`https://comment.bilibili.com/${cid}.xml`, { binary: true });
package/src/browser.js ADDED
@@ -0,0 +1,54 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+
5
+ /**
6
+ * 找 playwright-core:先看环境变量(离线回归用),再直接 import,最后翻宿主的 extensions 目录。
7
+ * 抖音和 B站抽帧都要用,所以才从 douyin.js 里抽出来——两边各写一份迟早会走偏。
8
+ */
9
+ export async function loadPlaywright(dataDir) {
10
+ if (process.env.STROLL_PLAYWRIGHT) return import(process.env.STROLL_PLAYWRIGHT);
11
+ try {
12
+ return await import('playwright-core');
13
+ } catch {
14
+ // 继续找宿主目录
15
+ }
16
+ const require = createRequire(import.meta.url);
17
+ const extensions = path.join(dataDir, '..', '..', '..', 'extensions');
18
+ const candidates = [extensions, process.cwd()];
19
+ // pnpm 的依赖在 .pnpm 里,不一定 hoist 到 node_modules 根
20
+ try {
21
+ const pnpm = path.join(extensions, 'node_modules', '.pnpm');
22
+ for (const name of fs.readdirSync(pnpm)) {
23
+ if (name.startsWith('playwright-core@')) candidates.push(path.join(pnpm, name, 'node_modules'));
24
+ }
25
+ } catch {
26
+ // 没有 .pnpm 就只试普通目录
27
+ }
28
+ for (const dir of candidates) {
29
+ try {
30
+ return require(require.resolve('playwright-core', { paths: [dir] }));
31
+ } catch {
32
+ // 换下一个
33
+ }
34
+ }
35
+ throw new Error('找不到 playwright-core:在插件目录执行 corepack pnpm install,或在扩展目录重装依赖。');
36
+ }
37
+
38
+ /**
39
+ * 后台无头浏览器。playwright-core 不自带内核,所以按 chrome → edge 的顺序借本机装好的,
40
+ * 都没有才退回 Playwright 自带的。
41
+ */
42
+ export async function launchHeadless(dataDir) {
43
+ const mod = await loadPlaywright(dataDir);
44
+ const chromium = mod.chromium ?? mod.default?.chromium;
45
+ const options = { headless: true, args: ['--disable-blink-features=AutomationControlled'] };
46
+ for (const channel of ['chrome', 'msedge']) {
47
+ try {
48
+ return await chromium.launch({ ...options, channel });
49
+ } catch {
50
+ // 试下一个渠道
51
+ }
52
+ }
53
+ return await chromium.launch(options);
54
+ }
package/src/config.js CHANGED
@@ -562,7 +562,9 @@ const VIEWING = {
562
562
  [P + 'framesPerOpen']: {
563
563
  type: 'integer',
564
564
  title: '看视频抽几帧',
565
- description: '0 最省;1 帧约 500 token。',
565
+ description:
566
+ 'B站用进度条缩略图(160×90,不用登录、不开播放页,只看得出画面大概在干嘛);' +
567
+ '抖音用播放页截图。0 最省;1 帧约 500 token。',
566
568
  minimum: 0,
567
569
  maximum: 3,
568
570
  'x-suffix': ' 帧',
package/src/douyin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
3
  import { cutChars, formatDuration, humanPause, imageMimeOf, parseDouyinId, parseKeywords, stripAtName, stripAtPlaceholder, truncate } from './util.js';
4
+ import { loadPlaywright } from './browser.js';
5
5
 
6
6
  const ORIGIN = 'https://www.douyin.com';
7
7
 
@@ -690,36 +690,16 @@ export class DouyinBrowser {
690
690
  * 依次尝试:环境变量 → 插件自己的依赖 → Coopanion 扩展目录里已有的 playwright-core。
691
691
  * link: 安装时依赖不一定被 hoist 到插件目录,这个兜底能少一堆“浏览器没起来”。
692
692
  */
693
+ /** 复用 src/browser.js 的共享实现(B站抽帧也要用),错误码仍按抖音这边抛。 */
693
694
  async loadPlaywright() {
694
- if (process.env.STROLL_PLAYWRIGHT) return import(process.env.STROLL_PLAYWRIGHT);
695
695
  try {
696
- return await import('playwright-core');
696
+ return await loadPlaywright(this.dataDir);
697
697
  } catch {
698
- // 继续找宿主目录
699
- }
700
- const require = createRequire(import.meta.url);
701
- const extensions = path.join(this.dataDir, '..', '..', '..', 'extensions');
702
- const candidates = [extensions, process.cwd()];
703
- // pnpm 的依赖在 .pnpm 里,不一定 hoist 到 node_modules 根
704
- try {
705
- const pnpm = path.join(extensions, 'node_modules', '.pnpm');
706
- for (const name of fs.readdirSync(pnpm)) {
707
- if (name.startsWith('playwright-core@')) candidates.push(path.join(pnpm, name, 'node_modules'));
708
- }
709
- } catch {
710
- // 没有 .pnpm 就只试普通目录
711
- }
712
- for (const dir of candidates) {
713
- try {
714
- return require(require.resolve('playwright-core', { paths: [dir] }));
715
- } catch {
716
- // 换下一个
717
- }
698
+ throw new DouyinError(
699
+ 'BROWSER_DEP',
700
+ '找不到 playwright-core:在插件目录执行 corepack pnpm install,或在扩展目录重装依赖。',
701
+ );
718
702
  }
719
- throw new DouyinError(
720
- 'BROWSER_DEP',
721
- '找不到 playwright-core:在插件目录执行 corepack pnpm install,或在扩展目录重装依赖。',
722
- );
723
703
  }
724
704
 
725
705
  /**
package/src/tools.js CHANGED
@@ -25,7 +25,13 @@ export const TOOL_DECLS = [
25
25
  chars: { type: 'integer', minimum: 200, maximum: 2000, description: 'read 一次读多少字,默认 900。' },
26
26
  text: { type: 'string', description: 'note 用:要记下来的那句摘抄。' },
27
27
  tag: { type: 'string', description: 'stickers 用:只看带这个标签的表情包。' },
28
- frames: { type: 'integer', minimum: 0, maximum: 3, description: '抖音抽几帧;默认 0,最省 token。' },
28
+ frames: {
29
+ type: 'integer',
30
+ minimum: 0,
31
+ maximum: 3,
32
+ description:
33
+ 'open 用:抽几帧画面。B站用进度条缩略图(160×90,不用登录),抖音用播放页截图。默认 0,最省 token。',
34
+ },
29
35
  at: {
30
36
  type: ['string', 'integer'],
31
37
  description:
package/src/world.js CHANGED
@@ -3426,7 +3426,9 @@ export class StrollWorld {
3426
3426
  })
3427
3427
  .catch(() => null);
3428
3428
  }
3429
- return JSON.stringify({
3429
+ // 抽帧:B站用进度条预览图,不用登录、不开播放页;没有就如实说,不硬凑
3430
+ const shots = bvid && frames > 0 ? await this.bili.framesOf(bvid, frames).catch(() => []) : [];
3431
+ const body = JSON.stringify({
3430
3432
  notice: CONTENT_NOTICE,
3431
3433
  ok: true,
3432
3434
  ...detail,
@@ -3439,9 +3441,27 @@ export class StrollWorld {
3439
3441
  mediaNote: this.mediaNoteOf(media, at),
3440
3442
  }
3441
3443
  : {}),
3444
+ ...(frames > 0
3445
+ ? {
3446
+ framesNote: shots.length
3447
+ ? `附了 ${shots.length} 张画面(约 ${shots.map((s) => clockText(s.at)).join(' / ')}),` +
3448
+ '是 B站进度条的缩略图(160×90),只看得出画面大概在干嘛,看不清字。'
3449
+ : '这条没拿到预览图(有些视频/番剧不提供),所以看不到画面。',
3450
+ }
3451
+ : {}),
3442
3452
  ...(this.cfg.openTalk === true ? { followUp: '看完用一两句跟主人说说你的反应,别只复述简介。' } : {}),
3443
3453
  window: '后台看的,没有开网页。要一起看就在面板点「一起看」。',
3444
3454
  });
3455
+ if (!shots.length) return body;
3456
+ return {
3457
+ text: body,
3458
+ blobs: shots.map((s, i) => ({
3459
+ bytes: s.bytes,
3460
+ mime: 'image/jpeg',
3461
+ name: `bili-${detail.id}-${Math.round(s.at)}s.jpg`,
3462
+ fallbackText: `B站画面 约 ${clockText(s.at)}(第 ${i + 1} 张,缩略图)`,
3463
+ })),
3464
+ };
3445
3465
  }
3446
3466
 
3447
3467
  if (platform === 'douyin') {