chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
const PKG = 'mcpforunityserver';
|
||||
const REPO = 'CoplayDev/unity-mcp';
|
||||
const GH = 'https://api.github.com';
|
||||
|
||||
export function normalizePypiRecent(recent) {
|
||||
if (recent == null) {
|
||||
return { lastDay: null, lastWeek: null, lastMonth: null, unavailable: true };
|
||||
}
|
||||
const d = (recent && recent.data) || {};
|
||||
return { lastDay: d.last_day ?? 0, lastWeek: d.last_week ?? 0, lastMonth: d.last_month ?? 0 };
|
||||
}
|
||||
|
||||
// Builds the unified stats object. Honest user signals (GitHub clones/stars,
|
||||
// and the in-product DAU/WAU surfaced elsewhere) sit alongside the noisy PyPI
|
||||
// download proxy, which renderSummary clearly labels as inflated.
|
||||
export function buildStats({ recent, repo = null, clones = null, views = null, webPageviews = null, generatedAt }) {
|
||||
return {
|
||||
generatedAt,
|
||||
github: {
|
||||
stars: repo?.stargazers_count ?? null,
|
||||
forks: repo?.forks_count ?? null,
|
||||
uniqueCloners14d: clones?.uniques ?? null,
|
||||
uniqueViewers14d: views?.uniques ?? null,
|
||||
},
|
||||
pypi: normalizePypiRecent(recent),
|
||||
web: webPageviews,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSummary(stats) {
|
||||
const g = stats.github, p = stats.pypi;
|
||||
const n = (x) => (x == null ? '—' : Number(x).toLocaleString('en-US'));
|
||||
const web = stats.web && stats.web.total != null
|
||||
? `${n(stats.web.total)} total pageviews`
|
||||
: '_pending GoatCounter provisioning_';
|
||||
const trafficMissing = g.uniqueCloners14d == null && g.uniqueViewers14d == null;
|
||||
return [
|
||||
'## MCP for Unity — adoption (maintainer-only)',
|
||||
`_generated ${stats.generatedAt}_`,
|
||||
'',
|
||||
'### Real-user signals (the honest ones)',
|
||||
'| Signal | Value | Reads as |',
|
||||
'| --- | ---: | --- |',
|
||||
`| In-product DAU / WAU | _pending_ | **true active users** — already collected by the in-product telemetry; needs a Coplay read API |`,
|
||||
`| Unique repo cloners (14d) | ${n(g.uniqueCloners14d)} | developers actually pulling the code |`,
|
||||
`| Unique repo viewers (14d) | ${n(g.uniqueViewers14d)} | distinct repo visitors |`,
|
||||
`| GitHub stars | ${n(g.stars)} | real accounts interested |`,
|
||||
`| GitHub forks | ${n(g.forks)} | |`,
|
||||
...(trafficMissing
|
||||
? ['', '> Cloners/viewers are blank: the workflow needs a maintainer PAT with **Administration: read** (`STATS_GITHUB_TOKEN`). See the external-analytics doc.']
|
||||
: []),
|
||||
'',
|
||||
'### Reach proxy (inflated — NOT a user count)',
|
||||
'| Window | PyPI downloads |',
|
||||
'| --- | ---: |',
|
||||
`| Last day | ${n(p.lastDay)} |`,
|
||||
`| Last week | ${n(p.lastWeek)} |`,
|
||||
`| Last month | ${n(p.lastMonth)} |`,
|
||||
'',
|
||||
...(p.unavailable
|
||||
? ['> PyPI stats are unavailable: the pypistats.org request failed. Do not treat these dashes as zero downloads.', '']
|
||||
: []),
|
||||
'> PyPI counts are install events — CI, mirrors, `uvx` re-fetches, and Docker rebuilds inflate them heavily. Treat as a trend line, not people.',
|
||||
'',
|
||||
'### Docs traffic',
|
||||
web,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const get = async (url, headers) => {
|
||||
try { const r = await fetch(url, { headers }); return r.ok ? await r.json() : null; } catch { return null; }
|
||||
};
|
||||
const ghToken = process.env.STATS_GITHUB_TOKEN || process.env.GITHUB_TOKEN;
|
||||
const ghHeaders = {
|
||||
'User-Agent': 'mcp-for-unity-stats',
|
||||
Accept: 'application/vnd.github+json',
|
||||
...(ghToken ? { Authorization: `Bearer ${ghToken}` } : {}),
|
||||
};
|
||||
|
||||
const recent = await get(`https://pypistats.org/api/packages/${PKG}/recent`, { Accept: 'application/json' });
|
||||
const repo = await get(`${GH}/repos/${REPO}`, ghHeaders);
|
||||
const clones = await get(`${GH}/repos/${REPO}/traffic/clones`, ghHeaders);
|
||||
const views = await get(`${GH}/repos/${REPO}/traffic/views`, ghHeaders);
|
||||
|
||||
let web = null;
|
||||
const gcToken = process.env.GOATCOUNTER_TOKEN, gcSite = process.env.GOATCOUNTER_SITE;
|
||||
if (gcToken && gcSite) {
|
||||
const j = await get(`https://${gcSite}.goatcounter.com/api/v0/stats/total`, { Authorization: `Bearer ${gcToken}` });
|
||||
if (j) web = { total: j.total ?? null };
|
||||
}
|
||||
|
||||
const stats = buildStats({ recent, repo, clones, views, webPageviews: web, generatedAt: new Date().toISOString() });
|
||||
process.stdout.write(renderSummary(stats));
|
||||
}
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizePypiRecent, buildStats, renderSummary } from './fetch-stats.mjs';
|
||||
|
||||
test('normalizePypiRecent extracts day/week/month', () => {
|
||||
assert.deepEqual(
|
||||
normalizePypiRecent({ data: { last_day: 100, last_week: 700, last_month: 3000 } }),
|
||||
{ lastDay: 100, lastWeek: 700, lastMonth: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizePypiRecent handles missing data', () => {
|
||||
assert.deepEqual(normalizePypiRecent({}), { lastDay: 0, lastWeek: 0, lastMonth: 0 });
|
||||
});
|
||||
|
||||
test('normalizePypiRecent marks failed requests as unavailable', () => {
|
||||
assert.deepEqual(
|
||||
normalizePypiRecent(null),
|
||||
{ lastDay: null, lastWeek: null, lastMonth: null, unavailable: true },
|
||||
);
|
||||
});
|
||||
|
||||
test('buildStats maps github + pypi + web', () => {
|
||||
const stats = buildStats({
|
||||
recent: { data: { last_day: 1, last_week: 2, last_month: 3 } },
|
||||
repo: { stargazers_count: 11102, forks_count: 1229 },
|
||||
clones: { uniques: 84 },
|
||||
views: { uniques: 530 },
|
||||
webPageviews: { total: 42 },
|
||||
generatedAt: 't',
|
||||
});
|
||||
assert.deepEqual(stats.github, { stars: 11102, forks: 1229, uniqueCloners14d: 84, uniqueViewers14d: 530 });
|
||||
assert.deepEqual(stats.pypi, { lastDay: 1, lastWeek: 2, lastMonth: 3 });
|
||||
assert.deepEqual(stats.web, { total: 42 });
|
||||
});
|
||||
|
||||
test('buildStats nulls github fields when sources unavailable', () => {
|
||||
const stats = buildStats({ recent: { data: {} }, generatedAt: 't' });
|
||||
assert.deepEqual(stats.github, { stars: null, forks: null, uniqueCloners14d: null, uniqueViewers14d: null });
|
||||
assert.equal(stats.web, null);
|
||||
});
|
||||
|
||||
test('renderSummary leads with real-user signals and labels PyPI as inflated', () => {
|
||||
const stats = buildStats({
|
||||
recent: { data: { last_day: 4912, last_week: 38270, last_month: 192776 } },
|
||||
repo: { stargazers_count: 11102, forks_count: 1229 },
|
||||
clones: { uniques: 84 },
|
||||
views: { uniques: 530 },
|
||||
generatedAt: 't',
|
||||
});
|
||||
const md = renderSummary(stats);
|
||||
assert.match(md, /Real-user signals/);
|
||||
assert.match(md, /GitHub stars \| 11,102/);
|
||||
assert.match(md, /Unique repo cloners \(14d\) \| 84/);
|
||||
assert.match(md, /true active users/); // DAU/WAU flagged first
|
||||
assert.match(md, /inflated — NOT a user count/);
|
||||
assert.match(md, /Last month \| 192,776/);
|
||||
});
|
||||
|
||||
test('renderSummary notes when the traffic token is missing', () => {
|
||||
const stats = buildStats({ recent: { data: {} }, repo: { stargazers_count: 1, forks_count: 0 }, generatedAt: 't' });
|
||||
assert.match(renderSummary(stats), /Administration: read/);
|
||||
});
|
||||
|
||||
test('renderSummary distinguishes unavailable PyPI stats from zero downloads', () => {
|
||||
const stats = buildStats({ recent: null, repo: { stargazers_count: 1, forks_count: 0 }, generatedAt: 't' });
|
||||
const md = renderSummary(stats);
|
||||
assert.match(md, /Last day \| —/);
|
||||
assert.match(md, /PyPI stats are unavailable/);
|
||||
assert.match(md, /Do not treat these dashes as zero downloads/);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Rasterizes the brand SVG marks into the favicon/icon PNG set.
|
||||
// Run from the website/ directory: `npm run gen:brand`
|
||||
// Text-bearing assets (hero banner, social card) are produced separately via
|
||||
// an HTML render so the Satoshi wordmark uses the real font.
|
||||
import sharp from 'sharp';
|
||||
import { readFile, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url)); // website/scripts
|
||||
const ROOT = path.resolve(HERE, '..', '..');
|
||||
const IMG = path.join(ROOT, 'website', 'static', 'img');
|
||||
|
||||
const full = path.join(IMG, 'logo-mark.svg');
|
||||
|
||||
async function rasterize(src, size, out) {
|
||||
const svg = await readFile(src);
|
||||
await sharp(svg, { density: 512 })
|
||||
.resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toFile(out);
|
||||
console.log(' wrote', path.relative(ROOT, out), `(${size}px)`);
|
||||
}
|
||||
|
||||
await mkdir(IMG, { recursive: true });
|
||||
await rasterize(full, 48, path.join(IMG, 'favicon.png'));
|
||||
await rasterize(full, 32, path.join(IMG, 'favicon-32.png'));
|
||||
await rasterize(full, 180, path.join(IMG, 'apple-touch-icon.png'));
|
||||
await rasterize(full, 192, path.join(IMG, 'android-chrome-192.png'));
|
||||
await rasterize(full, 512, path.join(IMG, 'android-chrome-512.png'));
|
||||
// 128px source for the UPM package icon — wiring it into MCPForUnity needs the
|
||||
// Unity Editor to generate the .meta, so it lives here as a source asset.
|
||||
await rasterize(full, 128, path.join(IMG, 'package-icon.png'));
|
||||
console.log('Brand icon set generated.');
|
||||
Reference in New Issue
Block a user