diff --git a/AGENTS.md b/AGENTS.md index 5436c3a..a5e50bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Structure & Module Organization -AsyncTalk is an Astro content site for a Chinese web development podcast. Source code lives in `src/`: pages in `src/pages`, layouts in `src/layouts`, shared UI in `src/components`, global styles in `src/global.css`, and content configuration in `src/content.config.ts`. Podcast episodes are MDX files in `src/content/posts` using names such as `ep59.mdx`. Episode media belongs under `src/assets//` when imported by Astro; public static files live in `public/`. +AsyncTalk is an Astro content site for a Chinese web development podcast. Source code lives in `src/`: pages in `src/pages`, layouts in `src/layouts`, shared UI in `src/components`, global styles in `src/global.css`, and content configuration in `src/content.config.ts`. Podcast episodes are MDX files in `src/content/posts` using names such as `ep59.mdx`. Episode media belongs under `src/assets//` when imported by Astro; public static files live in `public/`. Two pieces of episode media are resolved by path convention rather than frontmatter, so they appear as soon as the file lands and are skipped while it is missing: square cover art at `src/assets//cover.{jpg,jpeg,png,webp}` and subtitles at `public/subtitles/.srt`. ## Build, Test, and Development Commands diff --git a/CLAUDE.md b/CLAUDE.md index 1e14128..4898484 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ This is a content-driven static site using Astro's content collections system: - **No Testing Framework**: This project currently has no automated tests. When implementing features, manual testing is required. - **Content Management**: New episodes should be added as MDX files in `src/content/posts/` following the existing naming pattern (epX.mdx). +- **Episode Media**: Cover art and subtitles are declared in frontmatter, both optional. `cover` is a path relative to the MDX file (e.g. `../../assets/ep73/cover.png`), resolved by the schema's `image()` helper; it renders beside the title at its natural aspect ratio and feeds JSON-LD `image` plus the RSS ``. `srt` is a path under `public/` (e.g. `/subtitles/ep73.srt`), parsed at build time into an on-page 文字稿 section, a download link, and JSON-LD `transcript` — a declared file that is missing fails the build. - **Build Process**: Always run `pnpm astro check` before building to catch TypeScript errors in Astro components. - **Episode Metadata**: Ensure all required frontmatter fields are properly filled when creating new episodes to maintain RSS feed compatibility. - **Chinese Language**: All content and UI copy should be in Chinese. Pay attention to proper font rendering and character encoding. \ No newline at end of file diff --git a/public/subtitles/ep73.srt b/public/subtitles/ep73.srt new file mode 100644 index 0000000..c28c5a3 --- /dev/null +++ b/public/subtitles/ep73.srt @@ -0,0 +1,600 @@ +1 +00:00:00,000 --> 00:00:00,266 +大家好 + +2 +00:00:00,333 --> 00:00:01,466 +这里是 AsyncTalk + +3 +00:00:01,466 --> 00:00:02,766 +今天我想跟大家来聊 + +4 +00:00:02,766 --> 00:00:04,066 +一个比较有趣的项目 + +5 +00:00:04,133 --> 00:00:05,600 +它实际上是一个日志服务 + +6 +00:00:05,633 --> 00:00:07,900 +这个日志服务其实 callback 到我们前面 + +7 +00:00:07,900 --> 00:00:11,700 +关于如何做正确的日志的一个节目 + +8 +00:00:11,766 --> 00:00:14,266 +我相信大家在自己的一些项目中 + +9 +00:00:14,333 --> 00:00:15,433 +假设说你是一个后端 + +10 +00:00:15,466 --> 00:00:18,533 +你肯定是要给自己的项目打一些日志的 + +11 +00:00:18,600 --> 00:00:20,400 +那这些日志它有助于 + +12 +00:00:20,400 --> 00:00:22,033 +你在将来去做一些 debug + +13 +00:00:22,066 --> 00:00:23,533 +做一些任务的追踪 + +14 +00:00:23,600 --> 00:00:24,833 +做一些各种各样的情况 + +15 +00:00:24,866 --> 00:00:26,200 +那这个日志的重要性 + +16 +00:00:26,200 --> 00:00:28,033 +大家绝对都已经了解到了 + +17 +00:00:28,066 --> 00:00:30,366 +但是对于如何做好日志这件事情 + +18 +00:00:30,433 --> 00:00:34,066 +我也相信这个吵了 20 多年的一个话题 + +19 +00:00:34,133 --> 00:00:37,633 +到今天仍然没有一个很好的解答 + +20 +00:00:37,666 --> 00:00:41,266 +到今天仍然是一个很难处理的问题 + +21 +00:00:41,333 --> 00:00:43,166 +今天我觉得我可以分享一个 + +22 +00:00:43,166 --> 00:00:45,866 +关于我自己在用的 log 服务 + +23 +00:00:45,900 --> 00:00:47,133 +我觉得它很好 + +24 +00:00:47,200 --> 00:00:49,466 +也非常的 AI 时代 + +25 +00:00:49,500 --> 00:00:50,800 +那就是 Axiom + +26 +00:00:50,866 --> 00:00:51,566 +Axiom 这个项目 + +27 +00:00:51,633 --> 00:00:53,600 +它其实就是一个日志 + +28 +00:00:53,633 --> 00:00:55,566 +那我可以和大家去 share 一下 + +29 +00:00:55,566 --> 00:00:56,900 +这个 Axiom 的项目 + +30 +00:00:56,966 --> 00:00:58,100 +可以在这里边看到 + +31 +00:00:58,100 --> 00:01:00,400 +我的一个日志的情况 + +32 +00:01:00,433 --> 00:01:02,266 +这边比如说它打了一个 + +33 +00:01:02,266 --> 00:01:04,500 +在这个 component 那个 info + +34 +00:01:04,500 --> 00:01:07,100 +以及 start CC event cost enqueue + +35 +00:01:07,100 --> 00:01:07,466 + 这样一个事件 + +36 +00:01:07,500 --> 00:01:09,266 +那里边有一些什么 user ID + +37 +00:01:09,266 --> 00:01:10,566 +以及这些参数 + +38 +00:01:10,633 --> 00:01:11,933 +可以看到这边其实 + +39 +00:01:11,933 --> 00:01:13,633 +还是有蛮多的一些内容的 + +40 +00:01:13,666 --> 00:01:15,733 +那这个日志其实就是通过 + +41 +00:01:15,733 --> 00:01:17,933 +我部署的服务那边采集过来的 + +42 +00:01:18,000 --> 00:01:19,000 +但我的部署的服务化 + +43 +00:01:19,033 --> 00:01:20,600 +它是支持的接口 + +44 +00:01:20,633 --> 00:01:23,133 +会把一些 tracing 的 task 数据 + +45 +00:01:23,133 --> 00:01:24,200 +给传到服务上面 + +46 +00:01:24,233 --> 00:01:25,400 +同样这个 Axiom + +47 +00:01:25,400 --> 00:01:27,066 +它来接收我们的日志 + +48 +00:01:27,100 --> 00:01:27,933 +然后接收到日志 + +49 +00:01:28,000 --> 00:01:29,933 +我们就可以在这个后台看到这个日志 + +50 +00:01:29,933 --> 00:01:31,866 +以一种比较优美的方案 + +51 +00:01:31,900 --> 00:01:33,200 +因为它这个日志搜索 + +52 +00:01:33,200 --> 00:01:35,066 +我自己试过还是相当不错的 + +53 +00:01:35,100 --> 00:01:37,800 +这个界面我只能说我之前没有用到过 + +54 +00:01:37,800 --> 00:01:40,366 +这么漂亮的一个日志管理平台 + +55 +00:01:41,533 --> 00:01:43,300 +Kibana 还挺好用的 + +56 +00:01:43,333 --> 00:01:45,000 +但是 Kibana 你得自己部署 + +57 +00:01:45,000 --> 00:01:46,300 +是不是也挺麻烦的 + +58 +00:01:46,333 --> 00:01:48,666 +你还要部署 ES 之类的东西 + +59 +00:01:48,666 --> 00:01:49,966 +当这个量级大的时候 + +60 +00:01:50,033 --> 00:01:52,066 +这个搜索也会成问题 + +61 +00:01:52,133 --> 00:01:54,900 +但是 Axiom 它的项目并不在于搜索 + +62 +00:01:54,933 --> 00:01:55,833 +你可以看到上面 + +63 +00:01:55,833 --> 00:01:57,400 +它是有很多的 query + +64 +00:01:57,433 --> 00:01:58,833 +你可以通过 query 的形式 + +65 +00:01:58,833 --> 00:02:00,400 +来去拉一些数据 + +66 +00:02:00,433 --> 00:02:02,666 +那也可以自己建 dashboard + +67 +00:02:02,733 --> 00:02:03,966 +甚至也可以做 monitor + +68 +00:02:04,000 --> 00:02:05,066 +当然这个 data set + +69 +00:02:05,066 --> 00:02:07,433 +就是我的两个服务 + +70 +00:02:07,466 --> 00:02:09,033 +我这些服务我觉得到这里 + +71 +00:02:09,033 --> 00:02:10,100 +只能把它当做 + +72 +00:02:10,100 --> 00:02:11,733 +一个普通的日志服务来看 + +73 +00:02:11,800 --> 00:02:13,766 +但是 现在是 AI 时代 + +74 +00:02:13,800 --> 00:02:15,633 +可以去尝试看一下这个 MCP + +75 +00:02:15,666 --> 00:02:18,033 +它就变得非常的有趣了 + +76 +00:02:18,066 --> 00:02:19,833 +在这个 MCP 集成的情况下 + +77 +00:02:19,866 --> 00:02:21,600 +你就可以去通过 + +78 +00:02:21,600 --> 00:02:23,066 +比如你自己的 Claude + +79 +00:02:23,066 --> 00:02:23,766 + + +80 +00:02:27,800 --> 00:02:29,733 +对 你就可以拿自然语言来 + +81 +00:02:29,733 --> 00:02:32,366 +跟它说你的日志有没有什么问题 + +82 +00:02:32,433 --> 00:02:33,666 +让它来去处理 + +83 +00:02:33,700 --> 00:02:35,500 +可以看到它接上这个 MCP 之后 + +84 +00:02:35,533 --> 00:02:37,733 +它就可以去拿你的 coding agent + +85 +00:02:37,733 --> 00:02:39,400 +来直接的去聊 + +86 +00:02:39,466 --> 00:02:41,533 +然后拿到一些信息 + +87 +00:02:41,600 --> 00:02:43,566 +那有没有更闭环的一些的方案呢 + +88 +00:02:43,600 --> 00:02:44,833 +那完全是有的 + +89 +00:02:44,866 --> 00:02:46,033 +因为像是 Claude + +90 +00:02:46,033 --> 00:02:48,166 +它实际上你在 Claude 你配置一个 MCP + +91 +00:02:48,233 --> 00:02:50,466 +它可以在 Claude code 里边来去使用 + +92 +00:02:50,500 --> 00:02:52,000 +当把它给切到 + +93 +00:02:52,000 --> 00:02:54,533 +我们自己的代码项目里边的时候 + +94 +00:02:54,600 --> 00:02:56,833 +我们同样可以去执行 + +95 +00:02:56,866 --> 00:02:58,900 +比如说我这里可以看好 MCP + +96 +00:02:58,933 --> 00:03:00,466 +那这里可以看到 + +97 +00:03:00,466 --> 00:03:03,966 +MCP Axiom 的 MCP Server + +98 +00:03:03,966 --> 00:03:05,433 + 已经连在了这里 + +99 +00:03:05,500 --> 00:03:08,100 +那它就可以自主的帮我去完成 + +100 +00:03:08,100 --> 00:03:09,966 +这样找错误日志 + +101 +00:03:10,033 --> 00:03:11,300 +然后自动 bugfix + +102 +00:03:11,333 --> 00:03:13,366 +甚至说自己加日志的事情 + +103 +00:03:13,433 --> 00:03:15,466 +那有没有更进一步的 harness 呢 + +104 +00:03:15,500 --> 00:03:16,200 +是有的 + +105 +00:03:16,266 --> 00:03:19,300 +比如说我们可以切到 code 这边 + +106 +00:03:19,333 --> 00:03:20,966 +因为它其实有个 routine + +107 +00:03:21,033 --> 00:03:22,600 +那之前我们也介绍过 + +108 +00:03:22,633 --> 00:03:23,933 +那在这个 routine 里边 + +109 +00:03:23,933 --> 00:03:27,633 +我们可以去调用 Axiom MCP Server + +110 +00:03:27,666 --> 00:03:29,100 +让它来去定期的 + +111 +00:03:29,133 --> 00:03:30,700 +比如说每天 + +112 +00:03:30,700 --> 00:03:33,300 +来去拉一些最新的生产上面的日志 + +113 +00:03:33,333 --> 00:03:34,400 +来去查一些问题 + +114 +00:03:34,466 --> 00:03:36,033 +来去处理一些问题 + +115 +00:03:36,066 --> 00:03:38,300 +它也是完完全全可以做到 + +116 +00:03:38,333 --> 00:03:42,333 +那有没有更进一步的 Harness 方案呢 + +117 +00:03:42,400 --> 00:03:43,833 +因为现在这个 routine + +118 +00:03:43,833 --> 00:03:44,500 +它是每天运行 + +119 +00:03:44,533 --> 00:03:46,233 +那最近其实我有在观察 + +120 +00:03:46,233 --> 00:03:47,900 + Superlog 这个项目 + +121 +00:03:47,933 --> 00:03:48,966 +Superlog 这个项目 + +122 +00:03:48,966 --> 00:03:50,533 +看起来它会更进一步 + +123 +00:03:50,600 --> 00:03:52,300 +对 Superlog 这个方案 + +124 +00:03:52,300 --> 00:03:53,766 +就看起来就更有意思了 + +125 +00:03:53,800 --> 00:03:57,033 +它会自主地去连接我们的代码仓库 + +126 +00:03:57,066 --> 00:03:58,833 +自主地去发 pull request + +127 +00:03:58,866 --> 00:03:59,600 +但 Superlog + +128 +00:03:59,600 --> 00:04:01,366 +目前的 quota 量是比较少 + +129 +00:04:01,433 --> 00:04:02,533 +可以看我已经 + +130 +00:04:02,533 --> 00:04:05,333 +到了这个 free plan 的 limits + +131 +00:04:05,400 --> 00:04:07,300 +所以我还在观察这个项目 + +132 +00:04:07,333 --> 00:04:09,933 +但大家有兴趣可以自己来去研究看一看 + +133 +00:04:10,000 --> 00:04:13,066 +我觉得 Superlog 这个方案还可以 + +134 +00:04:13,100 --> 00:04:14,700 +但只是我个人目前 + +135 +00:04:14,700 --> 00:04:17,366 +可能更倾向于 Axiom + +136 +00:04:17,433 --> 00:04:18,333 +我觉得如果你自己 + +137 +00:04:18,333 --> 00:04:20,200 +有一些 hobby 的一些小项目 + +138 +00:04:20,233 --> 00:04:22,633 +一些用户量并没有那么大的项目 + +139 +00:04:22,666 --> 00:04:24,066 +你完全可以拿 Axiom + +140 +00:04:24,066 --> 00:04:26,633 +来去接入你的日志系统 + +141 +00:04:26,666 --> 00:04:28,033 +它也许可以去帮你 + +142 +00:04:28,033 --> 00:04:29,766 +更好的去维护你的项目 + +143 +00:04:29,833 --> 00:04:30,233 +OK + +144 +00:04:30,266 --> 00:04:31,966 +那我们今天的节目就到这里 + +145 +00:04:32,033 --> 00:04:34,700 +如果有什么更好的日志解决的管理方案 + +146 +00:04:34,733 --> 00:04:36,066 +或者说你对 Axiom + +147 +00:04:36,066 --> 00:04:37,966 + 这种日志服务的看法 + +148 +00:04:38,033 --> 00:04:39,600 +也欢迎在下面留言 + +149 +00:04:39,633 --> 00:04:40,733 +我们下期节目再见 + +150 +00:04:40,800 --> 00:04:41,233 +拜拜 + diff --git a/src/assets/ep73/cover.png b/src/assets/ep73/cover.png new file mode 100644 index 0000000..9faabb7 Binary files /dev/null and b/src/assets/ep73/cover.png differ diff --git a/src/components/EpisodeChannels.astro b/src/components/EpisodeChannels.astro new file mode 100644 index 0000000..7238242 --- /dev/null +++ b/src/components/EpisodeChannels.astro @@ -0,0 +1,34 @@ +--- +import IconExternalLink from "./icons/external-link.astro"; + +interface Props { + channels: { label: string; href: string }[]; +} + +const { channels } = Astro.props; +--- + + + + diff --git a/src/components/EpisodeCover.astro b/src/components/EpisodeCover.astro new file mode 100644 index 0000000..b735481 --- /dev/null +++ b/src/components/EpisodeCover.astro @@ -0,0 +1,19 @@ +--- +import { Image } from "astro:assets"; + +interface Props { + cover: ImageMetadata; + title: string; +} + +const { cover, title } = Astro.props; +--- + +{`${title} diff --git a/src/components/Transcript.astro b/src/components/Transcript.astro new file mode 100644 index 0000000..53c13e9 --- /dev/null +++ b/src/components/Transcript.astro @@ -0,0 +1,51 @@ +--- +import { formatTimestamp, type TranscriptParagraph } from "../lib/srt"; + +interface Props { + paragraphs: TranscriptParagraph[]; + srcUrl: string; +} + +const { paragraphs, srcUrl } = Astro.props; +--- + +
+ + ▶ +

