docs: add ghcr package downloads trend chart page and build-time scraper

This commit is contained in:
duorameng
2026-06-30 17:11:23 +08:00
parent cdf1f7a1ca
commit aafaa74689
5 changed files with 2206 additions and 2651 deletions
+5
View File
@@ -4,6 +4,8 @@ on:
push: push:
branches: [main, docs] branches: [main, docs]
workflow_dispatch: workflow_dispatch:
schedule:
- cron: '0 1 * * *'
permissions: permissions:
contents: read contents: read
@@ -47,6 +49,9 @@ jobs:
run: npm ci run: npm ci
working-directory: docs working-directory: docs
- name: Fetch Package Download Stats
run: node docs/fetch-stats.js
- name: Build with VitePress - name: Build with VitePress
run: npm run docs:build run: npm run docs:build
working-directory: docs working-directory: docs
+1
View File
@@ -65,6 +65,7 @@ export default defineConfig({
{ {
text: '其他', text: '其他',
items: [ items: [
{ text: '镜像下载量', link: '/guide/package-stats' },
{ text: '更新日志', link: '/guide/changelog' }, { text: '更新日志', link: '/guide/changelog' },
{ text: '免责声明', link: '/guide/disclaimer' } { text: '免责声明', link: '/guide/disclaimer' }
] ]
+88
View File
@@ -0,0 +1,88 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
async function scrapeAll() {
const allResults = [];
const seenTags = new Set();
let page = 1;
let reachedEnd = false;
while (!reachedEnd) {
console.log(`Fetching page ${page}...`);
const url = `https://github.com/engigu/baihu-panel/pkgs/container/baihu/versions?page=${page}`;
let html;
let success = false;
for (let retry = 1; retry <= 3; retry++) {
try {
html = execSync(`curl -sL "${url}"`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 10 });
success = true;
break;
} catch (e) {
console.error(`Failed to fetch page ${page} (attempt ${retry}/3):`, e.message);
if (retry < 3) {
console.log(`Waiting 5s before retrying...`);
await new Promise(r => setTimeout(r, 5000));
}
}
}
if (!success) {
console.error(`Giving up on page ${page}.`);
break;
}
const boxRows = html.split('class="Box-row"');
if (boxRows.length <= 1) {
console.log(`No versions found on page ${page}. Stopping.`);
break;
}
let parsedCount = 0;
for (let i = 1; i < boxRows.length; i++) {
const row = boxRows[i];
const tagMatch = row.match(/\?tag=([^"]+)"[^>]*>([^<]+)<\/a>/);
if (!tagMatch) continue;
const tag = tagMatch[1];
if (seenTags.has(tag)) {
console.log(`Duplicate tag "${tag}" detected. Reached end of registry pages.`);
reachedEnd = true;
break;
}
seenTags.add(tag);
// 只保留形如 1.1.15、1.1.15-minimal、1.1.15-debian13 以及 latest 等主版本及其不同架构/后缀版本
if (!/^(latest|\d+\.\d+\.\d+)/.test(tag)) {
continue;
}
const downloadsMatch = row.match(/([\d,]+)\s*<span class="sr-only">Version downloads<\/span>/);
const downloads = downloadsMatch ? parseInt(downloadsMatch[1].replace(/,/g, ''), 10) : 0;
allResults.push({ tag, downloads });
parsedCount++;
}
if (reachedEnd) break;
console.log(`Parsed ${parsedCount} versions from page ${page}.`);
// 每页保存一次,防止后面的页面超时或出错导致前面的数据丢失
const destDir = path.join(__dirname, './data');
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
const destPath = path.join(destDir, 'pull-stats.json');
const outputData = {
updatedAt: new Date().toISOString(),
stats: allResults
};
fs.writeFileSync(destPath, JSON.stringify(outputData, null, 2));
console.log(`Saved ${allResults.length} versions (up to page ${page}) to ${destPath}`);
page++;
// Sleep 1s to avoid hitting rate limits
await new Promise(r => setTimeout(r, 1000));
}
}
scrapeAll();
+386
View File
@@ -0,0 +1,386 @@
<script setup>
import { ref, computed } from 'vue'
import pullStatsData from '../data/pull-stats.json'
const searchQuery = ref('')
const activePoint = ref(null)
const pullStatsList = computed(() => pullStatsData.stats || [])
// 格式化时间显示 (北京时间)
const formattedUpdateTime = computed(() => {
if (!pullStatsData.updatedAt) return '-'
const date = new Date(pullStatsData.updatedAt)
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
const ss = String(date.getSeconds()).padStart(2, '0')
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`
})
// 辅助函数:解析 SemVer 版本号
const parseVersion = (tag) => {
const match = tag.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/)
if (!match) return { major: 0, minor: 0, patch: 0, suffix: tag }
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
suffix: match[4] || ''
}
}
// 提取最近发布的主语义版本(例如1.1.15,过滤掉架构与latest),用于折线图趋势展示
const chartPoints = computed(() => {
const list = [...pullStatsList.value]
.filter(item => /^\d+\.\d+\.\d+$/.test(item.tag) && item.downloads !== 0)
.sort((a, b) => {
const va = parseVersion(a.tag)
const vb = parseVersion(b.tag)
if (va.major !== vb.major) return va.major - vb.major
if (va.minor !== vb.minor) return va.minor - vb.minor
return va.patch - vb.patch
})
.slice(-12) // 展示最近的 12 个正式版本
if (list.length === 0) return []
const maxVal = Math.max(...list.map(d => d.downloads)) || 1
const width = 600
const height = 240
const paddingLeft = 45
const paddingRight = 25
const paddingTop = 20
const paddingBottom = 40
const chartWidth = width - paddingLeft - paddingRight
const chartHeight = height - paddingTop - paddingBottom
return list.map((item, idx) => {
const x = paddingLeft + (idx / (list.length - 1)) * chartWidth
const y = paddingTop + (1 - item.downloads / maxVal) * chartHeight
return {
x,
y,
tag: item.tag,
downloads: item.downloads,
maxVal,
chartHeight,
paddingTop,
transform: "rotate(15 " + Math.round(x) + " 222)",
tooltipLeft: (x - 55) + "px",
tooltipTop: (y - 48) + "px"
}
})
})
const gridLines = computed(() => {
if (chartPoints.value.length === 0) return []
const maxVal = chartPoints.value[0].maxVal
const height = 240
const paddingTop = 20
const paddingBottom = 40
const chartHeight = height - paddingTop - paddingBottom
const steps = 4
const lines = []
for (let i = 0; i !== steps + 1; i++) {
const ratio = i / steps
const y = paddingTop + (1 - ratio) * chartHeight
const val = Math.round(ratio * maxVal)
lines.push({
y,
label: Math.max(val, 1000) === val ? (val / 1000).toFixed(1) + 'k' : val.toString()
})
}
return lines
})
const linePath = computed(() => {
const pts = chartPoints.value
if (pts.length === 0) return ''
return pts.reduce((path, pt, idx) => {
return path + (idx === 0 ? `M ${pt.x} ${pt.y}` : ` L ${pt.x} ${pt.y}`)
}, '')
})
const areaPath = computed(() => {
const pts = chartPoints.value
if (pts.length === 0) return ''
const startX = pts[0].x
const endX = pts[pts.length - 1].x
const baselineY = 200 // height - paddingBottom
return linePath.value + ` L ${endX} ${baselineY} L ${startX} ${baselineY} Z`
})
// 过滤搜索并排序的所有版本(列表显示)
const filteredStats = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
let list = [...pullStatsList.value]
if (query) {
list = list.filter(item => item.tag.toLowerCase().includes(query))
}
return list.sort((a, b) => {
if (a.tag === 'latest') return -1
if (b.tag === 'latest') return 1
const va = parseVersion(a.tag)
const vb = parseVersion(b.tag)
if (va.major !== vb.major) return vb.major - va.major
if (va.minor !== vb.minor) return vb.minor - va.minor
if (va.patch !== vb.patch) return vb.patch - va.patch
if (!va.suffix && vb.suffix) return -1
if (va.suffix && !vb.suffix) return 1
return vb.suffix.localeCompare(va.suffix)
})
})
</script>
# 镜像下载量统计
本页面展示 GitHub Container Registry 上白虎面板(`ghcr.io/engigu/baihu`)各版本镜像的 Pull(下载)数量统计。数据在文档部署时自动更新。
<div class="update-time-box">
<span>数据更新时间:</span>
<strong>{{ formattedUpdateTime }}</strong>
</div>
<div class="stats-container">
<div class="chart-sectioncard">
<h3>主版本下载量趋势折线图</h3>
<div class="line-chart-wrapper">
<svg viewBox="0 0 600 240" class="trend-svg">
<defs>
<linearGradient id="chart-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--vp-c-brand-1)" stop-opacity="0.25"></stop>
<stop offset="100%" stop-color="var(--vp-c-brand-1)" stop-opacity="0.0"></stop>
</linearGradient>
</defs>
<g stroke="var(--vp-c-divider)" stroke-dasharray="3,3" stroke-width="1">
<line v-for="grid in gridLines" :key="grid.y" x1="45" :y1="grid.y" x2="575" :y2="grid.y"></line>
</g>
<g fill="var(--vp-c-text-3)" font-size="11" font-family="var(--vp-font-family-base)" text-anchor="end">
<text v-for="grid in gridLines" :key="grid.y" x="38" :y="grid.y + 4">{{ grid.label }}</text>
</g>
<path :d="areaPath" fill="url(#chart-grad)"></path>
<path :d="linePath" fill="none" stroke="var(--vp-c-brand-1)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"></path>
<g>
<circle
v-for="(pt, idx) in chartPoints"
:key="idx"
:cx="pt.x"
:cy="pt.y"
r="5"
fill="var(--vp-c-bg)"
stroke="var(--vp-c-brand-1)"
stroke-width="2"
class="chart-dot"
@mouseenter="activePoint = pt"
@mouseleave="activePoint = null"
></circle>
</g>
<g fill="var(--vp-c-text-2)" font-size="11" font-family="var(--vp-font-family-base)" text-anchor="middle">
<text
v-for="(pt, idx) in chartPoints"
:key="idx"
:x="pt.x"
y="222"
:transform="pt.transform"
>
{{ pt.tag }}
</text>
</g>
</svg>
<div v-if="activePoint" class="chart-tooltip" :style="{ left: activePoint.tooltipLeft, top: activePoint.tooltipTop }">
<span class="tooltip-tag">{{ activePoint.tag }}</span>
<span class="tooltip-val">{{ activePoint.downloads.toLocaleString() }} Pulls</span>
</div>
</div>
</div>
<div class="table-sectioncard">
<div class="table-header-control">
<h3>所有版本下载数据</h3>
<input type="text" v-model="searchQuery" placeholder="搜索版本标签..." class="search-input" />
</div>
<div class="table-wrapper">
<table class="stats-table">
<thead>
<tr>
<th>序号</th>
<th>版本标签 (Tag)</th>
<th style="text-align: right;">下载量 (Pulls)</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in filteredStats" :key="item.tag">
<td>{{ idx + 1 }}</td>
<td class="tag-name"><code>{{ item.tag }}</code></td>
<td style="text-align: right; font-weight: 500;">{{ item.downloads.toLocaleString() }}</td>
</tr>
<tr v-if="filteredStats.length === 0">
<td colspan="3" style="text-align: center; color: var(--vp-c-text-3); padding: 2rem 0;">没有找到匹配的版本</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<style scoped>
.stats-container {
display: flex;
flex-direction: column;
gap: 2rem;
margin-top: 1.5rem;
}
.update-time-box {
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-top: -0.5rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.update-time-box strong {
color: var(--vp-c-brand-1);
font-family: var(--vp-font-family-mono);
}
.chart-sectioncard, .table-sectioncard {
background-color: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-border);
border-radius: 8px;
padding: 1.5rem;
position: relative;
}
.chart-sectioncard h3, .table-sectioncard h3 {
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
.line-chart-wrapper {
position: relative;
width: 100%;
}
.trend-svg {
width: 100%;
height: auto;
overflow: visible;
}
.chart-dot {
cursor: pointer;
transition: r 0.2s, stroke-width 0.2s;
}
.chart-dot:hover {
r: 7;
stroke-width: 3px;
}
.chart-tooltip {
position: absolute;
background-color: var(--vp-c-bg-elv);
border: 1px solid var(--vp-c-brand-1);
border-radius: 4px;
padding: 0.35rem 0.6rem;
font-size: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
box-shadow: var(--vp-shadow-3);
pointer-events: none;
z-index: 10;
min-width: 110px;
text-align: center;
}
.tooltip-tag {
font-weight: 600;
font-family: var(--vp-font-family-mono);
color: var(--vp-c-text-1);
}
.tooltip-val {
color: var(--vp-c-brand-1);
font-weight: 500;
}
.table-header-control {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
}
.table-header-control h3 {
margin-bottom: 0;
}
.search-input {
background-color: var(--vp-c-bg);
border: 1px solid var(--vp-c-border);
border-radius: 6px;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
color: var(--vp-c-text-1);
outline: none;
min-width: 200px;
transition: border-color 0.25s;
}
.search-input:focus {
border-color: var(--vp-c-brand-1);
}
.table-wrapper {
max-height: 500px;
overflow-y: auto;
border: 1px solid var(--vp-c-border);
border-radius: 6px;
}
.stats-table {
width: 100%;
border-collapse: collapse;
margin: 0 !important;
}
.stats-table th, .stats-table td {
padding: 0.6rem 0.8rem;
font-size: 0.85rem;
text-align: left;
border-bottom: 1px solid var(--vp-c-border);
}
.stats-table th {
background-color: var(--vp-c-bg-mute);
position: sticky;
top: 0;
z-index: 1;
font-weight: 600;
color: var(--vp-c-text-2);
}
.stats-table tr:last-child td {
border-bottom: none;
}
.tag-name code {
font-size: 0.8rem;
}
</style>
+1726 -2651
View File
File diff suppressed because it is too large Load Diff