From 4813055b468577aed329dbfcb8b454bb20847db9 Mon Sep 17 00:00:00 2001 From: Joe Mou Date: Sat, 21 Feb 2026 21:53:32 +1100 Subject: Release assets Also fix release content parsing. --- src/scraper.test.ts | 6 ++++ src/scraper.ts | 100 ++++++++++++++++++++++++++++++++++++++-------------- static/style.css | 14 ++++++-- views/_release.eta | 13 +++++++ views/layout.eta | 2 +- 5 files changed, 106 insertions(+), 29 deletions(-) diff --git a/src/scraper.test.ts b/src/scraper.test.ts index 5bd609a..125e6a6 100644 --- a/src/scraper.test.ts +++ b/src/scraper.test.ts @@ -188,6 +188,12 @@ describe("GitHub scraper", () => { assert.ok(data.release.title.length > 0); assert.ok(data.release.publishedAt.length > 0); assert.ok(data.release.bodyHtml.length > 0); + assert.ok(data.release.assets && data.release.assets.length >= 2); + assert.strictEqual(data.release.assets[0].name, "Source code (zip)"); + assert.strictEqual( + data.release.assets[0].url, + "https://github.com/actions/deploy-pages/archive/refs/tags/v4.0.5.zip", + ); }); }); diff --git a/src/scraper.ts b/src/scraper.ts index 0ed77b5..769b870 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -196,6 +196,14 @@ interface Release { title: string; publishedAt: string; bodyHtml: string; + assets?: ReleaseAsset[]; +} + +export interface ReleaseAsset { + name: string; + url: string; + size?: string; + publishedAt?: string; } export interface GitHubReleases extends GitHubCommon { @@ -259,14 +267,14 @@ function parseRepoInfo(html: string): RepoInfo { const websiteElem = cssSelect.selectOne( "a[rel='noopener noreferrer nofollow']", parent, - ) as Element; + ) as Element | null; if (websiteElem) { website = getAttributeValue(websiteElem, "href") || null; } } } - const starsElem = cssSelect.selectOne("#repo-stars-counter-star", document) as unknown as Element; + const starsElem = cssSelect.selectOne("#repo-stars-counter-star", document) as Element | null; const stars = starsElem ? getAttributeValue(starsElem, "title") || textContent(starsElem).trim() : null; @@ -274,7 +282,7 @@ function parseRepoInfo(html: string): RepoInfo { const watchersElem = cssSelect.selectOne("a[href$='/watchers'] strong", document); const watchers = watchersElem ? textContent(watchersElem).trim() : null; - const forksElem = cssSelect.selectOne("#repo-network-counter", document) as unknown as Element; + const forksElem = cssSelect.selectOne("#repo-network-counter", document) as Element | null; const forks = forksElem ? getAttributeValue(forksElem, "title") || textContent(forksElem).trim() : null; @@ -282,7 +290,7 @@ function parseRepoInfo(html: string): RepoInfo { const releaseElem = cssSelect.selectOne( "a[href$='/releases'] .Counter", document, - ) as unknown as Element; + ) as Element | null; let numReleases: number | undefined; let latestReleaseTag: string | undefined; let latestReleaseDate: string | undefined; @@ -520,7 +528,6 @@ export async function getGitHubReleases(owner: string, repo: string): Promise { const html = await fetchGitHubPage(`${owner}/${repo}/releases/tag/${tag}`); - // Find the h1 tag with the release title - const h1Match = html.match(/]*class="[^"]*d-inline[^"]*"[^>]*>([^<]*)<\/h1>/); - const title = h1Match?.[1]?.trim() || tag; + const document = parseDocument(html); - // Extract tag name from tree link - const tagLinkMatch = html.match(/]*href="[^"]*\/tree\/([^"]+)"[^>]*>/); - const tagName = tagLinkMatch?.[1] || tag; + const box = cssSelect.selectOne(".Box:has(h1)", document) as Element | null; + if (!box) throw new Error("Could not find release box"); - // Extract published date - const dateMatch = html.match(/]*datetime="([^"]*)"[^>]*>/); - const publishedAt = dateMatch?.[1] || ""; + const h1 = cssSelect.selectOne("h1", box); + const title = h1 ? textContent(h1).trim() : ""; - // Extract body HTML (markdown content) - find the markdown-body div - const bodyMatch = html.match( - /]*class="[^"]*markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>\s*]*class="mb-3"/, - ); - const bodyHtml = bodyMatch?.[1]?.trim() || ""; + const release = parseRelease(box, title); return { repo: { owner, name: repo, }, - release: { - tagName, - title, - publishedAt, - bodyHtml, - }, + release, }; } + +export async function getGitHubReleaseAssets( + owner: string, + repo: string, + tag: string, +): Promise { + const html = await fetchGitHubPage(`${owner}/${repo}/releases/expanded_assets/${tag}`); + const document = parseDocument(html); + const assets: ReleaseAsset[] = []; + + for (const row of cssSelect.selectAll("li", document) as unknown as Element[]) { + const link = cssSelect.selectOne("a[href]", row); + if (!link) continue; + + const url = getAttributeValue(link, "href") || ""; + // Filename is in the link, often split into multiple spans + const name = textContent(link).replace(/\s+/g, " ").trim(); + + let size: string | undefined; + let publishedAt: string | undefined; + for (const s of cssSelect.selectAll("span.color-fg-muted.text-right", row)) { + // Size is in a span with color-fg-muted and text-right, but avoid the one with relative-time + const timeElem = cssSelect.selectOne("relative-time", row); + if (timeElem) { + publishedAt = timeElem ? getAttributeValue(timeElem, "datetime") || "" : ""; + } else { + const text = textContent(s).trim(); + if (text) size = text; + } + } + + assets.push({ + name, + url: url.startsWith("/") ? `https://github.com${url}` : url, + size, + publishedAt, + }); + } + + return assets; +} + +export async function getGitHubRelease( + owner: string, + repo: string, + tag: string, +): Promise { + const [base, assets] = await Promise.all([ + getGitHubReleaseBase(owner, repo, tag), + getGitHubReleaseAssets(owner, repo, tag), + ]); + base.release.assets = assets; + return base; +} diff --git a/static/style.css b/static/style.css index fdf1b34..b3ebb46 100644 --- a/static/style.css +++ b/static/style.css @@ -111,11 +111,16 @@ section { margin-bottom: 0; padding: .5rem; border-bottom: 1px solid var(--border-color); - font-weight: bold; + } + + > footer:last-child { + margin: 0 calc(-1 * var(--box-padding)); + padding: .5rem; + border-top: 1px solid var(--border-color); } } -footer { +body > footer { margin-top: 1rem; text-align: center; } @@ -207,11 +212,16 @@ pre.box { } } +.overview > header { + font-weight: bold; +} + .release > header { display: flex; align-items: baseline; > :first-child { flex-grow: 1; + font-weight: bold; } } diff --git a/views/_release.eta b/views/_release.eta index 4769f9b..a8838fb 100644 --- a/views/_release.eta +++ b/views/_release.eta @@ -16,4 +16,17 @@ <% if (it.release.bodyHtml) { %> <%~ it.release.bodyHtml %> <% } %> + <% if (it.release.assets && it.release.assets.length > 0) { %> +
+

Assets

+ <% for (const asset of it.release.assets) { %> +
+ <%= asset.name %> + <% if (asset.publishedAt) { %> + + <% } %> +
+ <% } %> +
+ <% } %> diff --git a/views/layout.eta b/views/layout.eta index 3910d4b..c42d593 100644 --- a/views/layout.eta +++ b/views/layout.eta @@ -13,10 +13,10 @@ git clone git@github.com:<%= it.repo.owner %>/<%= it.repo.name %>.git Code Issues + Releases <% if (it.branch) { %> Commits <% } %> - Releases -- cgit v1.3.1