diff options
| author | Joe Mou <dev@mou.fo> | 2026-07-15 01:18:50 -0400 |
|---|---|---|
| committer | Joe Mou <dev@mou.fo> | 2026-07-15 01:18:50 -0400 |
| commit | 7f38fbb0e3c852fda02986f3a755557938810062 (patch) | |
| tree | 6651667c8daecadee3199d169b25fa7ddcedef10 | |
| parent | 47ddd107c2f19a977798b8af230c652ba731cf45 (diff) | |
Add organization support to owner pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| -rw-r--r-- | cgithub/CLAUDE.md | 2 | ||||
| -rw-r--r-- | cgithub/src/index.ts | 8 | ||||
| -rw-r--r-- | cgithub/src/scraper.test.ts | 37 | ||||
| -rw-r--r-- | cgithub/src/scraper.ts | 121 | ||||
| -rw-r--r-- | cgithub/views/home.eta | 2 | ||||
| -rw-r--r-- | cgithub/views/owner.eta (renamed from cgithub/views/user.eta) | 2 |
6 files changed, 123 insertions, 49 deletions
diff --git a/cgithub/CLAUDE.md b/cgithub/CLAUDE.md index f6d8524..827cc3b 100644 --- a/cgithub/CLAUDE.md +++ b/cgithub/CLAUDE.md @@ -26,7 +26,7 @@ pnpm test **Templates:** `views/*.eta` - Eta templates with layout inheritance (`layout.eta` as base) **Routes:** -- `GET /:user` - User profile with pinned repositories +- `GET /:owner` - User or organization profile with pinned repositories - `GET /:owner/:repo` - Repository info with directory listing - `GET /:owner/:repo/tree/:branch/:path*` - Directory listing - `GET /:owner/:repo/blob/:branch/:path` - File content view diff --git a/cgithub/src/index.ts b/cgithub/src/index.ts index d8bdfec..f676bd6 100644 --- a/cgithub/src/index.ts +++ b/cgithub/src/index.ts @@ -9,12 +9,12 @@ import { getGitHubBlob, getGitHubCommits, getGitHubIssues, + getGitHubOwner, getGitHubPulls, getGitHubRelease, getGitHubReleases, getGitHubRepo, getGitHubTree, - getGitHubUser, GitHubHTTPError, RedirectError, } from "./scraper.ts"; @@ -52,9 +52,9 @@ async function tryRender<T extends object>(c: Context, template: string, promise return c.html(await eta.renderAsync(template, data)); } -app.get("/:user", async (c) => { - const { user } = c.req.param(); - return tryRender(c, "user.eta", getGitHubUser(user)); +app.get("/:owner", async (c) => { + const { owner } = c.req.param(); + return tryRender(c, "owner.eta", getGitHubOwner(owner)); }); app.get("/:owner/:repo", async (c) => { diff --git a/cgithub/src/scraper.test.ts b/cgithub/src/scraper.test.ts index 2438361..3048915 100644 --- a/cgithub/src/scraper.test.ts +++ b/cgithub/src/scraper.test.ts @@ -8,8 +8,8 @@ import { getGitHubRelease, getGitHubReleases, getGitHubRepo, + getGitHubOwner, getGitHubTree, - getGitHubUser, GitHubHTTPError, RedirectError, } from "./scraper.ts"; @@ -264,10 +264,11 @@ describe("GitHub scraper", () => { }); }); - describe("user pages", () => { + describe("owner pages", () => { it("should fetch user profile", async () => { - const data = await getGitHubUser("torvalds"); + const data = await getGitHubOwner("torvalds"); + assert.strictEqual(data.type, "user"); assert.strictEqual(data.login, "torvalds"); assert.strictEqual(data.name, "Linus Torvalds"); assert.strictEqual(data.company, "Linux Foundation"); @@ -287,7 +288,7 @@ describe("GitHub scraper", () => { }); it("should fetch user bio and links", async () => { - const data = await getGitHubUser("sindresorhus"); + const data = await getGitHubOwner("sindresorhus"); assert.strictEqual(data.login, "sindresorhus"); assert.ok(data.bio && data.bio.length > 0); @@ -295,16 +296,34 @@ describe("GitHub scraper", () => { assert.ok(data.links.every((link) => link.href.startsWith("https://"))); }); - it("should redirect to GitHub for organizations", async () => { - await assert.rejects(getGitHubUser("github"), (err) => { + it("should fetch organization profile", async () => { + const data = await getGitHubOwner("github"); + + assert.strictEqual(data.type, "organization"); + assert.strictEqual(data.login, "github"); + assert.strictEqual(data.name, "GitHub"); + assert.strictEqual(data.bio, "How people build software."); + assert.strictEqual(data.location, "United States of America"); + assert.ok(data.avatarUrl?.startsWith("https://avatars.githubusercontent.com/")); + assert.ok(data.followers); + assert.strictEqual(data.following, null); + assert.ok(data.links.some((link) => link.href === "https://github.com/about")); + + assert.ok(data.pinned.length > 0); + assert.ok(data.pinned.every((repo) => repo.owner === "github")); + assert.ok(data.pinned.some((repo) => repo.stars && repo.forks)); + }); + + it("should redirect to GitHub for non-profile pages", async () => { + await assert.rejects(getGitHubOwner("features"), (err) => { assert(err instanceof RedirectError, "error should be a RedirectError"); - assert.strictEqual(err.location, "https://github.com/github"); + assert.strictEqual(err.location, "https://github.com/features"); return true; }); }); - it("should throw for non-existent user", async () => { - await assert.rejects(getGitHubUser("nosuchuserzzzz"), (err) => { + it("should throw for non-existent owner", async () => { + await assert.rejects(getGitHubOwner("nosuchuserzzzz"), (err) => { assert(err instanceof GitHubHTTPError, "error should be an HTTPError"); assert.strictEqual(err.status, 404); return true; diff --git a/cgithub/src/scraper.ts b/cgithub/src/scraper.ts index 3434434..3e3ad75 100644 --- a/cgithub/src/scraper.ts +++ b/cgithub/src/scraper.ts @@ -292,7 +292,7 @@ export interface GitHubRelease extends GitHubCommon { release: Release; } -interface UserPinnedRepo { +interface OwnerPinnedRepo { owner: string; name: string; description?: string; @@ -301,17 +301,19 @@ interface UserPinnedRepo { forks?: string; } -export interface GitHubUser { +export interface GitHubOwner { + type: "user" | "organization"; login: string; name: string | null; bio: string | null; avatarUrl: string | null; followers: string | null; + // Users only; always null for organizations. following: string | null; company: string | null; location: string | null; links: { text: string; href: string }[]; - pinned: UserPinnedRepo[]; + pinned: OwnerPinnedRepo[]; } async function fetchGitHubPage(path: string): Promise<string> { @@ -768,16 +770,37 @@ function selectText(selector: string, context: AnyNode): string | null { return textContent(elem).replace(/\s+/g, " ").trim() || null; } -export async function getGitHubUser(user: string): Promise<GitHubUser> { - const html = await fetchGitHubPage(user); - const document = parseDocument(html); +// Pinned repositories appear as .pinned-item-list-item on user pages and as +// [itemprop='owns'] blocks on organization pages. +function parsePinnedRepos(document: AnyNode): OwnerPinnedRepo[] { + const pinned: OwnerPinnedRepo[] = []; + const items = cssSelect.selectAll( + ".pinned-item-list-item, [itemprop='owns']", + document, + ) as unknown as Element[]; + for (const item of items) { + const link = cssSelect.selectOne( + "a:has(span.repo), a[itemprop~='codeRepository']", + item, + ) as Element | null; + const href = link ? getAttributeValue(link, "href") : undefined; + if (!href) continue; + const [owner, name] = href.replace(/^\//, "").split("/"); + if (!owner || !name) continue; - // Organizations and other non-profile pages lack the user vcard. - const login = selectText(".p-nickname.vcard-username", document); - if (!login) { - throw new RedirectError(`https://github.com/${user}`); + pinned.push({ + owner, + name, + description: selectText("p.pinned-item-desc, [itemprop='description']", item) ?? undefined, + language: selectText("[itemprop='programmingLanguage']", item) ?? undefined, + stars: selectText("a[href$='/stargazers']", item) ?? undefined, + forks: selectText("a[href$='/forks']", item) ?? undefined, + }); } + return pinned; +} +function parseUser(document: AnyNode, login: string): GitHubOwner { const avatarElem = cssSelect.selectOne("a[itemprop='image'] img", document) as Element | null; const avatarUrl = avatarElem ? getAttributeValue(avatarElem, "src") || null : null; @@ -798,29 +821,8 @@ export async function getGitHubUser(user: string): Promise<GitHubUser> { links.push({ text: textContent(anchor).replace(/\s+/g, " ").trim(), href }); } - const pinned: UserPinnedRepo[] = []; - const pinnedItems = cssSelect.selectAll( - ".pinned-item-list-item", - document, - ) as unknown as Element[]; - for (const item of pinnedItems) { - const link = cssSelect.selectOne("a:has(span.repo)", item) as Element | null; - const href = link ? getAttributeValue(link, "href") : undefined; - if (!href) continue; - const [owner, name] = href.replace(/^\//, "").split("/"); - if (!owner || !name) continue; - - pinned.push({ - owner, - name, - description: selectText("p.pinned-item-desc", item) ?? undefined, - language: selectText("[itemprop='programmingLanguage']", item) ?? undefined, - stars: selectText("a[href$='/stargazers']", item) ?? undefined, - forks: selectText("a[href$='/forks']", item) ?? undefined, - }); - } - return { + type: "user", login, name: selectText(".p-name.vcard-fullname", document), bio: selectText(".p-note.user-profile-bio", document), @@ -830,6 +832,59 @@ export async function getGitHubUser(user: string): Promise<GitHubUser> { company: selectText("li[itemprop='worksFor']", document), location: selectText("li[itemprop='homeLocation']", document), links, - pinned, + pinned: parsePinnedRepos(document), }; } + +function parseOrganization(document: AnyNode, owner: string): GitHubOwner { + const avatarElem = cssSelect.selectOne(".orghead img[itemprop='image']", document) as + | Element + | null; + const avatarUrl = avatarElem ? getAttributeValue(avatarElem, "src") || null : null; + // The login only appears in the avatar's "@login" alt text. + const alt = avatarElem ? getAttributeValue(avatarElem, "alt") : undefined; + const login = alt?.replace(/^@/, "") || owner; + + const followers = + selectText("a[href$='/followers']", document)?.replace(/ followers?$/, "") ?? null; + + const links: { text: string; href: string }[] = []; + const anchors = cssSelect.selectAll( + ".orghead a[itemprop='url'], .orghead [itemprop='email'] a", + document, + ) as unknown as Element[]; + for (const anchor of anchors) { + const href = getAttributeValue(anchor, "href"); + if (!href) continue; + links.push({ text: textContent(anchor).replace(/\s+/g, " ").trim(), href }); + } + + return { + type: "organization", + login, + name: selectText(".orghead h1", document), + bio: selectText(".orghead h1 + div", document), + avatarUrl, + followers, + following: null, + company: null, + location: selectText(".orghead [itemprop='location']", document), + links, + pinned: parsePinnedRepos(document), + }; +} + +export async function getGitHubOwner(owner: string): Promise<GitHubOwner> { + const html = await fetchGitHubPage(owner); + const document = parseDocument(html); + + const login = selectText(".p-nickname.vcard-username", document); + if (login) { + return parseUser(document, login); + } + if (cssSelect.selectOne(".orghead", document)) { + return parseOrganization(document, owner); + } + // Not a profile page (marketing pages like github.com/features, etc.). + throw new RedirectError(`https://github.com/${owner}`); +} diff --git a/cgithub/views/home.eta b/cgithub/views/home.eta index 0026ea3..604e4d0 100644 --- a/cgithub/views/home.eta +++ b/cgithub/views/home.eta @@ -12,7 +12,7 @@ </header> <main> - <p>Browse any GitHub repository by navigating to <code>/:owner/:repo</code>, or a user profile at <code>/:user</code></p> + <p>Browse any GitHub repository by navigating to <code>/:owner/:repo</code>, or a user or organization profile at <code>/:owner</code></p> </main> <h2>Credits</h2> diff --git a/cgithub/views/user.eta b/cgithub/views/owner.eta index cf2da94..4426400 100644 --- a/cgithub/views/user.eta +++ b/cgithub/views/owner.eta @@ -12,7 +12,7 @@ <img class="avatar" src="<%= it.avatarUrl %>" alt="" width="64" height="64"> <% } %> <div class="repo"><%= it.name || it.login %></div> - <div class="detail"><%= it.login %></div> + <div class="detail"><%= it.login %><%= it.type === "organization" ? " (organization)" : "" %></div> <% if (it.bio) { %> <div class="description"><%= it.bio %></div> <% } %> |
