aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/index.ts6
-rw-r--r--src/scraper.test.ts14
-rw-r--r--src/scraper.ts63
-rw-r--r--static/style.css42
-rw-r--r--views/releases.eta25
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>
+<% } %>