aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoe Mou <dev@mou.fo>2026-01-22 13:27:36 -0500
committerJoe Mou <dev@mou.fo>2026-01-23 00:54:48 -0500
commit4bbc0c19076dc8490c451a1f535f3e018fb8dda3 (patch)
tree6fb88c80db9ffe252591a28daebd4f1e24233f57 /src
parent2baf478f8781374e753002639365766c5783c33a (diff)
Simple web server for viewing trees
Diffstat (limited to 'src')
-rw-r--r--src/index.ts36
-rw-r--r--src/scraper.ts91
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,
+ },
+ };
+}