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 | 934b2c39537d6718375170a029fc1d1df01024c5 (patch) | |
| tree | 0e105a684b16554a19d1ba40b7e977e0c18a94b8 | |
| parent | b75d4cfb610b8792e6f1236b613f554d1ac5b78d (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>
| -rw-r--r-- | src/index.ts | 6 | ||||
| -rw-r--r-- | src/scraper.test.ts | 14 | ||||
| -rw-r--r-- | src/scraper.ts | 63 | ||||
| -rw-r--r-- | static/style.css | 42 | ||||
| -rw-r--r-- | views/releases.eta | 25 |
5 files changed, 150 insertions, 0 deletions
diff --git a/src/index.ts b/src/index.ts index 4486dff..2f8efef 100644 --- a/src/index.ts +++ b/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/src/scraper.test.ts b/src/scraper.test.ts index 7cafd9c..8c11317 100644 --- a/src/scraper.test.ts +++ b/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/src/scraper.ts b/src/scraper.ts index 71d0e3e..e52ddac 100644 --- a/src/scraper.ts +++ b/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, + }; +} diff --git a/static/style.css b/static/style.css index 8709dc2..fdf3285 100644 --- a/static/style.css +++ b/static/style.css @@ -232,6 +232,48 @@ pre.box { font-size: 0.8rem; } +.release { + margin-bottom: 2rem; +} + +.release-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 1rem; + padding-bottom: .5rem; + border-bottom: 1px solid var(--border-color); + + h3 { + margin: 0; + } +} + +.release-body { + overflow-wrap: break-word; + + a { + text-decoration: underline; + } + + h1, h2 { + border-bottom: 1px solid var(--border-color); + } + + img { + max-width: 100%; + } + + code, pre { + background: var(--shade-background); + } + + pre { + overflow-x: auto; + scrollbar-color: var(--border-color) var(--shade-background); + } +} + /* TODO Strip permalinks? */ svg.octicon-link { display: none; diff --git a/views/releases.eta b/views/releases.eta new file mode 100644 index 0000000..39850d8 --- /dev/null +++ b/views/releases.eta @@ -0,0 +1,25 @@ +<% layout("./layout.eta") %> + +<h2>Releases</h2> + +<% for (const release of it.releases) { %> + <div class="box release"> + <div class="release-header"> + <h3> + <a href="/<%= it.repo.owner %>/<%= it.repo.name %>/releases/tag/<%= release.tagName %>"> + <%= release.title %> + </a> + </h3> + <% if (release.publishedAt) { %> + <time class="timestamp" title="<%= release.publishedAt %>"> + <%= release.publishedAt.split("T")[0] %> + </time> + <% } %> + </div> + <% if (release.bodyHtml) { %> + <div class="release-body"> + <%~ release.bodyHtml %> + </div> + <% } %> + </div> +<% } %> |
