-
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4 from jenslys/feat/patch-1
fix: forks + node-html-parser
- Loading branch information
Showing
2 changed files
with
129 additions
and
124 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,129 +1,135 @@ | ||
import { Hono } from "hono" | ||
import { cors } from "hono/cors" | ||
import { cache } from "hono/cache" | ||
import { prettyJSON } from "hono/pretty-json" | ||
import { secureHeaders } from "hono/secure-headers" | ||
import { Hono } from "hono"; | ||
import { cors } from "hono/cors"; | ||
import { cache } from "hono/cache"; | ||
import { prettyJSON } from "hono/pretty-json"; | ||
import { secureHeaders } from "hono/secure-headers"; | ||
|
||
import * as cheerio from "cheerio" | ||
import { parse, HTMLElement } from "node-html-parser"; | ||
|
||
const app = new Hono() | ||
|
||
// add middleware | ||
app.use("*", prettyJSON({ space: 4 })) | ||
app.use("*", secureHeaders()) | ||
app.use( | ||
"*", | ||
cors({ | ||
origin: "*", | ||
allowMethods: ["GET"] | ||
}) | ||
) | ||
|
||
// add 5 minute cache to all requests | ||
app.get( | ||
"*", | ||
cache({ | ||
cacheName: "gh-request-cache", | ||
cacheControl: "max-age=300", | ||
}) | ||
) | ||
type Bindings = { | ||
dev?: boolean; | ||
}; | ||
|
||
const app = new Hono<{ Bindings: Bindings }>(); | ||
|
||
// Configure middleware for JSON formatting, security headers and CORS | ||
app.use("*", prettyJSON({ space: 4 })); | ||
app.use("*", secureHeaders()); | ||
app.use( | ||
"*", | ||
cors({ | ||
origin: "*", | ||
allowMethods: ["GET"], | ||
}) | ||
); | ||
|
||
// Enable 5 minute caching for all routes in production | ||
app.use("*", async (c, next) => { | ||
if (!c.env.dev) { | ||
return cache({ | ||
cacheName: "gh-request-cache", | ||
cacheControl: "max-age=300", | ||
})(c, next); | ||
} | ||
return next(); | ||
}); | ||
|
||
// Redirect root path to GitHub repository | ||
app.get("/", async (c) => { | ||
return c.redirect("https://github.com/berrysauce/pinned", 301) | ||
// return c.text("📌 PINNED\nPlease use /get/username to get the pinned repositories of a GitHub user") | ||
}) | ||
|
||
|
||
app.get("/get/:username", async (c) => { | ||
const username = c.req.param("username") | ||
|
||
// get HTML of GitHub profile | ||
let request: Response | ||
try { | ||
request = await fetch(`https://github.com/${username}`) | ||
} catch { | ||
c.status(500) | ||
return c.json({ | ||
"detail": "Error fetching user" | ||
}) | ||
} | ||
|
||
// added some HTTP error handling | ||
if (request.status == 404) { | ||
c.status(404) | ||
return c.json({ | ||
"detail": "User not found" | ||
}) | ||
} else if (request.status == 429) { | ||
c.status(429) | ||
return c.json({ | ||
"detail": "Origin rate limit exceeded" | ||
}) | ||
} else if (request.status != 200) { | ||
c.status(500) | ||
return c.json({ | ||
"detail": "Error fetching user" | ||
}) | ||
} | ||
|
||
const html = await request.text() | ||
|
||
// create cheerio object with HTML | ||
const $ = cheerio.load(html) | ||
|
||
let pinned_repos: string[] = [] | ||
|
||
return c.redirect("https://github.com/berrysauce/pinned", 301); | ||
// return c.text("📌 PINNED\nPlease use /get/username to get the pinned repositories of a GitHub user") | ||
}); | ||
|
||
// Define structure for repository data | ||
interface RepositoryData { | ||
author: string; | ||
name: string; | ||
description: string; | ||
language: string; | ||
stars?: number; | ||
forks?: number; | ||
} | ||
|
||
function parseRepository(root: HTMLElement, el: HTMLElement): RepositoryData { | ||
const repoPath = | ||
el.querySelector("a")?.getAttribute("href")?.split("/") || []; | ||
const [, author = "", name = ""] = repoPath; | ||
|
||
const parseMetric = (index: number): number => { | ||
try { | ||
// loop through each pinned repository in the item list | ||
$(".js-pinned-item-list-item").each((i, el) => { | ||
// create interface for variable type and make stars and forks optional | ||
interface RepositoryData { | ||
author: string, | ||
name: string, | ||
description: string, | ||
language: string, | ||
stars?: number, | ||
forks?: number | ||
} | ||
|
||
/* | ||
.replace(/\n/g, "") removes all newline characters | ||
.trim() removes all leading and trailing whitespaces | ||
*/ | ||
let repo_data: RepositoryData = { | ||
"author": $(el).find("a").get(0).attribs.href.split("/")[1], | ||
"name": $(el).find("a").get(0).attribs.href.split("/")[2], | ||
"description": $(el).find("p.pinned-item-desc").text().replace(/\n/g, "").trim(), | ||
"language": $(el).find("span[itemprop='programmingLanguage']").text() | ||
} | ||
|
||
// run star and fork checks in try catch blocks to prevent errors (if they are not present in HTML) | ||
|
||
try { | ||
repo_data["stars"] = Number($(el).find("a.pinned-item-meta:first").text().replace(/\n/g, "").trim()) | ||
} catch { | ||
repo_data["stars"] = 0 | ||
} | ||
|
||
try { | ||
repo_data["forks"] = Number($(el).find("a.pinned-item-meta:second").text().replace(/\n/g, "").trim()) | ||
} catch { | ||
repo_data["forks"] = 0 | ||
} | ||
|
||
// add repository data to pinned_repos arrays | ||
pinned_repos.push(repo_data) | ||
}); | ||
return ( | ||
Number( | ||
el | ||
.querySelectorAll("a.pinned-item-meta") | ||
[index]?.text?.replace(/\n/g, "") | ||
.trim() | ||
) || 0 | ||
); | ||
} catch { | ||
c.status(500) | ||
return c.json({ | ||
"detail": "Error parsing user" | ||
}) | ||
return 0; | ||
} | ||
|
||
return c.json(pinned_repos) | ||
}) | ||
|
||
|
||
export default app | ||
}; | ||
|
||
return { | ||
author, | ||
name, | ||
description: | ||
el.querySelector("p.pinned-item-desc")?.text?.replace(/\n/g, "").trim() || | ||
"", | ||
language: | ||
el.querySelector("span[itemprop='programmingLanguage']")?.text || "", | ||
stars: parseMetric(0), | ||
forks: parseMetric(1), | ||
}; | ||
} | ||
|
||
// Fetch and parse pinned repositories for a given GitHub username | ||
app.get("/get/:username", async (c) => { | ||
const username = c.req.param("username"); | ||
|
||
// Fetch the GitHub profile HTML | ||
let request: Response; | ||
try { | ||
request = await fetch(`https://github.com/${username}`); | ||
} catch { | ||
c.status(500); | ||
return c.json({ | ||
detail: "Error fetching user", | ||
}); | ||
} | ||
|
||
// Handle common HTTP error responses | ||
const errorResponses: Record<number, { status: number; message: string }> = { | ||
404: { status: 404, message: "User not found" }, | ||
429: { status: 429, message: "Origin rate limit exceeded" }, | ||
}; | ||
|
||
const errorResponse = errorResponses[request.status]; | ||
if (errorResponse) { | ||
c.status(errorResponse.status); | ||
return c.json({ detail: errorResponse.message }); | ||
} | ||
|
||
if (request.status !== 200) { | ||
c.status(500); | ||
return c.json({ detail: "Error fetching user" }); | ||
} | ||
|
||
const html = await request.text(); | ||
const root = parse(html); | ||
|
||
try { | ||
const pinned_repos = root | ||
.querySelectorAll(".js-pinned-item-list-item") | ||
.map((el) => parseRepository(root, el)); | ||
|
||
return c.json(pinned_repos); | ||
} catch { | ||
c.status(500); | ||
return c.json({ | ||
detail: "Error parsing user", | ||
}); | ||
} | ||
}); | ||
|
||
export default app; |