dsh-lighthouse 0.0.0-stage → 0.3.4
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/LICENSE +21 -0
- package/README.md +167 -2
- package/bin/dsh-lighthouse.mjs +133 -0
- package/client.js +388 -0
- package/cordis.patch.yml +4 -0
- package/lib/collect.js +484 -0
- package/lib/fleet.js +139 -0
- package/lib/index.js +385 -0
- package/lib/render.js +415 -0
- package/lib/status-route.js +126 -0
- package/package.json +93 -6
package/lib/render.js
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-lighthouse · render —— 把报告渲染成两种东西:给 agent 看的一段话,和给人看的一张卡片。
|
|
3
|
+
*
|
|
4
|
+
* 卡片是**单文件 HTML**:无外链、无脚本依赖、深色、数字大 —— 落盘后可以直接打开、截图发出去。
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-lighthouse/render
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { hoursText } from './collect.js';
|
|
12
|
+
|
|
13
|
+
/** HTML 转义。 */
|
|
14
|
+
function esc(text) {
|
|
15
|
+
return String(text ?? '')
|
|
16
|
+
.replace(/&/g, '&')
|
|
17
|
+
.replace(/</g, '<')
|
|
18
|
+
.replace(/>/g, '>')
|
|
19
|
+
.replace(/"/g, '"');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** ISO 时间 → `MM-DD HH:mm`。 */
|
|
23
|
+
function shortTime(iso) {
|
|
24
|
+
if (typeof iso !== 'string' || iso === '') return '—';
|
|
25
|
+
const d = new Date(iso);
|
|
26
|
+
if (Number.isNaN(d.getTime())) return '—';
|
|
27
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
28
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 超过这个数量就自动切成紧凑视图(50 个作品时逐张卡片已经读不动了)。 */
|
|
32
|
+
export const COMPACT_THRESHOLD = 12;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 一个作品的"紧急度"排序键:**需要人动手的在前**。
|
|
36
|
+
* 0 = 有异常(GitHub/市场读不到、仓库不存在、没有 PR)
|
|
37
|
+
* 1 = 还在等收录(PR open,按等得久的优先)
|
|
38
|
+
* 2 = 已收录
|
|
39
|
+
* @param {object} item 报告里的一项
|
|
40
|
+
* @returns {{tier:number, waitHours:number}} 排序键
|
|
41
|
+
*/
|
|
42
|
+
export function urgencyOf(item) {
|
|
43
|
+
const { github, market } = item;
|
|
44
|
+
const pr = github?.pr ?? null;
|
|
45
|
+
const waitHours = pr?.createdAt ? (Date.now() - Date.parse(pr.createdAt)) / 3600000 : 0;
|
|
46
|
+
const broken = github?.exists !== true || market?.available !== true || (github?.exists === true && pr === null);
|
|
47
|
+
if (broken) return { tier: 0, waitHours };
|
|
48
|
+
if (market?.listed === true) return { tier: 2, waitHours };
|
|
49
|
+
return { tier: 1, waitHours };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 排好序的作品列表:需要盯的在前,同层里等得久的在前。
|
|
54
|
+
* @param {Array<object>} works 报告里的 works
|
|
55
|
+
* @returns {Array<object>} 新数组(不改原对象顺序)
|
|
56
|
+
*/
|
|
57
|
+
export function sortWorks(works) {
|
|
58
|
+
return [...(Array.isArray(works) ? works : [])]
|
|
59
|
+
.map((item) => ({ item, key: urgencyOf(item) }))
|
|
60
|
+
.sort((a, b) => a.key.tier - b.key.tier || b.key.waitHours - a.key.waitHours)
|
|
61
|
+
.map((entry) => entry.item);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 给 agent 的一段话。
|
|
66
|
+
* @param {object} report collectAll 的结果
|
|
67
|
+
* @returns {string} 文本
|
|
68
|
+
*/
|
|
69
|
+
export function renderText(report) {
|
|
70
|
+
const lines = [];
|
|
71
|
+
const s = report.summary;
|
|
72
|
+
const marketReadable = report.market.available === true;
|
|
73
|
+
lines.push(`作品看板|${shortTime(report.generatedAt)}|${report.owner}`);
|
|
74
|
+
// 市场读不到时**不许显示 0 收录 / 0 下载** —— 那会让人以为"没人用",而事实是"没读到"。
|
|
75
|
+
lines.push(
|
|
76
|
+
marketReadable
|
|
77
|
+
? ` 汇总:${s.works} 个作品 · 已发布 ${s.published} · 市场已收录 ${s.listed} · 总下载 ${s.totalDownloads} · 总 star ${s.totalStars} · 待合 PR ${s.openPrs}`
|
|
78
|
+
: ` 汇总:${s.works} 个作品 · 已发布 ${s.published} · **市场数据读不到**(不显示 0,免得被当成没人用) · 总 star ${s.totalStars} · 待合 PR ${s.openPrs}`,
|
|
79
|
+
);
|
|
80
|
+
if (marketReadable) {
|
|
81
|
+
lines.push(` 市场登记表:${report.market.count} 条(更新于 ${String(report.market.updated ?? '?')})`);
|
|
82
|
+
} else {
|
|
83
|
+
lines.push(` 市场登记表:**读不到**(${report.market.reason ?? '未知原因'})—— 已收录/下载量这几项本次一律不报数`);
|
|
84
|
+
}
|
|
85
|
+
if (report.upstream?.available === true) {
|
|
86
|
+
lines.push(` 上游合并节奏:中位数 ${hoursText(report.upstream.medianHours)}(样本 ${report.upstream.samples} 个已合并 PR)`);
|
|
87
|
+
} else {
|
|
88
|
+
lines.push(` 上游合并节奏:**没算出来**(${report.upstream?.reason ?? '未知原因'})`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 「等得比上游中位数还久」的 PR 单独点名 —— 这是真正需要盯的一批。
|
|
92
|
+
if (report.upstream?.available === true) {
|
|
93
|
+
const median = Number(report.upstream.medianHours);
|
|
94
|
+
const slow = report.works
|
|
95
|
+
.map((item) => {
|
|
96
|
+
const pr = item.github?.pr;
|
|
97
|
+
if (!pr || pr.state !== 'open' || pr.mergedAt) return null;
|
|
98
|
+
const hours = (Date.now() - Date.parse(pr.createdAt)) / 3600000;
|
|
99
|
+
return Number.isFinite(hours) && hours > median ? { name: item.work.name, number: pr.number, hours } : null;
|
|
100
|
+
})
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.sort((a, b) => b.hours - a.hours);
|
|
103
|
+
if (slow.length > 0) {
|
|
104
|
+
// 作品多了以后这一行会变成一坨 —— 只点名最久的几个,其余报总数(详情在卡片里)。
|
|
105
|
+
const shown = slow.slice(0, 6);
|
|
106
|
+
const rest = slow.length - shown.length;
|
|
107
|
+
lines.push(
|
|
108
|
+
` ⏳ 超过上游中位数的 PR ${slow.length} 条:${shown.map((x) => `#${x.number}(${hoursText(x.hours)})`).join('、')}`
|
|
109
|
+
+ (rest > 0 ? ` …其余 ${rest} 条见卡片` : ''),
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
lines.push(' ⏳ 没有超过上游中位数的 PR(都还在正常等待区间)');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const ordered = sortWorks(report.works);
|
|
116
|
+
const compact = ordered.length > COMPACT_THRESHOLD;
|
|
117
|
+
if (compact) {
|
|
118
|
+
lines.push(` (作品 ${ordered.length} 个,超过 ${COMPACT_THRESHOLD} 个自动切紧凑视图:一行一个,需要盯的排前面)`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const item of ordered) {
|
|
122
|
+
const { work, local, github, market, npm } = item;
|
|
123
|
+
const bits = [];
|
|
124
|
+
bits.push(local.available === true ? `本地 ${local.version ?? '?'} / ${local.files ?? '?'} 文件${local.hasSmoke ? ' / 有自测' : ''}` : `本地 **读不到**(${local.reason ?? '?'})`);
|
|
125
|
+
bits.push(github.exists === true ? `GitHub star ${github.stars} / 最后推送 ${shortTime(github.pushedAt)}` : (github.exists === false ? 'GitHub **没有仓库**' : `GitHub **读不到**(${github.reason ?? '?'})`));
|
|
126
|
+
if (market.listed === true) {
|
|
127
|
+
// 没下载数 ≠ 没发 npm:下载字段市场侧可能不统计。npm 发没发看下面那行 npm 事实,不在这里推。
|
|
128
|
+
bits.push(market.downloads === null || market.downloads === undefined
|
|
129
|
+
? '市场 **已收录**(没有下载数据)'
|
|
130
|
+
: `市场 **已收录**(下载 ${market.downloads}${market.rankInCategory ? `,同类第 ${market.rankInCategory}/${market.categorySize}` : ''})`);
|
|
131
|
+
} else if (market.available === true) {
|
|
132
|
+
bits.push('市场 **未收录**');
|
|
133
|
+
} else {
|
|
134
|
+
bits.push(`市场 **读不到**(${market.reason ?? '?'})`);
|
|
135
|
+
}
|
|
136
|
+
if (npm?.published === true) bits.push(`npm 有包(${npm.version})`);
|
|
137
|
+
else if (npm?.available === true) bits.push('npm 没有');
|
|
138
|
+
|
|
139
|
+
let prText = '';
|
|
140
|
+
if (github.pr) {
|
|
141
|
+
const waited = (Date.now() - Date.parse(github.pr.createdAt)) / 3600000;
|
|
142
|
+
if (github.pr.mergedAt) prText = `/PR #${github.pr.number} **已合并**`;
|
|
143
|
+
else if (github.pr.state === 'open') {
|
|
144
|
+
const median = report.upstream?.available === true ? `(上游中位数 ${hoursText(report.upstream.medianHours)})` : '';
|
|
145
|
+
prText = `/PR #${github.pr.number} 待合并,已等 ${hoursText(waited)}${median}`;
|
|
146
|
+
} else prText = `/PR #${github.pr.number} ${github.pr.state}`;
|
|
147
|
+
} else {
|
|
148
|
+
prText = '/**没有市场 PR**';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (compact) {
|
|
152
|
+
// 一行一个:名字(中文) · 版本 · star · 市场/PR 状态(详情与链接看卡片)
|
|
153
|
+
const dl = market.downloads === null || market.downloads === undefined ? '下载数暂无' : `${market.downloads}↓`;
|
|
154
|
+
const marketText = market.listed !== true
|
|
155
|
+
? (market.available === true ? '未收录' : '市场读不到')
|
|
156
|
+
: (npm?.published === true ? `已收录 · ${dl}` : (npm?.available === true ? `已收录 · ${dl} · npm 未发` : `已收录 · ${dl}`));
|
|
157
|
+
const prShort = github.pr
|
|
158
|
+
? (github.pr.mergedAt ? `#${github.pr.number} 已合并` : (github.pr.state === 'open' ? `#${github.pr.number} 等 ${hoursText((Date.now() - Date.parse(github.pr.createdAt)) / 3600000)}` : `#${github.pr.number} ${github.pr.state}`))
|
|
159
|
+
: '无 PR';
|
|
160
|
+
lines.push(` · ${displayName(item)}|v${local.version ?? '?'}|star ${github.exists === true ? github.stars : '—'}|${marketText}|${prShort}`);
|
|
161
|
+
if (github.exists !== true) lines.push(` ⚠ GitHub ${github.exists === false ? '没有仓库' : '读不到'}(${github.reason ?? '?'})`);
|
|
162
|
+
} else {
|
|
163
|
+
lines.push(` · ${displayName(item)}`);
|
|
164
|
+
lines.push(` ${bits.join('|')}${prText}`);
|
|
165
|
+
const link = item.links?.github;
|
|
166
|
+
if (link) lines.push(` 直达:${link}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
lines.push(' 卡片:见 htmlPath(可直接打开/截图)');
|
|
170
|
+
return lines.join('\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 显示名:英文名 +(中文名)。中文名来自名册 nameZh,其次 package.json 的 dsh.displayName。 */
|
|
174
|
+
function displayName(item) {
|
|
175
|
+
const zh = item?.work?.nameZh ?? item?.local?.displayName ?? null;
|
|
176
|
+
return zh ? `${item.work.name}(${zh})` : item.work.name;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 一个状态点。 */
|
|
180
|
+
function dot(kind) {
|
|
181
|
+
const color = { good: '#3fb950', warn: '#d29922', bad: '#f85149', idle: '#6e7681' }[kind] ?? '#6e7681';
|
|
182
|
+
return `<span class="dot" style="background:${color}"></span>`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 紧凑表格:作品多(> COMPACT_THRESHOLD)时用一屏多行的表格,而不是一作品一大卡。
|
|
187
|
+
* @param {Array<object>} ordered 已排序的作品
|
|
188
|
+
* @returns {string} HTML
|
|
189
|
+
*/
|
|
190
|
+
function renderGrid(ordered) {
|
|
191
|
+
const rows = ordered.map((item) => {
|
|
192
|
+
const { work, local, github, market, npm } = item;
|
|
193
|
+
const key = urgencyOf(item);
|
|
194
|
+
const pr = github?.pr;
|
|
195
|
+
const prText = pr
|
|
196
|
+
? (pr.mergedAt
|
|
197
|
+
? `${dot('good')} #${pr.number} 已合并`
|
|
198
|
+
: (pr.state === 'open'
|
|
199
|
+
? `${dot('warn')} #${pr.number} 等 ${hoursText((Date.now() - Date.parse(pr.createdAt)) / 3600000)}`
|
|
200
|
+
: `#${pr.number} ${esc(pr.state)}`))
|
|
201
|
+
: '<span class="bad">没有 PR</span>';
|
|
202
|
+
const dlGrid = market.downloads === null || market.downloads === undefined ? '下载数暂无' : `${market.downloads}↓`;
|
|
203
|
+
const marketText = market.listed !== true
|
|
204
|
+
? (market.available === true ? `${dot('warn')} 未收录` : '<span class="bad">读不到</span>')
|
|
205
|
+
: (npm?.available === true && npm.published !== true
|
|
206
|
+
? `${dot('warn')} 已收录 · ${dlGrid} · npm 未发`
|
|
207
|
+
: `${dot('good')} 已收录 · ${dlGrid}`);
|
|
208
|
+
const ghText = github.exists === true
|
|
209
|
+
? `${dot('idle')} star ${github.stars}`
|
|
210
|
+
: (github.exists === false ? '<span class="bad">仓库不存在</span>' : '<span class="bad">读不到</span>');
|
|
211
|
+
const why = key.tier === 0 ? '<div class="why">需要处理</div>' : '';
|
|
212
|
+
const link = item.links?.github ?? null;
|
|
213
|
+
const zh = work.nameZh ?? local.displayName ?? null;
|
|
214
|
+
const nameCell = link
|
|
215
|
+
? `<a href="${esc(link)}" target="_blank" rel="noreferrer">${esc(work.name)}</a>`
|
|
216
|
+
: esc(work.name);
|
|
217
|
+
return `<tr class="t${key.tier}">
|
|
218
|
+
<td class="name">${nameCell}${zh ? `<span class="zh">${esc(zh)}</span>` : ''}${why}</td>
|
|
219
|
+
<td>${esc(work.category ?? '—')}</td>
|
|
220
|
+
<td class="num">${esc(local.available === true ? local.version ?? '?' : '读不到')}</td>
|
|
221
|
+
<td>${ghText}</td>
|
|
222
|
+
<td>${marketText}</td>
|
|
223
|
+
<td class="num">${prText}</td>
|
|
224
|
+
</tr>`;
|
|
225
|
+
}).join('\n');
|
|
226
|
+
|
|
227
|
+
return `<table class="grid">
|
|
228
|
+
<thead><tr><th>作品(需要处理的排前面)</th><th>分类</th><th>版本</th><th>GitHub</th><th>市场</th><th>收录 PR</th></tr></thead>
|
|
229
|
+
<tbody>
|
|
230
|
+
${rows}
|
|
231
|
+
</tbody>
|
|
232
|
+
</table>`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 给人看的卡片(单文件 HTML)。
|
|
237
|
+
* @param {object} report collectAll 的结果
|
|
238
|
+
* @returns {string} HTML
|
|
239
|
+
*/
|
|
240
|
+
export function renderHtml(report) {
|
|
241
|
+
const s = report.summary;
|
|
242
|
+
const median = report.upstream?.available === true ? `上游中位数 ${hoursText(report.upstream.medianHours)}` : '上游节奏未知';
|
|
243
|
+
const ordered = sortWorks(report.works);
|
|
244
|
+
const compact = ordered.length > COMPACT_THRESHOLD;
|
|
245
|
+
|
|
246
|
+
const cards = ordered.map((item) => {
|
|
247
|
+
const { work, local, github, market, npm } = item;
|
|
248
|
+
const listed = market.listed === true;
|
|
249
|
+
const rows = [];
|
|
250
|
+
|
|
251
|
+
rows.push(`<div class="row"><span class="k">本地</span><span class="v">${
|
|
252
|
+
local.available === true
|
|
253
|
+
? `${esc(local.version ?? '?')} · ${local.files ?? '?'} 文件${local.hasSmoke ? ' · 有自测' : ''}`
|
|
254
|
+
: `<span class="bad">读不到(${esc(local.reason ?? '')})</span>`
|
|
255
|
+
}</span></div>`);
|
|
256
|
+
|
|
257
|
+
rows.push(`<div class="row"><span class="k">GitHub</span><span class="v">${
|
|
258
|
+
github.exists === true
|
|
259
|
+
? `${dot(github.stars > 0 ? 'good' : 'idle')} star ${github.stars} · fork ${github.forks} · 最后推送 ${shortTime(github.pushedAt)}`
|
|
260
|
+
: (github.exists === false ? '<span class="bad">仓库不存在</span>' : `<span class="bad">读不到(${esc(github.reason ?? '')})</span>`)
|
|
261
|
+
}</span></div>`);
|
|
262
|
+
|
|
263
|
+
rows.push(`<div class="row"><span class="k">市场</span><span class="v">${
|
|
264
|
+
listed
|
|
265
|
+
? (market.downloads === null || market.downloads === undefined
|
|
266
|
+
? `${dot('warn')} 已收录 · 下载数暂无`
|
|
267
|
+
: `${dot('good')} 已收录 · 下载 <b>${market.downloads}</b>${market.rankInCategory ? ` · 同类 ${market.rankInCategory}/${market.categorySize}` : ''}`)
|
|
268
|
+
: (market.available === true ? `${dot('warn')} 还没收录` : `<span class="bad">读不到(${esc(market.reason ?? '')})</span>`)
|
|
269
|
+
}</span></div>`);
|
|
270
|
+
|
|
271
|
+
let prHtml = `<div class="row"><span class="k">收录 PR</span><span class="v"><span class="bad">没有 PR</span></span></div>`;
|
|
272
|
+
if (github.pr) {
|
|
273
|
+
const waited = (Date.now() - Date.parse(github.pr.createdAt)) / 3600000;
|
|
274
|
+
if (github.pr.mergedAt) {
|
|
275
|
+
prHtml = `<div class="row"><span class="k">收录 PR</span><span class="v">${dot('good')} #${github.pr.number} 已合并</span></div>`;
|
|
276
|
+
} else if (github.pr.state === 'open') {
|
|
277
|
+
prHtml = `<div class="row"><span class="k">收录 PR</span><span class="v">${dot('warn')} #${github.pr.number} 待合并 · 已等 <b>${hoursText(waited)}</b><span class="muted">(${esc(median)})</span></span></div>`;
|
|
278
|
+
} else {
|
|
279
|
+
prHtml = `<div class="row"><span class="k">收录 PR</span><span class="v">#${github.pr.number} ${esc(github.pr.state)}</span></div>`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
rows.push(prHtml);
|
|
283
|
+
|
|
284
|
+
if (npm?.published === true) {
|
|
285
|
+
rows.push(`<div class="row"><span class="k">npm</span><span class="v">${dot('good')} 有包 ${esc(npm.version ?? '')}</span></div>`);
|
|
286
|
+
} else if (npm?.available === true) {
|
|
287
|
+
rows.push(`<div class="row"><span class="k">npm</span><span class="v">${dot('warn')} registry 上没有这个包</span></div>`);
|
|
288
|
+
} else if (npm?.reason) {
|
|
289
|
+
rows.push(`<div class="row"><span class="k">npm</span><span class="v">${dot('idle')} 没查到(${esc(npm.reason)})</span></div>`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const head = listed ? 'listed' : (github.exists === true ? 'shipped' : 'local');
|
|
293
|
+
const headText = listed ? '已上市场' : (github.exists === true ? '已发布 · 等收录' : '仅本地');
|
|
294
|
+
const link = item.links?.github ?? null;
|
|
295
|
+
const zh = work.nameZh ?? local.displayName ?? null;
|
|
296
|
+
const title = link
|
|
297
|
+
? `<a href="${esc(link)}" target="_blank" rel="noreferrer">${esc(work.name)}</a>`
|
|
298
|
+
: esc(work.name);
|
|
299
|
+
|
|
300
|
+
// 直达:GitHub / npm / 市场条目页(能确定才给,不编地址)
|
|
301
|
+
const linkBits = [];
|
|
302
|
+
if (link) linkBits.push(`<a href="${esc(link)}" target="_blank" rel="noreferrer">GitHub</a>`);
|
|
303
|
+
if (npm?.available === true && item.links?.npm) linkBits.push(`<a href="${esc(item.links.npm)}" target="_blank" rel="noreferrer">npm${npm.published === true ? '' : '(没有)'}</a>`);
|
|
304
|
+
if (item.links?.market) linkBits.push(`<a href="${esc(item.links.market)}" target="_blank" rel="noreferrer">市场条目</a>`);
|
|
305
|
+
if (linkBits.length > 0) rows.push(`<div class="row"><span class="k">直达</span><span class="v">${linkBits.join(' · ')}</span></div>`);
|
|
306
|
+
|
|
307
|
+
return `<section class="card ${head}">
|
|
308
|
+
<header>
|
|
309
|
+
<h2>${title}${zh ? `<span class="zh">${esc(zh)}</span>` : ''}</h2>
|
|
310
|
+
<span class="badge">${esc(work.category ?? '—')}</span>
|
|
311
|
+
<span class="state">${headText}</span>
|
|
312
|
+
</header>
|
|
313
|
+
${rows.join('\n ')}
|
|
314
|
+
</section>`;
|
|
315
|
+
}).join('\n ');
|
|
316
|
+
|
|
317
|
+
return `<!doctype html>
|
|
318
|
+
<html lang="zh">
|
|
319
|
+
<head>
|
|
320
|
+
<meta charset="utf-8">
|
|
321
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
322
|
+
<title>作品看板 · ${esc(report.owner)}</title>
|
|
323
|
+
<style>
|
|
324
|
+
:root { color-scheme: dark; }
|
|
325
|
+
* { box-sizing: border-box; }
|
|
326
|
+
body { margin: 0; padding: 32px; background: #0d1117; color: #e6edf3;
|
|
327
|
+
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif; }
|
|
328
|
+
.wrap { max-width: 980px; margin: 0 auto; }
|
|
329
|
+
h1 { font-size: 26px; margin: 0 0 4px; letter-spacing: .5px; }
|
|
330
|
+
.sub { color: #8b949e; font-size: 13px; margin-bottom: 22px; }
|
|
331
|
+
.tiles { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px;
|
|
332
|
+
position: sticky; top: 0; z-index: 2; background: #0d1117; padding: 8px 0 12px; }
|
|
333
|
+
.tile { background: #161b22; border: 1px solid #21262d; border-radius: 12px; padding: 14px 16px; }
|
|
334
|
+
.tile .n { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
335
|
+
.tile .l { color: #8b949e; font-size: 12px; margin-top: 2px; }
|
|
336
|
+
.card { background: #161b22; border: 1px solid #21262d; border-left: 3px solid #6e7681;
|
|
337
|
+
border-radius: 12px; padding: 16px 18px; margin-bottom: 14px; }
|
|
338
|
+
.card.listed { border-left-color: #3fb950; }
|
|
339
|
+
.card.shipped { border-left-color: #d29922; }
|
|
340
|
+
.card header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
|
341
|
+
.card h2 { font-size: 17px; margin: 0; font-family: ui-monospace, Consolas, monospace; }
|
|
342
|
+
.card h2 a { color: inherit; text-decoration: none; border-bottom: 1px dashed #30363d; }
|
|
343
|
+
.card h2 a:hover { border-bottom-color: #58a6ff; color: #58a6ff; }
|
|
344
|
+
.zh { font-family: "Microsoft YaHei", system-ui, sans-serif; font-size: 13px; color: #8b949e; margin-left: 6px; }
|
|
345
|
+
.grid td.name a { color: inherit; text-decoration: none; border-bottom: 1px dashed #30363d; }
|
|
346
|
+
.grid td.name a:hover { border-bottom-color: #58a6ff; color: #58a6ff; }
|
|
347
|
+
.badge { font-size: 11px; color: #8b949e; border: 1px solid #30363d; border-radius: 999px; padding: 1px 8px; }
|
|
348
|
+
.state { margin-left: auto; font-size: 12px; color: #8b949e; }
|
|
349
|
+
.row { display: flex; gap: 10px; padding: 3px 0; font-size: 13px; line-height: 1.7; }
|
|
350
|
+
.k { color: #8b949e; width: 68px; flex: none; }
|
|
351
|
+
.v { font-variant-numeric: tabular-nums; }
|
|
352
|
+
.muted { color: #6e7681; }
|
|
353
|
+
.bad { color: #f85149; }
|
|
354
|
+
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
|
355
|
+
.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
356
|
+
.grid th { text-align: left; color: #8b949e; font-weight: 600; font-size: 12px;
|
|
357
|
+
padding: 8px 10px; border-bottom: 1px solid #30363d; position: sticky; top: 88px;
|
|
358
|
+
background: #0d1117; z-index: 1; }
|
|
359
|
+
.grid td { padding: 7px 10px; border-bottom: 1px solid #21262d; vertical-align: top; }
|
|
360
|
+
.grid tr:hover td { background: #161b22; }
|
|
361
|
+
.grid td.name { font-family: ui-monospace, Consolas, monospace; }
|
|
362
|
+
.grid tr.t0 td.name { border-left: 3px solid #f85149; padding-left: 7px; }
|
|
363
|
+
.grid tr.t1 td.name { border-left: 3px solid #d29922; padding-left: 7px; }
|
|
364
|
+
.grid tr.t2 td.name { border-left: 3px solid #3fb950; padding-left: 7px; }
|
|
365
|
+
.grid .num { font-variant-numeric: tabular-nums; }
|
|
366
|
+
.grid .why { color: #6e7681; font-size: 12px; }
|
|
367
|
+
footer { color: #6e7681; font-size: 12px; margin-top: 20px; line-height: 1.8; }
|
|
368
|
+
</style>
|
|
369
|
+
</head>
|
|
370
|
+
<body>
|
|
371
|
+
<div class="wrap">
|
|
372
|
+
<h1>🚢 作品看板</h1>
|
|
373
|
+
<div class="sub">${esc(report.owner)} · 生成于 ${esc(shortTime(report.generatedAt))} · 登记表 ${esc(report.market.available === true ? `${report.market.count} 条(${report.market.updated ?? '?'})` : '读不到')}</div>
|
|
374
|
+
|
|
375
|
+
<div class="tiles">
|
|
376
|
+
<div class="tile"><div class="n">${s.works}</div><div class="l">作品</div></div>
|
|
377
|
+
<div class="tile"><div class="n">${s.published}</div><div class="l">已发布</div></div>
|
|
378
|
+
<div class="tile"><div class="n">${s.listed}</div><div class="l">市场已收录</div></div>
|
|
379
|
+
<div class="tile"><div class="n">${s.totalDownloads}</div><div class="l">总下载</div></div>
|
|
380
|
+
<div class="tile"><div class="n">${s.openPrs}</div><div class="l">待合 PR</div></div>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
${compact ? renderGrid(ordered) : cards}
|
|
384
|
+
|
|
385
|
+
<footer>
|
|
386
|
+
上游 ${esc(report.upstream?.upstream ?? '?')}:${esc(median)}${report.upstream?.samples ? `(样本 ${report.upstream.samples})` : ''}<br>
|
|
387
|
+
「市场已收录」= 市场登记表里能查到这个名字;「待合并」= 收录 PR 还开着。数字来自 GitHub API 与本机市场接口,**不经模型生成**。
|
|
388
|
+
</footer>
|
|
389
|
+
</div>
|
|
390
|
+
</body>
|
|
391
|
+
</html>
|
|
392
|
+
`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 把报告落盘成卡片(可选同时落一份 JSON)。
|
|
397
|
+
* @param {object} report 报告
|
|
398
|
+
* @param {object} opts 选项 `{ dir, html, json, now }`
|
|
399
|
+
* @returns {Promise<{htmlPath:string|null, jsonPath:string|null}>} 落盘结果
|
|
400
|
+
*/
|
|
401
|
+
export async function writeReport(report, { dir, html = true, json = false, now = Date.now() } = {}) {
|
|
402
|
+
const target = dir ?? join(process.env.USERPROFILE ?? process.env.HOME ?? '.', '.dsh', 'lighthouse-reports');
|
|
403
|
+
await mkdir(target, { recursive: true });
|
|
404
|
+
const stamp = new Date(now).toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
405
|
+
const out = { htmlPath: null, jsonPath: null };
|
|
406
|
+
if (html) {
|
|
407
|
+
out.htmlPath = join(target, `lighthouse-${stamp}.html`);
|
|
408
|
+
await writeFile(out.htmlPath, renderHtml(report), 'utf8');
|
|
409
|
+
}
|
|
410
|
+
if (json) {
|
|
411
|
+
out.jsonPath = join(target, `lighthouse-${stamp}.json`);
|
|
412
|
+
await writeFile(out.jsonPath, JSON.stringify(report, null, 2), 'utf8');
|
|
413
|
+
}
|
|
414
|
+
return out;
|
|
415
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-lighthouse · status-route —— 给界面观察区供数据的一条**只读**接口。
|
|
3
|
+
*
|
|
4
|
+
* `GET /dsh-lighthouse/status` → `{ ok, report }`(就是 `collectAll` 的那份报告)。
|
|
5
|
+
* 界面那一半(`client.js`)只负责画,不负责算 —— 所以同一个数字在面板、工具、卡片里是一致的。
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-lighthouse/status-route
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { collectAll } from './collect.js';
|
|
11
|
+
import { readFleet } from './fleet.js';
|
|
12
|
+
|
|
13
|
+
/** 观察区取数的路径。 */
|
|
14
|
+
export const STATUS_ROUTE = '/dsh-lighthouse/status';
|
|
15
|
+
|
|
16
|
+
/** 统一的 JSON 回包。 */
|
|
17
|
+
function send(response, status, payload) {
|
|
18
|
+
const body = JSON.stringify(payload);
|
|
19
|
+
response.writeHead(status, {
|
|
20
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
21
|
+
'Cache-Control': 'no-store',
|
|
22
|
+
'Content-Length': Buffer.byteLength(body),
|
|
23
|
+
});
|
|
24
|
+
response.end(body);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 本机匿名请求的缓存有效期 —— 免得谁都能借我的 PAT 反复去打 GitHub。 */
|
|
28
|
+
export const CACHE_TTL_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 造一个请求处理器。
|
|
32
|
+
* @param {object} options 选项
|
|
33
|
+
* @param {string} options.fleetFile 名册路径
|
|
34
|
+
* @param {string} [options.marketUrl] 市场接口根
|
|
35
|
+
* @param {string|null|Function} [options.token] GitHub PAT(也接受函数:请求时才解析)
|
|
36
|
+
* @param {Function} [options.collect] 采集实现(测试注入)
|
|
37
|
+
* @param {Function} [options.readFleetImpl] 读名册实现(测试注入)
|
|
38
|
+
* @param {Function} [options.now] 取时间(测试注入)
|
|
39
|
+
* @param {number} [options.cacheTtlMs] 匿名来源的缓存时长
|
|
40
|
+
* @returns {Function} `(request, response) => Promise<void>`
|
|
41
|
+
*/
|
|
42
|
+
export function createStatusHandler({
|
|
43
|
+
fleetFile,
|
|
44
|
+
marketUrl,
|
|
45
|
+
token = null,
|
|
46
|
+
collect = collectAll,
|
|
47
|
+
readFleetImpl = readFleet,
|
|
48
|
+
now = Date.now,
|
|
49
|
+
cacheTtlMs = CACHE_TTL_MS,
|
|
50
|
+
} = {}) {
|
|
51
|
+
let cache = null; // { at, payload }
|
|
52
|
+
|
|
53
|
+
/** 从请求头里取来源(Origin 优先,其次 Referer;都没有就返回 null)。 */
|
|
54
|
+
function sourceOf(request) {
|
|
55
|
+
const origin = request?.headers?.origin;
|
|
56
|
+
if (typeof origin === 'string' && origin !== '') return origin;
|
|
57
|
+
const referer = request?.headers?.referer;
|
|
58
|
+
if (typeof referer === 'string' && referer !== '') return referer;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 跨源来的请求一律拒掉。
|
|
64
|
+
*
|
|
65
|
+
* 背景(实测):**宿主不为插件注册的路由做认证** —— `webServer.register` 没有 auth 选项,
|
|
66
|
+
* 宿主根 `/` 要认证(401),插件路由却是裸的。所以准入只能自己加:
|
|
67
|
+
* · 带了来源头(界面 fetch 会带 Referer)→ 必须与市场接口同源,否则 403;
|
|
68
|
+
* · 没带来源头(curl / 本机脚本)→ 放行。这些信息本来就是公开的(仓库名、star 数),
|
|
69
|
+
* 真要挡本机进程也挡不住(它直接读工程目录就行),装样子没意义。
|
|
70
|
+
* 另加一层缓存兜底:界面来的请求**永远实时**(刷新按钮必须真的刷新),
|
|
71
|
+
* 匿名来源的重复请求 60 秒内吃缓存。
|
|
72
|
+
*/
|
|
73
|
+
function fromElsewhere(request) {
|
|
74
|
+
const seen = sourceOf(request);
|
|
75
|
+
if (seen === null) return false;
|
|
76
|
+
try {
|
|
77
|
+
return new URL(seen).origin !== new URL(marketUrl).origin;
|
|
78
|
+
} catch {
|
|
79
|
+
return true; // 来源头读不懂:当作别处来的
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 这次请求是不是界面(同源)来的。 */
|
|
84
|
+
function fromUi(request) {
|
|
85
|
+
const seen = sourceOf(request);
|
|
86
|
+
if (seen === null) return false;
|
|
87
|
+
try {
|
|
88
|
+
return new URL(seen).origin === new URL(marketUrl).origin;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return async function handleStatus(request, response) {
|
|
95
|
+
const method = String(request?.method ?? 'GET').toUpperCase();
|
|
96
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
97
|
+
send(response, 405, { ok: false, error: 'method-not-allowed' });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (fromElsewhere(request)) {
|
|
101
|
+
send(response, 403, { ok: false, error: 'cross-origin-denied' });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (fromUi(request) !== true && cache !== null && now() - cache.at < cacheTtlMs) {
|
|
105
|
+
send(response, 200, cache.payload);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const read = await readFleetImpl(fleetFile);
|
|
110
|
+
if (read.ok !== true) {
|
|
111
|
+
send(response, 200, { ok: false, errors: read.errors, fleetPath: fleetFile });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// token 允许传函数:注册路由时它还没被解析,第一次请求时再拿
|
|
115
|
+
// (省得为了它把 apply 变成异步 —— 插件加载不该等网络)
|
|
116
|
+
const resolvedToken = typeof token === 'function' ? await token() : token;
|
|
117
|
+
// 观察区不查 npm(省一次外网往返),其余与工具走同一条采集路径
|
|
118
|
+
const report = await collect(read.fleet, { token: resolvedToken, marketUrl, includeNpm: false, now: now() });
|
|
119
|
+
const payload = { ok: true, report };
|
|
120
|
+
cache = { at: now(), payload };
|
|
121
|
+
send(response, 200, payload);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
send(response, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,93 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-lighthouse",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-lighthouse",
|
|
3
|
+
"version": "0.3.4",
|
|
4
|
+
"description": "一屏看清每个作品现在什么样:本地版本、GitHub star、市场收没收、下载量和同类排名,还能落一张可截图的卡片。 One screen for every work you have shipped: local version, GitHub stars, market listing, downloads and category rank, plus a ready-to-screenshot card.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dsh-lighthouse": "bin/dsh-lighthouse.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./lib/index.js",
|
|
12
|
+
"./fleet": "./lib/fleet.js",
|
|
13
|
+
"./collect": "./lib/collect.js",
|
|
14
|
+
"./render": "./lib/render.js",
|
|
15
|
+
"./status-route": "./lib/status-route.js",
|
|
16
|
+
"./client": "./client.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib",
|
|
21
|
+
"bin",
|
|
22
|
+
"client.js",
|
|
23
|
+
"cordis.patch.yml",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"deepseek-harness",
|
|
29
|
+
"dsh",
|
|
30
|
+
"dsh-plugin",
|
|
31
|
+
"dashboard",
|
|
32
|
+
"release-tracker",
|
|
33
|
+
"market-registry",
|
|
34
|
+
"downloads",
|
|
35
|
+
"watchers",
|
|
36
|
+
"pull-request",
|
|
37
|
+
"observability",
|
|
38
|
+
"作品看板",
|
|
39
|
+
"观察区"
|
|
40
|
+
],
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=18"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node test/smoke.mjs"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/7gzgn/dsh-lighthouse.git"
|
|
54
|
+
},
|
|
55
|
+
"homepage": "https://github.com/7gzgn/dsh-lighthouse#readme",
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/7gzgn/dsh-lighthouse/issues"
|
|
58
|
+
},
|
|
59
|
+
"dsh": {
|
|
60
|
+
"compatibility": {
|
|
61
|
+
"dshReleases": {
|
|
62
|
+
"0.2.0-rc.2": "compatible",
|
|
63
|
+
"0.2.1-rc.2": "compatible"
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"bundle": {
|
|
67
|
+
"patch": "./cordis.patch.yml"
|
|
68
|
+
},
|
|
69
|
+
"client": {
|
|
70
|
+
"platform": "web",
|
|
71
|
+
"inject": [
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"peerDependencies": {
|
|
77
|
+
"@deepseek-ai/cordis": ">=4.0.4 <5.0.0",
|
|
78
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.2.0-rc.2 <0.3.0-0",
|
|
79
|
+
"@deepseek-ai/dsh-system-prompt": ">=0.2.0-rc.2 <0.3.0-0",
|
|
80
|
+
"@deepseek-ai/dsh-tools": ">=0.2.0-rc.2 <0.3.0-0"
|
|
81
|
+
},
|
|
82
|
+
"peerDependenciesMeta": {
|
|
83
|
+
"@deepseek-ai/dsh-client-ui-slots": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"@deepseek-ai/dsh-system-prompt": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"@deepseek-ai/dsh-tools": {
|
|
90
|
+
"optional": true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|