summaryrefslogtreecommitdiff
path: root/cgithub
diff options
context:
space:
mode:
authorJoe Mou <dev@mou.fo>2026-02-21 16:21:35 +1100
committerJoe Mou <dev@mou.fo>2026-02-21 16:21:57 +1100
commit1435dea09882f110448b034d7cffa32d03e19a8f (patch)
tree331ed53d8870c68537e78be2012bdf67f47e53f1 /cgithub
parent34eea157db19c276fa4a5578707d0f8af2eeffd3 (diff)
Parse release page HTML
Fixes reading complex release descriptions that had unbalanced tags. Written with Gemini.
Diffstat (limited to 'cgithub')
-rw-r--r--cgithub/package.json6
-rw-r--r--cgithub/pnpm-lock.yaml240
-rw-r--r--cgithub/src/scraper.test.ts1
-rw-r--r--cgithub/src/scraper.ts46
4 files changed, 102 insertions, 191 deletions
diff --git a/cgithub/package.json b/cgithub/package.json
index 3d339a4..06e2876 100644
--- a/cgithub/package.json
+++ b/cgithub/package.json
@@ -11,8 +11,12 @@
},
"dependencies": {
"@hono/node-server": "^1.13.7",
+ "css-select": "^6.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
"eta": "^3.5.0",
- "hono": "^4.6.14"
+ "hono": "^4.6.14",
+ "htmlparser2": "^10.1.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
diff --git a/cgithub/pnpm-lock.yaml b/cgithub/pnpm-lock.yaml
index 2cd9be3..4fd01e4 100644
--- a/cgithub/pnpm-lock.yaml
+++ b/cgithub/pnpm-lock.yaml
@@ -11,19 +11,28 @@ importers:
'@hono/node-server':
specifier: ^1.13.7
version: 1.19.9(hono@4.11.5)
+ css-select:
+ specifier: ^6.0.0
+ version: 6.0.0
+ domhandler:
+ specifier: ^5.0.3
+ version: 5.0.3
+ domutils:
+ specifier: ^3.2.2
+ version: 3.2.2
eta:
specifier: ^3.5.0
version: 3.5.0
hono:
specifier: ^4.6.14
version: 4.11.5
+ htmlparser2:
+ specifier: ^10.1.0
+ version: 10.1.0
devDependencies:
'@types/node':
specifier: ^22.10.2
version: 22.19.7
- concurrently:
- specifier: ^9.1.2
- version: 9.2.1
esbuild:
specifier: ^0.27.2
version: 0.27.2
@@ -201,105 +210,61 @@ packages:
'@types/node@22.19.7':
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
- ansi-regex@5.0.1:
- resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
- engines: {node: '>=8'}
+ boolbase@1.0.0:
+ resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
- ansi-styles@4.3.0:
- resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
- engines: {node: '>=8'}
+ css-select@6.0.0:
+ resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==}
- chalk@4.1.2:
- resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
- engines: {node: '>=10'}
+ css-what@7.0.0:
+ resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==}
+ engines: {node: '>= 6'}
- cliui@8.0.1:
- resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
- engines: {node: '>=12'}
+ dom-serializer@2.0.0:
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
- color-convert@2.0.1:
- resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
- engines: {node: '>=7.0.0'}
+ domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
- color-name@1.1.4:
- resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
- concurrently@9.2.1:
- resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
- engines: {node: '>=18'}
- hasBin: true
+ domutils@3.2.2:
+ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
- emoji-regex@8.0.0:
- resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
esbuild@0.27.2:
resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==}
engines: {node: '>=18'}
hasBin: true
- escalade@3.2.0:
- resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
- engines: {node: '>=6'}
-
eta@3.5.0:
resolution: {integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==}
engines: {node: '>=6.0.0'}
- get-caller-file@2.0.5:
- resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
- engines: {node: 6.* || 8.* || >= 10.*}
-
- has-flag@4.0.0:
- resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
- engines: {node: '>=8'}
-
hono@4.11.5:
resolution: {integrity: sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g==}
engines: {node: '>=16.9.0'}
- is-fullwidth-code-point@3.0.0:
- resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
- engines: {node: '>=8'}
+ htmlparser2@10.1.0:
+ resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
+
+ nth-check@2.1.1:
+ resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
prettier@3.8.1:
resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==}
engines: {node: '>=14'}
hasBin: true
- require-directory@2.1.1:
- resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
- engines: {node: '>=0.10.0'}
-
- rxjs@7.8.2:
- resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
-
- shell-quote@1.8.3:
- resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
- engines: {node: '>= 0.4'}
-
- string-width@4.2.3:
- resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
- engines: {node: '>=8'}
-
- strip-ansi@6.0.1:
- resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
- engines: {node: '>=8'}
-
- supports-color@7.2.0:
- resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
- engines: {node: '>=8'}
-
- supports-color@8.1.1:
- resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
- engines: {node: '>=10'}
-
- tree-kill@1.2.2:
- resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
- hasBin: true
-
- tslib@2.8.1:
- resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
-
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -308,22 +273,6 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
- wrap-ansi@7.0.0:
- resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
- engines: {node: '>=10'}
-
- y18n@5.0.8:
- resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
- engines: {node: '>=10'}
-
- yargs-parser@21.1.1:
- resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
- engines: {node: '>=12'}
-
- yargs@17.7.2:
- resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
- engines: {node: '>=12'}
-
snapshots:
'@esbuild/aix-ppc64@0.27.2':
@@ -412,39 +361,39 @@ snapshots:
dependencies:
undici-types: 6.21.0
- ansi-regex@5.0.1: {}
+ boolbase@1.0.0: {}
- ansi-styles@4.3.0:
+ css-select@6.0.0:
dependencies:
- color-convert: 2.0.1
+ boolbase: 1.0.0
+ css-what: 7.0.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ nth-check: 2.1.1
- chalk@4.1.2:
- dependencies:
- ansi-styles: 4.3.0
- supports-color: 7.2.0
+ css-what@7.0.0: {}
- cliui@8.0.1:
+ dom-serializer@2.0.0:
dependencies:
- string-width: 4.2.3
- strip-ansi: 6.0.1
- wrap-ansi: 7.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
- color-convert@2.0.1:
- dependencies:
- color-name: 1.1.4
+ domelementtype@2.3.0: {}
- color-name@1.1.4: {}
+ domhandler@5.0.3:
+ dependencies:
+ domelementtype: 2.3.0
- concurrently@9.2.1:
+ domutils@3.2.2:
dependencies:
- chalk: 4.1.2
- rxjs: 7.8.2
- shell-quote: 1.8.3
- supports-color: 8.1.1
- tree-kill: 1.2.2
- yargs: 17.7.2
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
- emoji-regex@8.0.0: {}
+ entities@4.5.0: {}
+
+ entities@7.0.1: {}
esbuild@0.27.2:
optionalDependencies:
@@ -475,70 +424,23 @@ snapshots:
'@esbuild/win32-ia32': 0.27.2
'@esbuild/win32-x64': 0.27.2
- escalade@3.2.0: {}
-
eta@3.5.0: {}
- get-caller-file@2.0.5: {}
-
- has-flag@4.0.0: {}
-
hono@4.11.5: {}
- is-fullwidth-code-point@3.0.0: {}
-
- prettier@3.8.1: {}
-
- require-directory@2.1.1: {}
-
- rxjs@7.8.2:
- dependencies:
- tslib: 2.8.1
-
- shell-quote@1.8.3: {}
-
- string-width@4.2.3:
+ htmlparser2@10.1.0:
dependencies:
- emoji-regex: 8.0.0
- is-fullwidth-code-point: 3.0.0
- strip-ansi: 6.0.1
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ entities: 7.0.1
- strip-ansi@6.0.1:
+ nth-check@2.1.1:
dependencies:
- ansi-regex: 5.0.1
+ boolbase: 1.0.0
- supports-color@7.2.0:
- dependencies:
- has-flag: 4.0.0
-
- supports-color@8.1.1:
- dependencies:
- has-flag: 4.0.0
-
- tree-kill@1.2.2: {}
-
- tslib@2.8.1: {}
+ prettier@3.8.1: {}
typescript@5.9.3: {}
undici-types@6.21.0: {}
-
- wrap-ansi@7.0.0:
- dependencies:
- ansi-styles: 4.3.0
- string-width: 4.2.3
- strip-ansi: 6.0.1
-
- y18n@5.0.8: {}
-
- yargs-parser@21.1.1: {}
-
- yargs@17.7.2:
- dependencies:
- cliui: 8.0.1
- escalade: 3.2.0
- get-caller-file: 2.0.5
- require-directory: 2.1.1
- string-width: 4.2.3
- y18n: 5.0.8
- yargs-parser: 21.1.1
diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts
index 8c11317..33f00f1 100644
--- a/cgithub/src/scraper.test.ts
+++ b/cgithub/src/scraper.test.ts
@@ -172,6 +172,7 @@ describe("GitHub scraper", () => {
assert.ok(firstRelease.tagName.length > 0);
assert.ok(firstRelease.title.match(/^v\d/));
assert.ok(firstRelease.publishedAt.length > 0);
+ assert.ok(firstRelease.bodyHtml.length > 0);
});
});
diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts
index e52ddac..af39660 100644
--- a/cgithub/src/scraper.ts
+++ b/cgithub/src/scraper.ts
@@ -1,3 +1,8 @@
+import { parseDocument } from "htmlparser2";
+import { type Element } from "domhandler";
+import { getAttributeValue, getInnerHTML, textContent } from "domutils";
+import * as cssSelect from "css-select";
+
export class GitHubHTTPError extends Error {
status: number;
@@ -418,37 +423,36 @@ export async function getGitHubCommits(
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 document = parseDocument(html);
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();
+ // Not really sure the right way to express this type.
+ const sections = cssSelect.selectAll("section", document) as unknown as Element[];
- // 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;
+ for (const section of sections) {
+ const h2 = cssSelect.selectOne("h2[id]", section);
+ if (!h2) continue;
- const sectionHtml = sectionMatch[1];
+ const title = textContent(h2).trim();
+ if (!title.startsWith("v")) continue;
// Extract tag name from link
- const tagMatch = sectionHtml.match(/<a[^>]*href="[^"]*\/tree\/([^"]+)"[^>]*>/);
- const tagName = tagMatch?.[1] || title;
+ const tagLink = cssSelect.selectOne('a[href*="/tree/"]', section);
+ let tagName = title;
+ if (tagLink) {
+ const href = getAttributeValue(tagLink, "href");
+ if (href) {
+ tagName = href.split("/").pop() || title;
+ }
+ }
// Extract published date
- const dateMatch = sectionHtml.match(/<relative-time[^>]*datetime="([^"]*)"[^>]*>/);
- const publishedAt = dateMatch?.[1] || "";
+ const relativeTime = cssSelect.selectOne("relative-time", section);
+ const publishedAt = relativeTime ? getAttributeValue(relativeTime, "datetime") || "" : "";
// Extract body HTML (markdown content)
- const bodyMatch = sectionHtml.match(/<div[^>]*class="[^"]*markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>/);
- const bodyHtml = bodyMatch?.[1]?.trim() || "";
+ const body = cssSelect.selectOne(".markdown-body", section);
+ const bodyHtml = body ? getInnerHTML(body).trim() : "";
releases.push({
tagName,