import { load } from "cheerio"; import { cacheGet, cacheSet } from "./cache.ts"; export interface ActivityDay { date: string; count: number; level: number; } export interface ActivitySeries { total: number; period: string; days: ActivityDay[]; } export interface TangledRepository { repoDid: string; name: string; description: string; website: string | null; url: string; } export interface TangledCommit { subject: string; repository: string; time: string; url: string; } export interface CodeActivityData { github: ActivitySeries | null; tangled: ActivitySeries | null; tangledPinned: TangledRepository[]; tangledRecent: TangledCommit[]; } interface DidDocument { service?: Array<{ type?: string; serviceEndpoint?: string; }>; } interface AtRecord> { uri: string; value: T; } interface TangledProfileRecord { pinnedRepositories?: unknown; } interface TangledRepoRecord { repoDid?: unknown; name?: unknown; description?: unknown; website?: unknown; createdAt?: unknown; } interface GetRecordResponse { value?: T; } interface ListRecordsResponse { records?: Array>; } const CAMERON_DID = "did:plc:gfrmhdmjvxn2sjedzboeudef"; const TANGLED_HANDLE = "cameron.stream"; const TID_RKEY = /^3[a-z2-7]{12}$/; const REQUEST_HEADERS = { "User-Agent": "cameron.stream code activity", Accept: "text/html", }; function contributionCount(label: string): number { if (/^no contributions/i.test(label.trim())) return 0; const match = label.match(/([\d,]+) contribution/i); return match ? Number(match[1].replaceAll(",", "")) : 0; } export function parseGithubActivity(html: string): ActivitySeries | null { const totalMatch = html.match( /]*id="js-contribution-activity-description"[^>]*>[\s\S]*?([\d,]+)[\s\S]*?contributions[\s\S]*?<\/h2>/i ); const days: ActivityDay[] = []; const dayPattern = /]*data-date="([^"]+)"[^>]*data-level="([0-4])"[^>]*><\/td>\s*]*>([^<]+)<\/tool-tip>/gi; for (const match of html.matchAll(dayPattern)) { days.push({ date: match[1], level: Number(match[2]), count: contributionCount(match[3]), }); } if (!totalMatch || days.length < 300) return null; days.sort((a, b) => a.date.localeCompare(b.date)); return { total: Number(totalMatch[1].replaceAll(",", "")), period: "in the last year", days, }; } function tangledLevel(count: number): number { if (count === 0) return 0; if (count === 1) return 1; if (count <= 3) return 2; if (count <= 7) return 3; return 4; } // Resolve the default branch from the repository landing page's own history // navigation, never from README links or an undocumented ATProto record field. export function parseTangledHistoryUrl(html: string, repositoryUrl: string): string | null { const $ = load(html); const href = $("#commit-log a[href]").first().attr("href"); if (!href || !URL.canParse(href, "https://tangled.org") || !URL.canParse(repositoryUrl)) return null; const url = new URL(href, "https://tangled.org"); const repository = new URL(repositoryUrl); const prefix = `${repository.pathname}/commits/`; return url.origin === "https://tangled.org" && url.pathname.startsWith(prefix) && url.pathname.length > prefix.length && !url.search && !url.hash ? url.href : null; } export function parseTangledRecentCommits(html: string, limit = 6): TangledCommit[] { const $ = load(html); const commits = new Map(); // Each history row includes a hash link, a nested subject link and a sibling // time column. Restrict all extraction to that row, including missing times. for (const row of $("div.grid-cols-14").toArray()) { const item = $(row); const time = item.find("time[datetime]").attr("datetime"); if (!time || Number.isNaN(Date.parse(time))) continue; for (const anchor of item.find(".col-span-6 a[href]").toArray()) { const link = $(anchor); const path = link.attr("href") ?? ""; const match = path.match(/^\/([^/]+\/[^/]+)\/commit\/([0-9a-f]{40})$/i); const subject = link.text().replace(/\s+/g, " ").trim(); if (!match || !subject) continue; const url = `https://tangled.org${path}`; commits.set(url, { subject, repository: match[1], time, url }); break; } } return [...commits.values()].slice(0, Math.max(0, limit)); } export function parseTangledActivity(html: string): ActivitySeries | null { const totalMatch = html.match(/Punchcard[\s\S]{0,500}?([\d,]+) commits/i); const days: ActivityDay[] = []; const dayPattern = /title="(\d{4}-\d{2}-\d{2}): ([\d,]+) commits"/gi; for (const match of html.matchAll(dayPattern)) { const count = Number(match[2].replaceAll(",", "")); days.push({ date: match[1], count, level: tangledLevel(count), }); } if (!totalMatch || days.length < 300) return null; days.sort((a, b) => a.date.localeCompare(b.date)); return { total: Number(totalMatch[1].replaceAll(",", "")), period: `in ${days[0].date.slice(0, 4)}`, days, }; } async function fetchText(url: string): Promise { try { const response = await fetch(url, { headers: REQUEST_HEADERS, signal: AbortSignal.timeout(8_000), }); return response.ok ? await response.text() : null; } catch { return null; } } async function fetchJson(url: string): Promise { try { const response = await fetch(url, { headers: { "User-Agent": "cameron.stream code activity", Accept: "application/json", }, signal: AbortSignal.timeout(8_000), }); return response.ok ? await response.json() as T : null; } catch { return null; } } function recordKey(uri: string): string { return uri.slice(uri.lastIndexOf("/") + 1); } function repositoryName(record: AtRecord): string | null { const rkey = recordKey(record.uri); if (!TID_RKEY.test(rkey)) return rkey; return typeof record.value.name === "string" && record.value.name ? record.value.name : null; } function repositoryNameQuality(record: AtRecord): number { return TID_RKEY.test(recordKey(record.uri)) ? 1 : 2; } function createdAt(record: AtRecord): number { if (typeof record.value.createdAt !== "string") return 0; const timestamp = Date.parse(record.value.createdAt); return Number.isNaN(timestamp) ? 0 : timestamp; } export function parseTangledPinnedRepositories( profile: TangledProfileRecord, records: Array>, ): TangledRepository[] { if (!Array.isArray(profile.pinnedRepositories)) return []; return profile.pinnedRepositories.flatMap((repoDid) => { if (typeof repoDid !== "string") return []; const candidates = records .filter((record) => record.value.repoDid === repoDid) .filter((record) => repositoryName(record) !== null) .sort((a, b) => { const quality = repositoryNameQuality(b) - repositoryNameQuality(a); return quality || createdAt(b) - createdAt(a); }); const selected = candidates[0]; if (!selected) return []; const name = repositoryName(selected); if (!name) return []; return [{ repoDid, name, description: typeof selected.value.description === "string" ? selected.value.description : "", website: typeof selected.value.website === "string" && selected.value.website ? selected.value.website : null, url: `https://tangled.org/${TANGLED_HANDLE}/${encodeURIComponent(name)}`, }]; }); } async function getTangledPinnedRepositories(): Promise { const didDocument = await fetchJson( `https://plc.directory/${CAMERON_DID}`, ); const pds = didDocument?.service?.find( (service) => service.type === "AtprotoPersonalDataServer", )?.serviceEndpoint; if (!pds) return []; const query = (collection: string) => `repo=${encodeURIComponent(CAMERON_DID)}&collection=${encodeURIComponent(collection)}`; const [profileResponse, repositoriesResponse] = await Promise.all([ fetchJson>( `${pds}/xrpc/com.atproto.repo.getRecord?${query("sh.tangled.actor.profile")}&rkey=self`, ), fetchJson>( `${pds}/xrpc/com.atproto.repo.listRecords?${query("sh.tangled.repo")}&limit=100`, ), ]); if (!profileResponse?.value || !repositoriesResponse?.records) return []; return parseTangledPinnedRepositories( profileResponse.value, repositoriesResponse.records, ); } export async function fetchPinnedRecentCommits(repositories: TangledRepository[]): Promise { const commits: TangledCommit[] = []; const pinned = [...new Map(repositories.map((repo) => [repo.url, repo])).values()].slice(0, 4); for (let index = 0; index < pinned.length; index += 2) { const batch = pinned.slice(index, index + 2).map(async (repository) => { const landing = await fetchText(repository.url); const historyUrl = landing ? parseTangledHistoryUrl(landing, repository.url) : null; if (!historyUrl) return []; const html = await fetchText(historyUrl); return html ? parseTangledRecentCommits(html).filter((commit) => commit.url.startsWith(`${repository.url}/commit/`)) : []; }); const results = await Promise.all(batch); commits.push(...results.flat()); } const unique = new Map(); for (const commit of commits) { const hash = commit.url.slice(commit.url.lastIndexOf("/") + 1); unique.set(`${commit.repository}:${hash}`, commit); } return [...unique.values()] .sort((a, b) => Date.parse(b.time) - Date.parse(a.time)) .slice(0, 6); } export async function getCodeActivity(): Promise { const cacheKey = "code-activity:v3"; const cached = await cacheGet(cacheKey); if (cached !== undefined) return cached as CodeActivityData; const [githubHtml, tangledHtml, tangledPinned] = await Promise.all([ fetchText("https://github.com/users/just-cameron/contributions"), fetchText("https://tangled.org/cameron.stream"), getTangledPinnedRepositories(), ]); const activity = { github: githubHtml ? parseGithubActivity(githubHtml) : null, tangled: tangledHtml ? parseTangledActivity(tangledHtml) : null, tangledPinned, tangledRecent: await fetchPinnedRecentCommits(tangledPinned), }; await cacheSet(cacheKey, activity, { life: activity.github && activity.tangled && activity.tangledPinned.length > 0 && activity.tangledRecent.length > 0 ? "hours" : "minutes", }); return activity; }