dsh-baleen 0.1.1 → 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 +7 -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
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { vnode } from '../vnode.js'
|
|
8
8
|
import { matchInfo } from '../match.js'
|
|
9
|
-
import { fmtDuration, readbackMiss, foldersUnreadNote } from '../format.js'
|
|
9
|
+
import { fmtDuration, readbackMiss, foldersUnreadNote, httpUrl } from '../format.js'
|
|
10
|
+
import { readerParagraphs } from './article.js'
|
|
10
11
|
|
|
11
12
|
const DELTA = { dismiss: '-0.1', save: '+0.1' }
|
|
12
13
|
/** 每种 body_state 一句说法:正文区与能力边界共用,别两处各写一遍再各自漂移 */
|
|
@@ -32,12 +33,56 @@ export function feedbackHint(action) {
|
|
|
32
33
|
* 早先只回一句的话,B 站硬字幕条目会被播放器的说法盖掉,看不出内容其实读不到。
|
|
33
34
|
* 播放器 M4a 没做(V5 先探后定),所以这里不许预告任何接入时间——跳转就是现状。
|
|
34
35
|
*/
|
|
36
|
+
/**
|
|
37
|
+
* 内嵌播放器(V5 真机实证 2026-11-01:面板页 iframe 不被宿主 CSP 拦)。
|
|
38
|
+
* B 站=完整页 iframe(bilibili 试图跳出顶层会被 Chromium 自动拦,实测无害;
|
|
39
|
+
* 官方 player.html 反而是空壳);YouTube=官方 embed,带 rel=0——播完不出
|
|
40
|
+
* 相关视频网格,跟"不让你顺着推荐刷下去"是同一条产品意图。作者关了嵌入的
|
|
41
|
+
* 视频会显示 YouTube 自己的提示,此时走「浏览器打开」。其余平台返回 null。
|
|
42
|
+
*/
|
|
43
|
+
export function playerEmbed(url) {
|
|
44
|
+
const s = String(url ?? '')
|
|
45
|
+
const bili = /bilibili\.com\/video\/(BV[0-9A-Za-z]+)/.exec(s)
|
|
46
|
+
if (bili) return { kind: 'bili', src: 'https://www.bilibili.com/video/' + bili[1] }
|
|
47
|
+
const yt = /[?&]v=([0-9A-Za-z_-]{11})/.exec(s) || /youtu\.be\/([0-9A-Za-z_-]{11})/.exec(s)
|
|
48
|
+
if (yt && /youtube\.com|youtu\.be/.test(s)) return { kind: 'youtube', src: 'https://www.youtube.com/embed/' + yt[1] + '?rel=0' }
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 播放器舞台:抽屉与侧栏 tab 共用这一份标记、同一套 .bl-stage* 样式。
|
|
54
|
+
* 裁切窗跟着容器宽度走(aspect-ratio 16:9 = 视频面本来的形状),不再是写死的
|
|
55
|
+
* 像素对——窄容器漏推荐流、宽容器把 B 站控制条(音量在那)裁出窗外,都是固定值惹的。
|
|
56
|
+
* B 站是完整页,多带一个 bl-stage-bili 让 CSS 做版心缩放 + 页头偏移(css.js 的
|
|
57
|
+
* --bl-frame/--bl-nav);YouTube embed 本来就是纯 16:9 播放器,精确合围即止。
|
|
58
|
+
* 裁切模式给 iframe 加 scrolling=no:内页滚不动,推荐流连滚出来的机会都没有;
|
|
59
|
+
* 还原模式(crop=false)保留滚动——那本来就是"看完整页"的路。
|
|
60
|
+
* 音量/倍速这些控件在跨源 iframe 里面,我们够不着,也不打算假装够得着——界面上明说用原播放器的。
|
|
61
|
+
*/
|
|
62
|
+
export function playerStage(embed, crop) {
|
|
63
|
+
const cls = ['bl-stage']
|
|
64
|
+
if (crop) cls.push('bl-stage-crop')
|
|
65
|
+
if (crop && embed.kind === 'bili') cls.push('bl-stage-bili')
|
|
66
|
+
return vnode('div', { className: cls.join(' ') }, [
|
|
67
|
+
vnode('iframe', {
|
|
68
|
+
className: 'bl-player',
|
|
69
|
+
src: embed.src,
|
|
70
|
+
title: '视频播放器',
|
|
71
|
+
allow: 'autoplay; fullscreen',
|
|
72
|
+
allowfullscreen: true,
|
|
73
|
+
scrolling: crop ? 'no' : 'auto'
|
|
74
|
+
})
|
|
75
|
+
])
|
|
76
|
+
}
|
|
77
|
+
|
|
35
78
|
export function boundaryNote(item) {
|
|
36
79
|
const bits = []
|
|
37
80
|
if (item?.kind === 'video') {
|
|
38
|
-
bits.push(item
|
|
39
|
-
? '
|
|
40
|
-
: '
|
|
81
|
+
bits.push(playerEmbed(item?.url)
|
|
82
|
+
? '内嵌播放器在上方,直接看;弹幕和评论区要去原站。'
|
|
83
|
+
: item.source_id === 'bili'
|
|
84
|
+
? '这个视频的地址解析不出嵌入形态,只能跳到浏览器里看。'
|
|
85
|
+
: '这个平台没有可靠的嵌入方案,只能跳到浏览器里看。')
|
|
41
86
|
}
|
|
42
87
|
if (LIMITS.has(item?.body_state)) bits.push(STATE_NOTE[item.body_state])
|
|
43
88
|
return bits.join(' ')
|
|
@@ -78,7 +123,7 @@ export function drawerItemWithFolders(found, knownFolders) {
|
|
|
78
123
|
const namesOf = (item, folders, ids) => (ids ?? []).map((id) =>
|
|
79
124
|
(folders ?? []).find((f) => f.id === id)?.name ?? `#${id}(这个夹已经没了)`).join(' / ') || '(现在不在任何夹里)'
|
|
80
125
|
|
|
81
|
-
export function Drawer({ item, api = {}, onClose, onDone, note, folders = [], collecting = false, newFolderName = '', onNewFolderName = () => {} }) {
|
|
126
|
+
export function Drawer({ item, api = {}, onClose, onDone, note, folders = [], collecting = false, newFolderName = '', onNewFolderName = () => {}, onOpenUrl, sidebar = null }) {
|
|
82
127
|
async function fire(action) {
|
|
83
128
|
let res = null
|
|
84
129
|
try {
|
|
@@ -139,7 +184,10 @@ export function Drawer({ item, api = {}, onClose, onDone, note, folders = [], co
|
|
|
139
184
|
|
|
140
185
|
const m = matchInfo(item)
|
|
141
186
|
const body = typeof item.body_text === 'string' ? item.body_text.trim() : ''
|
|
187
|
+
const paras = readerParagraphs(body)
|
|
142
188
|
const hasFolders = (folders ?? []).length > 0
|
|
189
|
+
const link = httpUrl(item.url)
|
|
190
|
+
const embed = playerEmbed(item.url)
|
|
143
191
|
|
|
144
192
|
return vnode('aside', { className: 'bl-drawer', role: 'dialog', 'aria-label': '条目详情' }, [
|
|
145
193
|
vnode('header', { className: 'bl-dbar' }, [
|
|
@@ -167,11 +215,24 @@ export function Drawer({ item, api = {}, onClose, onDone, note, folders = [], co
|
|
|
167
215
|
Section({
|
|
168
216
|
title: item.kind === 'video' ? '字幕原文' : '正文',
|
|
169
217
|
children: [
|
|
170
|
-
|
|
218
|
+
// 正文与阅读器同一条分段规则(readerParagraphs):空行成段、单换行保留,
|
|
219
|
+
// 字幕在抽屉里也逐行可读;排版类 bl-reader-body 与文章页同一份。
|
|
220
|
+
vnode('div', { className: 'bl-reader-body' },
|
|
221
|
+
paras.length ? paras.map((p, i) => vnode('p', { key: i }, [p])) : ['(没有可读内容)']),
|
|
171
222
|
STATE_NOTE[item.body_state] ? vnode('p', { className: 'bl-reader-hint' }, [STATE_NOTE[item.body_state]]) : null
|
|
172
223
|
]
|
|
173
224
|
}),
|
|
174
225
|
item.verdict_why ? Section({ title: '会话的判断', children: [vnode('p', {}, [`为什么判成「${item.verdict_tier}」:${item.verdict_why}`])] }) : null,
|
|
226
|
+
// 内嵌播放(V5 实证可行):视频且能解析出嵌入形态就直接在抽屉里放,
|
|
227
|
+
// 不把人跳去平台——须鲸的初衷是"在你待着的地方看"。舞台用与侧栏 tab 同一份
|
|
228
|
+
// playerStage():完整页 iframe 往下会带出平台的推荐流,裁掉是产品意图。
|
|
229
|
+
embed ? Section({
|
|
230
|
+
title: '内嵌播放',
|
|
231
|
+
children: [
|
|
232
|
+
playerStage(embed, true),
|
|
233
|
+
vnode('p', { className: 'bl-note' }, ['只露播放器本体,推荐流被裁掉是不想让你刷下去;音量、倍速用画面里的原播放器控件。'])
|
|
234
|
+
]
|
|
235
|
+
}) : null,
|
|
175
236
|
boundaryNote(item) ? Section({ title: '能力边界', children: [vnode('p', {}, [boundaryNote(item)])] }) : null,
|
|
176
237
|
Section({
|
|
177
238
|
title: '收藏到…',
|
|
@@ -204,7 +265,39 @@ export function Drawer({ item, api = {}, onClose, onDone, note, folders = [], co
|
|
|
204
265
|
])
|
|
205
266
|
]
|
|
206
267
|
}),
|
|
207
|
-
|
|
268
|
+
// 原文地址:真的 <a href>。早先这里是 <p>,那句话"跳到浏览器里看"等于空头支票。
|
|
269
|
+
// 地址不是 http/https 就整段不画——不拿一个点不动的假链接充数。
|
|
270
|
+
// (2026-10-31 真机验收:本宿主把客户端页面的弹窗与外链全部静默拦下,<a> 与
|
|
271
|
+
// window.open 都到不了系统浏览器——「浏览器打开」改走宿主侧 /open 路由,面板
|
|
272
|
+
// 只报条目 id、URL 由宿主从库里取。
|
|
273
|
+
// 「侧栏播放」在 0.30.1 语义下回归:面板在前台时 openTab 进 pending 队列,
|
|
274
|
+
// 会话回到前台由 better-sidebar 的 flushPending 落进右侧栏——不是死按钮,
|
|
275
|
+
// 是异步开;前提是排队语义写在按钮提示与回显里,不让人点了没反应。)
|
|
276
|
+
link ? Section({
|
|
277
|
+
title: '原文',
|
|
278
|
+
children: [
|
|
279
|
+
vnode('p', {}, [vnode('a', { className: 'bl-url', href: link, target: '_blank', rel: 'noopener noreferrer' }, [link])]),
|
|
280
|
+
vnode('div', { className: 'bl-openbtns' }, [
|
|
281
|
+
vnode('button', { type: 'button', onClick: () => onOpenUrl?.(link, item.id) }, ['浏览器打开']),
|
|
282
|
+
item.kind === 'video' && embed && sidebar?.available
|
|
283
|
+
? vnode('button', {
|
|
284
|
+
type: 'button',
|
|
285
|
+
title: '在侧栏里播这条,不去平台。面板在前台时它会先排队,切回会话页签自动出现',
|
|
286
|
+
onClick: () => {
|
|
287
|
+
if (sidebar.open(item.url, item.title)) {
|
|
288
|
+
onDone?.({ message: '已请求侧栏播放——右侧栏没立刻出现的话,切回会话页签它会自动开。' })
|
|
289
|
+
} else {
|
|
290
|
+
onDone?.({ message: '侧栏没接住这条——先在抽屉里播,或者去浏览器打开。' })
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}, ['侧栏播放'])
|
|
294
|
+
: null
|
|
295
|
+
].filter(Boolean)),
|
|
296
|
+
vnode('p', { className: 'bl-note' }, [embed
|
|
297
|
+
? '侧栏要是有会话在前台就当场开,否则先排队——切回会话页签它自动出现。'
|
|
298
|
+
: '这条解析不出嵌入形态,侧栏也播不了;要去系统浏览器就用上面的按钮。'])
|
|
299
|
+
]
|
|
300
|
+
}) : null,
|
|
208
301
|
vnode('p', { className: 'bl-note' }, ['下面两个按钮只调来源权重,不改你的主题词;当前偏移在设置页能看到也能一键归零。'])
|
|
209
302
|
]),
|
|
210
303
|
note ? vnode('p', { className: 'bl-feedback', role: 'status' }, [note]) : null,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 侧栏/工作台里的播放器外壳。与抽屉共用 playerEmbed() 与 playerStage()——
|
|
3
|
+
* 嵌入正则和舞台标记抄两遍必然漂移。
|
|
4
|
+
*
|
|
5
|
+
* 这里只读:不收藏、不调权重、不提供"去浏览器"。侧栏手里只有一条 URL,而宿主的
|
|
6
|
+
* /open 只认 item_id(URL 一律从库里取),这条守卫不为了侧栏放宽。
|
|
7
|
+
* 音量/倍速在跨源 iframe 里面,我们够不着——界面上明说用画面里的原播放器控件,
|
|
8
|
+
* 不摆假滑杆。裁切窗跟容器宽度走(aspect-ratio 16:9),B 站完整页多一层页头偏移,
|
|
9
|
+
* 让视频面精确填满窗口、控制条完整可见(css.js 的 .bl-stage-* 一族)。
|
|
10
|
+
*/
|
|
11
|
+
import { vnode } from '../vnode.js'
|
|
12
|
+
import { playerEmbed, playerStage } from './drawer.js'
|
|
13
|
+
|
|
14
|
+
const KIND_LABEL = { bili: 'B 站', youtube: 'YouTube' }
|
|
15
|
+
|
|
16
|
+
export function PlayerTab({ url, title = '', crop = true }) {
|
|
17
|
+
const embed = playerEmbed(url)
|
|
18
|
+
if (!embed) {
|
|
19
|
+
return vnode('div', { className: 'bl-tab' }, [
|
|
20
|
+
vnode('p', { className: 'bl-note' }, ['这个地址解析不出嵌入形态,侧栏播不了——回面板抽屉里用「浏览器打开」。'])
|
|
21
|
+
])
|
|
22
|
+
}
|
|
23
|
+
return vnode('div', { className: 'bl-tab' }, [
|
|
24
|
+
title ? vnode('p', { className: 'bl-tab-head' }, [
|
|
25
|
+
vnode('span', { className: 'bl-badge' }, [KIND_LABEL[embed.kind] || '视频']),
|
|
26
|
+
vnode('span', { className: 'bl-tab-title' }, [title])
|
|
27
|
+
]) : null,
|
|
28
|
+
playerStage(embed, crop),
|
|
29
|
+
vnode('p', { className: 'bl-note' }, [crop
|
|
30
|
+
? '只露播放器本体:推荐流被裁掉,是不想让你顺着刷下去;音量、倍速用画面里的原播放器控件。设置页「裁掉推荐区」关掉就还原完整页。'
|
|
31
|
+
: '完整页在这里,往下滑会看到平台的推荐流。'])
|
|
32
|
+
])
|
|
33
|
+
}
|
|
@@ -67,7 +67,7 @@ export function lastFetchNote(stateSources, id) {
|
|
|
67
67
|
const s = (stateSources ?? []).find((x) => x.id === id)
|
|
68
68
|
if (!s) return '还没抓过(保存后下一轮才碰它)'
|
|
69
69
|
if (s.ok) return '最近抓取:正常'
|
|
70
|
-
if (s.error_class === 'needs_login') return '
|
|
70
|
+
if (s.error_class === 'needs_login') return '最近抓取:需要重新登录(B 站贴 cookie / 小红书扫码,都在「凭据与环境」)'
|
|
71
71
|
return `最近抓取:失败(${s.error_class ?? '未知'}),游标没推进,下轮自动补`
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -315,6 +315,15 @@ export function PrefsTab({ pref, themesDraft, onThemesDraft, redlinesDraft, onRe
|
|
|
315
315
|
vnode('h4', {}, ['判断开关']),
|
|
316
316
|
flag('require_theme_hit', '没有命中任何主题组的条目直接丢弃', '关掉后全靠预筛分与源权重,条目会多很多', pref?.require_theme_hit),
|
|
317
317
|
flag('inject_prompt', '每轮把待判清单注入会话(判断轮的触发器)', '关掉能省一点上下文', pref?.inject_prompt),
|
|
318
|
+
// 正文补抓(M8):RSS 只给摘要,开着就会回原文抓正文。关掉 = 文章永远只有 feed 给的那一句话,
|
|
319
|
+
// 面板里的「抓取正文」按钮仍能手动点(那是按条目单次触发,不受这个开关管)。
|
|
320
|
+
flag('fulltext', '文章只有摘要时,回原文抓一次正文', '关掉就不抓:文章只剩 feed 给的那一句,且不会有任何额外请求', pref?.fulltext),
|
|
321
|
+
|
|
322
|
+
// 侧栏播放(M7):这两个开关只在装了 dsh-better-sidebar 的机器上有实际效果,
|
|
323
|
+
// 没装就是不注册侧栏 tab、面板照旧——界面上要说清,别让人以为关了它就能装出功能。
|
|
324
|
+
vnode('h4', {}, ['侧栏播放(需要 dsh-better-sidebar)']),
|
|
325
|
+
flag('sidebar_takeover', '会话里点 B 站 / YouTube 链接时,落进侧栏播放而不是系统浏览器', '关掉后这些链接照旧开去系统浏览器;侧栏 tab 本身还在,抽屉里的「侧栏播放」仍可用', pref?.sidebar_takeover),
|
|
326
|
+
flag('sidebar_crop', '侧栏播 B 站时裁掉下面的相关推荐', '平台改版导致裁切错位、把播放控制条也遮住了,就用这个还原完整页', pref?.sidebar_crop),
|
|
318
327
|
|
|
319
328
|
vnode('h4', {}, ['章程(charter)']),
|
|
320
329
|
Field({
|
|
@@ -347,7 +356,7 @@ export function PrefsTab({ pref, themesDraft, onThemesDraft, redlinesDraft, onRe
|
|
|
347
356
|
/* ---------------- 页签三:凭据与环境 ---------------- */
|
|
348
357
|
|
|
349
358
|
export function CredsTab({ cred, env, cookieDraft = '', onCookieDraft, onCookieSubmit,
|
|
350
|
-
diagText = null, onCopyDiag, busy = false }) {
|
|
359
|
+
diagText = null, onCopyDiag, busy = false, xhs = null, xhsQr = null, onXhsQr, onXhsStatus }) {
|
|
351
360
|
const bili = cred?.bili
|
|
352
361
|
const cookieStatus = bili == null
|
|
353
362
|
? 'B 站 cookie:状态还没读到'
|
|
@@ -362,6 +371,13 @@ export function CredsTab({ cred, env, cookieDraft = '', onCookieDraft, onCookieS
|
|
|
362
371
|
`库大小:${fmtBytes(env.db_bytes)}`,
|
|
363
372
|
`家目录:${env.home_writable === false ? '不可写(检查磁盘权限)' : '可写'}`
|
|
364
373
|
]
|
|
374
|
+
const xhsLine = xhs == null
|
|
375
|
+
? '小红书:状态还没读到(点「刷新登录状态」,首次会拉起 MCP 子进程)'
|
|
376
|
+
: !xhs.configured
|
|
377
|
+
? '小红书:MCP 未配置(preference.yaml 的 xhs.mcp_path 为空,通道不启用)'
|
|
378
|
+
: xhs.loggedIn
|
|
379
|
+
? `小红书:已登录(${xhs.username || '用户名未知'})`
|
|
380
|
+
: `小红书:未登录——${xhs.text || '点「扫码登录」'}`
|
|
365
381
|
return vnode('div', { className: 'bl-setblock' }, [
|
|
366
382
|
vnode('h4', {}, ['B 站凭据']),
|
|
367
383
|
vnode('p', { className: 'bl-kv' }, [cookieStatus]),
|
|
@@ -376,9 +392,17 @@ export function CredsTab({ cred, env, cookieDraft = '', onCookieDraft, onCookieS
|
|
|
376
392
|
SaveBtn({ text: '写入 cookie', disabled: busy || !String(cookieDraft).trim(), onClick: () => onCookieSubmit?.(cookieDraft) })
|
|
377
393
|
]
|
|
378
394
|
}),
|
|
395
|
+
vnode('h4', {}, ['小红书(实验接入)']),
|
|
396
|
+
vnode('p', { className: 'bl-kv' }, [xhsLine]),
|
|
397
|
+
vnode('div', { className: 'bl-setrow' }, [
|
|
398
|
+
vnode('button', { type: 'button', className: 'bl-setsave', disabled: busy || undefined, onClick: () => onXhsQr?.() },
|
|
399
|
+
['扫码登录(弹二维码,两分钟内有效)']),
|
|
400
|
+
vnode('button', { type: 'button', disabled: busy || undefined, onClick: () => onXhsStatus?.() },
|
|
401
|
+
['刷新登录状态'])
|
|
402
|
+
]),
|
|
403
|
+
xhsQr ? vnode('img', { src: xhsQr, className: 'bl-xhsqr', alt: '小红书登录二维码,用小红书 App 扫码' }) : null,
|
|
379
404
|
vnode('h4', {}, ['环境']),
|
|
380
405
|
vnode('ul', { className: 'bl-kv' }, envLines.map((l, i) => vnode('li', { key: i }, [l]))),
|
|
381
|
-
vnode('p', { className: 'bl-empty' }, ['小红书:未接入']),
|
|
382
406
|
vnode('p', { className: 'bl-empty' }, ['转写依赖:未实现(随 ASR 一起)']),
|
|
383
407
|
vnode('h4', {}, ['诊断']),
|
|
384
408
|
vnode('div', { className: 'bl-setrow' }, [
|
|
@@ -19,7 +19,7 @@ export const REASONS_READABLE = GAP_LABELS
|
|
|
19
19
|
const n = (v) => Number(v) || 0
|
|
20
20
|
import { vnode } from '../vnode.js'
|
|
21
21
|
import { reasonOf } from '../match.js'
|
|
22
|
-
import { fmtDuration, judgeHint } from '../format.js'
|
|
22
|
+
import { fmtDuration, judgeHint, thumbSrc } from '../format.js'
|
|
23
23
|
|
|
24
24
|
/** 一轮的结局 → 面板上的说法。'interrupted' 是进程被打断,不是抓取失败,得单独说。 */
|
|
25
25
|
const RUN_LABELS = {
|
|
@@ -64,7 +64,10 @@ function sourceIssues(state) {
|
|
|
64
64
|
|
|
65
65
|
function Card({ item, onOpen }) {
|
|
66
66
|
return vnode('button', { type: 'button', className: 'bl-card', onClick: () => onOpen?.(item.id) }, [
|
|
67
|
-
item.kind === 'video' ? vnode('span', { className: 'bl-thumb' }, [
|
|
67
|
+
item.kind === 'video' ? vnode('span', { className: 'bl-thumb' }, [
|
|
68
|
+
thumbSrc(item.thumb) ? vnode('img', { src: thumbSrc(item.thumb), alt: '', loading: 'lazy' }) : null,
|
|
69
|
+
vnode('span', { className: 'bl-dur' }, [fmtDuration(item.duration_sec) || '—'])
|
|
70
|
+
].filter(Boolean)) : null,
|
|
68
71
|
vnode('span', { className: 'bl-card-title' }, [item.title || '(无标题)']),
|
|
69
72
|
vnode('span', { className: 'bl-card-meta' }, [item.source_id, item.kind === 'note' ? '图文笔记' : item.kind === 'video' ? '视频' : '文章']),
|
|
70
73
|
vnode('span', { className: 'bl-tier' }, [item.verdict_tier ? `已判:${item.verdict_tier}` : '待判']),
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { vnode } from '../vnode.js'
|
|
7
7
|
import { reasonOf } from '../match.js'
|
|
8
|
-
import {
|
|
8
|
+
import { playerEmbed } from './drawer.js'
|
|
9
|
+
import { fmtDuration, thumbSrc } from '../format.js'
|
|
9
10
|
|
|
10
11
|
export const TIER_LABELS = { '值得看': 'good', '可以看': 'ok', '不值当': 'bad' }
|
|
11
12
|
const STATE_LABELS = {
|
|
@@ -35,7 +36,10 @@ function Picker({ label, filterKey, view, options, value, onFilter }) {
|
|
|
35
36
|
|
|
36
37
|
function VideoCard({ item, onOpen }) {
|
|
37
38
|
return vnode('button', { type: 'button', className: 'bl-card', key: item.id, onClick: () => onOpen?.(item.id) }, [
|
|
38
|
-
vnode('span', { className: 'bl-thumb' }, [
|
|
39
|
+
vnode('span', { className: 'bl-thumb' }, [
|
|
40
|
+
thumbSrc(item.thumb) ? vnode('img', { src: thumbSrc(item.thumb), alt: '', loading: 'lazy' }) : null,
|
|
41
|
+
vnode('span', { className: 'bl-dur' }, [fmtDuration(item.duration_sec) || '—'])
|
|
42
|
+
].filter(Boolean)),
|
|
39
43
|
vnode('span', { className: 'bl-card-title' }, [item.title || '(无标题)']),
|
|
40
44
|
vnode('span', { className: 'bl-card-meta' }, [
|
|
41
45
|
item.author || item.source_id,
|
|
@@ -43,9 +47,9 @@ function VideoCard({ item, onOpen }) {
|
|
|
43
47
|
]),
|
|
44
48
|
vnode('span', { className: 'bl-tier' }, [item.verdict_tier ? `${item.verdict_tier}:${item.verdict_why || '(这条没写理由)'}` : '待判']),
|
|
45
49
|
vnode('span', { className: 'bl-why' }, [reasonOf(item)]),
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
vnode('span', { className: 'bl-play' }, ['只能跳转'])
|
|
50
|
+
// 尾标必须与抽屉的能力边界一致:能解析出嵌入形态的(B 站 BV / YouTube)就是"抽屉里能播",
|
|
51
|
+
// 解析不出的(b23.tv 短链、别的平台)才写"只能跳转"。
|
|
52
|
+
vnode('span', { className: 'bl-play' }, [playerEmbed(item.url) ? '抽屉里能播' : '只能跳转'])
|
|
49
53
|
])
|
|
50
54
|
}
|
|
51
55
|
|
package/tools/build-client.mjs
CHANGED
|
@@ -21,7 +21,8 @@ const OUT = path.join(ROOT, 'client.js')
|
|
|
21
21
|
|
|
22
22
|
/** 顺序有意义:状态与取数在前,视图在后,装配(_footer)最后。加视图往这里追加。 */
|
|
23
23
|
export const PARTS = ['view-state.js', 'view-api.js', 'vnode.js', 'match.js', 'format.js', 'css.js',
|
|
24
|
-
'views/shell.js', 'views/today.js', 'views/video.js', 'views/article.js', 'views/digest.js', 'views/library.js', 'views/drawer.js', 'views/collections.js', 'views/settings.js'
|
|
24
|
+
'views/shell.js', 'views/today.js', 'views/video.js', 'views/article.js', 'views/digest.js', 'views/library.js', 'views/drawer.js', 'views/collections.js', 'views/settings.js',
|
|
25
|
+
'views/player-tab.js', 'sidebar-tab.js']
|
|
25
26
|
|
|
26
27
|
const HEADER = fs.readFileSync(path.join(ROOT, 'src', 'client', '_header.txt'), 'utf8')
|
|
27
28
|
const FOOTER = fs.readFileSync(path.join(ROOT, 'src', 'client', '_footer.txt'), 'utf8')
|
package/tools/client-loader.mjs
CHANGED
|
@@ -49,7 +49,7 @@ function requireModule(name) {
|
|
|
49
49
|
throw new Error(`桩里没有这个模块:${name}`)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export function makeStubs({ storage = {}, fetchImpl } = {}) {
|
|
52
|
+
export function makeStubs({ storage = {}, fetchImpl, services = {} } = {}) {
|
|
53
53
|
const store = { ...storage }
|
|
54
54
|
const registered = []
|
|
55
55
|
const injected = []
|
|
@@ -89,12 +89,13 @@ export function makeStubs({ storage = {}, fetchImpl } = {}) {
|
|
|
89
89
|
const target = {
|
|
90
90
|
get: (name) => {
|
|
91
91
|
if (!set.has(name)) throw new Error(`cannot get service "${name}" without inject`)
|
|
92
|
-
return name === 'slots' ? slots :
|
|
92
|
+
return name === 'slots' ? slots : services[name]
|
|
93
93
|
},
|
|
94
94
|
provide: () => {},
|
|
95
95
|
on: () => {},
|
|
96
96
|
effect: (fn) => fn(),
|
|
97
|
-
slots
|
|
97
|
+
slots,
|
|
98
|
+
...services
|
|
98
99
|
}
|
|
99
100
|
return new Proxy(target, {
|
|
100
101
|
get(t, prop) {
|
package/tools/preview.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { LibraryView } from '../src/client/views/library.js'
|
|
|
25
25
|
import { CollectionsView } from '../src/client/views/collections.js'
|
|
26
26
|
import { SettingsPage } from '../src/client/views/settings.js'
|
|
27
27
|
import { Drawer } from '../src/client/views/drawer.js'
|
|
28
|
+
import { PlayerTab } from '../src/client/views/player-tab.js'
|
|
28
29
|
import { previewState, previewItems, previewFolders, previewPreference, previewCred, previewEnv } from '../test/fixtures/preview-data.js'
|
|
29
30
|
|
|
30
31
|
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -129,14 +130,30 @@ export function renderPreview({ outDir = path.join(ROOT, 'out'), empty = false }
|
|
|
129
130
|
const items = empty ? [] : previewItems
|
|
130
131
|
const live = items.filter((i) => i.status !== 'archived')
|
|
131
132
|
const drawerItem = items[0] ?? null
|
|
133
|
+
// 抽屉页专门换一条可嵌入的 B 站地址:内嵌播放器、「侧栏播放」按钮、原文段的排队说明
|
|
134
|
+
// 都只有可嵌入形态才出场——预览要看的就是它们的真实长相。
|
|
135
|
+
const drawerVideo = drawerItem ? { ...drawerItem, kind: 'video', source_id: 'bili', url: 'https://www.bilibili.com/video/BV1GJ411x7h7', duration_sec: 1102 } : null
|
|
132
136
|
// 空态传空夹表:抽屉走"没有夹列表"那一支,收藏页走"读不到夹"那一支——诚实的缺席也要演一遍。
|
|
133
137
|
const folders = empty ? [] : previewFolders
|
|
134
|
-
const drawer =
|
|
135
|
-
? Drawer({ item:
|
|
138
|
+
const drawer = drawerVideo
|
|
139
|
+
? Drawer({ item: drawerVideo, api: {}, folders, note: empty ? '' : 'demo-longform 权重 +0.1——只改 tuning,你的 themes 一个字没动', onOpenUrl: () => {}, sidebar: { available: true, open: () => true } })
|
|
136
140
|
: Drawer({ item: null, api: {}, folders })
|
|
137
141
|
const pages = {
|
|
138
142
|
today: both(shell({ view: 'today', state, body: TodayView({ state, items: live, onRun: () => {}, onOpen: () => {}, onPickView: () => {} }) })),
|
|
139
143
|
video: both(shell({ view: 'video', state, body: VideoView({ state, items, filters: empty ? {} : { tier: '' } }) })),
|
|
144
|
+
// 侧栏播放(M7):裁切默认、关掉裁切、解析不出嵌入形态三种状态同屏对比。
|
|
145
|
+
// 这里给的是真 URL——裁切窗跟容器宽度走(aspect-ratio),B 站页头偏移是否对齐、
|
|
146
|
+
// 控制条(音量)完不完整,只能靠眼睛看,不能靠想。第三个容器故意给窄宽度演侧栏实况。
|
|
147
|
+
player: both(shell({
|
|
148
|
+
view: 'video',
|
|
149
|
+
state,
|
|
150
|
+
body: vnode('div', {}, [
|
|
151
|
+
PlayerTab({ url: 'https://www.bilibili.com/video/BV1GJ411x7h7', title: 'B 站完整页 · 版心缩放 + 页头偏移,窗口只剩视频面', crop: true }),
|
|
152
|
+
PlayerTab({ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', title: 'YouTube embed · 裁切(rel=0,播完不出相关视频)', crop: true }),
|
|
153
|
+
PlayerTab({ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', title: 'YouTube embed · 关掉裁切的样子', crop: false }),
|
|
154
|
+
PlayerTab({ url: 'https://b23.tv/abc', title: '短链 · 解析不出嵌入形态那一支' })
|
|
155
|
+
])
|
|
156
|
+
})),
|
|
140
157
|
article: both(shell({ view: 'article', state, body: ArticleView({ state, items, filters: {}, selectedId: items[4] ? items[4].id : null }) })),
|
|
141
158
|
collections: both(shell({
|
|
142
159
|
view: 'collections',
|