From f759f7a8ba4ee307cb31d0ec18d9f52e907d4573 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 11 Sep 2026 17:33:46 +0700 Subject: [PATCH] feat(newsletter): report post entry tally after each URL add --- .claude/skills/mt-add-image/SKILL.md | 2 + .claude/skills/mt-add-post/SKILL.md | 2 + .claude/skills/mt-add-url/SKILL.md | 11 ++ .../references/newsletter-post-mechanics.md | 17 +++ .claude/skills/mt-add-video/SKILL.md | 2 + AGENTS.md | 1 + scripts/newsletter/main.go | 3 + scripts/newsletter/post_stats.go | 101 ++++++++++++++++++ 8 files changed, 139 insertions(+) create mode 100644 scripts/newsletter/post_stats.go diff --git a/.claude/skills/mt-add-image/SKILL.md b/.claude/skills/mt-add-image/SKILL.md index e156260..91ba9d8 100644 --- a/.claude/skills/mt-add-image/SKILL.md +++ b/.claude/skills/mt-add-image/SKILL.md @@ -84,7 +84,9 @@ Create `### Bonus` / `**Images:**` if missing. ๐Ÿ“„ content/post/YYYY/MM/DD/index.md ๐Ÿท๏ธ label: "[label]" (source: candidate-pick | caption | user-input; post: [postTitle]) ๐Ÿ–ผ๏ธ [clean_image_url] +๐Ÿ“Š [tally] ``` +The tally comes from `post-stats` โ€” see *Post tally* in the shared post mechanics. ## Checklist diff --git a/.claude/skills/mt-add-post/SKILL.md b/.claude/skills/mt-add-post/SKILL.md index 5314ffa..3ebdf66 100644 --- a/.claude/skills/mt-add-post/SKILL.md +++ b/.claude/skills/mt-add-post/SKILL.md @@ -35,7 +35,9 @@ Trust `route: article`; skip if `duplicate` or not `accessible`. โœ… Article added to Newsletter #[number] ๐Ÿ“„ content/post/YYYY/MM/DD/index.md ๐Ÿ”— [clean_url] +๐Ÿ“Š [tally] ``` +The tally comes from `post-stats` โ€” see *Post tally* in the shared post mechanics. ## Checklist diff --git a/.claude/skills/mt-add-url/SKILL.md b/.claude/skills/mt-add-url/SKILL.md index 346484f..e95ed2f 100644 --- a/.claude/skills/mt-add-url/SKILL.md +++ b/.claude/skills/mt-add-url/SKILL.md @@ -59,10 +59,19 @@ Act on the user's choice. If they choose add/update, proceed to design that skil ### 5. Final report +Close with the target post's TL;DR tally, then the per-URL detail. Read the tally from the post itself so it reflects everything the post now holds, not just this batch: + +```bash +go run ./scripts/newsletter post-stats content/post/YYYY/MM/DD/index.md +``` + Aggregate across all URLs: ``` โœ… Newsletter URL Dispatch Complete +๐Ÿ“Š Newsletter #[number]: [articles] articles ยท [videos] videos ยท [images] images + (omit zero counts; documents too when present) + โœ… Dispatched: [count] - [count] โ†’ mt-add-post (articles) - [count] โ†’ mt-add-video (YouTube) @@ -75,6 +84,8 @@ Aggregate across all URLs: - [url] (route: [route]): [user decision] ``` +The tally is report-only โ€” never write it into `index.md`. See *Post tally* in `references/newsletter-post-mechanics.md`. + ## Notes - Handlers (`mt-add-post`, `mt-add-video`, `mt-add-image`) remain directly invocable for single-purpose use, but `mt-add-url` is the normal entry point when a user pastes a URL. 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 e86ba52..db48514 100644 --- a/.claude/skills/mt-add-url/references/newsletter-post-mechanics.md +++ b/.claude/skills/mt-add-url/references/newsletter-post-mechanics.md @@ -83,6 +83,23 @@ If the post has **no `### Bonus`** yet: ``` When a subsection (e.g. `**Videos:**`) already exists, append under it; otherwise create it. Keep subsections in this order: **Images** โ†’ **Videos** โ†’ **Documents**. +## 4a. Post tally (report line) + +After every successful insertion, report the target post's running totals so the user can see what the post now holds: + +```bash +go run ./scripts/newsletter post-stats content/post/YYYY/MM/DD/index.md +``` +Output (JSON): `{ post, newsletter, articles, images, videos, documents, total }`. + +Render it as a single TL;DR line in the handler's report, omitting zero counts: + +``` +๐Ÿ“Š 4 articles ยท 2 videos ยท 1 image +``` + +Use the counts the command returns โ€” do not tally by hand. This is a **report-only** line: never write it into `index.md`. + ## 5. Post content language guidelines These rules apply only to text written into `content/post/**/index.md`. Keep user-facing questions, status updates, reports, and final responses in English unless the user explicitly requests another language. diff --git a/.claude/skills/mt-add-video/SKILL.md b/.claude/skills/mt-add-video/SKILL.md index c658eaf..8db1a81 100644 --- a/.claude/skills/mt-add-video/SKILL.md +++ b/.claude/skills/mt-add-video/SKILL.md @@ -48,7 +48,9 @@ A clean YouTube URL (passed by `mt-add-url`, or given directly). โœ… Video added to Newsletter #[number] (Bonus โ†’ Videos) ๐Ÿ“„ content/post/YYYY/MM/DD/index.md ๐ŸŽฌ [Vietnamese title] โ€” [clean_url] + ๐Ÿ“Š [tally] ``` + The tally comes from `post-stats` โ€” see *Post tally* in the shared post mechanics. ## Checklist diff --git a/AGENTS.md b/AGENTS.md index cdf2137..2a3be6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ go run ./scripts/newsletter list-existing-tags # existing tag frequenc 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 (tier 1 of the fetch chain) +go run ./scripts/newsletter post-stats # count a post's articles/images/videos/documents ``` These are shared by all three tools โ€” no tool-specific copies. diff --git a/scripts/newsletter/main.go b/scripts/newsletter/main.go index ea03cc7..b49941d 100644 --- a/scripts/newsletter/main.go +++ b/scripts/newsletter/main.go @@ -23,6 +23,7 @@ Commands: detect-image-source detect Substack image + uuid find-substack-post --uuid [--deep] find the post embedding an image uuid fetch-via-defuddle fallback fetch via defuddle.md proxy + post-stats count the post's articles/images/videos/documents `) } @@ -57,6 +58,8 @@ func main() { runFindSubstackPost(args) case "fetch-via-defuddle": runFetchViaDefuddle(args) + case "post-stats": + runPostStats(args) default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) usage() diff --git a/scripts/newsletter/post_stats.go b/scripts/newsletter/post_stats.go new file mode 100644 index 0000000..ab42d3b --- /dev/null +++ b/scripts/newsletter/post_stats.go @@ -0,0 +1,101 @@ +// Count the entries already present in a newsletter post, so a handler can +// report a running tally after each insertion. +// Usage: go run ./scripts/newsletter post-stats +// Outputs: JSON { post, newsletter, articles, images, videos, documents, total } +package main + +import ( + "fmt" + "os" + "regexp" + "strconv" + "strings" +) + +// Entry shapes, per the Bonus format in the shared post mechanics: +// +// articles "## [Title](url)" (level-2 heading, main content) +// images "![label](url)" (under **Images:**) +// videos "[Title](url)" (under **Videos:**) +// documents "[PDF: title](url)" (under **Documents:**) +var ( + articleHeadingRe = regexp.MustCompile(`^##\s+\[`) + bonusHeadingRe = regexp.MustCompile(`^###\s+Bonus\b`) + subsectionRe = regexp.MustCompile(`^\*\*(Images|Videos|Documents):\*\*`) + imageEntryRe = regexp.MustCompile(`^!\[`) + linkEntryRe = regexp.MustCompile(`^\[`) +) + +type postStats struct { + Post string `json:"post"` + Newsletter int `json:"newsletter"` + Articles int `json:"articles"` + Images int `json:"images"` + Videos int `json:"videos"` + Documents int `json:"documents"` + Total int `json:"total"` +} + +// countPostEntries walks the post once. Article headings are counted anywhere +// outside Bonus; asset entries are attributed to whichever subsection is open. +func countPostEntries(content string) postStats { + var s postStats + inBonus := false + subsection := "" + + for _, raw := range strings.Split(content, "\n") { + line := strings.TrimSpace(raw) + switch { + case bonusHeadingRe.MatchString(line): + inBonus = true + subsection = "" + continue + case subsectionRe.MatchString(line): + subsection = subsectionRe.FindStringSubmatch(line)[1] + continue + case articleHeadingRe.MatchString(line): + s.Articles++ + continue + } + if !inBonus { + continue + } + switch subsection { + case "Images": + if imageEntryRe.MatchString(line) { + s.Images++ + } + case "Videos": + // A direct video file entry looks the same as a YouTube entry; + // both belong to the Videos tally. + if linkEntryRe.MatchString(line) { + s.Videos++ + } + case "Documents": + if linkEntryRe.MatchString(line) { + s.Documents++ + } + } + } + s.Total = s.Articles + s.Images + s.Videos + s.Documents + return s +} + +func runPostStats(args []string) { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "usage: post-stats ") + os.Exit(1) + } + path := args[0] + content, err := os.ReadFile(path) + if err != nil { + fmt.Fprintln(os.Stderr, "read post:", err) + os.Exit(1) + } + stats := countPostEntries(string(content)) + stats.Post = path + if m := newsletterNumRe.FindSubmatch(content); m != nil { + stats.Newsletter, _ = strconv.Atoi(string(m[1])) + } + printJSON(stats) +}