feat: scrape HN favorites as a Kubernetes CronJob and write to DynamoDB
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
35e9c73cf2
18 changed files with 5353 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.forgejo
|
||||||
29
.forgejo/workflows/ci.yaml
Normal file
29
.forgejo/workflows/ci.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Full pipeline: checks -> build+push image -> bump tag in gitops repo.
|
||||||
|
# The heavy lifting lives in nholtware/workflows composite actions; ArgoCD
|
||||||
|
# takes over from the gitops commit.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/node-checks@v1
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/lint-checks@v1
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: checks
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/build-push@v1
|
||||||
|
with:
|
||||||
|
app-name: hackernews-favs-cron
|
||||||
|
registry-user: ${{ secrets.REGISTRY_USER }}
|
||||||
|
registry-token: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/gitops-bump@v1
|
||||||
|
with:
|
||||||
|
app-name: hackernews-favs-cron
|
||||||
|
gitops-token: ${{ secrets.GITOPS_TOKEN }}
|
||||||
12
.forgejo/workflows/pr.yaml
Normal file
12
.forgejo/workflows/pr.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# PR checks: lint + test only. No build/deploy -- that's ci.yaml's job on push to main.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/node-checks@v1
|
||||||
|
- uses: http://192.168.1.230:3000/nholtware/workflows/lint-checks@v1
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v26
|
||||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
FROM node:22 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ARG GIT_SHA=dev
|
||||||
|
ENV GIT_SHA=${GIT_SHA}
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
USER node
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
18
README.md
Normal file
18
README.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# hackernews-favs-cron
|
||||||
|
|
||||||
|
Runs the Hacker News favorites scrape from `nholtware/hackernews-favs` (the AWS
|
||||||
|
Lambda version) as a Kubernetes CronJob on the homelab k3s cluster. It still
|
||||||
|
writes each batch to the AWS DynamoDB table `hackernews-favs` (same item shape:
|
||||||
|
`pk=date#YYYY-MM-DD`, `sk=batch#<uuid>`), so existing readers are unaffected.
|
||||||
|
|
||||||
|
- Code: `src/favorites.ts` (fetch + parse), `src/main.ts` (one-shot entrypoint).
|
||||||
|
- Deploy: push to `main` -> Forgejo CI builds the image -> `gitops-bump` ->
|
||||||
|
ArgoCD syncs `manifests/hackernews-favs-cron` (CronJob, Sundays 02:00 UTC).
|
||||||
|
- Config env: `TABLE_NAME`, `HN_USER` (default `noaoh`), `AWS_REGION`.
|
||||||
|
- Credentials: Secret `aws-dynamodb` in namespace `hackernews-favs-cron`, keys
|
||||||
|
`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`. Created by kubectl, never in git.
|
||||||
|
Scope the IAM user to `dynamodb:PutItem` on the table.
|
||||||
|
- Run once now: `kubectl create job -n hackernews-favs-cron --from=cronjob/hackernews-favs-cron manual-$(date +%s)`
|
||||||
|
- Remove the Lambda schedule once happy: `cdk destroy` in `hackernews-favs`
|
||||||
|
would also delete the table (RemovalPolicy.DESTROY), so disable the EventBridge
|
||||||
|
rule instead.
|
||||||
3
commitlint.config.js
Normal file
3
commitlint.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default {
|
||||||
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
};
|
||||||
8
eslint.config.js
Normal file
8
eslint.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ["dist/**"],
|
||||||
|
},
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
);
|
||||||
5027
package-lock.json
generated
Normal file
5027
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
34
package.json
Normal file
34
package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "hackernews-favs-cron",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Kubernetes CronJob: scrapes Hacker News favorites and writes a batch to AWS DynamoDB",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"dev": "tsx src/main.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"lint": "eslint . && tsc --noEmit",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-dynamodb": "^3.1141.0",
|
||||||
|
"@aws-sdk/lib-dynamodb": "^3.1141.0",
|
||||||
|
"cheerio": "^1.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^19.5.0",
|
||||||
|
"@commitlint/config-conventional": "^19.5.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"eslint": "^9.13.0",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"tsx": "^4.20.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"typescript-eslint": "^8.15.0",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/favorites.test.ts
Normal file
42
src/favorites.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseFavorites } from "./favorites.js";
|
||||||
|
|
||||||
|
const row = (id: number) => `
|
||||||
|
<tr class="athing submission" id="${id}">
|
||||||
|
<td class="title"><span class="titleline"><a href="https://example.com/${id}">Title ${id}</a>
|
||||||
|
<span class="sitebit comhead">(<a href="from?site=example.com"><span class="sitestr">example.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td class="subtext"><span class="subline">
|
||||||
|
<span class="score">${id} points</span> by <a class="hnuser">alice</a>
|
||||||
|
<span class="age"><a href="item?id=${id}">3 hours ago</a></span>
|
||||||
|
| <a href="item?id=${id}">${id} comments</a></span></td></tr>`;
|
||||||
|
|
||||||
|
const page = (n: number) =>
|
||||||
|
`<table>${Array.from({ length: n }, (_, i) => row(i + 1)).join("")}</table>`;
|
||||||
|
|
||||||
|
describe("parseFavorites", () => {
|
||||||
|
it("extracts every field of a submission", () => {
|
||||||
|
expect(parseFavorites(page(1))[0]).toEqual({
|
||||||
|
id: "1",
|
||||||
|
title: "Title 1",
|
||||||
|
url: "https://example.com/1",
|
||||||
|
site: "example.com",
|
||||||
|
score: 1,
|
||||||
|
by: "alice",
|
||||||
|
age: "3 hours ago",
|
||||||
|
itemUrl: "https://news.ycombinator.com/item?id=1",
|
||||||
|
comments: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the result at the limit (10 by default)", () => {
|
||||||
|
expect(parseFavorites(page(15))).toHaveLength(10);
|
||||||
|
expect(parseFavorites(page(15), 3)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for a page without submissions", () => {
|
||||||
|
expect(parseFavorites("<html><body>rate limited</body></html>")).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/favorites.ts
Normal file
63
src/favorites.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { load } from "cheerio";
|
||||||
|
|
||||||
|
export interface Favorite {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
site: string | null;
|
||||||
|
score: number | null;
|
||||||
|
by: string;
|
||||||
|
age: string;
|
||||||
|
itemUrl: string;
|
||||||
|
comments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HN = "https://news.ycombinator.com";
|
||||||
|
|
||||||
|
/** Parse the HTML of an HN /favorites page into at most `limit` favorites. */
|
||||||
|
export function parseFavorites(html: string, limit = 10): Favorite[] {
|
||||||
|
const $ = load(html);
|
||||||
|
return $("tr.athing.submission")
|
||||||
|
.map((_, el): Favorite => {
|
||||||
|
const id = $(el).attr("id") || "";
|
||||||
|
const titleAnchor = $(el).find("span.titleline > a").first();
|
||||||
|
const detailsRow = $(el).next();
|
||||||
|
|
||||||
|
const scoreMatch = detailsRow.find(".subtext .score").text().match(/\d+/);
|
||||||
|
const ageLink = detailsRow.find(".subtext .age > a");
|
||||||
|
const itemPath = ageLink.attr("href") || "";
|
||||||
|
const commentsText = detailsRow
|
||||||
|
.find('.subtext a[href^="item?id="]')
|
||||||
|
.last()
|
||||||
|
.text();
|
||||||
|
const commentsMatch = commentsText.match(/\d+/);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: titleAnchor.text(),
|
||||||
|
url: titleAnchor.attr("href") || "",
|
||||||
|
site: $(el).find(".sitebit .sitestr").text() || null,
|
||||||
|
score: scoreMatch ? Number(scoreMatch[0]) : null,
|
||||||
|
by: detailsRow.find(".subtext .hnuser").text() || "",
|
||||||
|
age: ageLink.text() || "",
|
||||||
|
itemUrl: itemPath ? `${HN}/${itemPath}` : "",
|
||||||
|
comments: commentsMatch ? Number(commentsMatch[0]) : 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.get()
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFavoritesHtml(user: string): Promise<string> {
|
||||||
|
const resp = await fetch(`${HN}/favorites?id=${encodeURIComponent(user)}`, {
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const html = await resp.text();
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`HN responded ${resp.status}: ${html.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
63
src/main.ts
Normal file
63
src/main.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||||
|
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { fetchFavoritesHtml, parseFavorites } from "./favorites.js";
|
||||||
|
|
||||||
|
// One-shot job for a Kubernetes CronJob: scrape, write one batch, exit.
|
||||||
|
// AWS credentials come from the standard AWS_* env vars (a cluster Secret).
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const tableName = process.env.TABLE_NAME;
|
||||||
|
if (!tableName) throw new Error("TABLE_NAME environment variable is not set");
|
||||||
|
const hnUser = process.env.HN_USER || "noaoh";
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({ level: "info", msg: "cron job started", hnUser }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = await fetchFavoritesHtml(hnUser);
|
||||||
|
const favorites = parseFavorites(html);
|
||||||
|
if (favorites.length === 0) {
|
||||||
|
throw new Error(`Parsed 0 favorites: ${html.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const batchId = randomUUID();
|
||||||
|
const dateKey = now.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
|
||||||
|
await docClient.send(
|
||||||
|
new PutCommand({
|
||||||
|
TableName: tableName,
|
||||||
|
Item: {
|
||||||
|
pk: `date#${dateKey}`,
|
||||||
|
sk: `batch#${batchId}`,
|
||||||
|
fetchedAt: now.toISOString(),
|
||||||
|
batchId,
|
||||||
|
count: favorites.length,
|
||||||
|
favorites,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
level: "info",
|
||||||
|
msg: "wrote batch",
|
||||||
|
batchId,
|
||||||
|
dateKey,
|
||||||
|
count: favorites.length,
|
||||||
|
table: tableName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
level: "error",
|
||||||
|
msg: "failed to fetch or write favorites",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
vitest.config.ts
Normal file
7
vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue