| |
| const fs = require('node:fs'); |
| const path = require('node:path'); |
|
|
| const { loadConfig } = require('../src/generator.js'); |
| const { |
| extractYearlyContributionsInnerHtml, |
| } = require('../src/generator/utils/githubContributions'); |
| const { createLogger, isVerbose, startTimer } = require('../src/generator/utils/logger'); |
|
|
| const log = createLogger('sync:heatmap'); |
|
|
| const DEFAULT_SETTINGS = { |
| enabled: true, |
| cacheDir: 'dev', |
| fetch: { |
| timeoutMs: 10_000, |
| userAgent: 'MeNavHeatmapSync/1.0', |
| }, |
| }; |
|
|
| function parseBooleanEnv(value, fallback) { |
| if (value === undefined || value === null || value === '') return fallback; |
| const v = String(value).trim().toLowerCase(); |
| if (v === '1' || v === 'true' || v === 'yes' || v === 'y') return true; |
| if (v === '0' || v === 'false' || v === 'no' || v === 'n') return false; |
| return fallback; |
| } |
|
|
| function parseIntegerEnv(value, fallback) { |
| if (value === undefined || value === null || value === '') return fallback; |
| const n = Number.parseInt(String(value), 10); |
| return Number.isFinite(n) ? n : fallback; |
| } |
|
|
| function getSettings(config) { |
| const fromConfig = |
| config && config.site && config.site.github && typeof config.site.github === 'object' |
| ? config.site.github |
| : {}; |
|
|
| const merged = { |
| ...DEFAULT_SETTINGS, |
| ...fromConfig, |
| fetch: { |
| ...DEFAULT_SETTINGS.fetch, |
| ...(fromConfig.fetch || {}), |
| }, |
| }; |
|
|
| merged.enabled = parseBooleanEnv(process.env.HEATMAP_ENABLED, merged.enabled); |
|
|
| |
| merged.cacheDir = process.env.PROJECTS_CACHE_DIR |
| ? String(process.env.PROJECTS_CACHE_DIR) |
| : process.env.HEATMAP_CACHE_DIR |
| ? String(process.env.HEATMAP_CACHE_DIR) |
| : merged.cacheDir; |
|
|
| merged.fetch.timeoutMs = parseIntegerEnv( |
| process.env.HEATMAP_FETCH_TIMEOUT, |
| merged.fetch.timeoutMs |
| ); |
| merged.fetch.timeoutMs = Math.max(1_000, merged.fetch.timeoutMs); |
|
|
| return merged; |
| } |
|
|
| function ensureDir(dirPath) { |
| fs.mkdirSync(dirPath, { recursive: true }); |
| } |
|
|
| function findProjectsPages(config) { |
| const pages = []; |
| const nav = Array.isArray(config.navigation) ? config.navigation : []; |
| nav.forEach((item) => { |
| const pageId = item && item.id ? String(item.id) : ''; |
| if (!pageId || !config[pageId]) return; |
| const page = config[pageId]; |
| const templateName = page && page.template ? String(page.template) : pageId; |
| if (templateName !== 'projects') return; |
| pages.push({ pageId, page }); |
| }); |
| return pages; |
| } |
|
|
| function getGithubUsernameFromConfig(config) { |
| const username = |
| config && config.site && config.site.github && config.site.github.username |
| ? String(config.site.github.username).trim() |
| : ''; |
| return username; |
| } |
|
|
| async function fetchTextWithTimeout(url, { timeoutMs, headers }) { |
| const controller = new AbortController(); |
| const timer = setTimeout(() => controller.abort(), timeoutMs); |
|
|
| try { |
| const response = await fetch(url, { method: 'GET', headers, signal: controller.signal }); |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| return await response.text(); |
| } finally { |
| clearTimeout(timer); |
| } |
| } |
|
|
| async function main() { |
| const elapsedMs = startTimer(); |
| const config = loadConfig(); |
| const settings = getSettings(config); |
|
|
| log.info('开始'); |
|
|
| if (!settings.enabled) { |
| log.ok('heatmap 同步已禁用,跳过', { env: 'HEATMAP_ENABLED=false' }); |
| return; |
| } |
|
|
| const username = getGithubUsernameFromConfig(config); |
| if (!username) { |
| log.ok('未配置 site.github.username,跳过'); |
| return; |
| } |
|
|
| const pages = findProjectsPages(config); |
| if (!pages.length) { |
| log.ok('未找到 template=projects 的页面,跳过同步'); |
| return; |
| } |
|
|
| const cacheBaseDir = path.isAbsolute(settings.cacheDir) |
| ? settings.cacheDir |
| : path.join(process.cwd(), settings.cacheDir); |
| ensureDir(cacheBaseDir); |
|
|
| const url = `https://github.com/users/${encodeURIComponent(username)}/contributions`; |
| const headers = { |
| 'user-agent': settings.fetch.userAgent, |
| accept: 'text/html', |
| }; |
|
|
| let html; |
| try { |
| html = await fetchTextWithTimeout(url, { timeoutMs: settings.fetch.timeoutMs, headers }); |
| } catch (error) { |
| log.warn('获取 GitHub contributions 失败(best-effort)', { |
| url, |
| message: String(error && error.message ? error.message : error), |
| }); |
| return; |
| } |
|
|
| const innerHtml = extractYearlyContributionsInnerHtml(html); |
| if (!innerHtml) { |
| log.warn('解析 contributions HTML 失败(best-effort)', { url, username }); |
| return; |
| } |
|
|
| for (const { pageId } of pages) { |
| const payload = { |
| version: '1.0', |
| pageId, |
| generatedAt: new Date().toISOString(), |
| username, |
| sourceUrl: url, |
| html: innerHtml, |
| }; |
|
|
| const cachePath = path.join(cacheBaseDir, `${pageId}.heatmap-cache.json`); |
| fs.writeFileSync(cachePath, JSON.stringify(payload, null, 2), 'utf8'); |
| log.ok('写入 heatmap 缓存', { page: pageId, cache: cachePath }); |
| } |
|
|
| log.ok('完成', { ms: elapsedMs(), pages: pages.length, username }); |
| } |
|
|
| main().catch((error) => { |
| log.error('执行异常(best-effort,不阻断后续 build)', { |
| message: error && error.message ? error.message : String(error), |
| }); |
| if (isVerbose() && error && error.stack) console.error(error.stack); |
| process.exitCode = 0; |
| }); |
|
|