From a44d13a437ee9997f147fdb0b6eb5a9e85c9f63f Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 18 Aug 2026 21:23:05 +0700 Subject: [PATCH] refactor(scripts): migrate newsletter engine from Node to Go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Go binary with a subcommand per former script, invoked as 'go run ./scripts/newsletter ' from repo root. Stdlib only; publications config embedded via go:embed (go run recompiles on edit). Behavior parity verified side-by-side before deleting the JS — see plans/reports/parity-260818-newsletter-go-migration-report.md. Permissions: allow 'Bash(go *)', drop 'Bash(node *)' and the redundant 'Write(content)' rule (Edit rules cover all file-editing tools). --- .claude/settings.json | 3 +- .claude/skills/mt-add-image/SKILL.md | 8 +- .claude/skills/mt-add-post/SKILL.md | 2 +- .claude/skills/mt-add-tags/SKILL.md | 2 +- .claude/skills/mt-add-url/SKILL.md | 2 +- .../references/newsletter-post-mechanics.md | 2 +- .claude/skills/mt-add-video/SKILL.md | 2 +- .claude/skills/mt-webfetch/SKILL.md | 4 +- AGENTS.md | 14 +- README.md | 2 +- docs/multi-tool-usage.md | 4 +- go.mod | 3 + .../phase-01-port-to-go.md | 64 ++++ .../phase-02-parity-verification.md | 38 +++ .../phase-03-cutover-and-docs.md | 41 +++ .../plan.md | 36 +++ ...y-260818-newsletter-go-migration-report.md | 36 +++ scripts/newsletter/add-url.js | 112 ------- scripts/newsletter/add_url.go | 138 +++++++++ scripts/newsletter/detect-image-source.js | 43 --- scripts/newsletter/detect_image_source.go | 63 ++++ scripts/newsletter/fetch-via-defuddle.js | 37 --- scripts/newsletter/fetch_via_defuddle.go | 51 ++++ scripts/newsletter/find-newsletter-number.js | 74 ----- scripts/newsletter/find-substack-post.js | 151 ---------- scripts/newsletter/find_newsletter_number.go | 77 +++++ scripts/newsletter/find_substack_post.go | 202 +++++++++++++ scripts/newsletter/html-text-utils.js | 91 ------ scripts/newsletter/html_text.go | 116 ++++++++ scripts/newsletter/list-existing-tags.js | 65 ----- scripts/newsletter/list_existing_tags.go | 75 +++++ scripts/newsletter/main.go | 65 +++++ scripts/newsletter/url-utils.js | 166 ----------- scripts/newsletter/url_utils.go | 276 ++++++++++++++++++ 34 files changed, 1303 insertions(+), 762 deletions(-) create mode 100644 go.mod create mode 100644 plans/260818-2056-migrate-newsletter-scripts-to-go/phase-01-port-to-go.md create mode 100644 plans/260818-2056-migrate-newsletter-scripts-to-go/phase-02-parity-verification.md create mode 100644 plans/260818-2056-migrate-newsletter-scripts-to-go/phase-03-cutover-and-docs.md create mode 100644 plans/260818-2056-migrate-newsletter-scripts-to-go/plan.md create mode 100644 plans/reports/parity-260818-newsletter-go-migration-report.md delete mode 100644 scripts/newsletter/add-url.js create mode 100644 scripts/newsletter/add_url.go delete mode 100644 scripts/newsletter/detect-image-source.js create mode 100644 scripts/newsletter/detect_image_source.go delete mode 100644 scripts/newsletter/fetch-via-defuddle.js create mode 100644 scripts/newsletter/fetch_via_defuddle.go delete mode 100644 scripts/newsletter/find-newsletter-number.js delete mode 100644 scripts/newsletter/find-substack-post.js create mode 100644 scripts/newsletter/find_newsletter_number.go create mode 100644 scripts/newsletter/find_substack_post.go delete mode 100644 scripts/newsletter/html-text-utils.js create mode 100644 scripts/newsletter/html_text.go delete mode 100644 scripts/newsletter/list-existing-tags.js create mode 100644 scripts/newsletter/list_existing_tags.go create mode 100644 scripts/newsletter/main.go delete mode 100644 scripts/newsletter/url-utils.js create mode 100644 scripts/newsletter/url_utils.go diff --git a/.claude/settings.json b/.claude/settings.json index 46f7ba6..0075713 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,10 +10,10 @@ "Bash(find *)", "Bash(git add *)", "Bash(git commit *)", + "Bash(go *)", "Bash(ls *)", "Bash(mkdir *)", "Bash(mv *)", - "Bash(node *)", "Bash(npm install *)", "Bash(pip install *)", "Bash(pip3 install *)", @@ -27,7 +27,6 @@ "Skill(update-config)", "WebFetch(*)", "WebSearch(*)", - "Write(content)", "mcp__acp__Bash", "mcp__acp__Edit", "mcp__acp__Write", diff --git a/.claude/skills/mt-add-image/SKILL.md b/.claude/skills/mt-add-image/SKILL.md index 67aafec..3d3237b 100644 --- a/.claude/skills/mt-add-image/SKILL.md +++ b/.claude/skills/mt-add-image/SKILL.md @@ -21,24 +21,24 @@ A clean image URL (passed by `mt-add-url`, or given directly). ### 1. Detect source ```bash -node scripts/newsletter/detect-image-source.js "" +go run ./scripts/newsletter detect-image-source "" ``` → `{ original_url, clean_url, isSubstack, uuid?, innerUrl? }`. When invoked **directly** (not via `mt-add-url`), first run the router to get accessibility + duplicate status and skip accordingly: ```bash -node scripts/newsletter/add-url.js "" # expect route:image; skip if duplicate/!accessible +go run ./scripts/newsletter add-url "" # expect route:image; skip if duplicate/!accessible ``` (When dispatched by `mt-add-url`, that check already ran — don't repeat it.) ### 2a. Substack image (`isSubstack: true` with `uuid`) Find the source post: ```bash -node scripts/newsletter/find-substack-post.js --uuid +go run ./scripts/newsletter find-substack-post --uuid ``` - `found: false` → retry with the deeper sitemap crawl (slower — fetches posts ~3 months back, capped at 40 fetches total across all publications; warn the user it may take a while): ```bash - node scripts/newsletter/find-substack-post.js --uuid --deep + go run ./scripts/newsletter find-substack-post --uuid --deep ``` On a miss the result reports `scanned` (posts fetched), `budget` (the 40-fetch cap), and `cutoff` (oldest date looked at) — mention how far back it looked. - `found: false` after `--deep` → no source post; go to step 3 (ask) and/or step 4 (add publication). diff --git a/.claude/skills/mt-add-post/SKILL.md b/.claude/skills/mt-add-post/SKILL.md index 44ffc1b..7b69005 100644 --- a/.claude/skills/mt-add-post/SKILL.md +++ b/.claude/skills/mt-add-post/SKILL.md @@ -13,7 +13,7 @@ Shared scripts: `scripts/newsletter/`. Shared procedure: `../mt-add-url/referenc A clean article URL (passed by `mt-add-url`, or given directly). If a raw URL is provided directly, you may run the classifier to clean/dedup it first: ```bash -node scripts/newsletter/add-url.js "" +go run ./scripts/newsletter add-url "" ``` Trust `route: article`; skip if `duplicate` or not `accessible`. diff --git a/.claude/skills/mt-add-tags/SKILL.md b/.claude/skills/mt-add-tags/SKILL.md index 99a1d0c..7eeec55 100644 --- a/.claude/skills/mt-add-tags/SKILL.md +++ b/.claude/skills/mt-add-tags/SKILL.md @@ -50,7 +50,7 @@ Read the full post body. Identify: Before generating, run: ```bash -node scripts/newsletter/list-existing-tags.js +go run ./scripts/newsletter list-existing-tags ``` When a proposed tag matches an existing one case-insensitively, use the existing casing. --> diff --git a/.claude/skills/mt-add-url/SKILL.md b/.claude/skills/mt-add-url/SKILL.md index 04d964e..2e02b5f 100644 --- a/.claude/skills/mt-add-url/SKILL.md +++ b/.claude/skills/mt-add-url/SKILL.md @@ -20,7 +20,7 @@ Everything else (direct `video` file, `document`, or anything unrecognized) is * For every URL the user provides: ```bash -node scripts/newsletter/add-url.js "" +go run ./scripts/newsletter add-url "" ``` Output (JSON): `{ original_url, clean_url, http_status, accessible, duplicate, route, title?, author? }`. diff --git a/.claude/skills/mt-add-url/references/newsletter-post-mechanics.md b/.claude/skills/mt-add-url/references/newsletter-post-mechanics.md index 4a2af82..a4e931d 100644 --- a/.claude/skills/mt-add-url/references/newsletter-post-mechanics.md +++ b/.claude/skills/mt-add-url/references/newsletter-post-mechanics.md @@ -18,7 +18,7 @@ Get current date in `YYYY-MM-DD` (UTC+7). Check `content/post/YYYY/MM/DD/index.m ## 2. Newsletter number ```bash -node scripts/newsletter/find-newsletter-number.js +go run ./scripts/newsletter find-newsletter-number ``` Searches backwards from today for the most recent newsletter and returns the next number. Only needed when **creating** a new post. diff --git a/.claude/skills/mt-add-video/SKILL.md b/.claude/skills/mt-add-video/SKILL.md index 41a38a4..c07632a 100644 --- a/.claude/skills/mt-add-video/SKILL.md +++ b/.claude/skills/mt-add-video/SKILL.md @@ -19,7 +19,7 @@ A clean YouTube URL (passed by `mt-add-url`, or given directly). 1. **Classify / fetch title** — run the router to get the canonical URL + title: ```bash - node scripts/newsletter/add-url.js "" + go run ./scripts/newsletter add-url "" ``` Confirm `route: youtube`; skip if `duplicate` or not `accessible`. Use the returned `clean_url` (canonical `watch?v=ID`) and `title`. - If `title` is missing (oEmbed failed), fetch the title via WebFetch on the watch URL. diff --git a/.claude/skills/mt-webfetch/SKILL.md b/.claude/skills/mt-webfetch/SKILL.md index 553852e..455348b 100644 --- a/.claude/skills/mt-webfetch/SKILL.md +++ b/.claude/skills/mt-webfetch/SKILL.md @@ -28,7 +28,7 @@ Use this skill only after a WebFetch attempt returned one of: 1. Confirm WebFetch already failed on the target URL 2. Run the fetch script: ```bash - node scripts/newsletter/fetch-via-defuddle.js "" + go run ./scripts/newsletter fetch-via-defuddle "" ``` Alternatively, use WebFetch with the defuddle-prefixed URL: ``` @@ -70,7 +70,7 @@ Never loop. Never retry more than once. User wanted to extract content from https://example.com/article WebFetch returned: "Request failed with status code 403" → Trigger mt-webfetch -→ node scripts/newsletter/fetch-via-defuddle.js "https://example.com/article" +→ go run ./scripts/newsletter fetch-via-defuddle "https://example.com/article" → Parse markdown output → Summarize as usual ``` diff --git a/AGENTS.md b/AGENTS.md index ef8dc2b..a8037f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,15 +39,15 @@ The site will be available at `http://localhost:1313` ## Shared Engine -All portable newsletter scripts live in **`scripts/newsletter/`** and are invoked from the repo root with plain Node (stdlib only, no deps): +The portable newsletter engine lives in **`scripts/newsletter/`** (Go, stdlib only, no deps — one binary, one subcommand per task) and is invoked from the repo root with `go run`: ```bash -node scripts/newsletter/add-url.js "" # classify + dedup a URL → JSON route -node scripts/newsletter/find-newsletter-number.js # next newsletter number -node scripts/newsletter/list-existing-tags.js # existing tag frequencies -node scripts/newsletter/detect-image-source.js "" # detect Substack image + uuid -node scripts/newsletter/find-substack-post.js --uuid -node scripts/newsletter/fetch-via-defuddle.js "" # fallback fetch (defuddle proxy) +go run ./scripts/newsletter add-url "" # classify + dedup a URL → JSON route +go run ./scripts/newsletter find-newsletter-number # next newsletter number +go run ./scripts/newsletter list-existing-tags # existing tag frequencies +go run ./scripts/newsletter detect-image-source "" # detect Substack image + uuid +go run ./scripts/newsletter find-substack-post --uuid +go run ./scripts/newsletter fetch-via-defuddle "" # fallback fetch (defuddle proxy) ``` These are shared by all three tools — no tool-specific copies. diff --git a/README.md b/README.md index 5d5acf0..007c4f4 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The Stack theme is pulled in as a git submodule under `themes/hugo-theme-stack/` ## Working with AI tools -This repo runs from Claude Code, OpenCode, or Codex off one shared script engine (`scripts/newsletter/`) and one instruction file (`AGENTS.md`). Repository-scoped Codex skills use the official `.agents/skills/` format; no installer is required. Setup, invocation per tool, and how to pick one and remove the rest: see [docs/multi-tool-usage.md](docs/multi-tool-usage.md). +This repo runs from Claude Code, OpenCode, or Codex off one shared engine (`scripts/newsletter/`, Go via `go run`) and one instruction file (`AGENTS.md`). Repository-scoped Codex skills use the official `.agents/skills/` format; no installer is required. Setup, invocation per tool, and how to pick one and remove the rest: see [docs/multi-tool-usage.md](docs/multi-tool-usage.md). ## License diff --git a/docs/multi-tool-usage.md b/docs/multi-tool-usage.md index 75e0592..f812422 100644 --- a/docs/multi-tool-usage.md +++ b/docs/multi-tool-usage.md @@ -1,6 +1,6 @@ # Multi-tool usage (Claude Code · OpenCode · Codex) -This repo is usable from three AI coding tools off **one shared engine**. The newsletter scripts live in `scripts/newsletter/` and every tool calls them as `node scripts/newsletter/*.js` from the repo root. Project instructions live once in `AGENTS.md`. +This repo is usable from three AI coding tools off **one shared engine**. The newsletter engine lives in `scripts/newsletter/` (Go, stdlib only) and every tool calls it as `go run ./scripts/newsletter ` from the repo root. Project instructions live once in `AGENTS.md`. ## Per-tool setup & invocation @@ -10,7 +10,7 @@ This repo is usable from three AI coding tools off **one shared engine**. The ne | **OpenCode** | `AGENTS.md` (auto-read) | `.claude/skills/` auto-discovered via the `skill` tool, governed by `opencode.json` | None beyond `opencode.json` (committed) | Ask to add a URL; pick/`skill` `mt-add-url` | | **Codex** | `AGENTS.md` (auto-read) | Repository skills discovered from `.agents/skills/` | None (works as-is) | Ask to add a URL or invoke `$mt-add-url` | -**Shared engine:** all three call `node scripts/newsletter/*.js` from repo root — no per-tool script copies. +**Shared engine:** all three call `go run ./scripts/newsletter ` from repo root — no per-tool script copies. ### Notes per tool diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dbc45c1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/tiennm99/miti99 + +go 1.26 diff --git a/plans/260818-2056-migrate-newsletter-scripts-to-go/phase-01-port-to-go.md b/plans/260818-2056-migrate-newsletter-scripts-to-go/phase-01-port-to-go.md new file mode 100644 index 0000000..215241e --- /dev/null +++ b/plans/260818-2056-migrate-newsletter-scripts-to-go/phase-01-port-to-go.md @@ -0,0 +1,64 @@ +# Phase 1 — Port to Go + +## Context + +- Source: `scripts/newsletter/*.js` (8 files, ~740 LoC, Node stdlib only, CommonJS). +- Read first: all JS files, `AGENTS.md` (Shared Engine section), `docs/multi-tool-usage.md`. +- Go 1.26.5 available (linux/arm64). No existing `go.mod` anywhere in the repo. + +## Files to Create + +``` +go.mod module github.com/tiennm99/miti99, go 1.26 +scripts/newsletter/main.go subcommand dispatch + usage text +scripts/newsletter/url_utils.go cleanUrl, bareUrl, isSubstackImage, substackImageUuid, + httpGet/httpHead helpers, checkAccessibility, + checkDuplicate, classifyType, collectMarkdown +scripts/newsletter/html_text.go stripTags, itemTitle, itemLink, extractCandidates, + captionForUuid, postTitleFromHtml +scripts/newsletter/add_url.go add-url: YouTube detect, oEmbed meta, route JSON +scripts/newsletter/detect_image_source.go detect-image-source: CDN unwrap + uuid JSON +scripts/newsletter/find_substack_post.go find-substack-post: RSS search, --deep sitemap crawl +scripts/newsletter/find_newsletter_number.go find-newsletter-number: scan YYYY/MM/DD, print max+1 +scripts/newsletter/list_existing_tags.go list-existing-tags: frontmatter tag frequency top-40 +scripts/newsletter/fetch_via_defuddle.go fetch-via-defuddle: proxy fetch, exit codes 0/1/2 +``` + +`scripts/newsletter/config/substack-publications.json` — unchanged, loaded via `go:embed` (fallback `["blog.bytebytego.com"]` on parse error, matching JS). + +All files are one `package main`; no `internal/` packaging (KISS — this is a script bundle, not a library). + +## Implementation Steps + +1. `go.mod` at repo root; verify `hugo` build still works untouched (it should — no `[module]` config in use). +2. `main.go`: `os.Args[1]` switch → handler funcs; unknown/missing subcommand prints usage to stderr, exit 1. +3. Port `url-utils.js` → `url_utils.go`. See parity traps below — this file has all the hard ones. +4. Port `html-text-utils.js` → `html_text.go`. Use `html.UnescapeString` (stdlib) instead of hand-rolled `decodeEntities` — strictly more complete, acceptable improvement. +5. Port the 6 entrypoints. JSON output via structs + `json.MarshalIndent(v, "", " ")` with field order matching the JS key order; optional fields (`title`, `author`, `uuid`, `innerUrl`, `caption`…) use `omitempty` to reproduce JS's conditional key emission. **Skills parse these JSON shapes — field names are a public contract.** +6. `gofmt` + `go vet ./scripts/newsletter/`. + +## Parity Traps (must handle explicitly) + +| # | JS behavior | Go trap | Resolution | +|---|-------------|---------|------------| +| 1 | `checkDuplicate` boundary regexes use negative lookahead `(?![0-9a-f])` | RE2 has **no lookahead** | Scan `strings.Index` occurrences of the needle; check the following byte(s) in code: uuid → next char ∉ `[0-9a-f]`; URL → optional `/` then one of `)]"'?#<_&,` whitespace or end | +| 2 | `cleanUrl` rebuilds query via `URLSearchParams` preserving insertion order | `url.Values.Encode()` **sorts keys alphabetically** | Split `RawQuery` on `&`, drop `utm_*`/tracker keys (case-insensitive match on the key before `=`), rejoin the surviving pairs verbatim. Closer to "don't corrupt" than JS re-encoding; document any percent-encoding drift in phase 2 | +| 3 | `new URL()` lowercases host in output | Go keeps host casing as parsed | Lowercase host manually when rebuilding (`clean_url`, `bareUrl`) | +| 4 | `[\s\S]*?` in figure/caption/candidate regexes | Go `.` excludes newline by default | Use `(?s)` flag | +| 5 | `new Date(lastmod)` accepts RFC3339 and date-only | `time.Parse` needs explicit layouts | Try `time.RFC3339`, then `"2006-01-02"`; unparseable → skip entry (JS `isNaN` path) | +| 6 | `fetch` HEAD, 10s timeout, follows redirects; network error → status `"000"` | — | `http.Client{Timeout: 10s}` (follows redirects by default); any error → `"000"` | +| 7 | defuddle: 30s timeout, UA `mt-webfetch/1.0`, exit 0/1/2, raw body to stdout, diagnostics to stderr | — | Same client pattern; `os.Exit` codes identical | +| 8 | `--uuid --deep` flag parsing | — | stdlib `flag` on a subcommand FlagSet (`--uuid v` and `--uuid=v` both accepted); missing uuid → usage + exit 1 | +| 9 | uuid/substack regexes are case-insensitive | — | `(?i)` prefix | +| 10 | oEmbed URL built with `encodeURIComponent` | — | `url.QueryEscape` | +| 11 | Deep-crawl shared budget `DEEP_FETCH_BUDGET = 40` across publications; miss JSON includes `scanned`, `budget`, `cutoff` (ISO date) | — | Same constant, same miss shape; cutoff `time.Format("2006-01-02")` | +| 12 | `classifyType` extension regexes allow trailing `?query` | — | Port regex as-is (RE2-safe) | + +## Validation + +- `go vet` clean; each subcommand runs without args → same usage/exit behavior as its JS counterpart. +- Spot-run each subcommand once (real output sanity, not yet full parity — that is phase 2). + +## Risks / Rollback + +- No call sites change in this phase; JS remains the live engine. Rollback = delete the `.go` files and `go.mod`. diff --git a/plans/260818-2056-migrate-newsletter-scripts-to-go/phase-02-parity-verification.md b/plans/260818-2056-migrate-newsletter-scripts-to-go/phase-02-parity-verification.md new file mode 100644 index 0000000..4f0b724 --- /dev/null +++ b/plans/260818-2056-migrate-newsletter-scripts-to-go/phase-02-parity-verification.md @@ -0,0 +1,38 @@ +# Phase 2 — Parity Verification (JS vs Go side-by-side) + +## Context + +Both engines coexist. For each case below, run `node scripts/newsletter/