dsh-hackertube 0.1.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hackertube",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "DeepSeek Harness 插件:在面板里看 Hacker News 二十年推荐的 6454 个技术视频",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,16 @@
27
27
  "exports": {
28
28
  ".": "./index.js",
29
29
  "./client": "./client.js",
30
+ "./recommend": "./recommend.js",
31
+ "./mirror": "./mirror.js",
30
32
  "./locale/*.json": "./locale/*.json",
31
33
  "./icon": "./icon.svg"
32
34
  },
33
35
  "files": [
34
36
  "index.js",
35
37
  "client.js",
38
+ "recommend.js",
39
+ "mirror.js",
36
40
  "cordis.patch.yml",
37
41
  "locale/*.json",
38
42
  "data/*.json",
@@ -40,7 +44,8 @@
40
44
  "README.en.md"
41
45
  ],
42
46
  "scripts": {
43
- "test": "node test/host-smoke.mjs && node test/client-render.mjs client.js"
47
+ "test": "node test/mirror.mjs && node test/watch-session.mjs && node test/host-smoke.mjs && node test/client-render.mjs client.js",
48
+ "test:mirror-live": "node test/mirror-live.mjs"
44
49
  },
45
50
  "devDependencies": {
46
51
  "react": "^18.3.1",
package/recommend.js ADDED
@@ -0,0 +1,431 @@
1
+ /**
2
+ * 推荐画像(Taste Profile)。
3
+ *
4
+ * 只用站点本地导出里已有的两样数据:likes 视频的向量质心,以及
5
+ * related.jsonl 的共推荐边。纯函数,不读文件、不联网;冷启动所需的
6
+ * 播放列表与 HN 分数由调用方传入,方便用 fixture 测试。
7
+ */
8
+
9
+ const MAX_LIKES = 60
10
+ /* 单个已喜欢视频最多贡献几条推荐,避免整行都被同一个来源占满 */
11
+ const MAX_PER_SOURCE = 3
12
+ /* 向量是主信号;HN 共推荐边只做次级加权。related.jsonl 里混有泛热门条目,
13
+ 如果权重过高会把「Sketchpad」「Startup School」这类噪声顶到线性代数视频前面。 */
14
+ const VECTOR_WEIGHT = 0.9
15
+ const RELATED_WEIGHT = 0.1
16
+
17
+ /** 清掉坏 id、去重并限量;前端 query 与 prefs 都走这一套。 */
18
+ export function normalizeIds(list, max = 400) {
19
+ const out = []
20
+ const seen = new Set()
21
+ for (const raw of Array.isArray(list) ? list : []) {
22
+ const id = String(raw || '')
23
+ if (!/^[A-Za-z0-9_-]{6,20}$/.test(id) || seen.has(id)) continue
24
+ seen.add(id)
25
+ out.push(id)
26
+ if (out.length >= max) break
27
+ }
28
+ return out
29
+ }
30
+
31
+ function relatedList(related, vid) {
32
+ if (!related) return []
33
+ if (typeof related.get === 'function') {
34
+ const list = related.get(vid)
35
+ return Array.isArray(list) ? list : []
36
+ }
37
+ const list = related[vid]
38
+ return Array.isArray(list) ? list : []
39
+ }
40
+
41
+ function vectorIndex(vectors, vid) {
42
+ if (!vectors || !vectors.index || typeof vectors.index.get !== 'function') return -1
43
+ const at = vectors.index.get(vid)
44
+ return at === undefined ? -1 : at
45
+ }
46
+
47
+ function cosineAt(vectors, a, b) {
48
+ const { vec, dim, norms } = vectors
49
+ const baseA = a * dim
50
+ const baseB = b * dim
51
+ let dot = 0
52
+ for (let d = 0; d < dim; d += 1) dot += vec[baseA + d] * vec[baseB + d]
53
+ return dot / ((norms[a] || 1) * (norms[b] || 1))
54
+ }
55
+
56
+ function buildCentroid(vectors, likes) {
57
+ const indexes = []
58
+ for (const vid of likes) {
59
+ const at = vectorIndex(vectors, vid)
60
+ if (at >= 0) indexes.push(at)
61
+ }
62
+ if (!indexes.length) return null
63
+ const { vec, dim } = vectors
64
+ const centroid = new Float32Array(dim)
65
+ for (const at of indexes) {
66
+ const base = at * dim
67
+ for (let d = 0; d < dim; d += 1) centroid[d] += vec[base + d]
68
+ }
69
+ let norm = 0
70
+ for (let d = 0; d < dim; d += 1) norm += centroid[d] * centroid[d]
71
+ return { vec: centroid, norm: Math.sqrt(norm) || 1, count: indexes.length }
72
+ }
73
+
74
+ function centroidScore(vectors, at, centroid) {
75
+ const { vec, dim, norms } = vectors
76
+ const base = at * dim
77
+ let dot = 0
78
+ for (let d = 0; d < dim; d += 1) dot += vec[base + d] * centroid.vec[d]
79
+ return dot / ((norms[at] || 1) * centroid.norm)
80
+ }
81
+
82
+ /** 已喜欢视频的共推荐边:越靠前权重越高,同一候选保留最强的来源。 */
83
+ function buildAffinity(related, likes) {
84
+ const weights = new Map()
85
+ for (const like of likes) {
86
+ const list = relatedList(related, like)
87
+ const total = list.length
88
+ list.forEach((rid, index) => {
89
+ const vid = typeof rid === 'string' ? rid : rid && rid.vid
90
+ if (!vid || vid === like) return
91
+ const w = total > 1 ? (total - index) / total : 1
92
+ const prev = weights.get(vid)
93
+ if (!prev || w > prev.w) weights.set(vid, { w, source: like })
94
+ })
95
+ }
96
+ return weights
97
+ }
98
+
99
+ function bestLikeSource(itemVid, likes, vectors, affinity) {
100
+ if (vectors) {
101
+ const at = vectorIndex(vectors, itemVid)
102
+ if (at >= 0) {
103
+ let best = null
104
+ let bestScore = -Infinity
105
+ for (const like of likes) {
106
+ const likeAt = vectorIndex(vectors, like)
107
+ if (likeAt < 0) continue
108
+ const score = cosineAt(vectors, at, likeAt)
109
+ if (score > bestScore) {
110
+ bestScore = score
111
+ best = like
112
+ }
113
+ }
114
+ if (best) return best
115
+ }
116
+ }
117
+ const rel = affinity.get(itemVid)
118
+ return rel ? rel.source : likes[0]
119
+ }
120
+
121
+ function publicItem(item, reason) {
122
+ return {
123
+ v: item.v,
124
+ t: item.t,
125
+ c: item.c,
126
+ d: item.d,
127
+ th: item.th,
128
+ p: item.p,
129
+ w: item.w,
130
+ y: item.y,
131
+ s: item.s,
132
+ r: item.r,
133
+ reason,
134
+ }
135
+ }
136
+
137
+ function coldStart({ videos, excluded, playlists, max }) {
138
+ const byId = new Map()
139
+ for (const item of videos) if (item && item.v) byId.set(item.v, item)
140
+ const picked = []
141
+ const used = new Set()
142
+ const playlistTitle = (pl) => pl.titleZh || pl.title || pl.slug || ''
143
+ const playlistIds = (pl) => {
144
+ const ids = []
145
+ if (Array.isArray(pl.cover)) ids.push(...pl.cover)
146
+ if (Array.isArray(pl.videos)) {
147
+ for (const entry of pl.videos) ids.push(entry && (entry.vid || entry.v))
148
+ }
149
+ return ids
150
+ }
151
+
152
+ /* 冷启动先给编辑信号:每个播放列表取前两条没看过的视频。 */
153
+ for (const pl of Array.isArray(playlists) ? playlists : []) {
154
+ let taken = 0
155
+ for (const raw of playlistIds(pl)) {
156
+ if (taken >= 2 || picked.length >= max) break
157
+ const vid = String(raw || '')
158
+ const item = byId.get(vid)
159
+ if (!item || excluded.has(vid) || used.has(vid)) continue
160
+ picked.push(
161
+ publicItem(item, {
162
+ kind: 'playlist',
163
+ title: playlistTitle(pl),
164
+ slug: pl.slug || '',
165
+ }),
166
+ )
167
+ used.add(vid)
168
+ taken += 1
169
+ }
170
+ if (picked.length >= max) break
171
+ }
172
+
173
+ /* 再用 HN 分数补满:没有播放列表或列表不够长时也能给出稳定推荐。 */
174
+ const ranked = videos
175
+ .filter((item) => item && item.v && !excluded.has(item.v) && !used.has(item.v))
176
+ .sort((a, b) => (Number(b.s) || 0) - (Number(a.s) || 0) || (Number(b.r) || 0) - (Number(a.r) || 0))
177
+ for (const item of ranked) {
178
+ if (picked.length >= max) break
179
+ picked.push(publicItem(item, { kind: 'hn', score: Number(item.s) || 0, recommenders: Number(item.r) || 0 }))
180
+ }
181
+ return picked
182
+ }
183
+
184
+
185
+ /* ---------------- 打散与随机插入 ---------------- */
186
+
187
+ /** 可复现的伪随机:同一个 seed 给出同一次顺序,刷新就换 seed。 */
188
+ function makeRandom(seed) {
189
+ let a = (Number(seed) || 1) >>> 0
190
+ return () => {
191
+ a = (a + 0x6d2b79f5) >>> 0
192
+ let t = a
193
+ t = Math.imul(t ^ (t >>> 15), t | 1)
194
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
195
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
196
+ }
197
+ }
198
+
199
+ function shuffle(list, rand) {
200
+ const out = list.slice()
201
+ for (let i = out.length - 1; i > 0; i -= 1) {
202
+ const j = Math.floor(rand() * (i + 1))
203
+ const tmp = out[i]
204
+ out[i] = out[j]
205
+ out[j] = tmp
206
+ }
207
+ return out
208
+ }
209
+
210
+ /** 两条视频的相似度:同频道最明显,其次共用分类。 */
211
+ function overlap(a, b) {
212
+ if (!a || !b) return 0
213
+ let same = 0
214
+ if (a.c && b.c && a.c === b.c) same += 0.6
215
+ const ac = Array.isArray(a.cats) ? a.cats : []
216
+ const bc = Array.isArray(b.cats) ? b.cats : []
217
+ if (ac.length && bc.length) {
218
+ let shared = 0
219
+ for (const id of ac) if (bc.indexOf(id) >= 0) shared += 1
220
+ same += 0.4 * (shared / Math.max(ac.length, bc.length))
221
+ }
222
+ return same
223
+ }
224
+
225
+ /**
226
+ * 从一个候选池里按「和已选内容越像越往后」挑出 count 条。
227
+ * 只在前若干名里随机,既打散主题,又不至于把真正相关的挤掉。
228
+ */
229
+ function pickDiverse(pool, count, picked, rand, spread = 8) {
230
+ const out = []
231
+ const rest = pool.slice()
232
+ while (out.length < count && rest.length) {
233
+ const window = rest.slice(0, Math.min(spread, rest.length))
234
+ let bestAt = 0
235
+ let bestScore = -Infinity
236
+ for (let i = 0; i < window.length; i += 1) {
237
+ const entry = window[i]
238
+ let penalty = 0
239
+ for (const chosen of picked.concat(out)) penalty += overlap(entry.item, chosen.item)
240
+ /* 干扰项打破平局,让每次刷新都换一批 */
241
+ const score = -penalty + rand() * 0.35 - i * 0.01
242
+ if (score > bestScore) {
243
+ bestScore = score
244
+ bestAt = i
245
+ }
246
+ }
247
+ out.push(rest.splice(bestAt, 1)[0])
248
+ }
249
+ return out
250
+ }
251
+
252
+ /**
253
+ * 首页前排顺序。
254
+ *
255
+ * 不是因为「多一栏」而存在:它只决定网格里前几格放谁。相关、半相关、
256
+ * 完全无关三种各取一部分,按固定节奏交替插进前排,看起来像随手翻出来的
257
+ * 一屏,而不是同一个主题刷一列。
258
+ */
259
+ const MIX = [['related', 5], ['semi', 3], ['wild', 2]]
260
+
261
+ function interleave(buckets, max, rand) {
262
+ const queues = {
263
+ related: buckets.related.slice(),
264
+ semi: buckets.semi.slice(),
265
+ wild: buckets.wild.slice(),
266
+ }
267
+ /* 每一轮把节奏本身轻微打乱,避免每次刷新都是同一个排列骨架 */
268
+ const pattern = []
269
+ for (const [name, weight] of MIX) for (let i = 0; i < weight; i += 1) pattern.push(name)
270
+ const out = []
271
+ while (out.length < max && (queues.related.length || queues.semi.length || queues.wild.length)) {
272
+ let placed = false
273
+ for (const name of shuffle(pattern, rand)) {
274
+ const queue = queues[name]
275
+ if (!queue.length) continue
276
+ out.push(queue.shift())
277
+ placed = true
278
+ if (out.length >= max) break
279
+ }
280
+ if (!placed) break
281
+ }
282
+ return out
283
+ }
284
+
285
+ function toPublic(entry, likes, byId, vectors, affinity, tier) {
286
+ const source = bestLikeSource(entry.item.v, likes, vectors, affinity)
287
+ const sourceItem = source ? byId.get(source) : null
288
+ return publicItem(entry.item, {
289
+ kind: 'like',
290
+ tier,
291
+ vid: source || likes[0],
292
+ title: sourceItem ? sourceItem.t : '',
293
+ })
294
+ }
295
+
296
+ /**
297
+ * 生成首页前排顺序。
298
+ *
299
+ * @param {object} input
300
+ * @param {Array} input.videos 瘦身后的视频列表
301
+ * @param {string[]} input.likes 已喜欢视频 id
302
+ * @param {string[]} input.seen 已看过视频 id
303
+ * @param {string[]} input.dismissed 点了「不感兴趣」的视频 id
304
+ * @param {Map<string,string[]>|null} input.related 共推荐图
305
+ * @param {object|null} input.vectors embeddings.npy 解析结果
306
+ * @param {number} input.limit 前排条数
307
+ * @param {number} input.seed 刷新用的随机种子
308
+ */
309
+ export function recommend(input = {}) {
310
+ const videos = Array.isArray(input.videos) ? input.videos : []
311
+ const related = input.related || null
312
+ const vectors = input.vectors || null
313
+ const max = Math.max(1, Math.min(Number(input.limit) || 12, 24))
314
+ const seed = Number(input.seed) || 1
315
+ const rand = makeRandom(seed)
316
+ const byId = new Map()
317
+ for (const item of videos) if (item && item.v) byId.set(item.v, item)
318
+
319
+ const allLikes = normalizeIds(input.likes)
320
+ const likes = allLikes.filter((id) => byId.has(id)).slice(-MAX_LIKES)
321
+ const excluded = new Set(allLikes.concat(normalizeIds(input.seen), normalizeIds(input.dismissed)))
322
+
323
+ /* 冷启动没有偏好信号:编辑播放列表与 HN 高分混着抽,同样打散主题 */
324
+ if (!likes.length) {
325
+ const playlistEntries = []
326
+ const seenPlaylist = new Set()
327
+ const playlists = Array.isArray(input.playlists) ? shuffle(input.playlists, rand) : []
328
+ for (const pl of playlists) {
329
+ const ids = []
330
+ if (Array.isArray(pl.cover)) ids.push(...pl.cover)
331
+ if (Array.isArray(pl.videos)) {
332
+ for (const entry of pl.videos) ids.push(entry && (entry.vid || entry.v))
333
+ }
334
+ let taken = 0
335
+ for (const raw of ids) {
336
+ if (taken >= 1) break
337
+ const vid = String(raw || '')
338
+ const item = byId.get(vid)
339
+ if (!item || excluded.has(vid) || seenPlaylist.has(vid)) continue
340
+ seenPlaylist.add(vid)
341
+ playlistEntries.push({
342
+ item,
343
+ score: 1,
344
+ source: { kind: 'playlist', tier: 'playlist', title: pl.titleZh || pl.title || pl.slug || '', slug: pl.slug || '' },
345
+ })
346
+ taken += 1
347
+ }
348
+ }
349
+ const hnPool = videos
350
+ .filter((item) => item && item.v && !excluded.has(item.v) && !seenPlaylist.has(item.v))
351
+ .sort((a, b) => (Number(b.s) || 0) - (Number(a.s) || 0))
352
+ .slice(0, 240)
353
+ .map((item) => ({ item, score: Number(item.s) || 0, source: { kind: 'hn', tier: 'hn', score: Number(item.s) || 0 } }))
354
+
355
+ const wantPlaylist = Math.max(1, Math.min(playlistEntries.length, Math.round(max * 0.4)))
356
+ const wantHn = Math.max(1, max - wantPlaylist)
357
+ const pickedPlaylist = pickDiverse(playlistEntries, wantPlaylist, [], rand, 6)
358
+ const pickedHn = pickDiverse(hnPool, wantHn, pickedPlaylist, rand, 10)
359
+ const picked = interleave(
360
+ {
361
+ related: pickedPlaylist.map((e) => publicItem(e.item, e.source)),
362
+ semi: pickedHn.map((e) => publicItem(e.item, e.source)),
363
+ wild: [],
364
+ },
365
+ max,
366
+ rand,
367
+ )
368
+ return {
369
+ mode: 'cold',
370
+ seed,
371
+ items: picked,
372
+ profile: { likes: 0, vectors: !!vectors, playlists: playlists.length },
373
+ }
374
+ }
375
+
376
+ const centroid = vectors ? buildCentroid(vectors, likes) : null
377
+ const affinity = buildAffinity(related, likes)
378
+ const scored = []
379
+ for (const item of videos) {
380
+ if (!item || !item.v || excluded.has(item.v)) continue
381
+ let score = 0
382
+ let hasSignal = false
383
+ if (centroid) {
384
+ const at = vectorIndex(vectors, item.v)
385
+ if (at >= 0) {
386
+ score += VECTOR_WEIGHT * ((centroidScore(vectors, at, centroid) + 1) / 2)
387
+ hasSignal = true
388
+ }
389
+ }
390
+ const rel = affinity.get(item.v)
391
+ if (rel) {
392
+ score += (centroid ? RELATED_WEIGHT : 1) * rel.w
393
+ hasSignal = true
394
+ }
395
+ if (!hasSignal) score += (Number(item.s) || 0) / 100000
396
+ scored.push({ item, score })
397
+ }
398
+ scored.sort((a, b) => b.score - a.score || (Number(b.item.s) || 0) - (Number(a.item.s) || 0))
399
+
400
+ /* 三段:相关(前 45%)、半相关(中间)、完全无关(尾部随机) */
401
+ const cutRelated = Math.max(1, Math.round(scored.length * 0.45))
402
+ const cutSemi = Math.max(cutRelated + 1, Math.round(scored.length * 0.8))
403
+ const nearBand = scored.slice(0, cutRelated)
404
+ const semiBand = scored.slice(cutRelated, cutSemi)
405
+ const wildBand = scored.slice(cutSemi)
406
+
407
+ const wantRelated = Math.max(1, Math.round(max * 0.5))
408
+ const wantSemi = Math.max(1, Math.round(max * 0.3))
409
+ const wantWild = Math.max(1, max - wantRelated - wantSemi)
410
+
411
+ const relatedPick = pickDiverse(nearBand, wantRelated, [], rand, 8)
412
+ const semiPick = pickDiverse(semiBand, wantSemi, relatedPick, rand, 10)
413
+ const wildPick = pickDiverse(shuffle(wildBand, rand), wantWild, relatedPick.concat(semiPick), rand, 12)
414
+
415
+ const ordered = interleave(
416
+ {
417
+ related: relatedPick.map((e) => toPublic(e, likes, byId, vectors, affinity, 'related')),
418
+ semi: semiPick.map((e) => toPublic(e, likes, byId, vectors, affinity, 'semi')),
419
+ wild: wildPick.map((e) => toPublic(e, likes, byId, vectors, affinity, 'wild')),
420
+ },
421
+ max,
422
+ rand,
423
+ )
424
+
425
+ return {
426
+ mode: 'likes',
427
+ seed,
428
+ items: ordered,
429
+ profile: { likes: likes.length, vectors: !!vectors },
430
+ }
431
+ }