diff options
| author | Joe Mou <dev@mou.fo> | 2026-02-11 01:56:37 -0500 |
|---|---|---|
| committer | Joe Mou <dev@mou.fo> | 2026-02-11 01:56:49 -0500 |
| commit | 34eea157db19c276fa4a5578707d0f8af2eeffd3 (patch) | |
| tree | 38eb21d948b74930aaecbb52512cad1211c9794a /cgithub/src | |
| parent | 906b021bd44759d14c278a4859fcac9007c9b577 (diff) | |
Add GitHub releases support
Scrapes releases page HTML to display version tags, publish dates, and changelog content.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'cgithub/src')
| -rw-r--r-- | cgithub/src/index.ts | 6 | ||||
| -rw-r--r-- | cgithub/src/scraper.test.ts | 14 | ||||
| -rw-r--r-- | cgithub/src/scraper.ts | 63 |
3 files changed, 83 insertions, 0 deletions
diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index 4486dff..2f8efef 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -9,6 +9,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubReleases, getGitHubRepo, getGitHubTree, GitHubHTTPError, @@ -73,6 +74,11 @@ app.get("/:owner/:repo/commits/:branch/:path{.*}?", async (c) => { return tryRender(c, "commits.eta", getGitHubCommits(owner, repo, branch, path)); }); +app.get("/:owner/:repo/releases", async (c) => { + const { owner, repo } = c.req.param(); + return tryRender(c, "releases.eta", getGitHubReleases(owner, repo)); +}); + app.all("*", async (c) => { const path = c.req.path; c.header("Referrer-Policy", "no-referrer"); diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 7cafd9c..8c11317 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -4,6 +4,7 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubReleases, getGitHubRepo, getGitHubTree, GitHubHTTPError, @@ -159,6 +160,19 @@ describe("GitHub scraper", () => { assert.ok(data.commitGroups.length > 0); }); + + it("should fetch releases", async () => { + const data = await getGitHubReleases("actions", "deploy-pages"); + + assert.strictEqual(data.repo.owner, "actions"); + assert.strictEqual(data.repo.name, "deploy-pages"); + + assert.ok(data.releases.length > 0); + const firstRelease = data.releases[0]; + assert.ok(firstRelease.tagName.length > 0); + assert.ok(firstRelease.title.match(/^v\d/)); + assert.ok(firstRelease.publishedAt.length > 0); + }); }); describe("rich file types", () => { diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 71d0e3e..e52ddac 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -184,6 +184,17 @@ export interface GitHubCommits extends GitHubNav { commitGroups: CommitGroup[]; } +interface Release { + tagName: string; + title: string; + publishedAt: string; + bodyHtml: string; +} + +export interface GitHubReleases extends GitHubCommon { + releases: Release[]; +} + async function fetchGitHubPage(path: string): Promise<string> { // GitHub throttles/blocks requests without realistic browser headers. // These headers make the request appear as a standard browser visit. @@ -403,3 +414,55 @@ export async function getGitHubCommits( commitGroups: payload.commitGroups, }; } + +export async function getGitHubReleases(owner: string, repo: string): Promise<GitHubReleases> { + const html = await fetchGitHubPage(`${owner}/${repo}/releases`); + + // Releases page doesn't have embedded JSON, so we parse HTML directly + const releases: Release[] = []; + + // Find all h2 tags with version numbers + const h2Regex = /<h2[^>]*id="([^"]*)"[^>]*>(v[^<]*)<\/h2>/g; + + for (const h2Match of html.matchAll(h2Regex)) { + const id = h2Match[1]; + const title = h2Match[2].trim(); + + // Find the section containing this h2 + const sectionRegex = new RegExp( + `<section[^>]*>\\s*<h2[^>]*id="${id}"[^>]*>[^<]*<\\/h2>([\\s\\S]*?)<\\/section>`, + "s", + ); + const sectionMatch = html.match(sectionRegex); + if (!sectionMatch) continue; + + const sectionHtml = sectionMatch[1]; + + // Extract tag name from link + const tagMatch = sectionHtml.match(/<a[^>]*href="[^"]*\/tree\/([^"]+)"[^>]*>/); + const tagName = tagMatch?.[1] || title; + + // Extract published date + const dateMatch = sectionHtml.match(/<relative-time[^>]*datetime="([^"]*)"[^>]*>/); + const publishedAt = dateMatch?.[1] || ""; + + // Extract body HTML (markdown content) + const bodyMatch = sectionHtml.match(/<div[^>]*class="[^"]*markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>/); + const bodyHtml = bodyMatch?.[1]?.trim() || ""; + + releases.push({ + tagName, + title, + publishedAt, + bodyHtml, + }); + } + + return { + repo: { + owner, + name: repo, + }, + releases, + }; +} |
