diff options
| author | Joe Mou <dev@mou.fo> | 2026-01-22 13:27:36 -0500 |
|---|---|---|
| committer | Joe Mou <dev@mou.fo> | 2026-01-23 00:54:48 -0500 |
| commit | 4bbc0c19076dc8490c451a1f535f3e018fb8dda3 (patch) | |
| tree | 6fb88c80db9ffe252591a28daebd4f1e24233f57 /src | |
| parent | 2baf478f8781374e753002639365766c5783c33a (diff) | |
Simple web server for viewing trees
Diffstat (limited to 'src')
| -rw-r--r-- | src/index.ts | 36 | ||||
| -rw-r--r-- | src/scraper.ts | 91 |
2 files changed, 127 insertions, 0 deletions
diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..df0a116 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,36 @@ +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { Eta } from "eta"; +import { getGitHubTree } from "./scraper.js"; + +const app = new Hono(); +const eta = new Eta({ views: "./views" }); + +app.use("/static/*", serveStatic({ root: "." })); + +app.get("/:owner/:repo/tree/:branch/:path{.*}?", async (c) => { + try { + const owner = c.req.param("owner"); + const repo = c.req.param("repo"); + const branch = c.req.param("branch"); + const path = c.req.param("path") || ""; + + const data = await getGitHubTree(owner, repo, branch, path); + const html = await eta.renderAsync("tree.eta", data); + + return c.html(html); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + return c.html( + `<html><body><h1>Error</h1><p>${errorMessage}</p></body></html>`, + 500, + ); + } +}); + +const port = parseInt(process.env.PORT || "3000", 10); +serve({ fetch: app.fetch, port }, (info) => { + console.log(`Server running on http://localhost:${info.port}`); +}); diff --git a/src/scraper.ts b/src/scraper.ts new file mode 100644 index 0000000..add03b2 --- /dev/null +++ b/src/scraper.ts @@ -0,0 +1,91 @@ +export interface GitHubTreeItem { + name: string; + path: string; + contentType: "directory" | "file"; +} + +export interface GitHubTree { + path: string; + branch: string; + items: GitHubTreeItem[]; + repo: { + name: string; + owner: string; + isPublic: boolean; + }; +} + +export async function getGitHubTree( + owner: string, + repo: string, + branch: string, + path: string, +): Promise<GitHubTree> { + const url = `https://github.com/${owner}/${repo}/tree/${branch}/${path}`; + + // GitHub throttles/blocks requests without realistic browser headers. + // These headers make the request appear as a standard browser visit. + const response = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + }, + }); + const html = await response.text(); + + // GitHub uses different formats for root vs subdirectories. + let payload; + + // Try root format first. + const rootFormatMatches = [ + ...html.matchAll( + /<script type="application\/json" data-target="react-partial\.embeddedData">([^<]+)<\/script>/g, + ), + ]; + for (const match of rootFormatMatches) { + try { + const data = JSON.parse(match[1]); + if (data.props?.initialPayload?.tree) { + payload = data.props.initialPayload; + break; + } + } catch (e) { + continue; + } + } + + // Try subdirectory format. + if (!payload) { + const dirFormatMatch = html.match( + /<script type="application\/json" data-target="react-app\.embeddedData">([^<]+)<\/script>/, + ); + if (dirFormatMatch) { + try { + const data = JSON.parse(dirFormatMatch[1]); + if (data.payload?.tree) { + payload = data.payload; + } + } catch (e) { + // Skip invalid JSON + } + } + } + + if (!payload) { + throw new Error("Could not find tree data in embedded JSON"); + } + + return { + path: payload.path, + branch: payload.refInfo.name, + items: payload.tree.items, + repo: { + name: payload.repo.name, + owner: payload.repo.ownerLogin, + isPublic: payload.repo.public, + }, + }; +} |
