dsh-baleen 0.1.0 → 0.2.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/AGENTS.md +4 -0
- package/README.md +18 -3
- package/client.js +509 -31
- package/index.js +153 -3
- package/lib/adapters/index.js +5 -4
- package/lib/adapters/xhs.js +173 -0
- package/lib/dedup.js +16 -1
- package/lib/fulltext.js +222 -0
- package/lib/jobs.js +4 -0
- package/lib/mcp/xhs-client.js +206 -0
- package/lib/normalize.js +4 -2
- package/lib/preference.js +23 -2
- package/lib/runner.js +53 -2
- package/lib/store.js +11 -0
- package/package.json +1 -1
- package/src/client/_footer.txt +173 -4
- package/src/client/css.js +48 -10
- package/src/client/format.js +15 -0
- package/src/client/sidebar-tab.js +56 -0
- package/src/client/view-api.js +10 -0
- package/src/client/views/article.js +37 -3
- package/src/client/views/drawer.js +100 -7
- package/src/client/views/player-tab.js +33 -0
- package/src/client/views/settings.js +27 -3
- package/src/client/views/today.js +5 -2
- package/src/client/views/video.js +9 -5
- package/tools/build-client.mjs +2 -1
- package/tools/client-loader.mjs +4 -3
- package/tools/preview.mjs +19 -2
package/client.js
CHANGED
|
@@ -234,6 +234,16 @@ window.__ModuleLoader__.load({
|
|
|
234
234
|
...(typeof newFolder === 'string' && newFolder.trim() ? { new_folder: newFolder.trim() } : {})
|
|
235
235
|
}),
|
|
236
236
|
folderOp: (action, { id, name } = {}) => post('/folder', { action, ...(id != null ? { id } : {}), ...(name != null ? { name } : {}) }),
|
|
237
|
+
// 「浏览器打开」走宿主侧 /open:面板只报条目 id,URL 由宿主从库里取(终审真修——
|
|
238
|
+
// 本宿主把客户端页面的弹窗与外链全部静默拦下,window.open 与 <a target=_blank> 都到不了系统浏览器)。
|
|
239
|
+
openItem: (itemId) => post('/open', { item_id: itemId }),
|
|
240
|
+
// 「抓取正文」:面板→宿主的补抓路由(只报条目 id,抓哪条、抓成什么样由宿主定),
|
|
241
|
+
// 不是 agent 工具——判断那一轮的只读面里没有它。
|
|
242
|
+
fulltext: (itemId) => post('/fulltext', { item_id: itemId }),
|
|
243
|
+
// 小红书(M6):登录态探活与扫码登录。都是面板→宿主路由,不进 agent 工具面。
|
|
244
|
+
// 状态探活会按需拉起 MCP 子进程,所以是手动按钮触发而非挂载即打。
|
|
245
|
+
xhsStatus: () => plainGet('/xhs-status'),
|
|
246
|
+
xhsLoginQr: () => plainGet('/xhs-login-qr'),
|
|
237
247
|
/* 设置页(settings.section)的取数与动作:GET 走 plainGet(不碰共享快照),写走 post/put */
|
|
238
248
|
preferenceGet: () => plainGet('/preference'),
|
|
239
249
|
preferencePut: (section, value) => put('/preference', { section, value }),
|
|
@@ -320,6 +330,21 @@ window.__ModuleLoader__.load({
|
|
|
320
330
|
*/
|
|
321
331
|
const judgeHint = '对会话说一句「把今天的候选判一下」'
|
|
322
332
|
|
|
333
|
+
/**
|
|
334
|
+
* 只认 http/https 的绝对地址,其余(空、非字符串、别的协议、javascript:)一律 null。
|
|
335
|
+
* 库里 url/thumb 都是平台给的原样字段,直接塞进 href/src 等于把外来的字符串当可执行入口。
|
|
336
|
+
*/
|
|
337
|
+
function httpUrl(v) {
|
|
338
|
+
const s = typeof v === 'string' ? v.trim() : ''
|
|
339
|
+
return /^https?:\/\//i.test(s) ? s : null
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 封面地址:和链接同一套判定。视图拿它决定画不画 <img>——坏值不许变成
|
|
344
|
+
* "空 src 的裂图标",那比灰占位更难看也更可疑。
|
|
345
|
+
*/
|
|
346
|
+
const thumbSrc = (thumb) => httpUrl(thumb)
|
|
347
|
+
|
|
323
348
|
// ---- src/client/css.js ----
|
|
324
349
|
/**
|
|
325
350
|
* 面板样式。**这份 CSS 里一个颜色字面量都没有**:所有颜色都从宿主 token 取,
|
|
@@ -347,9 +372,11 @@ window.__ModuleLoader__.load({
|
|
|
347
372
|
.bl-issues{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-state-error-primary)}
|
|
348
373
|
.bl-pending{font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
349
374
|
.bl-actions{display:flex;gap:8px;margin:10px 0 2px}
|
|
350
|
-
.bl-actions button,.bl-detail{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}
|
|
351
|
-
.bl-actions button:hover,.bl-detail:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
352
|
-
.bl-actions button:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
|
|
375
|
+
.bl-actions button,.bl-detail,.bl-fetch{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}
|
|
376
|
+
.bl-actions button:hover,.bl-detail:hover,.bl-fetch:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
377
|
+
.bl-actions button:disabled,.bl-fetch:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
|
|
378
|
+
/* 「抓取正文」是这一屏唯一会出去抓东西的按钮,给个品牌色边框让它站得出来(仍走宿主 token) */
|
|
379
|
+
.bl-fetch{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);align-self:flex-start}
|
|
353
380
|
.bl-today,.bl-video,.bl-article,.bl-digest{display:flex;flex-direction:column;gap:8px;min-width:0}
|
|
354
381
|
.bl-rail{margin-top:18px;min-width:0}
|
|
355
382
|
.bl-rail h3{font-size:13px;font-weight:500;margin:0 0 8px;color:var(--dsw-alias-label-primary)}
|
|
@@ -361,20 +388,25 @@ window.__ModuleLoader__.load({
|
|
|
361
388
|
.bl-grid{display:grid;grid-template-columns:repeat(auto-fill, minmax(260px, 1fr));gap:12px;min-width:0}
|
|
362
389
|
.bl-card,.bl-row{appearance:none;text-align:left;font-family:inherit;color:inherit;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:9px 10px;cursor:pointer;display:flex;flex-direction:column;gap:5px;min-height:32px;min-width:0}
|
|
363
390
|
.bl-card:hover,.bl-row:hover,.bl-row[aria-selected="true"]{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2)}
|
|
364
|
-
.bl-card:focus-visible,.bl-row:focus-visible,.bl-navitem:focus-visible,.bl-actions button:focus-visible,.bl-detail:focus-visible,.bl-dbtns button:focus-visible,.bl-dbar button:focus-visible,.bl-facets input:focus-visible,.bl-collect input:focus-visible,.bl-newfolder input:focus-visible,.bl-newfolder button:focus-visible,.bl-toolbar button:focus-visible,.bl-foldertab:focus-visible,.bl-confirm button:focus-visible,.bl-setsave:focus-visible,.bl-setflag input:focus-visible,.bl-srcrow-head button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
391
|
+
.bl-card:focus-visible,.bl-row:focus-visible,.bl-navitem:focus-visible,.bl-actions button:focus-visible,.bl-detail:focus-visible,.bl-dbtns button:focus-visible,.bl-dbar button:focus-visible,.bl-facets input:focus-visible,.bl-collect input:focus-visible,.bl-newfolder input:focus-visible,.bl-newfolder button:focus-visible,.bl-toolbar button:focus-visible,.bl-foldertab:focus-visible,.bl-openbtns button:focus-visible,.bl-url:focus-visible,.bl-confirm button:focus-visible,.bl-setsave:focus-visible,.bl-setflag input:focus-visible,.bl-srcrow-head button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
365
392
|
.bl-card-title,.bl-row-title{font-size:13px;line-height:1.4;color:var(--dsw-alias-label-primary)}
|
|
366
393
|
.bl-card-meta,.bl-row-meta{font-size:11px;color:var(--dsw-alias-label-tertiary);display:flex;gap:6px;flex-wrap:wrap}
|
|
367
394
|
.bl-why,.bl-row-why{font-size:12px;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-border-l2);padding-left:7px;margin-top:auto}
|
|
368
395
|
.bl-tier{font-size:11px;color:var(--dsw-alias-label-primary)}
|
|
369
396
|
.bl-play{font-size:11px;color:var(--dsw-alias-label-dimmed)}
|
|
370
|
-
.bl-thumb{position:relative;aspect-ratio:16/9;border-radius:6px;background:var(--dsw-alias-bg-layer-3);display:block}
|
|
397
|
+
.bl-thumb{position:relative;aspect-ratio:16/9;border-radius:6px;background:var(--dsw-alias-bg-layer-3);display:block;overflow:hidden}
|
|
398
|
+
.bl-thumb img{width:100%;height:100%;object-fit:cover;display:block}
|
|
371
399
|
.bl-dur{position:absolute;right:5px;bottom:5px;font-size:11px;padding:1px 5px;border-radius:4px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary)}
|
|
372
400
|
.bl-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:14px;align-items:start;min-width:0}
|
|
373
401
|
.bl-list{display:flex;flex-direction:column;gap:8px;min-width:0}
|
|
374
|
-
.bl-reader{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:
|
|
375
|
-
.bl-reader h4{margin:0;font-size:
|
|
402
|
+
.bl-reader{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:16px 18px;min-height:200px;min-width:0;display:flex;flex-direction:column;gap:9px}
|
|
403
|
+
.bl-reader h4{margin:0;font-size:15px;font-weight:500;line-height:1.45;color:var(--dsw-alias-label-primary)}
|
|
376
404
|
.bl-reader-by,.bl-reader-hint,.bl-reader-link{font-size:11px;color:var(--dsw-alias-label-secondary);margin:0}
|
|
377
|
-
.
|
|
405
|
+
/* 阅读排版(护眼三件事):14px/1.8 的中文长文才读得动;行长封顶 42em(约 42 个汉字,
|
|
406
|
+
超过就得转头);空行成段、段间 0.75em,段内单换行原样保留(字幕逐行紧排靠它)。
|
|
407
|
+
限宽只落在文字本身,阅读卡照常填满栏宽——不碰「容器不许限宽」的那条规矩。 */
|
|
408
|
+
.bl-reader-body{font-size:14px;line-height:1.8;white-space:pre-wrap;word-break:break-word;color:var(--dsw-alias-label-primary);max-width:42em;display:flex;flex-direction:column;gap:0.75em}
|
|
409
|
+
.bl-reader-body p{margin:0}
|
|
378
410
|
.bl-library{display:grid;grid-template-columns:150px minmax(0,1fr);gap:14px;align-items:start;min-width:0}
|
|
379
411
|
.bl-library-body{display:flex;flex-direction:column;gap:10px;min-width:0}
|
|
380
412
|
.bl-facets{display:flex;flex-direction:column;gap:12px;font-size:12px;min-width:0}
|
|
@@ -406,7 +438,7 @@ window.__ModuleLoader__.load({
|
|
|
406
438
|
.bl-dmain{padding:12px 14px;display:flex;flex-direction:column;gap:11px;overflow-y:auto;font-size:12px;min-width:0}
|
|
407
439
|
.bl-dmain section{display:flex;flex-direction:column;gap:4px}
|
|
408
440
|
.bl-dmain h5{margin:0 0 3px;font-size:11px;font-weight:500;color:var(--dsw-alias-label-tertiary)}
|
|
409
|
-
.bl-dmain p{margin:0;word-break:break-word;color:var(--dsw-alias-label-primary)}
|
|
441
|
+
.bl-dmain p{margin:0;word-break:break-word;color:var(--dsw-alias-label-primary);line-height:1.65}
|
|
410
442
|
.bl-kv{display:flex;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
411
443
|
.bl-missing{color:var(--dsw-alias-state-error-primary)}
|
|
412
444
|
.bl-collect{display:flex;flex-wrap:wrap;gap:2px 12px;font-size:12px}
|
|
@@ -420,7 +452,36 @@ window.__ModuleLoader__.load({
|
|
|
420
452
|
.bl-newfolder button:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
421
453
|
.bl-newfolder button:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
|
|
422
454
|
.bl-kw{font-size:11px;color:var(--dsw-alias-label-secondary)}
|
|
423
|
-
.bl-url{font-size:12px;color:var(--dsw-alias-brand-primary);word-break:break-all}
|
|
455
|
+
.bl-url{font-size:12px;color:var(--dsw-alias-brand-primary);word-break:break-all;text-decoration:underline}
|
|
456
|
+
/* 内嵌播放器(V5)与侧栏播放(M7)共用一套舞台。宿主不拦 iframe(真机实证),
|
|
457
|
+
bilibili 试图跳出顶层会被 Chromium 自己拦下,无需 sandbox(加了反而杀播放器脚本) */
|
|
458
|
+
.bl-player{display:block;width:100%;border:0;background:var(--dsw-alias-bg-layer-2)}
|
|
459
|
+
/* 舞台:裁切窗与 16:9 视频面等大(aspect-ratio 跟容器宽度走),不再是写死的像素对——
|
|
460
|
+
早先 360px 窗 + 900px iframe,窄容器漏推荐流、宽容器把 B 站控制条(音量在那)裁出窗外。
|
|
461
|
+
YouTube embed 本来就是纯 16:9 播放器,窗口精确合围即止。
|
|
462
|
+
B 站是完整页,实测(无头 Edge 量四档宽度,见 docs/verification.md):视频面恒为
|
|
463
|
+
~660x371(版心固定;容器再宽只是右侧多出推荐列,更窄则横向溢出),页头到视频顶
|
|
464
|
+
~170px。所以 iframe 恒定 660 虚拟宽(永不进"播放器+推荐列"的宽版式)、按容器宽
|
|
465
|
+
等比缩放(tan(atan2()) 是 CSS 做长度除法的既定写法,Chromium 111+;宿主是 2026 年
|
|
466
|
+
的 Electron,Chromium 远新于此,不支持的老引擎会退化成"页头占掉窗口"的坏样子,
|
|
467
|
+
那不是可支持的配置),页头偏移同比上移——任何宽度下视频都精确 16:9 填满
|
|
468
|
+
窗口、控制条(音量)完整在窗内、页头/推荐流/弹幕侧栏都推出窗外。两个常数是真机
|
|
469
|
+
待校值,平台改版错位的退路仍是设置页「裁掉推荐区」开关。 */
|
|
470
|
+
.bl-stage{--bl-nav:170px;--bl-frame:660px;container-type:inline-size;width:100%;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}
|
|
471
|
+
.bl-stage-crop{aspect-ratio:16/9}
|
|
472
|
+
.bl-stage>.bl-player{border-radius:0}
|
|
473
|
+
.bl-stage:not(.bl-stage-crop)>.bl-player{height:640px}
|
|
474
|
+
.bl-stage-crop>.bl-player{height:100%}
|
|
475
|
+
.bl-stage-bili>.bl-player{--bl-scale:tan(atan2(100cqw, var(--bl-frame)));width:var(--bl-frame);height:calc(var(--bl-nav) + var(--bl-frame) * 0.5625);margin-top:calc(var(--bl-nav) * var(--bl-scale) * -1);transform:scale(var(--bl-scale));transform-origin:0 0}
|
|
476
|
+
/* 内层全屏(播放器自己的全屏键)把宿主 iframe 提成 :fullscreen——把缩放与偏移还原,
|
|
477
|
+
不然全屏画面缩在角落 */
|
|
478
|
+
.bl-player:fullscreen{width:100%;height:100%;margin-top:0;transform:none}
|
|
479
|
+
.bl-tab{padding:8px;display:flex;flex-direction:column;gap:6px}
|
|
480
|
+
.bl-tab-head{display:flex;align-items:center;gap:8px;min-width:0;margin:0}
|
|
481
|
+
.bl-tab-title{flex:1;min-width:0;font-size:12px;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
482
|
+
.bl-openbtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
|
|
483
|
+
.bl-openbtns button{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;white-space:nowrap}
|
|
484
|
+
.bl-openbtns button:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
424
485
|
.bl-feedback{margin:0;padding:7px 14px;font-size:12px;background:var(--dsw-alias-bg-layer-2);border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary)}
|
|
425
486
|
.bl-dbtns{display:flex;gap:8px;padding:11px 14px;border-top:1px solid var(--dsw-alias-border-l1)}
|
|
426
487
|
.bl-dbtns button{flex:1;appearance:none;font-family:inherit;font-size:12px;min-height:32px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer}
|
|
@@ -442,6 +503,8 @@ window.__ModuleLoader__.load({
|
|
|
442
503
|
.bl-setsave{appearance:none;font-family:inherit;font-size:12px;min-height:32px;padding:5px 11px;border-radius:7px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;align-self:flex-start}
|
|
443
504
|
.bl-setsave:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
444
505
|
.bl-setsave:disabled{color:var(--dsw-alias-label-dimmed);cursor:progress}
|
|
506
|
+
/* 小红书扫码二维码(M6):PNG 自带白底(QR 可读性在图里),尺寸固定不参与伸缩 */
|
|
507
|
+
.bl-xhsqr{width:180px;height:180px;padding:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}
|
|
445
508
|
.bl-setflag{display:flex;gap:6px;align-items:center;flex-wrap:wrap;min-height:32px;font-size:12px;color:var(--dsw-alias-label-secondary);cursor:pointer}
|
|
446
509
|
.bl-setflag:hover{color:var(--dsw-alias-label-primary)}
|
|
447
510
|
.bl-setflag input{width:14px;height:14px;flex:none;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}
|
|
@@ -583,7 +646,10 @@ window.__ModuleLoader__.load({
|
|
|
583
646
|
|
|
584
647
|
function Card({ item, onOpen }) {
|
|
585
648
|
return vnode('button', { type: 'button', className: 'bl-card', onClick: () => onOpen?.(item.id) }, [
|
|
586
|
-
item.kind === 'video' ? vnode('span', { className: 'bl-thumb' }, [
|
|
649
|
+
item.kind === 'video' ? vnode('span', { className: 'bl-thumb' }, [
|
|
650
|
+
thumbSrc(item.thumb) ? vnode('img', { src: thumbSrc(item.thumb), alt: '', loading: 'lazy' }) : null,
|
|
651
|
+
vnode('span', { className: 'bl-dur' }, [fmtDuration(item.duration_sec) || '—'])
|
|
652
|
+
].filter(Boolean)) : null,
|
|
587
653
|
vnode('span', { className: 'bl-card-title' }, [item.title || '(无标题)']),
|
|
588
654
|
vnode('span', { className: 'bl-card-meta' }, [item.source_id, item.kind === 'note' ? '图文笔记' : item.kind === 'video' ? '视频' : '文章']),
|
|
589
655
|
vnode('span', { className: 'bl-tier' }, [item.verdict_tier ? `已判:${item.verdict_tier}` : '待判']),
|
|
@@ -658,7 +724,10 @@ window.__ModuleLoader__.load({
|
|
|
658
724
|
|
|
659
725
|
function VideoCard({ item, onOpen }) {
|
|
660
726
|
return vnode('button', { type: 'button', className: 'bl-card', key: item.id, onClick: () => onOpen?.(item.id) }, [
|
|
661
|
-
vnode('span', { className: 'bl-thumb' }, [
|
|
727
|
+
vnode('span', { className: 'bl-thumb' }, [
|
|
728
|
+
thumbSrc(item.thumb) ? vnode('img', { src: thumbSrc(item.thumb), alt: '', loading: 'lazy' }) : null,
|
|
729
|
+
vnode('span', { className: 'bl-dur' }, [fmtDuration(item.duration_sec) || '—'])
|
|
730
|
+
].filter(Boolean)),
|
|
662
731
|
vnode('span', { className: 'bl-card-title' }, [item.title || '(无标题)']),
|
|
663
732
|
vnode('span', { className: 'bl-card-meta' }, [
|
|
664
733
|
item.author || item.source_id,
|
|
@@ -666,9 +735,9 @@ window.__ModuleLoader__.load({
|
|
|
666
735
|
]),
|
|
667
736
|
vnode('span', { className: 'bl-tier' }, [item.verdict_tier ? `${item.verdict_tier}:${item.verdict_why || '(这条没写理由)'}` : '待判']),
|
|
668
737
|
vnode('span', { className: 'bl-why' }, [reasonOf(item)]),
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
vnode('span', { className: 'bl-play' }, ['只能跳转'])
|
|
738
|
+
// 尾标必须与抽屉的能力边界一致:能解析出嵌入形态的(B 站 BV / YouTube)就是"抽屉里能播",
|
|
739
|
+
// 解析不出的(b23.tv 短链、别的平台)才写"只能跳转"。
|
|
740
|
+
vnode('span', { className: 'bl-play' }, [playerEmbed(item.url) ? '抽屉里能播' : '只能跳转'])
|
|
672
741
|
])
|
|
673
742
|
}
|
|
674
743
|
|
|
@@ -712,6 +781,8 @@ window.__ModuleLoader__.load({
|
|
|
712
781
|
missing: '读不到内容(没有语音,也没抓到正文)',
|
|
713
782
|
burned_in: '硬字幕读不到(字烧在画面上,不是插件偷懒)'
|
|
714
783
|
}
|
|
784
|
+
/** 已经有正文的那几种:再摆一颗「抓取正文」就是假动作——点了也不会变,反倒让人怀疑是没生效 */
|
|
785
|
+
const HAS_BODY = new Set(['full', 'subtitle', 'transcribed'])
|
|
715
786
|
const STATE_OPTIONS = [
|
|
716
787
|
['full', '全文'], ['summary_only', '仅摘要'], ['subtitle', '字幕'],
|
|
717
788
|
['transcribed', '转写'], ['missing', '读不到'], ['burned_in', '硬字幕']
|
|
@@ -732,10 +803,30 @@ window.__ModuleLoader__.load({
|
|
|
732
803
|
return typeof t === 'string' ? t.trim() : ''
|
|
733
804
|
}
|
|
734
805
|
|
|
735
|
-
|
|
806
|
+
/**
|
|
807
|
+
* 正文分段:空行(\n{2,})是段界,段内单换行原样保留。一条规则服务两种内容——
|
|
808
|
+
* 文章有真实段距,字幕逐行紧排不被拆散(行间的单换行交给 CSS pre-wrap 折行)。
|
|
809
|
+
* 先把 \r\n / \r 归一成 \n:Windows 作者的 feed 里段界是 \r\n\r\n,两个 \n 被
|
|
810
|
+
* \r 隔开就永远凑不成"连续两个换行",分段会静默失效。空白/非字符串返回空数组,
|
|
811
|
+
* 调用方给占位句。
|
|
812
|
+
*/
|
|
813
|
+
function readerParagraphs(text) {
|
|
814
|
+
const s = typeof text === 'string' ? text.replace(/\r\n?/g, '\n') : ''
|
|
815
|
+
return s.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** 右栏当前是哪一条:选中项被筛掉时回落第一条。视图与装配层共用这一条规则,
|
|
819
|
+
* 否则「正在抓的是哪条」会跟右栏画的那条各算一遍,忙态就贴到别人身上。 */
|
|
820
|
+
function currentArticle(items, filters = {}, selectedId) {
|
|
736
821
|
const list = visibleArticles(items, filters)
|
|
737
|
-
|
|
822
|
+
return list.find((i) => i.id === selectedId) ?? list[0] ?? null
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function ArticleView({ items = [], filters = {}, selectedId, onFilter, onSelect, onOpen, onFulltext, fetching = false }) {
|
|
826
|
+
const list = visibleArticles(items, filters)
|
|
827
|
+
const cur = currentArticle(items, filters, selectedId)
|
|
738
828
|
const body = cur ? readableBody(cur) : ''
|
|
829
|
+
const paras = readerParagraphs(body)
|
|
739
830
|
const sources = [...new Set(items.filter((i) => i.kind !== 'video').map((i) => i.source_id))]
|
|
740
831
|
|
|
741
832
|
return vnode('div', { className: 'bl-article' }, [
|
|
@@ -771,8 +862,20 @@ window.__ModuleLoader__.load({
|
|
|
771
862
|
Number(cur.time_estimated) > 0 ? `约 ${Math.round(Number(cur.time_estimated))} 分钟` : ''
|
|
772
863
|
]),
|
|
773
864
|
STATE_HINT[cur.body_state] ? vnode('p', { className: 'bl-reader-hint' }, [STATE_HINT[cur.body_state]]) : null,
|
|
774
|
-
vnode('div', { className: 'bl-reader-body' },
|
|
865
|
+
vnode('div', { className: 'bl-reader-body' },
|
|
866
|
+
paras.length ? paras.map((p, i) => vnode('p', { key: i }, [p])) : ['(没有正文,只能看原文)']),
|
|
775
867
|
vnode('p', { className: 'bl-reader-link' }, ['读原文:' + (cur.url || '(这条没带链接)')]),
|
|
868
|
+
// 已经有正文的(full/subtitle/transcribed)不画这颗按钮:点了也不会有新东西,
|
|
869
|
+
// 摆在那儿只会让人把手里已经有的正文再怀疑一遍。
|
|
870
|
+
HAS_BODY.has(cur.body_state) ? null : vnode('button', {
|
|
871
|
+
type: 'button', className: 'bl-fetch', disabled: Boolean(fetching),
|
|
872
|
+
onClick: () => onFulltext?.(cur.id)
|
|
873
|
+
}, [fetching ? '正在抓取…' : '抓取正文']),
|
|
874
|
+
// 这句不是内容说明(那句在 STATE_HINT 里),是这颗按钮的预期管理:抓不到是常态,
|
|
875
|
+
// 别让人点完一次就以为插件坏了。
|
|
876
|
+
cur.body_state === 'summary_only'
|
|
877
|
+
? vnode('p', { className: 'bl-reader-hint' }, ['只拿到摘要。点上面的按钮去原文抓一次正文;抓不到就还是看原文。'])
|
|
878
|
+
: null,
|
|
776
879
|
vnode('button', { type: 'button', className: 'bl-detail', onClick: () => onOpen?.(cur.id) }, ['详情与反馈'])
|
|
777
880
|
].filter(Boolean) : [
|
|
778
881
|
vnode('p', { className: 'bl-empty' }, ['左边没有条目。多半是这个筛选太窄,或者这一轮还没抓到东西。'])
|
|
@@ -929,12 +1032,56 @@ window.__ModuleLoader__.load({
|
|
|
929
1032
|
* 早先只回一句的话,B 站硬字幕条目会被播放器的说法盖掉,看不出内容其实读不到。
|
|
930
1033
|
* 播放器 M4a 没做(V5 先探后定),所以这里不许预告任何接入时间——跳转就是现状。
|
|
931
1034
|
*/
|
|
1035
|
+
/**
|
|
1036
|
+
* 内嵌播放器(V5 真机实证 2026-11-01:面板页 iframe 不被宿主 CSP 拦)。
|
|
1037
|
+
* B 站=完整页 iframe(bilibili 试图跳出顶层会被 Chromium 自动拦,实测无害;
|
|
1038
|
+
* 官方 player.html 反而是空壳);YouTube=官方 embed,带 rel=0——播完不出
|
|
1039
|
+
* 相关视频网格,跟"不让你顺着推荐刷下去"是同一条产品意图。作者关了嵌入的
|
|
1040
|
+
* 视频会显示 YouTube 自己的提示,此时走「浏览器打开」。其余平台返回 null。
|
|
1041
|
+
*/
|
|
1042
|
+
function playerEmbed(url) {
|
|
1043
|
+
const s = String(url ?? '')
|
|
1044
|
+
const bili = /bilibili\.com\/video\/(BV[0-9A-Za-z]+)/.exec(s)
|
|
1045
|
+
if (bili) return { kind: 'bili', src: 'https://www.bilibili.com/video/' + bili[1] }
|
|
1046
|
+
const yt = /[?&]v=([0-9A-Za-z_-]{11})/.exec(s) || /youtu\.be\/([0-9A-Za-z_-]{11})/.exec(s)
|
|
1047
|
+
if (yt && /youtube\.com|youtu\.be/.test(s)) return { kind: 'youtube', src: 'https://www.youtube.com/embed/' + yt[1] + '?rel=0' }
|
|
1048
|
+
return null
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* 播放器舞台:抽屉与侧栏 tab 共用这一份标记、同一套 .bl-stage* 样式。
|
|
1053
|
+
* 裁切窗跟着容器宽度走(aspect-ratio 16:9 = 视频面本来的形状),不再是写死的
|
|
1054
|
+
* 像素对——窄容器漏推荐流、宽容器把 B 站控制条(音量在那)裁出窗外,都是固定值惹的。
|
|
1055
|
+
* B 站是完整页,多带一个 bl-stage-bili 让 CSS 做版心缩放 + 页头偏移(css.js 的
|
|
1056
|
+
* --bl-frame/--bl-nav);YouTube embed 本来就是纯 16:9 播放器,精确合围即止。
|
|
1057
|
+
* 裁切模式给 iframe 加 scrolling=no:内页滚不动,推荐流连滚出来的机会都没有;
|
|
1058
|
+
* 还原模式(crop=false)保留滚动——那本来就是"看完整页"的路。
|
|
1059
|
+
* 音量/倍速这些控件在跨源 iframe 里面,我们够不着,也不打算假装够得着——界面上明说用原播放器的。
|
|
1060
|
+
*/
|
|
1061
|
+
function playerStage(embed, crop) {
|
|
1062
|
+
const cls = ['bl-stage']
|
|
1063
|
+
if (crop) cls.push('bl-stage-crop')
|
|
1064
|
+
if (crop && embed.kind === 'bili') cls.push('bl-stage-bili')
|
|
1065
|
+
return vnode('div', { className: cls.join(' ') }, [
|
|
1066
|
+
vnode('iframe', {
|
|
1067
|
+
className: 'bl-player',
|
|
1068
|
+
src: embed.src,
|
|
1069
|
+
title: '视频播放器',
|
|
1070
|
+
allow: 'autoplay; fullscreen',
|
|
1071
|
+
allowfullscreen: true,
|
|
1072
|
+
scrolling: crop ? 'no' : 'auto'
|
|
1073
|
+
})
|
|
1074
|
+
])
|
|
1075
|
+
}
|
|
1076
|
+
|
|
932
1077
|
function boundaryNote(item) {
|
|
933
1078
|
const bits = []
|
|
934
1079
|
if (item?.kind === 'video') {
|
|
935
|
-
bits.push(item
|
|
936
|
-
? '
|
|
937
|
-
: '
|
|
1080
|
+
bits.push(playerEmbed(item?.url)
|
|
1081
|
+
? '内嵌播放器在上方,直接看;弹幕和评论区要去原站。'
|
|
1082
|
+
: item.source_id === 'bili'
|
|
1083
|
+
? '这个视频的地址解析不出嵌入形态,只能跳到浏览器里看。'
|
|
1084
|
+
: '这个平台没有可靠的嵌入方案,只能跳到浏览器里看。')
|
|
938
1085
|
}
|
|
939
1086
|
if (LIMITS.has(item?.body_state)) bits.push(STATE_NOTE[item.body_state])
|
|
940
1087
|
return bits.join(' ')
|
|
@@ -975,7 +1122,7 @@ window.__ModuleLoader__.load({
|
|
|
975
1122
|
const namesOf = (item, folders, ids) => (ids ?? []).map((id) =>
|
|
976
1123
|
(folders ?? []).find((f) => f.id === id)?.name ?? `#${id}(这个夹已经没了)`).join(' / ') || '(现在不在任何夹里)'
|
|
977
1124
|
|
|
978
|
-
function Drawer({ item, api = {}, onClose, onDone, note, folders = [], collecting = false, newFolderName = '', onNewFolderName = () => {} }) {
|
|
1125
|
+
function Drawer({ item, api = {}, onClose, onDone, note, folders = [], collecting = false, newFolderName = '', onNewFolderName = () => {}, onOpenUrl, sidebar = null }) {
|
|
979
1126
|
async function fire(action) {
|
|
980
1127
|
let res = null
|
|
981
1128
|
try {
|
|
@@ -1036,7 +1183,10 @@ window.__ModuleLoader__.load({
|
|
|
1036
1183
|
|
|
1037
1184
|
const m = matchInfo(item)
|
|
1038
1185
|
const body = typeof item.body_text === 'string' ? item.body_text.trim() : ''
|
|
1186
|
+
const paras = readerParagraphs(body)
|
|
1039
1187
|
const hasFolders = (folders ?? []).length > 0
|
|
1188
|
+
const link = httpUrl(item.url)
|
|
1189
|
+
const embed = playerEmbed(item.url)
|
|
1040
1190
|
|
|
1041
1191
|
return vnode('aside', { className: 'bl-drawer', role: 'dialog', 'aria-label': '条目详情' }, [
|
|
1042
1192
|
vnode('header', { className: 'bl-dbar' }, [
|
|
@@ -1064,11 +1214,24 @@ window.__ModuleLoader__.load({
|
|
|
1064
1214
|
Section({
|
|
1065
1215
|
title: item.kind === 'video' ? '字幕原文' : '正文',
|
|
1066
1216
|
children: [
|
|
1067
|
-
|
|
1217
|
+
// 正文与阅读器同一条分段规则(readerParagraphs):空行成段、单换行保留,
|
|
1218
|
+
// 字幕在抽屉里也逐行可读;排版类 bl-reader-body 与文章页同一份。
|
|
1219
|
+
vnode('div', { className: 'bl-reader-body' },
|
|
1220
|
+
paras.length ? paras.map((p, i) => vnode('p', { key: i }, [p])) : ['(没有可读内容)']),
|
|
1068
1221
|
STATE_NOTE[item.body_state] ? vnode('p', { className: 'bl-reader-hint' }, [STATE_NOTE[item.body_state]]) : null
|
|
1069
1222
|
]
|
|
1070
1223
|
}),
|
|
1071
1224
|
item.verdict_why ? Section({ title: '会话的判断', children: [vnode('p', {}, [`为什么判成「${item.verdict_tier}」:${item.verdict_why}`])] }) : null,
|
|
1225
|
+
// 内嵌播放(V5 实证可行):视频且能解析出嵌入形态就直接在抽屉里放,
|
|
1226
|
+
// 不把人跳去平台——须鲸的初衷是"在你待着的地方看"。舞台用与侧栏 tab 同一份
|
|
1227
|
+
// playerStage():完整页 iframe 往下会带出平台的推荐流,裁掉是产品意图。
|
|
1228
|
+
embed ? Section({
|
|
1229
|
+
title: '内嵌播放',
|
|
1230
|
+
children: [
|
|
1231
|
+
playerStage(embed, true),
|
|
1232
|
+
vnode('p', { className: 'bl-note' }, ['只露播放器本体,推荐流被裁掉是不想让你刷下去;音量、倍速用画面里的原播放器控件。'])
|
|
1233
|
+
]
|
|
1234
|
+
}) : null,
|
|
1072
1235
|
boundaryNote(item) ? Section({ title: '能力边界', children: [vnode('p', {}, [boundaryNote(item)])] }) : null,
|
|
1073
1236
|
Section({
|
|
1074
1237
|
title: '收藏到…',
|
|
@@ -1101,7 +1264,39 @@ window.__ModuleLoader__.load({
|
|
|
1101
1264
|
])
|
|
1102
1265
|
]
|
|
1103
1266
|
}),
|
|
1104
|
-
|
|
1267
|
+
// 原文地址:真的 <a href>。早先这里是 <p>,那句话"跳到浏览器里看"等于空头支票。
|
|
1268
|
+
// 地址不是 http/https 就整段不画——不拿一个点不动的假链接充数。
|
|
1269
|
+
// (2026-10-31 真机验收:本宿主把客户端页面的弹窗与外链全部静默拦下,<a> 与
|
|
1270
|
+
// window.open 都到不了系统浏览器——「浏览器打开」改走宿主侧 /open 路由,面板
|
|
1271
|
+
// 只报条目 id、URL 由宿主从库里取。
|
|
1272
|
+
// 「侧栏播放」在 0.30.1 语义下回归:面板在前台时 openTab 进 pending 队列,
|
|
1273
|
+
// 会话回到前台由 better-sidebar 的 flushPending 落进右侧栏——不是死按钮,
|
|
1274
|
+
// 是异步开;前提是排队语义写在按钮提示与回显里,不让人点了没反应。)
|
|
1275
|
+
link ? Section({
|
|
1276
|
+
title: '原文',
|
|
1277
|
+
children: [
|
|
1278
|
+
vnode('p', {}, [vnode('a', { className: 'bl-url', href: link, target: '_blank', rel: 'noopener noreferrer' }, [link])]),
|
|
1279
|
+
vnode('div', { className: 'bl-openbtns' }, [
|
|
1280
|
+
vnode('button', { type: 'button', onClick: () => onOpenUrl?.(link, item.id) }, ['浏览器打开']),
|
|
1281
|
+
item.kind === 'video' && embed && sidebar?.available
|
|
1282
|
+
? vnode('button', {
|
|
1283
|
+
type: 'button',
|
|
1284
|
+
title: '在侧栏里播这条,不去平台。面板在前台时它会先排队,切回会话页签自动出现',
|
|
1285
|
+
onClick: () => {
|
|
1286
|
+
if (sidebar.open(item.url, item.title)) {
|
|
1287
|
+
onDone?.({ message: '已请求侧栏播放——右侧栏没立刻出现的话,切回会话页签它会自动开。' })
|
|
1288
|
+
} else {
|
|
1289
|
+
onDone?.({ message: '侧栏没接住这条——先在抽屉里播,或者去浏览器打开。' })
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}, ['侧栏播放'])
|
|
1293
|
+
: null
|
|
1294
|
+
].filter(Boolean)),
|
|
1295
|
+
vnode('p', { className: 'bl-note' }, [embed
|
|
1296
|
+
? '侧栏要是有会话在前台就当场开,否则先排队——切回会话页签它自动出现。'
|
|
1297
|
+
: '这条解析不出嵌入形态,侧栏也播不了;要去系统浏览器就用上面的按钮。'])
|
|
1298
|
+
]
|
|
1299
|
+
}) : null,
|
|
1105
1300
|
vnode('p', { className: 'bl-note' }, ['下面两个按钮只调来源权重,不改你的主题词;当前偏移在设置页能看到也能一键归零。'])
|
|
1106
1301
|
]),
|
|
1107
1302
|
note ? vnode('p', { className: 'bl-feedback', role: 'status' }, [note]) : null,
|
|
@@ -1308,7 +1503,7 @@ window.__ModuleLoader__.load({
|
|
|
1308
1503
|
const s = (stateSources ?? []).find((x) => x.id === id)
|
|
1309
1504
|
if (!s) return '还没抓过(保存后下一轮才碰它)'
|
|
1310
1505
|
if (s.ok) return '最近抓取:正常'
|
|
1311
|
-
if (s.error_class === 'needs_login') return '
|
|
1506
|
+
if (s.error_class === 'needs_login') return '最近抓取:需要重新登录(B 站贴 cookie / 小红书扫码,都在「凭据与环境」)'
|
|
1312
1507
|
return `最近抓取:失败(${s.error_class ?? '未知'}),游标没推进,下轮自动补`
|
|
1313
1508
|
}
|
|
1314
1509
|
|
|
@@ -1556,6 +1751,15 @@ window.__ModuleLoader__.load({
|
|
|
1556
1751
|
vnode('h4', {}, ['判断开关']),
|
|
1557
1752
|
flag('require_theme_hit', '没有命中任何主题组的条目直接丢弃', '关掉后全靠预筛分与源权重,条目会多很多', pref?.require_theme_hit),
|
|
1558
1753
|
flag('inject_prompt', '每轮把待判清单注入会话(判断轮的触发器)', '关掉能省一点上下文', pref?.inject_prompt),
|
|
1754
|
+
// 正文补抓(M8):RSS 只给摘要,开着就会回原文抓正文。关掉 = 文章永远只有 feed 给的那一句话,
|
|
1755
|
+
// 面板里的「抓取正文」按钮仍能手动点(那是按条目单次触发,不受这个开关管)。
|
|
1756
|
+
flag('fulltext', '文章只有摘要时,回原文抓一次正文', '关掉就不抓:文章只剩 feed 给的那一句,且不会有任何额外请求', pref?.fulltext),
|
|
1757
|
+
|
|
1758
|
+
// 侧栏播放(M7):这两个开关只在装了 dsh-better-sidebar 的机器上有实际效果,
|
|
1759
|
+
// 没装就是不注册侧栏 tab、面板照旧——界面上要说清,别让人以为关了它就能装出功能。
|
|
1760
|
+
vnode('h4', {}, ['侧栏播放(需要 dsh-better-sidebar)']),
|
|
1761
|
+
flag('sidebar_takeover', '会话里点 B 站 / YouTube 链接时,落进侧栏播放而不是系统浏览器', '关掉后这些链接照旧开去系统浏览器;侧栏 tab 本身还在,抽屉里的「侧栏播放」仍可用', pref?.sidebar_takeover),
|
|
1762
|
+
flag('sidebar_crop', '侧栏播 B 站时裁掉下面的相关推荐', '平台改版导致裁切错位、把播放控制条也遮住了,就用这个还原完整页', pref?.sidebar_crop),
|
|
1559
1763
|
|
|
1560
1764
|
vnode('h4', {}, ['章程(charter)']),
|
|
1561
1765
|
Field({
|
|
@@ -1588,7 +1792,7 @@ window.__ModuleLoader__.load({
|
|
|
1588
1792
|
/* ---------------- 页签三:凭据与环境 ---------------- */
|
|
1589
1793
|
|
|
1590
1794
|
function CredsTab({ cred, env, cookieDraft = '', onCookieDraft, onCookieSubmit,
|
|
1591
|
-
diagText = null, onCopyDiag, busy = false }) {
|
|
1795
|
+
diagText = null, onCopyDiag, busy = false, xhs = null, xhsQr = null, onXhsQr, onXhsStatus }) {
|
|
1592
1796
|
const bili = cred?.bili
|
|
1593
1797
|
const cookieStatus = bili == null
|
|
1594
1798
|
? 'B 站 cookie:状态还没读到'
|
|
@@ -1603,6 +1807,13 @@ window.__ModuleLoader__.load({
|
|
|
1603
1807
|
`库大小:${fmtBytes(env.db_bytes)}`,
|
|
1604
1808
|
`家目录:${env.home_writable === false ? '不可写(检查磁盘权限)' : '可写'}`
|
|
1605
1809
|
]
|
|
1810
|
+
const xhsLine = xhs == null
|
|
1811
|
+
? '小红书:状态还没读到(点「刷新登录状态」,首次会拉起 MCP 子进程)'
|
|
1812
|
+
: !xhs.configured
|
|
1813
|
+
? '小红书:MCP 未配置(preference.yaml 的 xhs.mcp_path 为空,通道不启用)'
|
|
1814
|
+
: xhs.loggedIn
|
|
1815
|
+
? `小红书:已登录(${xhs.username || '用户名未知'})`
|
|
1816
|
+
: `小红书:未登录——${xhs.text || '点「扫码登录」'}`
|
|
1606
1817
|
return vnode('div', { className: 'bl-setblock' }, [
|
|
1607
1818
|
vnode('h4', {}, ['B 站凭据']),
|
|
1608
1819
|
vnode('p', { className: 'bl-kv' }, [cookieStatus]),
|
|
@@ -1617,9 +1828,17 @@ window.__ModuleLoader__.load({
|
|
|
1617
1828
|
SaveBtn({ text: '写入 cookie', disabled: busy || !String(cookieDraft).trim(), onClick: () => onCookieSubmit?.(cookieDraft) })
|
|
1618
1829
|
]
|
|
1619
1830
|
}),
|
|
1831
|
+
vnode('h4', {}, ['小红书(实验接入)']),
|
|
1832
|
+
vnode('p', { className: 'bl-kv' }, [xhsLine]),
|
|
1833
|
+
vnode('div', { className: 'bl-setrow' }, [
|
|
1834
|
+
vnode('button', { type: 'button', className: 'bl-setsave', disabled: busy || undefined, onClick: () => onXhsQr?.() },
|
|
1835
|
+
['扫码登录(弹二维码,两分钟内有效)']),
|
|
1836
|
+
vnode('button', { type: 'button', disabled: busy || undefined, onClick: () => onXhsStatus?.() },
|
|
1837
|
+
['刷新登录状态'])
|
|
1838
|
+
]),
|
|
1839
|
+
xhsQr ? vnode('img', { src: xhsQr, className: 'bl-xhsqr', alt: '小红书登录二维码,用小红书 App 扫码' }) : null,
|
|
1620
1840
|
vnode('h4', {}, ['环境']),
|
|
1621
1841
|
vnode('ul', { className: 'bl-kv' }, envLines.map((l, i) => vnode('li', { key: i }, [l]))),
|
|
1622
|
-
vnode('p', { className: 'bl-empty' }, ['小红书:未接入']),
|
|
1623
1842
|
vnode('p', { className: 'bl-empty' }, ['转写依赖:未实现(随 ASR 一起)']),
|
|
1624
1843
|
vnode('h4', {}, ['诊断']),
|
|
1625
1844
|
vnode('div', { className: 'bl-setrow' }, [
|
|
@@ -1699,6 +1918,96 @@ window.__ModuleLoader__.load({
|
|
|
1699
1918
|
])
|
|
1700
1919
|
}
|
|
1701
1920
|
|
|
1921
|
+
// ---- src/client/views/player-tab.js ----
|
|
1922
|
+
/**
|
|
1923
|
+
* 侧栏/工作台里的播放器外壳。与抽屉共用 playerEmbed() 与 playerStage()——
|
|
1924
|
+
* 嵌入正则和舞台标记抄两遍必然漂移。
|
|
1925
|
+
*
|
|
1926
|
+
* 这里只读:不收藏、不调权重、不提供"去浏览器"。侧栏手里只有一条 URL,而宿主的
|
|
1927
|
+
* /open 只认 item_id(URL 一律从库里取),这条守卫不为了侧栏放宽。
|
|
1928
|
+
* 音量/倍速在跨源 iframe 里面,我们够不着——界面上明说用画面里的原播放器控件,
|
|
1929
|
+
* 不摆假滑杆。裁切窗跟容器宽度走(aspect-ratio 16:9),B 站完整页多一层页头偏移,
|
|
1930
|
+
* 让视频面精确填满窗口、控制条完整可见(css.js 的 .bl-stage-* 一族)。
|
|
1931
|
+
*/
|
|
1932
|
+
|
|
1933
|
+
const KIND_LABEL = { bili: 'B 站', youtube: 'YouTube' }
|
|
1934
|
+
|
|
1935
|
+
function PlayerTab({ url, title = '', crop = true }) {
|
|
1936
|
+
const embed = playerEmbed(url)
|
|
1937
|
+
if (!embed) {
|
|
1938
|
+
return vnode('div', { className: 'bl-tab' }, [
|
|
1939
|
+
vnode('p', { className: 'bl-note' }, ['这个地址解析不出嵌入形态,侧栏播不了——回面板抽屉里用「浏览器打开」。'])
|
|
1940
|
+
])
|
|
1941
|
+
}
|
|
1942
|
+
return vnode('div', { className: 'bl-tab' }, [
|
|
1943
|
+
title ? vnode('p', { className: 'bl-tab-head' }, [
|
|
1944
|
+
vnode('span', { className: 'bl-badge' }, [KIND_LABEL[embed.kind] || '视频']),
|
|
1945
|
+
vnode('span', { className: 'bl-tab-title' }, [title])
|
|
1946
|
+
]) : null,
|
|
1947
|
+
playerStage(embed, crop),
|
|
1948
|
+
vnode('p', { className: 'bl-note' }, [crop
|
|
1949
|
+
? '只露播放器本体:推荐流被裁掉,是不想让你顺着刷下去;音量、倍速用画面里的原播放器控件。设置页「裁掉推荐区」关掉就还原完整页。'
|
|
1950
|
+
: '完整页在这里,往下滑会看到平台的推荐流。'])
|
|
1951
|
+
])
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// ---- src/client/sidebar-tab.js ----
|
|
1955
|
+
/**
|
|
1956
|
+
* 侧栏播放 tab 的纯构建层:外链认领谓词 + descriptor + 手动播放的 seed。
|
|
1957
|
+
* 这层不认识宿主也不认识 ctx——装配(真 ctx)留在 _footer,全部可以拿假 service 单测。
|
|
1958
|
+
*
|
|
1959
|
+
* 页签有两个入口:① 认领——better-sidebar 自己在拦截会话外链时打开(URL 挂在
|
|
1960
|
+
* `tab.path` 上,实测);② 手动——抽屉「侧栏播放」按钮经 videoTabSeed 构造 seed
|
|
1961
|
+
* 调 openTab。两条路都走 `url` 载荷(实测定的,docs/verification.md 的 M7 探针):
|
|
1962
|
+
* 带 url/path 的 open 落 DSH 原生右侧栏。面板在前台时 open 会先排队,
|
|
1963
|
+
* 会话回到前台由 better-sidebar 的 flushPending 落地——回显必须把这话讲清楚。
|
|
1964
|
+
*/
|
|
1965
|
+
|
|
1966
|
+
const TAB_ID = 'dsh-baleen:player'
|
|
1967
|
+
|
|
1968
|
+
/** 手动播放的 seed:url 载荷进原生右侧栏,标题缺省给个能认的名字 */
|
|
1969
|
+
function videoTabSeed(url, title = '') {
|
|
1970
|
+
return { type: TAB_ID, url, title: title || 'Baleen 播放' }
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/** 认不出来就返回 null:宁可让链接照旧去系统浏览器,也不开一个播不了的门 */
|
|
1974
|
+
function parseVideoUrl(raw) {
|
|
1975
|
+
let u = null
|
|
1976
|
+
try { u = raw instanceof URL ? raw : new URL(String(raw ?? '')) } catch { return null }
|
|
1977
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null
|
|
1978
|
+
if (u.username || u.password) return null
|
|
1979
|
+
const embed = playerEmbed(u.href)
|
|
1980
|
+
return embed ? { ...embed, url: u.href } : null
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
/** 原生侧栏把认领来的 URL 挂在页签记录的 path 上(实测 tab18),不是 tab.url */
|
|
1984
|
+
const urlOf = (tab) => String(tab?.path ?? '')
|
|
1985
|
+
|
|
1986
|
+
function buildTabDescriptor({ render, flags }) {
|
|
1987
|
+
return {
|
|
1988
|
+
id: TAB_ID,
|
|
1989
|
+
title: 'Baleen 播放',
|
|
1990
|
+
description: '把 B 站 / YouTube 视频在侧栏里播,不跳平台',
|
|
1991
|
+
order: 60,
|
|
1992
|
+
single: false,
|
|
1993
|
+
dedupeKey: (tab) => TAB_ID + ':' + urlOf(tab),
|
|
1994
|
+
urlTarget: (url) => Boolean(flags.takeover()) && Boolean(parseVideoUrl(url)),
|
|
1995
|
+
component: (props) => render({ url: urlOf(props?.tab), title: String(props?.tab?.title ?? 'Baleen 播放') })
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
function registerSidebarTab(service, descriptor) {
|
|
2000
|
+
const noop = () => {}
|
|
2001
|
+
if (!service || typeof service.registerTab !== 'function') return noop
|
|
2002
|
+
try {
|
|
2003
|
+
const disposer = service.registerTab(descriptor)
|
|
2004
|
+
return typeof disposer === 'function' ? disposer : noop
|
|
2005
|
+
} catch (e) {
|
|
2006
|
+
console.error('[dsh-baleen] 侧栏 tab 注册失败:' + ((e && e.message) || e))
|
|
2007
|
+
return noop
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
1702
2011
|
|
|
1703
2012
|
/**
|
|
1704
2013
|
* 视图只产 {type, props, children} 这种纯数据(不碰 React,才能在 Node 里断言),
|
|
@@ -1715,7 +2024,7 @@ window.__ModuleLoader__.load({
|
|
|
1715
2024
|
}
|
|
1716
2025
|
|
|
1717
2026
|
let clientCtx = null
|
|
1718
|
-
const inject = ['slots', 'layout']
|
|
2027
|
+
const inject = ['slots', 'layout', 'betterSidebar']
|
|
1719
2028
|
|
|
1720
2029
|
/** 只有声明过的服务读得到;这条 helper 存在的意义就是不让人手写裸属性回退 */
|
|
1721
2030
|
function safeGet(name) {
|
|
@@ -1732,6 +2041,45 @@ window.__ModuleLoader__.load({
|
|
|
1732
2041
|
try { return (clientCtx && clientCtx.layout) || null } catch { return null }
|
|
1733
2042
|
}
|
|
1734
2043
|
|
|
2044
|
+
/**
|
|
2045
|
+
* 侧栏播放(M7)只由 better-sidebar 的 urlTarget 认领触发:用户点会话里的视频链接时
|
|
2046
|
+
* 必然有会话在前台,那是唯一满足原生挂载条件的路径。面板里不放「侧栏播放」按钮——
|
|
2047
|
+
* 实测面板在前台时 openTab 只进 pending 队列(见上面的机制注释),点了什么都不出现,
|
|
2048
|
+
* 和 2026-10-31 撤掉的那个死按钮是同一个坑。想看就用抽屉里的内嵌播放器。
|
|
2049
|
+
*/
|
|
2050
|
+
let takeoverOn = true // 偏好读回来之前按默认值,与 DEFAULTS.sidebar_takeover 一致
|
|
2051
|
+
let cropOn = true
|
|
2052
|
+
|
|
2053
|
+
/**
|
|
2054
|
+
* 侧栏播放能力(抽屉「侧栏播放」按钮的落点)。available 与 open 都在调用时现取服务:
|
|
2055
|
+
* better-sidebar 可能被禁用或根本没装,取不到就如实返回 false(按钮压根不画、点了明说
|
|
2056
|
+
* 没接住),面板其余功能不牵连。手动 open 与认领(urlTarget)互不挂钩——关掉
|
|
2057
|
+
* 「侧栏接管链接」只是不抢会话里的链接,按钮照样能用。
|
|
2058
|
+
*/
|
|
2059
|
+
const sidebarTab = {
|
|
2060
|
+
get available() {
|
|
2061
|
+
const bs = safeGet('betterSidebar')
|
|
2062
|
+
return Boolean(bs && typeof bs.openTab === 'function')
|
|
2063
|
+
},
|
|
2064
|
+
open(url, title) {
|
|
2065
|
+
const bs = safeGet('betterSidebar')
|
|
2066
|
+
if (!bs || typeof bs.openTab !== 'function' || !parseVideoUrl(url)) return false
|
|
2067
|
+
try { bs.openTab(videoTabSeed(url, title)); return true }
|
|
2068
|
+
catch (e) {
|
|
2069
|
+
console.error('[dsh-baleen] 侧栏没接住这条:' + ((e && e.message) || e))
|
|
2070
|
+
return false
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
/** 打开原链接只剩一条路:宿主侧 /open 路由。2026-10-31 真机验收实证,本宿主把
|
|
2076
|
+
* 客户端页面的弹窗与外链全部静默拦下(webview 客体的 window-open 处理),window.open
|
|
2077
|
+
* 与 <a target=_blank> 都到不了系统浏览器。
|
|
2078
|
+
* M7 补了机制(docs/verification.md 的探针):dsh-better-sidebar 的原生挂载按
|
|
2079
|
+
* `mountedSessionId(ctx) === sessionId` 决定放不放行,会话不在前台时 open 是**进队列
|
|
2080
|
+
* 等着**,`openTab` 返回 void、调用方拿不到失败信号——所以"面板在前台开侧栏"这条
|
|
2081
|
+
* 不是被拒绝,是看起来什么都没发生。 */
|
|
2082
|
+
|
|
1735
2083
|
/** 导航项 ↔ 渲染器。VIEW_RENDER 的键必须与 view-state 的 VIEWS 一字不差(测试守着)。
|
|
1736
2084
|
* 六个值全是视图本身:收藏页要多带的夹列表、草稿与二次确认态由 Panel 在调用时并进 props
|
|
1737
2085
|
* (那些 state 只存在于组件里,包在 VIEW_RENDER 里反而取不到)。 */
|
|
@@ -1757,6 +2105,28 @@ window.__ModuleLoader__.load({
|
|
|
1757
2105
|
/** 取数的请求序号(终审 C14):只在工厂作用域,Panel 每帧重跑不能把它清零。 */
|
|
1758
2106
|
let refreshSeq = 0
|
|
1759
2107
|
|
|
2108
|
+
/** 抽屉滚动位置的记忆:宿主侧的重渲染会波及面板 DOM,把 .bl-dmain 的 scrollTop
|
|
2109
|
+
* 钳回 0(2026-10-31 真机实证:同节点、scrollHeight 不变、scrollTop 归零,且面板
|
|
2110
|
+
* 自己没有定时器——唯一执行源就是重渲染)。对策是渲染期捕捉提交前的位置、提交后
|
|
2111
|
+
* 回填,重渲染从哪来都免疫。两个纯函数只算"记不记、回不回",DOM 读写挂线在
|
|
2112
|
+
* Panel 里,Node 测试不碰 document。 */
|
|
2113
|
+
let drawerScrollMemo = { id: null, top: 0, max: 0 }
|
|
2114
|
+
|
|
2115
|
+
/** 记忆更新:换条目从零记;同条目只记"自洽"的滚动(top 在 [0, 可滚上限] 内)——
|
|
2116
|
+
* 内容塌陷瞬间的钳位读数(top=0 而 max 为负)不许覆盖旧值,否则记忆会被弄丢
|
|
2117
|
+
* 它的那一下自己抹掉。 */
|
|
2118
|
+
function scrollMemoNext(memo, id, top, max) {
|
|
2119
|
+
if (id !== memo.id) return { id, top: 0, max: 0 }
|
|
2120
|
+
if (top < 0 || top > max) return memo
|
|
2121
|
+
return { id, top, max }
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/** 回填判定:同一条目、记忆停得比现在深、且那个位置在当前布局下仍达得到,才回填。 */
|
|
2125
|
+
function scrollRestoreTarget(memo, id, top, max) {
|
|
2126
|
+
if (memo.id !== id || memo.top <= top || memo.top > max) return null
|
|
2127
|
+
return memo.top
|
|
2128
|
+
}
|
|
2129
|
+
|
|
1760
2130
|
function Panel() {
|
|
1761
2131
|
// api 与首屏状态都得只建一次:useState 的惰性初始化是唯一入口(useMemo 在桩里等价于直接调)
|
|
1762
2132
|
const [api] = React.useState(() => createApi({ fetch: globalThis.fetch, storage: globalThis.localStorage }))
|
|
@@ -1774,6 +2144,9 @@ window.__ModuleLoader__.load({
|
|
|
1774
2144
|
// 第二次会拿到 name_taken,回显对着刚建好的夹说"已经有这个夹了"。
|
|
1775
2145
|
const [folderBusy, setFolderBusy] = React.useState(false)
|
|
1776
2146
|
const [knownFolders, setKnownFolders] = React.useState(null)
|
|
2147
|
+
// 「抓取正文」的在途条目(照抄 collecting 的规矩:没有它,连点会叠请求,
|
|
2148
|
+
// 也说不清"正在抓的到底是哪一条")。null = 没在抓。
|
|
2149
|
+
const [fetchingId, setFetchingId] = React.useState(null)
|
|
1777
2150
|
// 收藏页的瞬时态(一律不进 reducer:刷新就没的输入草稿 + 一次二次确认 + 一行回显)。
|
|
1778
2151
|
// renameId 记着"这份草稿是改哪个夹的",换夹就丢掉,不许把上一个夹的半截名字带过去。
|
|
1779
2152
|
const [pendingDelete, setPendingDelete] = React.useState(null)
|
|
@@ -1814,6 +2187,16 @@ window.__ModuleLoader__.load({
|
|
|
1814
2187
|
})
|
|
1815
2188
|
}
|
|
1816
2189
|
React.useEffect(() => { refresh() }, [snap.view, snap.filters.collections?.folder])
|
|
2190
|
+
// 抽屉滚动回填(无依赖:每帧提交后都跑)。回调引用了下方才声明的 drawerOpen /
|
|
2191
|
+
// snap——执行时它们已初始化。测试桩会执行 effect 且 document 是没有 querySelector
|
|
2192
|
+
// 的假货,守卫要查到函数级。
|
|
2193
|
+
React.useEffect(() => {
|
|
2194
|
+
if (!drawerOpen || typeof document === 'undefined' || typeof document.querySelector !== 'function') return
|
|
2195
|
+
const dmEl = document.querySelector('.bl-dmain')
|
|
2196
|
+
if (!dmEl) return
|
|
2197
|
+
const target = scrollRestoreTarget(drawerScrollMemo, snap.drawerItemId, dmEl.scrollTop, dmEl.scrollHeight - dmEl.clientHeight)
|
|
2198
|
+
if (target != null) dmEl.scrollTop = target
|
|
2199
|
+
})
|
|
1817
2200
|
|
|
1818
2201
|
function onRun() {
|
|
1819
2202
|
setBusy(true)
|
|
@@ -1892,7 +2275,12 @@ window.__ModuleLoader__.load({
|
|
|
1892
2275
|
onFacet: dispatch,
|
|
1893
2276
|
onSelect: (id) => dispatch({ type: 'select', id }),
|
|
1894
2277
|
onOpen: (id) => dispatch({ type: 'open-drawer', id }),
|
|
1895
|
-
onPickView: pickView
|
|
2278
|
+
onPickView: pickView,
|
|
2279
|
+
// 「抓取正文」只归文章区认领。忙态按"正在抓的是不是右栏这一条"算——
|
|
2280
|
+
// 拿 fetchingId 一刀切会让右栏换了条之后,忙态贴在一条没在抓的正文上。
|
|
2281
|
+
onFulltext: runFulltext,
|
|
2282
|
+
fetching: view === 'article' && fetchingId != null &&
|
|
2283
|
+
currentArticle(data.items, snap.filters.article || {}, snap.selectedId)?.id === fetchingId
|
|
1896
2284
|
}
|
|
1897
2285
|
const body = render(view === 'collections' ? { ...baseProps, ...collectionsProps } : baseProps)
|
|
1898
2286
|
const drawerOpen = snap.drawerItemId != null
|
|
@@ -1901,6 +2289,15 @@ window.__ModuleLoader__.load({
|
|
|
1901
2289
|
// 所以收藏成功后要把服务端读回的归属覆盖上去——只覆盖抽屉这一条的展示,不改 data.items。
|
|
1902
2290
|
// 覆盖规则抽成纯函数 drawerItemWithFolders(views/drawer.js),连点不互抹的不变量由测试钉住。
|
|
1903
2291
|
const drawerItem = drawerItemWithFolders(found, knownFolders)
|
|
2292
|
+
// 渲染期捕捉(提交前):此刻的 scrollTop 还是上一帧的,正是上游重渲染可能弄丢的
|
|
2293
|
+
// 那个值。抽屉刚开时 memo.id 不匹配,从零起记。测试桩的 document 是没有
|
|
2294
|
+
// querySelector 的假货,守卫要查到函数级。
|
|
2295
|
+
if (drawerOpen && typeof document !== 'undefined' && typeof document.querySelector === 'function') {
|
|
2296
|
+
const dmEl = document.querySelector('.bl-dmain')
|
|
2297
|
+
if (dmEl) {
|
|
2298
|
+
drawerScrollMemo = scrollMemoNext(drawerScrollMemo, snap.drawerItemId, dmEl.scrollTop, dmEl.scrollHeight - dmEl.clientHeight)
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
1904
2301
|
// 包一层 collecting:点一下即生效意味着连点会叠请求,全量替换下后到的旧集合会盖掉新决定。
|
|
1905
2302
|
function runCollect(...args) {
|
|
1906
2303
|
setCollecting(true)
|
|
@@ -1909,6 +2306,40 @@ window.__ModuleLoader__.load({
|
|
|
1909
2306
|
(e) => { setCollecting(false); throw e }
|
|
1910
2307
|
)
|
|
1911
2308
|
}
|
|
2309
|
+
/**
|
|
2310
|
+
* 「抓取正文」:面板只报条目 id,抓哪条、抓成什么样由宿主定(/fulltext)。
|
|
2311
|
+
* 成败都要收口:成功就重读当前视图,让右栏换成新正文;失败给一句人话并指回原文——
|
|
2312
|
+
* 静默失败会让人以为"点了没反应",而这颗按钮恰恰是摘要条目唯一的出路。
|
|
2313
|
+
* 在途标记按"是不是这一条"清,不吃掉别人后来点的那一次。
|
|
2314
|
+
*/
|
|
2315
|
+
function runFulltext(itemId) {
|
|
2316
|
+
if (!Number.isInteger(itemId)) return Promise.resolve()
|
|
2317
|
+
setFetchingId(itemId)
|
|
2318
|
+
return api.fulltext(itemId).then(
|
|
2319
|
+
(r) => {
|
|
2320
|
+
setFetchingId((cur) => (cur === itemId ? null : cur))
|
|
2321
|
+
if (!r.ok) {
|
|
2322
|
+
setFeedbackMsg('这次没抓到正文,还是只有摘要——原文链接在上面')
|
|
2323
|
+
return undefined
|
|
2324
|
+
}
|
|
2325
|
+
return refresh()
|
|
2326
|
+
},
|
|
2327
|
+
(e) => {
|
|
2328
|
+
setFetchingId((cur) => (cur === itemId ? null : cur))
|
|
2329
|
+
setFeedbackMsg(`这次没抓到正文,还是只有摘要——原文链接在上面(${(e && e.message) || '宿主接口没应答'})`)
|
|
2330
|
+
return undefined
|
|
2331
|
+
}
|
|
2332
|
+
)
|
|
2333
|
+
}
|
|
2334
|
+
// 「浏览器打开」:成功不需要说什么——浏览器自己会弹到前头;失败要说明白,
|
|
2335
|
+
// 顺手给出路(复制地址)。清掉旧回显免得上一句收藏反馈还挂在上面。
|
|
2336
|
+
function runOpenExternal(itemId) {
|
|
2337
|
+
if (!Number.isInteger(itemId)) return Promise.resolve()
|
|
2338
|
+
return api.openItem(itemId).then(
|
|
2339
|
+
() => setFeedbackMsg(''),
|
|
2340
|
+
() => setFeedbackMsg('宿主没能拉起系统浏览器,直接复制上面的地址吧。')
|
|
2341
|
+
)
|
|
2342
|
+
}
|
|
1912
2343
|
return h('div', { className: 'bl-root' }, [
|
|
1913
2344
|
// 外壳三件(Nav / TopBar / NotReady)与视图同构,都产纯对象,所以一律经 toElement,
|
|
1914
2345
|
// 不能 h(Nav):函数组件返回普通对象的话,真 React 会直接抛"Objects are not valid as a React child"。
|
|
@@ -1924,6 +2355,8 @@ window.__ModuleLoader__.load({
|
|
|
1924
2355
|
collecting,
|
|
1925
2356
|
newFolderName,
|
|
1926
2357
|
onNewFolderName: setNewFolderName,
|
|
2358
|
+
onOpenUrl: (url, itemId) => runOpenExternal(itemId),
|
|
2359
|
+
sidebar: sidebarTab,
|
|
1927
2360
|
onClose: () => { setFeedbackMsg(''); setNewFolderName(''); dispatch({ type: 'close-drawer' }) },
|
|
1928
2361
|
onDone: (r) => {
|
|
1929
2362
|
setFeedbackMsg(r.message)
|
|
@@ -1970,6 +2403,10 @@ window.__ModuleLoader__.load({
|
|
|
1970
2403
|
// 凭据页签
|
|
1971
2404
|
const [cookieDraft, setCookieDraft] = React.useState('')
|
|
1972
2405
|
const [diagText, setDiagText] = React.useState(null)
|
|
2406
|
+
// 小红书(M6):登录状态与扫码二维码。都是手动触发(探活会按需拉起 MCP 子进程,
|
|
2407
|
+
// 挂载即打会让每次开设置页都 spawn 一个浏览器栈)。
|
|
2408
|
+
const [xhsInfo, setXhsInfo] = React.useState(null)
|
|
2409
|
+
const [xhsQr, setXhsQr] = React.useState(null)
|
|
1973
2410
|
// 库页签
|
|
1974
2411
|
const [cleanupDays, setCleanupDays] = React.useState('')
|
|
1975
2412
|
const [askCleanup, setAskCleanup] = React.useState(false)
|
|
@@ -2059,6 +2496,28 @@ window.__ModuleLoader__.load({
|
|
|
2059
2496
|
return undefined
|
|
2060
2497
|
})
|
|
2061
2498
|
}
|
|
2499
|
+
/** 小红书(M6):登录态探活与扫码。成功失败都落在 xhsInfo/回显里,不静默。 */
|
|
2500
|
+
function onXhsStatus() {
|
|
2501
|
+
setBusy(true)
|
|
2502
|
+
api.xhsStatus().then((r) => {
|
|
2503
|
+
setBusy(false)
|
|
2504
|
+
if (r.ok && r.data) setXhsInfo(r.data)
|
|
2505
|
+
else setNote(`小红书状态没读到(${r.error || '宿主接口没应答'})`)
|
|
2506
|
+
})
|
|
2507
|
+
}
|
|
2508
|
+
function onXhsQr() {
|
|
2509
|
+
setBusy(true)
|
|
2510
|
+
api.xhsLoginQr().then((r) => {
|
|
2511
|
+
setBusy(false)
|
|
2512
|
+
if (r.ok && r.data && r.data.image) {
|
|
2513
|
+
setXhsQr(r.data.image)
|
|
2514
|
+
setXhsInfo({ configured: true, loggedIn: false, text: r.data.text })
|
|
2515
|
+
setNote('二维码两分钟内有效——扫完点「刷新登录状态」')
|
|
2516
|
+
return
|
|
2517
|
+
}
|
|
2518
|
+
setNote(`二维码没拿到(${r.error || '宿主接口没应答'})`)
|
|
2519
|
+
})
|
|
2520
|
+
}
|
|
2062
2521
|
/** 复制诊断:tail 已在服务端逐行脱敏;剪贴板拿不到就降级成文本块让人手动复制。 */
|
|
2063
2522
|
function onCopyDiag() {
|
|
2064
2523
|
api.diagTail(50).then((r) => {
|
|
@@ -2124,6 +2583,7 @@ window.__ModuleLoader__.load({
|
|
|
2124
2583
|
onSave: putSection,
|
|
2125
2584
|
askResetTuning, onAskResetTuning: () => setAskResetTuning(true), onCancelResetTuning: () => setAskResetTuning(false), onResetTuning,
|
|
2126
2585
|
cred, env, cookieDraft, onCookieDraft: setCookieDraft, onCookieSubmit, diagText, onCopyDiag,
|
|
2586
|
+
xhs: xhsInfo, xhsQr, onXhsQr, onXhsStatus,
|
|
2127
2587
|
home: hostState?.home ?? '<HOME>',
|
|
2128
2588
|
cleanupDays, onCleanupDays: setCleanupDays, askCleanup, onAskCleanup: () => setAskCleanup(true), onCancelCleanup: () => setAskCleanup(false), onCleanup,
|
|
2129
2589
|
wipeText, onWipeText: setWipeText, onWipe, onExport
|
|
@@ -2137,7 +2597,7 @@ window.__ModuleLoader__.load({
|
|
|
2137
2597
|
const slots = safeGet('slots')
|
|
2138
2598
|
if (!slots) {
|
|
2139
2599
|
console.error('[dsh-baleen] 拿不到 slots 服务,界面不注册')
|
|
2140
|
-
return { registeredSlots: [] }
|
|
2600
|
+
return { registeredSlots: [], sidebarTab }
|
|
2141
2601
|
}
|
|
2142
2602
|
const registered = []
|
|
2143
2603
|
|
|
@@ -2175,7 +2635,23 @@ window.__ModuleLoader__.load({
|
|
|
2175
2635
|
() => h(SettingsView, { key: 'settings' }),
|
|
2176
2636
|
'settings.section')
|
|
2177
2637
|
|
|
2178
|
-
|
|
2638
|
+
// 侧栏播放 tab(M7):只在拿得到服务时注册。取不到就整段跳过,面板其余照旧。
|
|
2639
|
+
const betterSidebar = safeGet('betterSidebar')
|
|
2640
|
+
if (betterSidebar && typeof betterSidebar.registerTab === 'function') {
|
|
2641
|
+
ctx.effect(() => registerSidebarTab(betterSidebar, buildTabDescriptor({
|
|
2642
|
+
flags: { takeover: () => takeoverOn },
|
|
2643
|
+
render: ({ url, title }) => toElement(PlayerTab({ url, title, crop: cropOn }))
|
|
2644
|
+
})))
|
|
2645
|
+
// urlTarget 是同步谓词,所以偏好只能读一次缓存进布尔;读不到就保持默认开
|
|
2646
|
+
const prefApi = createApi({ fetch: globalThis.fetch, storage: globalThis.localStorage })
|
|
2647
|
+
prefApi.preferenceGet().then((r) => {
|
|
2648
|
+
const p = r && r.data && r.data.pref // plainGet 的形状是 {ok, stale, data},pref 在 data 里
|
|
2649
|
+
if (typeof p?.sidebar_takeover === 'boolean') takeoverOn = p.sidebar_takeover
|
|
2650
|
+
if (typeof p?.sidebar_crop === 'boolean') cropOn = p.sidebar_crop
|
|
2651
|
+
}).catch(() => {})
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
return { registeredSlots: registered, sidebarTab }
|
|
2179
2655
|
}
|
|
2180
2656
|
|
|
2181
2657
|
exports.apply = apply
|
|
@@ -2183,6 +2659,8 @@ window.__ModuleLoader__.load({
|
|
|
2183
2659
|
exports.__test = {
|
|
2184
2660
|
PANEL_ID, VIEW_KEY, API_BASE, SNAPSHOT_KEY, VIEWS, VIEW_RENDER, viewQuery, NAV_ITEMS,
|
|
2185
2661
|
Nav, NotReady, TopBar, Panel, toElement, createApi, getLayout, safeGet,
|
|
2662
|
+
scrollMemoNext, scrollRestoreTarget,
|
|
2663
|
+
scrollMemoNext, scrollRestoreTarget,
|
|
2186
2664
|
reduce, encodeState, decodeState, TodayView, Drawer, CollectionsView,
|
|
2187
2665
|
SettingsView, SettingsPage, parseThemes, serializeThemes, splitThemes
|
|
2188
2666
|
}
|