735 lines
30 KiB
JavaScript
735 lines
30 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Normalize the read-only captures in .codex/traffic-snapshots.
|
|
*
|
|
* The source dashboards have different grains. GitHub supplies rolling daily
|
|
* rows, while Search Console can be a snapshot total and Bing can supply both
|
|
* totals and daily rows. This script deliberately keeps dashboard properties
|
|
* separate: a legacy Pages property and a current Pages property are not a
|
|
* time series that may safely be summed together.
|
|
*/
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const DEFAULT_INPUT = path.resolve(__dirname, "..", "..", ".codex", "traffic-snapshots");
|
|
const DEFAULT_OUTPUT = path.join(DEFAULT_INPUT, "daily-normalized.json");
|
|
const SNAPSHOT_DIRECTORY = /^\d{4}-\d{2}-\d{2}$/;
|
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
const MONTH_NAMES = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
|
|
const CURRENT_REPOSITORY = "sickn33/agentic-awesome-skills";
|
|
const REPOSITORY_ALIASES = new Map([
|
|
["sickn33/antigravity-awesome-skills", CURRENT_REPOSITORY],
|
|
[CURRENT_REPOSITORY, CURRENT_REPOSITORY],
|
|
]);
|
|
const CURRENT_PAGES_PROPERTY = "https://sickn33.github.io/agentic-awesome-skills/";
|
|
const LEGACY_PAGES_PROPERTY = "https://sickn33.github.io/antigravity-awesome-skills/";
|
|
const LEGACY_PACKAGE = "antigravity-awesome-skills";
|
|
const CURRENT_PACKAGE = "agentic-awesome-skills";
|
|
const NPM_PACKAGES = new Set([LEGACY_PACKAGE, CURRENT_PACKAGE]);
|
|
|
|
const DASHBOARDS = [
|
|
{
|
|
file: "google-search-console.json",
|
|
source: "google_search_console",
|
|
dashboardHosts: new Set(["search.google.com"]),
|
|
dashboardPathPrefix: "/search-console/",
|
|
primaryPropertyParam: "resource_id",
|
|
requiredDailyMetrics: ["clicks", "impressions"],
|
|
requiredTotalMetrics: ["clicks", "impressions"],
|
|
dailyMetrics: (value) => ({
|
|
clicks: nonNegativeIntegerOrNull(value.clicks),
|
|
impressions: nonNegativeIntegerOrNull(value.impressions),
|
|
}),
|
|
totals: (value) => ({
|
|
clicks: nonNegativeIntegerOrNull(value?.totals?.clicks ?? value.clicks),
|
|
impressions: nonNegativeIntegerOrNull(value?.totals?.impressions ?? value?.totals?.impressions_visible ?? value.impressions),
|
|
ctr: value?.totals?.ctr ?? value.ctr ?? null,
|
|
average_position: nonNegativeNumberOrNull(value?.totals?.average_position ?? value?.totals?.position ?? value.average_position),
|
|
}),
|
|
},
|
|
{
|
|
file: "bing-webmaster-search-performance.json",
|
|
source: "bing_search",
|
|
dashboardHosts: new Set(["bing.com", "www.bing.com"]),
|
|
dashboardPathPrefix: "/webmasters/",
|
|
primaryPropertyParam: "siteUrl",
|
|
requiredDailyMetrics: ["clicks", "impressions"],
|
|
requiredTotalMetrics: ["clicks", "impressions"],
|
|
dailyMetrics: (value) => ({
|
|
clicks: nonNegativeIntegerOrNull(value.clicks),
|
|
impressions: nonNegativeIntegerOrNull(value.impressions),
|
|
}),
|
|
totals: (value) => ({
|
|
clicks: nonNegativeIntegerOrNull(value?.totals?.clicks ?? value.clicks),
|
|
impressions: nonNegativeIntegerOrNull(value?.totals?.impressions ?? value?.totals?.impressions_label ?? value.impressions),
|
|
ctr: value?.totals?.ctr ?? value.ctr ?? null,
|
|
}),
|
|
},
|
|
{
|
|
file: "bing-webmaster-ai-performance.json",
|
|
source: "bing_ai",
|
|
dashboardHosts: new Set(["bing.com", "www.bing.com"]),
|
|
dashboardPathPrefix: "/webmasters/",
|
|
primaryPropertyParam: "siteUrl",
|
|
requiredDailyMetrics: ["citations"],
|
|
requiredTotalMetrics: ["citations"],
|
|
dailyMetrics: (value) => ({
|
|
citations: nonNegativeIntegerOrNull(value.citations ?? value.total_citations),
|
|
avg_cited_pages: nonNegativeNumberOrNull(value.avg_cited_pages),
|
|
}),
|
|
totals: (value) => ({
|
|
citations: nonNegativeIntegerOrNull(value.total_citations ?? value?.totals?.citations),
|
|
avg_cited_pages: nonNegativeNumberOrNull(value.avg_cited_pages ?? value?.totals?.avg_cited_pages),
|
|
}),
|
|
},
|
|
];
|
|
|
|
function numberOrNull(value) {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value !== "string") return null;
|
|
|
|
const raw = value.trim().replace(/\s/g, "");
|
|
if (!raw) return null;
|
|
const hasK = /k$/i.test(raw);
|
|
let numeric = raw.replace(/k$/i, "");
|
|
|
|
// Dashboard captures use both 5.1K and Italian-style 39,4K labels.
|
|
if (hasK) {
|
|
numeric = numeric.replace(",", ".");
|
|
} else if (/^\d{1,3}(?:[.,]\d{3})+$/.test(numeric)) {
|
|
numeric = numeric.replace(/[.,]/g, "");
|
|
} else {
|
|
numeric = numeric.replace(",", ".");
|
|
}
|
|
|
|
const parsed = Number(numeric);
|
|
return Number.isFinite(parsed) ? (hasK ? parsed * 1000 : parsed) : null;
|
|
}
|
|
|
|
function nonNegativeNumberOrNull(value) {
|
|
const parsed = numberOrNull(value);
|
|
return parsed !== null && parsed >= 0 ? parsed : null;
|
|
}
|
|
|
|
function nonNegativeIntegerOrNull(value) {
|
|
const parsed = nonNegativeNumberOrNull(value);
|
|
return parsed !== null && Number.isInteger(parsed) ? parsed : null;
|
|
}
|
|
|
|
function isCompleteMetrics(metrics) {
|
|
return Object.values(metrics).some((value) => value !== null);
|
|
}
|
|
|
|
function normalizedDate(value) {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
if (ISO_DATE.test(trimmed)) return isValidIsoDate(trimmed) ? trimmed : null;
|
|
if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
|
|
const date = trimmed.slice(0, 10);
|
|
return isValidIsoDate(date) ? date : null;
|
|
}
|
|
const match = trimmed.match(
|
|
/^(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),?\s+(\d{4})$/i,
|
|
);
|
|
if (!match) return null;
|
|
const month = MONTH_NAMES.indexOf(match[1].toLowerCase()) + 1;
|
|
const date = `${match[3]}-${String(month).padStart(2, "0")}-${String(match[2]).padStart(2, "0")}`;
|
|
return isValidIsoDate(date) ? date : null;
|
|
}
|
|
|
|
function isValidIsoDate(value) {
|
|
if (!ISO_DATE.test(value)) return false;
|
|
const [year, month, day] = value.split("-").map(Number);
|
|
const parsed = new Date(Date.UTC(year, month - 1, day));
|
|
return parsed.getUTCFullYear() === year
|
|
&& parsed.getUTCMonth() === month - 1
|
|
&& parsed.getUTCDate() === day;
|
|
}
|
|
|
|
function canonicalProperty(candidate) {
|
|
if (typeof candidate !== "string" || !candidate.trim()) return null;
|
|
try {
|
|
const url = new URL(candidate.trim());
|
|
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
url.hash = "";
|
|
url.search = "";
|
|
const pathname = url.pathname.endsWith("/") ? url.pathname : `${url.pathname}/`;
|
|
return `${url.protocol}//${url.host}${pathname}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function propertyFromDashboardUrl(dashboardUrl, config) {
|
|
if (typeof dashboardUrl !== "string") return { valid: false, signals: [], reason: "missing dashboard_url" };
|
|
try {
|
|
const url = new URL(dashboardUrl);
|
|
const hostValid = url.protocol === "https:" && config.dashboardHosts.has(url.hostname.toLowerCase());
|
|
const pathValid = url.pathname.startsWith(config.dashboardPathPrefix);
|
|
const primary = canonicalProperty(url.searchParams.get(config.primaryPropertyParam));
|
|
if (!hostValid || !pathValid || !primary) {
|
|
return { valid: false, signals: [], reason: "dashboard host, path, or primary property parameter is invalid" };
|
|
}
|
|
const signals = [];
|
|
for (const param of ["resource_id", "siteUrl"]) {
|
|
const value = canonicalProperty(url.searchParams.get(param));
|
|
if (value) signals.push({ source: `dashboard_url:${param}`, value });
|
|
}
|
|
return { valid: true, signals, reason: null };
|
|
} catch {
|
|
return { valid: false, signals: [], reason: "dashboard_url is malformed" };
|
|
}
|
|
}
|
|
|
|
function sourceProperty(snapshot, config) {
|
|
const observed = [];
|
|
for (const [source, raw] of [
|
|
["source_property", snapshot.source_property],
|
|
["property_url", snapshot.property_url],
|
|
["propertyUrl", snapshot.propertyUrl],
|
|
["site_url", snapshot.site_url],
|
|
["siteUrl", snapshot.siteUrl],
|
|
]) {
|
|
const value = canonicalProperty(raw);
|
|
if (value) observed.push({ source, value });
|
|
}
|
|
const dashboard = propertyFromDashboardUrl(snapshot.dashboard_url, config);
|
|
if (snapshot.dashboard_url && !dashboard.valid) {
|
|
return { property: null, provenance: "invalid", conflict: true, reason: dashboard.reason };
|
|
}
|
|
observed.push(...dashboard.signals);
|
|
const distinct = [...new Set(observed.map((signal) => signal.value))];
|
|
if (distinct.length > 1) {
|
|
return { property: null, provenance: "conflict", conflict: true, reason: "observed property signals conflict" };
|
|
}
|
|
const intended = canonicalProperty(snapshot.intended_property);
|
|
if (distinct.length === 1) {
|
|
return {
|
|
property: distinct[0],
|
|
provenance: "observed",
|
|
conflict: false,
|
|
reason: intended && intended !== distinct[0] ? "intended property disagrees with observed property" : null,
|
|
};
|
|
}
|
|
if (intended) {
|
|
return { property: intended, provenance: "intended_only", conflict: false, reason: "property identity comes from intended_property only" };
|
|
}
|
|
return { property: null, provenance: "unknown", conflict: false, reason: "no property identity signal" };
|
|
}
|
|
|
|
function propertyIdentity(property) {
|
|
const value = canonicalProperty(property);
|
|
if (value === LEGACY_PAGES_PROPERTY) return "legacy";
|
|
if (value === CURRENT_PAGES_PROPERTY) return "current";
|
|
return "unknown";
|
|
}
|
|
|
|
function coverageEnd(snapshot) {
|
|
const dailyValues = Array.isArray(snapshot.daily_values) ? snapshot.daily_values : [];
|
|
const dailyDates = dailyValues.map((row) => normalizedDate(row?.date)).filter(Boolean).sort();
|
|
if (dailyDates.length) return dailyDates.at(-1);
|
|
|
|
const text = [
|
|
snapshot.date_range_visible,
|
|
snapshot.date_range,
|
|
snapshot.chart_visible?.description,
|
|
].filter((value) => typeof value === "string").join(" ");
|
|
const isoDates = (text.match(/\d{4}-\d{2}-\d{2}/g) || []).map(normalizedDate).filter(Boolean);
|
|
if (isoDates?.length) return isoDates.sort().at(-1);
|
|
|
|
const monthDates = [...text.matchAll(
|
|
/\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2}),\s+(\d{4})\b/gi,
|
|
)].map((match) => {
|
|
const month = MONTH_NAMES.indexOf(match[1].toLowerCase()) + 1;
|
|
return normalizedDate(`${match[3]}-${String(month).padStart(2, "0")}-${String(match[2]).padStart(2, "0")}`);
|
|
}).filter(Boolean);
|
|
return monthDates.length ? monthDates.sort().at(-1) : null;
|
|
}
|
|
|
|
function readJson(filePath, warnings, label) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
} catch (error) {
|
|
warnings.push(`${label}: skipped malformed JSON (${error.message.split("\n")[0]})`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setLatest(map, key, observation) {
|
|
const previous = map.get(key);
|
|
if (!previous || observation.observed_from_snapshot > previous.observed_from_snapshot) {
|
|
map.set(key, observation);
|
|
}
|
|
}
|
|
|
|
function setBestDashboardEvidence(map, key, observation) {
|
|
const previous = map.get(key);
|
|
const strength = { observed: 2, intended_only: 1, unknown: 0 };
|
|
const candidateStrength = strength[observation.property_provenance] ?? 0;
|
|
const previousStrength = strength[previous?.property_provenance] ?? -1;
|
|
if (!previous
|
|
|| candidateStrength > previousStrength
|
|
|| (candidateStrength === previousStrength && observation.observed_from_snapshot > previous.observed_from_snapshot)) {
|
|
map.set(key, observation);
|
|
}
|
|
}
|
|
|
|
function snapshotDirectories(inputDirectory) {
|
|
if (!fs.existsSync(inputDirectory)) throw new Error(`input directory does not exist: ${inputDirectory}`);
|
|
if (!fs.statSync(inputDirectory).isDirectory()) throw new Error(`input path is not a directory: ${inputDirectory}`);
|
|
const snapshots = fs.readdirSync(inputDirectory, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && SNAPSHOT_DIRECTORY.test(entry.name) && isValidIsoDate(entry.name))
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
if (!snapshots.length) throw new Error(`input directory has no valid snapshot directories: ${inputDirectory}`);
|
|
return snapshots;
|
|
}
|
|
|
|
function canonicalRepository(value) {
|
|
if (typeof value !== "string" || !value.trim()) return null;
|
|
const normalized = value.trim().toLowerCase();
|
|
return REPOSITORY_ALIASES.get(normalized) || normalized;
|
|
}
|
|
|
|
function npmPackageEntries(payload) {
|
|
if (!payload || payload.status !== "success") return { ok: false, reason: "non-success capture" };
|
|
if (Array.isArray(payload.responses)) return { ok: true, entries: payload.responses };
|
|
if (Array.isArray(payload.packages)) return { ok: true, entries: payload.packages };
|
|
if (typeof payload.package === "string" && Array.isArray(payload.downloads)) return { ok: true, entries: [payload] };
|
|
if (!payload.packages || typeof payload.packages !== "object") {
|
|
return { ok: false, reason: "missing packages collection" };
|
|
}
|
|
return { ok: false, reason: "packages must contain official npm range responses" };
|
|
}
|
|
|
|
function nextIsoDate(date) {
|
|
const [year, month, day] = date.split("-").map(Number);
|
|
const next = new Date(Date.UTC(year, month - 1, day + 1));
|
|
return `${next.getUTCFullYear()}-${String(next.getUTCMonth() + 1).padStart(2, "0")}-${String(next.getUTCDate()).padStart(2, "0")}`;
|
|
}
|
|
|
|
function parseNpmDownloads(payload) {
|
|
const collection = npmPackageEntries(payload);
|
|
if (!collection.ok) return collection;
|
|
|
|
const packages = new Map();
|
|
const issues = [];
|
|
for (const entry of collection.entries) {
|
|
const name = typeof entry?.package === "string" ? entry.package.trim() : null;
|
|
if (!name || !NPM_PACKAGES.has(name)) {
|
|
issues.push("unknown package identity");
|
|
continue;
|
|
}
|
|
if (packages.has(name)) {
|
|
issues.push(`duplicate package identity: ${name}`);
|
|
continue;
|
|
}
|
|
if (!Array.isArray(entry.downloads)) {
|
|
issues.push(`missing npm range downloads for ${name}`);
|
|
continue;
|
|
}
|
|
|
|
const daily = new Map();
|
|
let valid = true;
|
|
for (const row of entry.downloads) {
|
|
const date = normalizedDate(row?.day);
|
|
const downloads = nonNegativeIntegerOrNull(row?.downloads);
|
|
if (!date || downloads === null) {
|
|
issues.push(`malformed daily downloads for ${name}`);
|
|
valid = false;
|
|
break;
|
|
}
|
|
if (daily.has(date)) {
|
|
issues.push(`duplicate daily downloads for ${name}/${date}`);
|
|
valid = false;
|
|
break;
|
|
}
|
|
daily.set(date, downloads);
|
|
}
|
|
const start = normalizedDate(entry.start);
|
|
const end = normalizedDate(entry.end);
|
|
const dates = [...daily.keys()].sort();
|
|
if (!start || !end || !dates.length || dates[0] !== start || dates.at(-1) !== end) {
|
|
issues.push(`npm range boundaries do not match daily downloads for ${name}`);
|
|
valid = false;
|
|
}
|
|
for (let index = 1; valid && index < dates.length; index += 1) {
|
|
if (nextIsoDate(dates[index - 1]) !== dates[index]) {
|
|
issues.push(`npm range daily downloads are incomplete for ${name}`);
|
|
valid = false;
|
|
}
|
|
}
|
|
if (!valid) continue;
|
|
packages.set(name, daily);
|
|
}
|
|
return { ok: true, packages, issues: [...new Set(issues)].sort() };
|
|
}
|
|
|
|
function setNpmObservation(observations, conflicts, packageName, date, observation) {
|
|
const key = `${packageName}\u0000${date}`;
|
|
if (conflicts.has(key)) return;
|
|
const previous = observations.get(key);
|
|
if (!previous) {
|
|
observations.set(key, observation);
|
|
return;
|
|
}
|
|
if (previous.downloads !== observation.downloads) {
|
|
observations.delete(key);
|
|
conflicts.set(key, `conflicting npm download observations for ${packageName}/${date}`);
|
|
return;
|
|
}
|
|
if (observation.observed_from_snapshot > previous.observed_from_snapshot) observations.set(key, observation);
|
|
}
|
|
|
|
function npmCombinedRecord(date, observations, conflicts, captureIssues) {
|
|
const legacyKey = `${LEGACY_PACKAGE}\u0000${date}`;
|
|
const currentKey = `${CURRENT_PACKAGE}\u0000${date}`;
|
|
const legacy = observations.get(legacyKey) || null;
|
|
const current = observations.get(currentKey) || null;
|
|
const conflictReasons = [conflicts.get(legacyKey), conflicts.get(currentKey)].filter(Boolean);
|
|
const issues = captureIssues.get(date) || [];
|
|
const packages = {
|
|
[LEGACY_PACKAGE]: legacy,
|
|
[CURRENT_PACKAGE]: current,
|
|
};
|
|
const base = {
|
|
label: "same-day old-plus-current npm downloads only",
|
|
packages,
|
|
total_downloads: null,
|
|
current_package_share: null,
|
|
};
|
|
if (issues.length) return { ...base, status: "invalid_capture", reason: issues.join("; ") };
|
|
if (conflictReasons.length) {
|
|
return { ...base, status: "conflicting_observations", reason: conflictReasons.join("; ") };
|
|
}
|
|
if (!legacy && !current) return { ...base, status: "not_observed", reason: "no accepted npm download observations for this date" };
|
|
if (!legacy) return { ...base, status: "missing_legacy", reason: `missing ${LEGACY_PACKAGE} observation for this date` };
|
|
if (!current) return { ...base, status: "missing_current", reason: `missing ${CURRENT_PACKAGE} observation for this date` };
|
|
|
|
const total = legacy.downloads + current.downloads;
|
|
if (total === 0) {
|
|
return {
|
|
...base,
|
|
status: "complete_zero_total",
|
|
reason: "both package observations exist but current-package share is undefined for a zero total",
|
|
total_downloads: 0,
|
|
};
|
|
}
|
|
return {
|
|
...base,
|
|
status: "complete",
|
|
reason: null,
|
|
total_downloads: total,
|
|
current_package_share: current.downloads / total,
|
|
};
|
|
}
|
|
|
|
function normalizeSnapshots(inputDirectory) {
|
|
const warnings = [];
|
|
const github = { views: new Map(), clones: new Map() };
|
|
const npmDownloads = new Map();
|
|
const npmConflicts = new Map();
|
|
const npmCaptureIssues = new Map();
|
|
const dashboardDaily = new Map();
|
|
const dashboardTotals = new Map();
|
|
const discoveredSnapshots = snapshotDirectories(inputDirectory);
|
|
const snapshots = [];
|
|
const sourceRepositories = new Set();
|
|
let repo = null;
|
|
let timezone = null;
|
|
|
|
for (const snapshotDate of discoveredSnapshots) {
|
|
const directory = path.join(inputDirectory, snapshotDate);
|
|
const manifestPath = path.join(directory, "manifest.json");
|
|
if (!fs.existsSync(manifestPath)) {
|
|
warnings.push(`${snapshotDate}/manifest.json: skipped snapshot without identity manifest`);
|
|
continue;
|
|
}
|
|
const manifest = readJson(manifestPath, warnings, `${snapshotDate}/manifest.json`);
|
|
const rawRepository = typeof manifest?.repo === "string" ? manifest.repo.trim() : null;
|
|
const snapshotRepository = canonicalRepository(rawRepository);
|
|
const snapshotTimezone = typeof manifest?.timezone === "string" ? manifest.timezone.trim() : null;
|
|
if (!snapshotRepository || !snapshotTimezone) {
|
|
warnings.push(`${snapshotDate}/manifest.json: skipped snapshot with missing repository or timezone identity`);
|
|
continue;
|
|
}
|
|
if ((repo && snapshotRepository !== repo) || (timezone && snapshotTimezone !== timezone)) {
|
|
warnings.push(`${snapshotDate}/manifest.json: skipped snapshot with repository or timezone mismatch`);
|
|
continue;
|
|
}
|
|
repo ||= snapshotRepository;
|
|
timezone ||= snapshotTimezone;
|
|
sourceRepositories.add(rawRepository);
|
|
snapshots.push(snapshotDate);
|
|
|
|
for (const [kind, file] of [["views", "views.json"], ["clones", "clones.json"]]) {
|
|
const filePath = path.join(directory, file);
|
|
if (!fs.existsSync(filePath)) continue;
|
|
const payload = readJson(filePath, warnings, `${snapshotDate}/${file}`);
|
|
const values = Array.isArray(payload?.[kind]) ? payload[kind] : null;
|
|
if (!values) {
|
|
warnings.push(`${snapshotDate}/${file}: skipped missing ${kind} array`);
|
|
continue;
|
|
}
|
|
for (const value of values) {
|
|
const date = normalizedDate(value?.timestamp);
|
|
const count = nonNegativeIntegerOrNull(value?.count);
|
|
const uniques = nonNegativeIntegerOrNull(value?.uniques);
|
|
if (!date || !ISO_DATE.test(date) || count === null || uniques === null) {
|
|
warnings.push(`${snapshotDate}/${file}: skipped malformed daily row`);
|
|
continue;
|
|
}
|
|
setLatest(github[kind], date, { count, uniques, observed_from_snapshot: snapshotDate });
|
|
}
|
|
}
|
|
|
|
const npmPath = path.join(directory, "npm-downloads.json");
|
|
if (fs.existsSync(npmPath)) {
|
|
const payload = readJson(npmPath, warnings, `${snapshotDate}/npm-downloads.json`);
|
|
if (payload) {
|
|
const parsed = parseNpmDownloads(payload);
|
|
if (!parsed.ok) {
|
|
warnings.push(`${snapshotDate}/npm-downloads.json: skipped ${parsed.reason}`);
|
|
} else {
|
|
for (const issue of parsed.issues) warnings.push(`${snapshotDate}/npm-downloads.json: ${issue}`);
|
|
for (const [packageName, daily] of parsed.packages) {
|
|
for (const [date, downloads] of daily) {
|
|
setNpmObservation(npmDownloads, npmConflicts, packageName, date, {
|
|
package_name: packageName,
|
|
downloads,
|
|
observed_from_snapshot: snapshotDate,
|
|
});
|
|
if (parsed.issues.length) {
|
|
const dateIssues = npmCaptureIssues.get(date) || [];
|
|
dateIssues.push(...parsed.issues.map((issue) => `${snapshotDate}: ${issue}`));
|
|
npmCaptureIssues.set(date, [...new Set(dateIssues)].sort());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const config of DASHBOARDS) {
|
|
const filePath = path.join(directory, config.file);
|
|
if (!fs.existsSync(filePath)) continue;
|
|
const payload = readJson(filePath, warnings, `${snapshotDate}/${config.file}`);
|
|
if (!payload) continue;
|
|
if (payload.status !== "success") {
|
|
warnings.push(`${snapshotDate}/${config.file}: skipped non-success capture`);
|
|
continue;
|
|
}
|
|
|
|
const propertyEvidence = sourceProperty(payload, config);
|
|
if (propertyEvidence.conflict) {
|
|
warnings.push(`${snapshotDate}/${config.file}: skipped capture because ${propertyEvidence.reason}`);
|
|
continue;
|
|
}
|
|
const property = propertyEvidence.property;
|
|
if (propertyEvidence.reason) warnings.push(`${snapshotDate}/${config.file}: ${propertyEvidence.reason}`);
|
|
const identity = propertyIdentity(property);
|
|
if (identity === "unknown") {
|
|
warnings.push(`${snapshotDate}/${config.file}: property identity is unknown and remains isolated`);
|
|
}
|
|
const end = coverageEnd(payload);
|
|
const base = {
|
|
source_property: property,
|
|
property_provenance: propertyEvidence.provenance,
|
|
property_identity: identity,
|
|
coverage_end: end,
|
|
observed_from_snapshot: snapshotDate,
|
|
};
|
|
const propertyKey = property || `unknown:${snapshotDate}`;
|
|
const dailyValues = Array.isArray(payload.daily_values) ? payload.daily_values : [];
|
|
|
|
for (const value of dailyValues) {
|
|
const date = normalizedDate(value?.date);
|
|
if (!date) {
|
|
warnings.push(`${snapshotDate}/${config.file}: skipped malformed dashboard daily row`);
|
|
continue;
|
|
}
|
|
const metrics = config.dailyMetrics(value);
|
|
if (!isCompleteMetrics(metrics)) {
|
|
warnings.push(`${snapshotDate}/${config.file}: skipped dashboard daily row without metrics`);
|
|
continue;
|
|
}
|
|
// Never stitch a partial payload together with an older snapshot. A
|
|
// dashboard row is useful only when its source-specific core metrics
|
|
// arrived in the same source observation.
|
|
if (config.requiredDailyMetrics.some((metric) => metrics[metric] === null)) {
|
|
warnings.push(`${snapshotDate}/${config.file}: skipped partial dashboard daily row`);
|
|
continue;
|
|
}
|
|
const key = `${config.source}\u0000${propertyKey}\u0000${date}`;
|
|
setBestDashboardEvidence(dashboardDaily, key, { source: config.source, date, ...base, ...metrics });
|
|
}
|
|
|
|
const totals = config.totals(payload);
|
|
if (config.requiredTotalMetrics.every((metric) => totals[metric] !== null)) {
|
|
const key = `${config.source}\u0000${propertyKey}\u0000${end || "unknown"}`;
|
|
setBestDashboardEvidence(dashboardTotals, key, { source: config.source, ...base, ...totals });
|
|
} else if (isCompleteMetrics(totals)) {
|
|
warnings.push(`${snapshotDate}/${config.file}: skipped partial dashboard totals`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!snapshots.length) {
|
|
throw new Error(`no snapshots with a consistent repository and timezone identity were accepted from: ${inputDirectory}`);
|
|
}
|
|
|
|
const rowsByDate = new Map();
|
|
const ensureRow = (date) => {
|
|
if (!rowsByDate.has(date)) rowsByDate.set(date, { date });
|
|
return rowsByDate.get(date);
|
|
};
|
|
for (const [kind, values] of Object.entries(github)) {
|
|
for (const [date, observation] of values) {
|
|
const row = ensureRow(date);
|
|
row.github ||= {};
|
|
row.github[kind] = observation;
|
|
}
|
|
}
|
|
for (const observation of dashboardDaily.values()) {
|
|
const row = ensureRow(observation.date);
|
|
row[observation.source] ||= [];
|
|
const { source, date, ...record } = observation;
|
|
row[source].push(record);
|
|
}
|
|
const npmDates = new Set([
|
|
...[...npmDownloads.keys()].map((key) => key.split("\u0000")[1]),
|
|
...[...npmConflicts.keys()].map((key) => key.split("\u0000")[1]),
|
|
...npmCaptureIssues.keys(),
|
|
]);
|
|
for (const date of npmDates) ensureRow(date).npm_downloads = npmCombinedRecord(date, npmDownloads, npmConflicts, npmCaptureIssues);
|
|
|
|
const rows = [...rowsByDate.values()].sort((left, right) => left.date.localeCompare(right.date));
|
|
for (const row of rows) {
|
|
for (const source of DASHBOARDS.map((config) => config.source)) {
|
|
if (Array.isArray(row[source])) {
|
|
row[source].sort((left, right) =>
|
|
`${left.property_identity}\u0000${left.source_property || ""}\u0000${left.observed_from_snapshot}`.localeCompare(
|
|
`${right.property_identity}\u0000${right.source_property || ""}\u0000${right.observed_from_snapshot}`,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const snapshot_totals = {};
|
|
for (const config of DASHBOARDS) {
|
|
snapshot_totals[config.source] = [...dashboardTotals.values()]
|
|
.filter((record) => record.source === config.source)
|
|
.map(({ source, ...record }) => record)
|
|
.sort((left, right) =>
|
|
`${left.property_identity}\u0000${left.source_property || ""}\u0000${left.coverage_end || ""}\u0000${left.observed_from_snapshot}`.localeCompare(
|
|
`${right.property_identity}\u0000${right.source_property || ""}\u0000${right.coverage_end || ""}\u0000${right.observed_from_snapshot}`,
|
|
),
|
|
);
|
|
}
|
|
|
|
return {
|
|
schema_version: "3.0.0",
|
|
repo,
|
|
source_repositories: [...sourceRepositories].sort(),
|
|
timezone,
|
|
grain: "date",
|
|
caveats: [
|
|
"GitHub rolling-window rows are deduplicated by date and retain the latest snapshot observation.",
|
|
"Dashboard data is separated by source_property and property_identity; legacy and current properties are never summed or overwritten.",
|
|
"The combined view unifies GitHub repository rename aliases and combines npm downloads only when exact old/current package observations exist for the same date.",
|
|
"Search Console and Bing dashboard properties are explicitly not_combined, even when both legacy and current captures exist.",
|
|
"coverage_end describes the latest date visible in a dashboard capture, not a promise of complete data.",
|
|
"Malformed, partial, or non-success captures are skipped with warnings rather than merged into another observation.",
|
|
],
|
|
discovered_snapshots: discoveredSnapshots,
|
|
snapshots,
|
|
rows,
|
|
snapshot_totals,
|
|
combined_view: {
|
|
label: "rename-unified measurement view",
|
|
github_repository: {
|
|
status: repo === CURRENT_REPOSITORY ? "rename_unified" : "canonical_only",
|
|
canonical_repository: repo,
|
|
source_repositories: [...sourceRepositories].sort(),
|
|
},
|
|
npm_downloads: {
|
|
label: "same-day old-plus-current npm downloads only",
|
|
legacy_package: LEGACY_PACKAGE,
|
|
current_package: CURRENT_PACKAGE,
|
|
status: npmDates.size ? "per_date_status_in_rows" : "not_observed",
|
|
unavailable_behavior: "total_downloads and current_package_share are null unless both exact package observations are accepted for the same date",
|
|
},
|
|
dashboard_properties: {
|
|
status: "not_combined",
|
|
reason: "Google Search Console and Bing properties remain separate by source_property and property_identity.",
|
|
},
|
|
},
|
|
warnings: warnings.sort(),
|
|
};
|
|
}
|
|
|
|
function atomicWrite(outputPath, value) {
|
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
const temporary = path.join(
|
|
path.dirname(outputPath),
|
|
`.${path.basename(outputPath)}.${process.pid}.${Date.now()}.tmp`,
|
|
);
|
|
try {
|
|
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
fs.renameSync(temporary, outputPath);
|
|
} finally {
|
|
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
}
|
|
}
|
|
|
|
function parseArgs(args) {
|
|
const options = { input: DEFAULT_INPUT, output: DEFAULT_OUTPUT };
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const option = args[index];
|
|
if (option === "--input" || option === "--output") {
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith("--")) throw new Error(`${option} requires a value`);
|
|
options[option.slice(2)] = path.resolve(value);
|
|
index += 1;
|
|
} else if (option === "--help" || option === "-h") {
|
|
options.help = true;
|
|
} else {
|
|
throw new Error(`Unknown option: ${option}`);
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
console.log("Usage: node tools/scripts/normalize_traffic_snapshots.js [--input DIR] [--output FILE]");
|
|
return;
|
|
}
|
|
const normalized = normalizeSnapshots(options.input);
|
|
atomicWrite(options.output, normalized);
|
|
console.log(`Normalized ${normalized.snapshots.length} snapshot directories to ${options.output}`);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(`traffic normalization failed: ${error.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
DEFAULT_INPUT,
|
|
DEFAULT_OUTPUT,
|
|
atomicWrite,
|
|
normalizeSnapshots,
|
|
parseArgs,
|
|
propertyIdentity,
|
|
sourceProperty,
|
|
};
|