diff --git a/web-pages/product-site/content/legacy-manifest.json b/web-pages/product-site/content/legacy-manifest.json index 56e398158..d15f8a36c 100644 --- a/web-pages/product-site/content/legacy-manifest.json +++ b/web-pages/product-site/content/legacy-manifest.json @@ -35,7 +35,7 @@ "blog/japanese-speech-recognition.html": "399f5ce84e68ac00854bdf70b52b1cde6795efca7c800fd6492035a37a7c1b68", "blog/lightweight-speech-recognition-cpu.html": "d6270066222ed5baef0df108e6a4155f4a87bb5482169f23f218794d371f3281", "blog/meeting-transcript-acceptance.html": "8173b3049ca707bdac46013748bc0ca7d9576ebf6f7ff6db6c940a833b12e4a9", - "blog/punctuation-restoration-python.html": "6cd26e03bf75b4343afd7b351c2681ebd513b058fb549c5888802be9ff3a1229", + "blog/punctuation-restoration-python.html": "063c5fe584bff780edc50f95214decbb3432326d4983334fcd8b38aabeb16128", "blog/self-hosted-deepgram-assemblyai-alternative.html": "feb17aae8e0f3fe46914c8725eacc8fcdf7fe78bf22a291347d6107c24e3992e", "blog/self-hosted-google-aws-azure-speech-to-text.html": "8e4000fb9e2b33a48a3907d4bd0f55db57ba74121a89b5fa12b412c3c963f49a", "blog/self-hosted-openai-whisper-api-alternative.html": "254184cd753f30e308329c301ae47e8f5f5dfb57c01d0abf3ce341034f2990f9", @@ -77,7 +77,7 @@ "en/blog/japanese-speech-recognition.html": "c476adcc2be1ed7c19e2345cc91b8ee6a0e04efd794b9b6476dd5d9b3a2c8b04", "en/blog/lightweight-speech-recognition-cpu.html": "0e02c0e0853b12613d32c250f593c05c2052f18231207b63ee01d6e6600a86f7", "en/blog/meeting-transcript-acceptance.html": "ffc6eb2329267515bb367f21bb2d1c0523ff90d3f02b2e734ae41f78b02d0515", - "en/blog/punctuation-restoration-python.html": "d6e30049f48d9dc6e6bc22eb567830013e4161eca51215616738b675dd1ffc40", + "en/blog/punctuation-restoration-python.html": "5853658aa8e936eda988fa087cba4a297de8044f979f6a9deef49206b8967a79", "en/blog/self-hosted-deepgram-assemblyai-alternative.html": "4239a93246726c6f010a2fb20208211e050792a372e3ea356d1a93ac028a8a96", "en/blog/self-hosted-google-aws-azure-speech-to-text.html": "4a8453451722b3ec8735224a586744ddfbfec9458cedd433f9fc908351027e9a", "en/blog/self-hosted-openai-whisper-api-alternative.html": "14bdb877adfd113eeb04b215ffa015c29d8323859eeddd00d9e03d166dfa0473", diff --git a/web-pages/product-site/data/blog.json b/web-pages/product-site/data/blog.json index 11fa5777c..a504812ed 100644 --- a/web-pages/product-site/data/blog.json +++ b/web-pages/product-site/data/blog.json @@ -337,14 +337,14 @@ { "slug": "punctuation-restoration-python", "category": "explanations", - "reviewed": false, + "reviewed": true, "zh": { - "title": "怎样给转写结果补标点", - "summary": "" + "title": "文字都在,标点为什么还是断错?", + "summary": "保留原始转写,生成标点候选,再检查归句和专名,不把句号当作语义修复。" }, "en": { - "title": "Restoring punctuation in a transcript", - "summary": "" + "title": "The words are right. Why is the punctuation wrong?", + "summary": "Keep the original, generate a candidate, then review sentence meaning and names." } }, { diff --git a/web-pages/product-site/legacy/blog/punctuation-restoration-python.html b/web-pages/product-site/legacy/blog/punctuation-restoration-python.html index f87e3bd7e..7a8fc3b62 100644 --- a/web-pages/product-site/legacy/blog/punctuation-restoration-python.html +++ b/web-pages/product-site/legacy/blog/punctuation-restoration-python.html @@ -3,134 +3,68 @@ -标点恢复(Punctuation Restoration)Python 实战:给无标点文本/ASR 结果自动加标点 | FunASR 博客 - - +文字都在,标点为什么还是断错? | FunASR + - - - - - - + - - - + + + + + + - +
-

标点恢复(Punctuation Restoration)Python 实战:给无标点文本/ASR 结果自动加标点

- - -

很多语音识别(ASR)模型输出的是没有标点的纯文本——一长串字读起来很费劲。标点恢复就是把"。"",""?"这些标点自动补回去,让转写结果可读。FunASR 的 ct-punc 是一个开源的标点恢复模型,中英文都支持,3 行 Python 就能给任意文本加标点。下面全是真实实测输出。

- -

3 行代码做标点恢复(实测输出)

-
pip install funasr
-
+

文字都在,标点为什么还是断错?

+

2026-06-23 · 更新 2026-09-10 · FunASR

+

转写没有漏字,读起来却像另一句话,问题可能出在标点归句。先别挪字幕时间,也别重跑整段音频:留下原始文本,单独生成标点候选,再比较意思有没有被改变。

+

1. 留一份原文,再生成候选

+

先按安装与环境验证指南准备 CPU 环境,确认 PyTorch 与 torchaudio 配套且可导入,安装 funasr==1.4.15 并运行 python -m pip check。这里用独立的 ct-punc 文本模型,不需要音频或 VAD;首次运行仍需下载权重。

+
import json
 from funasr import AutoModel
 
-model = AutoModel(model="ct-punc", disable_update=True)
-print(model.generate(input="我们都是木头人不许说话不许动")[0]["text"])
-# 我们都是木头人,不许说话,不许动。
-

输入是没有标点的连续文本,输出自动补上了逗号和句号。

- -

中英文真实示例

- - - - - -
输入(无标点)输出(ct-punc 加标点)
我们都是木头人不许说话不许动我们都是木头人,不许说话,不许动。
今天天气怎么样我想出去走走你要一起吗今天天气怎么样,我想出去走走,你要一起吗?
the meeting is at 3 pm please bring your laptop and the reportThe meeting is at 3 pm, please bring your laptop and the report.
-

可以看到:中文补「,。?」(问句还会自动判断用问号);英文补逗号、句号,还会把句首字母大写。同一个模型双语通吃。

- -

最常见用法:给 ASR 结果加标点

-

标点恢复最大的用处是清洗语音识别的输出。FunASR 的 ASR 模型可以一行挂载 ct-punc,识别完直接带标点:

-
from funasr import AutoModel
-
-# ASR + VAD + 标点,一次完成(以中文 Paraformer 为例)
-model = AutoModel(model="paraformer-zh", vad_model="fsmn-vad", punc_model="ct-punc")
-result = model.generate(input="audio.wav")
-print(result[0]["text"])   # 输出已带标点
-

当然你也可以像最上面那样单独用 ct-punc 处理任意文本——不限于语音,任何无标点文本(老字幕、OCR 结果、聊天记录)都能加标点。

- -

为什么用 ct-punc

- - - - - - -
ct-punc(FunASR)
语言中文 + 英文(单模型双语)
用法3 行 Python,输入文本即可;也能一行挂到 ASR 上
标点,。?等;英文还做句首大写
许可证开源,可商用
-

想要完整的语音流程,看 中文语音识别实战VAD/去静音说话人分离;不确定选哪个 ASR 模型看 模型选型指南(默认推荐旗舰 Fun-ASR-Nano)。

- -

FunASR 全家桶开源(MIT)——标点恢复、ASR、VAD、说话人、情感、LLM-ASR(旗舰 Fun-ASR-Nano),拿来即用。觉得有用就点个 Star 👇

⭐ Star FunASR

也欢迎 Star:SenseVoice · Fun-ASR · FunClip

- -

相关文章

- +model = AutoModel(model="ct-punc", device="cpu", disable_update=True) +texts = [ + "我们都是木头人不许说话不许动", + "the meeting is at 3 pm please bring your laptop and the report", + "人不是石头人有主观价值", +] +observed = [] +for original in texts: + candidate = model.generate(input=original)[0]["text"] + observed.append({"original": original, "candidate": candidate}) +print(json.dumps(observed, ensure_ascii=False, indent=2))
+

original 保留输入,candidate 单独保存模型结果。先保留空白和大小写,避免显示清洗掩盖差异;这些短文本是演示,不是准确率评测集。

+

2. 看看模型实际做了什么

+
[
+  {
+    "original": "我们都是木头人不许说话不许动",
+    "candidate": "我们都是木头人,不许说话,不许动。"
+  },
+  {
+    "original": "the meeting is at 3 pm please bring your laptop and the report",
+    "candidate": " The meeting is at 3 pm, please bring your laptop and the report."
+  },
+  {
+    "original": "人不是石头人有主观价值",
+    "candidate": "人不是石头人有主观价值。"
+  }
+]
+

第一条得到了可读的停顿;英文结果包含句首大写,也包含一个前导空格。第三条只多了句号,仍没有把第二个“人”归到后半句。输出能读出来,不代表标点已经放对。

+

人工编辑候选,不是本次模型输出:
人不是石头,人有主观价值。

+

一次社区断句反馈区分了播放器时间修正和标点归句问题。这里用的是重新构造的短文本,不是该录音的完整上下文;不能据此认定原问题已修复,也不建议把很短的字幕间隔直接强拼。

+

3. 交付前,检查三个地方

+ +

punc_array 是标点类别,不是时间戳或置信度。独立文本调用不会告诉你句子在音频中的起止位置;不要靠补标点或整体挪字幕时间来声称精准对齐。ASR+VAD 流水线还有独立的文本重组逻辑,不能把这里的大小写表现泛化到所有调用路径。

+

使用 Fun-ASR-Nano 原生 Transformers时,也先保留原始识别结果。ct-punc 是可选后处理,不是原生 Nano 自带的时间戳、说话人分离或实时流式能力;已有合理标点的结果不必再盲目处理一遍。

+

需要交付字幕时,接着走SRT、VTT 与回听检查流程,把文字编辑和时间校验分开完成。

+
本次复现环境与实现依据

2026-09-10:FunASR 1.4.15,PyTorch / torchaudio 2.10.0 CPU,ModelScope iic/punc_ct-transformer_cn-en-common-vocab471067-large,缓存 revision 为 master。它会变化,不等于固定权重;本次 model.pt SHA256 为 7176cae922a872e130e6b88aef9a1153581711baf79c9124c7c95be383cd6f81。代码与权重许可应分别核对模型卡和仓库,本文不替代授权说明。

可核对大小写与句末处理流水线文本重组。单次输出不代表准确率、实时延迟或所有权重版本的结果。

- + - \ No newline at end of file + diff --git a/web-pages/product-site/legacy/en/blog/punctuation-restoration-python.html b/web-pages/product-site/legacy/en/blog/punctuation-restoration-python.html index fd34f6b01..d8058ee61 100644 --- a/web-pages/product-site/legacy/en/blog/punctuation-restoration-python.html +++ b/web-pages/product-site/legacy/en/blog/punctuation-restoration-python.html @@ -3,134 +3,68 @@ -Punctuation Restoration in Python — Add Punctuation to Text / ASR Output | FunASR Blog - - +The words are right. Why is the punctuation wrong? | FunASR + - - - - - - + - - - + + + + + + - +
-

Punctuation Restoration in Python — Add Punctuation to Unpunctuated Text / ASR Output

- - -

Many speech recognition (ASR) models output unpunctuated text — one long run of words that is hard to read. Punctuation restoration adds the commas, periods, and question marks back so the transcript is readable. FunASR's ct-punc is an open-source punctuation-restoration model that works for both Chinese and English, adding punctuation to any text in three lines of Python. Everything below is real measured output.

- -

Punctuation restoration in three lines (real output)

-
pip install funasr
-
+

The words are right. Why is the punctuation wrong?

+

2026-06-23 · Updated 2026-09-10 · FunASR

+

A transcript can keep every word and still split a thought in the wrong place. Before shifting subtitle times or transcribing the recording again, keep the raw text, generate a separate punctuation candidate, and check whether its meaning survives.

+

1. Keep the input and generate a candidate

+

Follow the installation and environment checks for CPU. Confirm compatible PyTorch and torchaudio imports, install funasr==1.4.15, and run python -m pip check. This standalone ct-punc call takes text, not audio or VAD output. The first run still downloads model weights.

+
import json
 from funasr import AutoModel
 
-model = AutoModel(model="ct-punc", disable_update=True)
-print(model.generate(input="the meeting is at 3 pm please bring your laptop and the report")[0]["text"])
-# The meeting is at 3 pm, please bring your laptop and the report.
-

The input is a continuous string with no punctuation; the output gets a comma, a period, and a capitalized first letter.

- -

Real Chinese & English examples

- - - - - -
Input (no punctuation)Output (ct-punc)
the meeting is at 3 pm please bring your laptop and the reportThe meeting is at 3 pm, please bring your laptop and the report.
我们都是木头人不许说话不许动我们都是木头人,不许说话,不许动。
今天天气怎么样我想出去走走你要一起吗今天天气怎么样,我想出去走走,你要一起吗?
-

So: English gets commas, periods, and a capitalized first letter; Chinese gets ,。? — and it even picks a question mark for questions. One model handles both languages.

- -

The most common use: punctuate ASR output

-

The biggest use of punctuation restoration is cleaning up speech-to-text output. FunASR's ASR models let you attach ct-punc in one line so the transcript comes out punctuated:

-
from funasr import AutoModel
-
-# ASR + VAD + punctuation in one call (Chinese Paraformer here)
-model = AutoModel(model="paraformer-zh", vad_model="fsmn-vad", punc_model="ct-punc")
-result = model.generate(input="audio.wav")
-print(result[0]["text"])   # output already has punctuation
-

You can also use ct-punc standalone on any text — not just speech. Old subtitles, OCR output, chat logs: anything unpunctuated can be punctuated.

- -

Why ct-punc

- - - - - - -
ct-punc (FunASR)
LanguagesChinese + English (one bilingual model)
Usage3 lines of Python on raw text; or attach to ASR in one line
Punctuation,。? etc.; also capitalizes the first letter in English
Licenseopen-source, commercial-friendly
-

For full speech pipelines, see Chinese speech recognition, VAD / silence removal, and speaker diarization; to pick an ASR model, see the model selection guide (default recommendation: the flagship Fun-ASR-Nano).

- -

The whole FunASR stack is open-source (MIT) — punctuation restoration, ASR, VAD, speaker, emotion, LLM-ASR (flagship Fun-ASR-Nano), ready to use. If it helps, a GitHub Star supports the project 👇

⭐ Star FunASR

Also star:SenseVoice · Fun-ASR · FunClip

- -

Related posts

- +model = AutoModel(model="ct-punc", device="cpu", disable_update=True) +texts = [ + "我们都是木头人不许说话不许动", + "the meeting is at 3 pm please bring your laptop and the report", + "人不是石头人有主观价值", +] +observed = [] +for original in texts: + candidate = model.generate(input=original)[0]["text"] + observed.append({"original": original, "candidate": candidate}) +print(json.dumps(observed, ensure_ascii=False, indent=2))
+

Keep original unchanged and store candidate separately. Preserve whitespace and case initially so display cleanup does not hide differences. These short examples are demonstrations, not an accuracy benchmark.

+

2. Inspect what the model actually changed

+
[
+  {
+    "original": "我们都是木头人不许说话不许动",
+    "candidate": "我们都是木头人,不许说话,不许动。"
+  },
+  {
+    "original": "the meeting is at 3 pm please bring your laptop and the report",
+    "candidate": " The meeting is at 3 pm, please bring your laptop and the report."
+  },
+  {
+    "original": "人不是石头人有主观价值",
+    "candidate": "人不是石头人有主观价值。"
+  }
+]
+

The first result adds readable pauses. The English result capitalizes the opening word but also contains a leading space. The last Chinese result only adds a full stop: it does not move the second “人” into the next clause. A readable string is not necessarily a correctly punctuated sentence.

+

A human edit, not the model output above:
人不是石头,人有主观价值。

+

A community sentence-boundary report separated player timing adjustments from punctuation errors. This is a reconstructed short text, not that recording's full context. It does not establish that the reported problem is fixed, or justify joining subtitle segments solely because the gap is short.

+

3. Review three things before delivery

+ +

punc_array contains punctuation classes, not timestamps or confidence scores. A standalone text call does not locate the sentence in audio. Adding punctuation or shifting subtitle times cannot establish precise alignment. ASR+VAD pipelines also have separate text-reassembly logic, so their casing behavior need not match this standalone call.

+

With native Fun-ASR-Nano in Transformers, keep the original recognition result too. ct-punc is optional postprocessing, not native Nano timestamps, diarization or realtime streaming. Do not automatically repunctuate an already satisfactory transcript.

+

For subtitle delivery, continue with the SRT, VTT and listening-review workflow, treating text editing and timing checks as separate steps.

+
Reproduction environment and implementation

Run on 2026-09-10 with FunASR 1.4.15 and PyTorch / torchaudio 2.10.0 CPU. ModelScope artifact: iic/punc_ct-transformer_cn-en-common-vocab471067-large, cached revision master. That revision can change; it is not an immutable weight pin. The observed model.pt SHA256 is 7176cae922a872e130e6b88aef9a1153581711baf79c9124c7c95be383cd6f81. Check code and weight licensing separately in the repository and model card; this article is not an authorization statement.

See the pinned case and sentence-ending implementation and pipeline text reassembly. These observations are not an accuracy, realtime-latency or all-revisions guarantee.

- + - \ No newline at end of file + diff --git a/web-pages/product-site/tests/browser/punctuation-article.spec.ts b/web-pages/product-site/tests/browser/punctuation-article.spec.ts new file mode 100644 index 000000000..9ef8a0f21 --- /dev/null +++ b/web-pages/product-site/tests/browser/punctuation-article.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test'; + +for (const prefix of ['', 'en/']) { + for (const width of [320, 390, 1440]) { + test(`Punctuation candidate ${prefix || 'zh'} at ${width}px`, async ({ page }, testInfo) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(String(error))); + await page.setViewportSize({ width, height: 1000 }); + await page.goto(`/${prefix}blog/explanations/`); + await page.locator(`a[data-blog-story][href="/${prefix}blog/punctuation-restoration-python.html"]`).click(); + const article = page.locator('article'); + await expect(article.locator('h1')).toBeVisible(); + await expect(article.locator('[data-editorial="opening"]')).toBeVisible(); + await page.screenshot({ path: testInfo.outputPath('opening.png') }); + for (const name of ['punctuate', 'observed']) { + const example = article.locator(`pre[data-example="${name}"]`); + await example.scrollIntoViewIfNeeded(); + expect(await example.evaluate(node => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1); + expect(await example.evaluate(node => { + const text = [...node.childNodes].find(child => child.nodeType === Node.TEXT_NODE && child.textContent?.trim()); + const button = node.querySelector('button'); + if (!text || !button) return false; + const range = document.createRange(); + const start = text.textContent!.search(/\S/); + range.setStart(text, start); + range.setEnd(text, start + 1); + return range.getBoundingClientRect().top >= button.getBoundingClientRect().bottom + 2; + }), `${name} text must not overlap its copy button`).toBeTruthy(); + } + await expect(article.locator('[data-example="observed"]')).toContainText('人不是石头人有主观价值。'); + await article.locator('[data-editorial="manual-edit"]').scrollIntoViewIfNeeded(); + await expect(article.locator('[data-editorial="manual-edit"]')).toContainText('人不是石头,人有主观价值。'); + await page.screenshot({ path: testInfo.outputPath('review.png') }); + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + const peer = prefix ? '' : 'en/'; + await page.locator(`.header-actions a[href="/${peer}blog/punctuation-restoration-python.html"]`).click(); + await expect(page).toHaveURL(new RegExp(`/${peer}blog/punctuation-restoration-python\\.html$`)); + expect(errors).toEqual([]); + }); + } +} diff --git a/web-pages/product-site/tests/test_punctuation_article.py b/web-pages/product-site/tests/test_punctuation_article.py new file mode 100644 index 000000000..c1c582afc --- /dev/null +++ b/web-pages/product-site/tests/test_punctuation_article.py @@ -0,0 +1,98 @@ +"""Punctuation is an editable text candidate, not timing or a semantic guarantee.""" +import ast +import json +from pathlib import Path +import sys + +from bs4 import BeautifulSoup +import pytest + +SITE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SITE)) +from build import build + +SLUG = "punctuation-restoration-python" +INPUTS = [ + "我们都是木头人不许说话不许动", + "the meeting is at 3 pm please bring your laptop and the report", + "人不是石头人有主观价值", +] +EXPECTED = [ + {"original": INPUTS[0], "candidate": "我们都是木头人,不许说话,不许动。"}, + {"original": INPUTS[1], "candidate": " The meeting is at 3 pm, please bring your laptop and the report."}, + {"original": INPUTS[2], "candidate": "人不是石头人有主观价值。"}, +] + + +@pytest.fixture(scope="module", params=["source", "built"]) +def root(request, tmp_path_factory): + if request.param == "source": + return SITE / "legacy" + output = tmp_path_factory.mktemp("punctuation-story") + build(output) + return output + + +@pytest.mark.parametrize("prefix", ["", "en/"]) +def test_reader_sees_candidate_boundaries_and_one_next_step(root, prefix): + soup = BeautifulSoup((root / prefix / "blog" / f"{SLUG}.html").read_text(), "html.parser") + article = soup.select_one("article") + assert article.select_one('[data-editorial="opening"]') + assert len(article.select("h1")) == 1 + assert len(article.select("h2")) <= 4 + assert "ct-punc" in article.select_one('[data-editorial="environment"]').get_text() + assert article.select_one('a[data-installation-guide]') + boundary = article.select_one('[data-editorial="timing-boundary"]').get_text().lower() + assert ("timestamps" if prefix else "时间戳") in boundary + assert article.select_one('[data-editorial="native-boundary"] a')["href"] == f"/{prefix}docs/native-transformers.html" + assert article.select_one('[data-editorial="case-boundary"] a')["href"] == "https://github.com/modelscope/FunASR/issues/3644" + assert len(article.select('[data-editorial="next-step"] a')) == 1 + assert article.select_one('[data-editorial="manual-edit"]') + assert not any(text in article.get_text() for text in [ + "任何无标点文本", "同一个模型双语通吃", "全家桶开源(MIT)", + "Everything below is real measured output", "anything unpunctuated", + "commercial-friendly", + ]) + metadata = json.loads(soup.select_one('script[type="application/ld+json"]').get_text()) + assert metadata["datePublished"] == "2026-06-23" + assert metadata["dateModified"] == "2026-09-10" + assert metadata["headline"] == article.h1.get_text() + assert soup.select_one('link[rel="canonical"]')["href"] == f"https://www.funasr.com/{prefix}blog/{SLUG}.html" + + +@pytest.mark.parametrize("prefix", ["", "en/"]) +def test_example_keeps_raw_text_and_observed_counterexample(root, prefix): + soup = BeautifulSoup((root / prefix / "blog" / f"{SLUG}.html").read_text(), "html.parser") + code = soup.select_one('pre[data-example="punctuate"]') + assert code is not None + tree = ast.parse(code.get_text()) + calls = [node for node in ast.walk(tree) if isinstance(node, ast.Call)] + model = next(node for node in calls if isinstance(node.func, ast.Name) and node.func.id == "AutoModel") + assert {kw.arg: ast.literal_eval(kw.value) for kw in model.keywords} == { + "model": "ct-punc", "device": "cpu", "disable_update": True, + } + text_assignment = next(node for node in ast.walk(tree) if isinstance(node, ast.Assign) + and any(isinstance(t, ast.Name) and t.id == "texts" for t in node.targets)) + assert ast.literal_eval(text_assignment.value) == INPUTS + assert '"original": original' in code.get_text() + assert ".strip(" not in code.get_text() + assert json.loads(soup.select_one('pre[data-example="observed"]').get_text()) == EXPECTED + assert "人不是石头,人有主观价值。" in soup.select_one('[data-editorial="manual-edit"]').get_text() + + +def test_bilingual_examples_match_and_homepage_is_not_expanded(): + examples = [] + for prefix in ["", "en/"]: + soup = BeautifulSoup((SITE / "legacy" / prefix / "blog" / f"{SLUG}.html").read_text(), "html.parser") + code = soup.select_one('pre[data-example="punctuate"]') + assert code is not None + examples.append(code.get_text()) + assert examples[0] == examples[1] + data = json.loads((SITE / "data/blog.json").read_text()) + row = next(entry for entry in data["articles"] if entry["slug"] == SLUG) + assert row["reviewed"] and row["category"] == "explanations" + assert data["lead"] == "funclip-v2-2-0-moss-speaker-clipping" + assert data["selected"] == [ + "meeting-transcript-acceptance", "fun-asr-nano-transformers", + "self-hosted-openai-whisper-api-alternative", "funasr-transcribe-long-audio", + ]