dsh-hackertube 0.1.6 → 0.2.1
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/README.en.md +146 -173
- package/README.md +147 -175
- package/client.js +3635 -2568
- package/index.js +4126 -3215
- package/mirror.js +768 -0
- package/package.json +7 -2
- package/recommend.js +431 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hackertube",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
+
}
|