📝 本期文字稿

+ ({paragraphs.length} 段) +
+ + + 下载 .srt 字幕文件 ↓ + + +
+ { + paragraphs.map((paragraph) => ( +

+ + [{formatTimestamp(paragraph.start)}] + + {paragraph.text} +

+ )) + } +
+
+ + diff --git a/src/components/kv-info.astro b/src/components/kv-info.astro deleted file mode 100644 index a6a0a22..0000000 --- a/src/components/kv-info.astro +++ /dev/null @@ -1,12 +0,0 @@ ---- -type Props = { - title: string; -}; - -const { title } = Astro.props; ---- - -
- {title}: - -
diff --git a/src/components/tags/tags.astro b/src/components/tags/tags.astro index f7979b1..3c19bf1 100644 --- a/src/components/tags/tags.astro +++ b/src/components/tags/tags.astro @@ -6,12 +6,12 @@ type Props = { const { tags } = Astro.props; --- -
+
    { tags.map((x) => ( -
    - {x} -
    +
  • + {x} +
  • )) } -
+ diff --git a/src/content.config.ts b/src/content.config.ts index 138e9f8..6b8371a 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -4,7 +4,7 @@ import { z } from 'astro/zod'; const posts = defineCollection({ loader: glob({ pattern: '**/*.mdx', base: './src/content/posts' }), - schema: z.object({ + schema: ({ image }) => z.object({ type: z.literal('podcast-episode'), slug: z.string().startsWith('/posts/'), guid: z.number().int().nonnegative(), @@ -26,6 +26,8 @@ const posts = defineCollection({ draftLink: z.url().optional(), youtubeId: z.string().optional(), biliUrl: z.string().optional(), + cover: image().optional(), + srt: z.string().startsWith('/').endsWith('.srt').optional(), hasSlides: z.boolean().optional(), slidesUrl: z.url().optional(), }) diff --git a/src/content/posts/ep73.mdx b/src/content/posts/ep73.mdx new file mode 100644 index 0000000..ae0b68b --- /dev/null +++ b/src/content/posts/ep73.mdx @@ -0,0 +1,64 @@ +--- +type: podcast-episode +status: published +slug: /posts/ep73 +guid: 73 +title: "EP73 Axiom 日志:哪 26 个人拿走了我的电影票?" +subtitle: "从日志平台到 MCP Server,聊聊 AI Agent 如何看见真实的生产环境" +publicationDate: 2026-08-31 17:30:00 +author: AnnatarHe +season: 3 +episodeNumber: 73 +episodeType: full +excerpt: "从传统日志查询到 Dataset、Dashboard 与 Monitor,再聊 Axiom MCP Server:让 Coding Agent 直接分析生产日志、定位错误,并结合代码自动修复 Bug、补充日志。" +url: https://youtu.be/UuwMMLmO7MQ +size: 0 +duration: 0 +explicit: false +xyzLink: https://www.xiaoyuzhoufm.com/episode/6a954d292e27ea5b21a118aa +youtubeId: UuwMMLmO7MQ +biliUrl: //player.bilibili.com/player.html?isOutside=true&aid=117185632539081&bvid=BV1pdtp63ESw&cid=41439792336&p=1 +cover: ../../assets/ep73/cover.png +srt: /subtitles/ep73.srt +categories: + - Axiom + - 日志 + - AI编程 + - MCP + - Harness + - DeepSeekHarness + - ClaudeCode + - CodingAgent + - AI智能体 + - 可观测性 + - DevOps + - Superlog + - AsyncTalk +--- + +### 📕 Shownotes + +最近 Harness 成了 AI 圈的热门话题,但当我们讨论 AI Agent 如何真正完成任务时,我觉得有一个经常被忽略的问题: + +AI 怎么知道生产环境到底发生了什么? + +这期我想聊聊我最近一直在用的日志服务 Axiom。 + +从传统的日志查询,到 Dataset、Dashboard 和 Monitor,Axiom 本身已经是一个相当不错的日志平台。但真正让我觉得它有意思的,是它提供的 MCP Server。 + +当日志系统接入 MCP 之后,Coding Agent 就不只是写代码了。它可以直接分析生产日志、查找错误、定位问题,甚至结合代码自动修复 Bug、补充日志。 + +进一步想象一下:让 Agent 定期检查生产环境的日志 → 发现异常 → 分析代码 → 尝试修复 → 提交 PR。 + +日志不再只是给开发者 Debug 的工具,而开始成为 AI Agent 了解真实生产环境的一扇窗口。 + +这期视频会聊到: + +- 传统日志查询,和 Dataset、Dashboard、Monitor 的差别 +- Axiom MCP Server 能给 Coding Agent 带来什么 +- 让 Agent 直接分析生产日志、定位错误、补充日志 +- 从「发现异常」到「自动提 PR」的完整设想 +- 我最近在关注的 Superlog +- 对「Harness + MCP + 可观测性」这条路线的一些想法 + +如果你有更好的日志管理方案,或者已经在尝试让 AI Agent 自动分析生产环境,欢迎一起讨论。 diff --git a/src/lib/episode-media.ts b/src/lib/episode-media.ts new file mode 100644 index 0000000..7c31010 --- /dev/null +++ b/src/lib/episode-media.ts @@ -0,0 +1,23 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +/** + * Cover art is a `cover` frontmatter field resolved by the content schema's + * `image()` helper, so Astro optimizes it like any other asset. + * + * Subtitles live under `public/` so the browser can download the raw .srt, and + * the `srt` frontmatter field holds that public path (e.g. `/subtitles/ep73.srt`). + * A declared file that is missing fails the build instead of silently dropping + * the transcript. + */ +export function readEpisodeSubtitles(publicPath: string) { + const file = path.join(process.cwd(), "public", publicPath); + + if (!existsSync(file)) { + throw new Error( + `Subtitle file not found: ${publicPath} (looked in ${file}). Fix the "srt" frontmatter field or add the file.`, + ); + } + + return readFileSync(file, "utf8"); +} diff --git a/src/lib/srt.ts b/src/lib/srt.ts new file mode 100644 index 0000000..652875e --- /dev/null +++ b/src/lib/srt.ts @@ -0,0 +1,149 @@ +export type SrtCue = { + start: number; + end: number; + text: string; +}; + +export type TranscriptParagraph = { + start: number; + text: string; +}; + +const TIMECODE = /(\d+):(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d+):(\d{2}):(\d{2})[,.](\d{1,3})/; +const PARAGRAPH_MIN_LENGTH = 60; +const PARAGRAPH_MAX_CUES = 12; +/** A silence this long reads as a new thought, so it always breaks a paragraph. */ +const PARAGRAPH_GAP = 2500; +const SENTENCE_ENDINGS = /[。!?…?!]["'”’))】」』]*$/; +const CJK = /[\u3000-\u303f\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uff00-\uffef]/; + +function toMilliseconds( + hours: string, + minutes: string, + seconds: string, + fraction: string, +) { + return ( + Number(hours) * 3600000 + + Number(minutes) * 60000 + + Number(seconds) * 1000 + + Number(fraction.padEnd(3, "0")) + ); +} + +function cleanCueText(lines: string[]) { + return lines + .join(" ") + .replace(/<\/?[a-z][^>]*>/gi, "") + .replace(/\{\\[^}]*\}/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +export function parseSrt(raw: string): SrtCue[] { + const normalized = raw.replace(/^/, "").replace(/\r\n?/g, "\n"); + const cues: SrtCue[] = []; + + for (const block of normalized.split(/\n{2,}/)) { + const lines = block.split("\n").filter((line) => line.trim().length > 0); + + if (lines.length === 0) { + continue; + } + + const timeIndex = lines.findIndex((line) => TIMECODE.test(line)); + + if (timeIndex === -1) { + continue; + } + + const matched = lines[timeIndex].match(TIMECODE); + + if (!matched) { + continue; + } + + const text = cleanCueText(lines.slice(timeIndex + 1)); + + if (text.length === 0) { + continue; + } + + const previous = cues.at(-1); + + if (previous?.text === text) { + previous.end = toMilliseconds(matched[5], matched[6], matched[7], matched[8]); + continue; + } + + cues.push({ + start: toMilliseconds(matched[1], matched[2], matched[3], matched[4]), + end: toMilliseconds(matched[5], matched[6], matched[7], matched[8]), + text, + }); + } + + return cues; +} + +function joinCueText(previous: string, next: string) { + if (previous.length === 0) { + return next; + } + + const needsSpace = !CJK.test(previous.at(-1) ?? "") && !CJK.test(next.at(0) ?? ""); + + return needsSpace ? `${previous} ${next}` : previous + next; +} + +export function groupCues(cues: SrtCue[]): TranscriptParagraph[] { + const paragraphs: TranscriptParagraph[] = []; + let start = 0; + let end = 0; + let count = 0; + let text = ""; + + const flush = () => { + if (count === 0) { + return; + } + + paragraphs.push({ start, text }); + count = 0; + text = ""; + }; + + for (const cue of cues) { + if (count > 0 && cue.start - end > PARAGRAPH_GAP) { + flush(); + } + + if (count === 0) { + start = cue.start; + } + + text = joinCueText(text, cue.text); + end = cue.end; + count += 1; + + const complete = SENTENCE_ENDINGS.test(text) && text.length >= PARAGRAPH_MIN_LENGTH; + + if (complete || count >= PARAGRAPH_MAX_CUES) { + flush(); + } + } + + flush(); + + return paragraphs; +} + +export function formatTimestamp(milliseconds: number) { + const total = Math.max(0, Math.floor(milliseconds / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + const padded = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + + return hours > 0 ? `${hours}:${padded}` : padded; +} diff --git a/src/pages/posts/[...slug].astro b/src/pages/posts/[...slug].astro index a839194..0ffe97f 100644 --- a/src/pages/posts/[...slug].astro +++ b/src/pages/posts/[...slug].astro @@ -3,9 +3,7 @@ import { getCollection, render } from "astro:content"; import PageLayout from "../../layouts/Layout.astro"; import SEOTags from "../../components/seo/seo-tags.astro"; import WarningAlert from "../../components/alert/warning.astro"; -import KvInfo from "../../components/kv-info.astro"; import Tags from "../../components/tags/tags.astro"; -import IconExternalLink from "../../components/icons/external-link.astro"; import Heading1 from "../../components/markdown/Heading1.astro"; import Heading2 from "../../components/markdown/Heading2.astro"; @@ -21,7 +19,12 @@ import Player from "../../components/Player.astro"; import AdSense from "../../components/AdSense.astro"; import VideoEmbed from "../../components/VideoEmbed.astro"; import SlidesEmbed from "../../components/SlidesEmbed.astro"; +import EpisodeCover from "../../components/EpisodeCover.astro"; +import EpisodeChannels from "../../components/EpisodeChannels.astro"; +import Transcript from "../../components/Transcript.astro"; import { SLIDES_BASE_URL } from "../../constants"; +import { readEpisodeSubtitles } from "../../lib/episode-media"; +import { groupCues, parseSrt } from "../../lib/srt"; import { SITE_URL, bilibiliUrl as getBilibiliUrl, @@ -70,12 +73,29 @@ const imgUrl = new URL( const podcastId = `${new URL("/", Astro.site ?? SITE_URL)}#podcast`; const organizationId = `${new URL("/", Astro.site ?? SITE_URL)}#organization`; const authorEntities = parseAuthors(frontData.author); -const sameAs = compactUrls([ - frontData.xyzLink || undefined, - youtubeUrl(frontData.youtubeId), - getBilibiliUrl(frontData.biliUrl), -]); +const xyzHref = frontData.xyzLink || undefined; +const youtubeHref = youtubeUrl(frontData.youtubeId); +const biliHref = getBilibiliUrl(frontData.biliUrl); +const sameAs = compactUrls([xyzHref, youtubeHref, biliHref]); +const channels = [ + { label: "小宇宙", href: xyzHref }, + { label: "YouTube", href: youtubeHref }, + { label: "Bilibili", href: biliHref }, + { label: "Notion 草稿", href: frontData.draftLink }, +].filter((channel): channel is { label: string; href: string } => + Boolean(channel.href), +); const title = `${frontData.title}|AsyncTalk`; + +const cover = frontData.cover; +const coverUrl = cover + ? new URL(cover.src, Astro.site ?? SITE_URL).toString() + : undefined; +const subtitles = frontData.srt + ? readEpisodeSubtitles(frontData.srt) + : undefined; +const transcript = subtitles ? groupCues(parseSrt(subtitles)) : []; +const transcriptText = transcript.map((paragraph) => paragraph.text).join("\n"); --- @@ -105,8 +125,9 @@ const title = `${frontData.title}|AsyncTalk`; datePublished: frontData.publicationDate.toISOString(), inLanguage: "zh-CN", episodeNumber: frontData.episodeNumber, - image: imgUrl, + image: coverUrl ? [coverUrl, imgUrl] : imgUrl, isAccessibleForFree: true, + ...(transcriptText ? { transcript: transcriptText } : {}), keywords: tags, author: authorEntities, publisher: { "@id": organizationId }, @@ -125,7 +146,7 @@ const title = `${frontData.title}|AsyncTalk`; />
{ isPreRelease && ( @@ -135,57 +156,28 @@ const title = `${frontData.title}|AsyncTalk`; /> ) } -

- {frontData.title} -

-

{frontData.excerpt}

- - - { - frontData.xyzLink && ( - - + {cover && } +
+
+ - - ) - } - - { - frontData.draftLink && ( - - - Notion - {frontData.title} - - - - ) - } - - { - tags.length > 0 && ( - - - - ) - } + {new Intl.DateTimeFormat("zh-CN").format(frontData.publicationDate)} + +

+ {frontData.title} +

+

+ {frontData.excerpt} +

+
+ {channels.length > 0 && } + {tags.length > 0 && } +
+
{ @@ -218,6 +210,15 @@ const title = `${frontData.title}|AsyncTalk`; ) } + { + frontData.srt && transcript.length > 0 && ( + <> + +
+ + ) + } +
diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts index ac98eef..2659675 100644 --- a/src/pages/rss.xml.ts +++ b/src/pages/rss.xml.ts @@ -25,6 +25,8 @@ export async function GET(context: APIContext) { const site = context.site ?? new URL(SITE_URL); const items = posts.map((post) => { const pageUrl = canonicalUrl(episodePath(post.id), site); + const cover = post.data.cover; + const coverUrl = cover ? new URL(cover.src, site).toString() : undefined; return { title: post.data.title, @@ -33,7 +35,10 @@ export async function GET(context: APIContext) { pubDate: post.data.publicationDate, author: post.data.author, categories: post.data.categories, - customData: `${escapeXml(pageUrl)}`, + customData: [ + `${escapeXml(pageUrl)}`, + coverUrl ? `` : "", + ].join(""), }; }); @@ -42,6 +47,7 @@ export async function GET(context: APIContext) { description: "关注 Web 开发、前端工程化与 AI,分享前沿技术、工程实践与开发者观点。和我们一起,将 Web 开发带向下一个高度。", site, + xmlns: { itunes: "http://www.itunes.com/dtds/podcast-1.0/" }, customData: "zh-CN", items, });