AI生成記事をnoteに自動投稿したら、**太字のつもり**がそのまま文字として残っていた——そんな経験はありませんか?原因はnoteのエディタ仕様にあり、正規表現ひとつで解決できます。ハナが実際に検証した手順をもとに、原因の特定から完全解決まで丁寧に解説していきます。
フェーズ1:なぜ**がnoteで変換されないのか
「Claude(クロード)やChatGPTで記事を生成してPlaywright(プレイライト:ブラウザを自動操作するテストフレームワーク)でnoteに流し込んだら、**重要**という文字列がそのまま表示されてしまった」——これはAI自動投稿あるあるのトラブルです。なぜこうなるのか、まずは仕組みから理解しましょう。
noteのエディタはMarkdownを解釈しない
noteのテキストエディタはProseMirror(プロスミラー)というリッチテキストエディタ(書式付きテキストを視覚的に編集できるエディタ)をベースに構築されています。ProseMirrorはドキュメントを内部的にJSON形式のデータ構造として管理しており、ユーザーがブラウザ上でCtrl+Bを押したり、ツールバーの太字ボタンをクリックしたりすると、その操作がJSONに反映されて太字として表示されます。
重要なのは、ProseMirrorはMarkdown(マークダウン:記号を使った軽量マークアップ言語)をネイティブに解釈しないという点です。Markdownの**太字**という記法は、あくまでもMarkdownパーサー(解析プログラム)が読み込んで初めて「太字」として機能します。noteのエディタにはそのパーサーが存在しないため、**は「アスタリスク2個」という文字列としてそのまま表示されてしまうのです。
Playwrightのfill()・type()がMarkdownを素通りさせる
Playwrightでテキストを入力する際に使うfill()やkeyboard.type()は、キーボードから文字を1文字ずつ打ち込む動作をシミュレートします。つまり、**Playwright**を使えばというテキストを渡すと、エディタには文字通り**Playwright**を使えばという文字列が入力されます。エディタ側でMarkdownを解釈する仕組みがない以上、**はそのまま残ります。
これはPlaywrightのバグでも設定ミスでもなく、noteのエディタ仕様とMarkdown記法の根本的な相性問題です。
AI出力にはMarkdown記法が混入しやすい
Claude・GPT-4o・Geminiなど主要なAIモデルは、デフォルトでMarkdown形式のテキストを出力する傾向があります。見出しには##、太字には**、リストには-や1.が自動的に付与されます。これはチャット画面やAPIのレスポンスをMarkdownレンダラーで表示することを前提にした仕様です。
プロンプトで「Markdownを使わないで」と指示しても、長文になるほど記法が混入しやすく、完全に制御するのは難しいのが現実です。結果として、自動投稿パイプラインでは「AIが生成したMarkdown混じりのテキストをそのままnoteに投稿してしまう」という問題が頻発します。
実際に、note自動投稿で6700円を瞬時に溶かした失敗談のように、仕様を理解せずに自動投稿を走らせてしまうと思わぬ損失につながるケースもあります。仕組みを正しく把握してから実装を進めることが大切です。
フェーズ2:解決アプローチの設計——前処理 vs エディタ操作
問題の原因が分かったところで、解決策を設計しましょう。大きく分けて2つのアプローチがあります。それぞれのメリット・デメリットを整理してから、ハナが推奨する方針を説明します。
アプローチA:Python側で正規表現による前処理(推奨)
Playwrightにテキストを渡す前に、Python側でMarkdown記法を除去または変換してしまうアプローチです。re.sub()(正規表現による文字列置換)を使って**太字**を太字に変換し、プレーンテキスト(書式情報のない純粋な文字列)としてエディタに流し込みます。
メリット:
- 実装がシンプルで、Pythonの標準ライブラリだけで完結する
- PlaywrightのバージョンアップやnoteのDOM構造変更に影響されない
- ユニットテスト(単体テスト)が書きやすく、品質管理がしやすい
- note以外のCMSにも同じ関数を使い回せる
デメリット:
- 太字・斜体などの書式情報が失われ、プレーンテキストになる
- 書式を完全に再現したい場合は追加の工夫が必要
アプローチB:PlaywrightでProseMirrorのDOM操作やキーボードショートカット
Playwrightを使ってProseMirrorのDOM(Document Object Model:ウェブページの構造を表すデータ)を直接操作したり、Ctrl+Bなどのキーボードショートカットを駆使して太字を再現するアプローチです。
メリット:
- 太字・斜体などの書式を実際にnote上で再現できる
デメリット:
- 実装が非常に複雑で、テキスト内の書式位置を正確に計算する必要がある
- noteのDOM構造やセレクター(HTML要素を指定する記述)が変わると動かなくなる
- メンテナンスコストが高く、長期運用に向かない
- 日本語テキストでの動作が不安定になりやすい
実際に半月かけて作ったnote自動投稿スクリプトが突然死んだという事例が示すように、DOM操作に頼った実装はnote側のアップデートで一瞬にして使えなくなるリスクがあります。
本記事の方針:アプローチAを採用する理由
ハナが推奨するのは迷わずアプローチA(正規表現前処理)です。自動投稿の目的は「記事を安定して公開し続けること」であり、書式の完全再現よりも運用の安定性と保守性のほうが長期的な価値があります。
noteはプレーンテキストでも十分に読みやすいプラットフォームです。太字がなくても、適切な段落分けと自然な文章があれば読者に伝わります。むしろ「AI感丸出しの**が残った記事」よりも「書式なしでも読みやすいプレーンな記事」のほうが、読者体験として優れています。
また、Claude Codeからnoteに自動投稿するPlaywright完全ガイドでも解説されているように、前処理パイプラインを整備することが自動投稿の品質向上に直結します。
正規表現前処理の実装手順
いよいよ実装です。ハナが実際に動作確認したコードをステップごとに解説します。コピー&ペーストしてすぐに使えるよう、完全なコードで提供しています。
ステップ0:対処するMarkdown記法の一覧を整理する
まず、AI出力テキストに含まれる可能性のあるMarkdown記法と、その対処方針を整理しておきましょう。これが前処理関数の設計図になります。
| Markdown記法 | 例 | 対処方針 |
|---|---|---|
**太字** |
**重要** |
記号を除去してテキストのみ残す |
*斜体* |
*簡単* |
記号を除去してテキストのみ残す |
## 見出し |
## はじめに |
行頭の#と空白を除去 |
`インラインコード` |
`re.sub` |
バッククォートを除去 |
| コードブロック | ```python ...``` |
フェンス記号を除去して中身を残す |
~~打ち消し~~ |
~~古い方法~~ |
記号を除去してテキストのみ残す |
[テキスト](URL) |
[公式](https://...) |
リンクテキストのみ残す |
| リスト記号 | - 項目 / 1. 項目 |
行頭の記号を除去 |
ステップ1:Markdownクリーニング関数の実装
AI生成テキストに含まれる**・*・##・バッククォートを正規表現で一括除去します。noteのProseMirrorエディタはMarkdown記法をそのまま文字列として扱うため、投稿前にプレーンテキストへ変換する必要があります。
import re
def clean_markdown_for_note(text: str) -> str:
# **太字** → 太字
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
# *斜体* → 斜体
text = re.sub(r'\*(.+?)\*', r'\1', text)
# ## 見出し → 見出し(行頭の#と空白を除去)
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
# `インラインコード` → インラインコード
text = re.sub(r'`(.+?)`', r'\1', text)
# ```コードブロック``` → 中身だけ残す
text = re.sub(r'```[\w]*\n(.*?)```', r'\1', text, flags=re.DOTALL)
# ~~打ち消し~~ → 打ち消し
text = re.sub(r'~~(.+?)~~', r'\1', text)
# [リンクテキスト](URL) → リンクテキスト
text = re.sub(r'\[(.+?)\]\(https?://[^\)]+\)', r'\1', text)
# 行頭の - や 1. などのリスト記号を除去
text = re.sub(r'^\s*[-\*]\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
# 連続する空行を1行に圧縮
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
ステップ2:動作確認用のテストコード
クリーニング関数が正しく動作するか、AI生成テキストを模したサンプルで確認します。
if __name__ == "__main__":
sample = """
## はじめに
**Playwright**を使えば、note.comへの自動投稿が*簡単*に実現できます。
### 手順
1. ライブラリをインストールする
2. `playwright install`を実行する
3. スクリプトを書く
```python
print("Hello, note!")
```
~~古い方法~~ではなく新しいアプローチを使いましょう。
詳細は[公式ドキュメント](https://playwright.dev)を参照してください。
"""
result = clean_markdown_for_note(sample)
print(result)
このサンプルを実行すると、以下のようなプレーンテキストが出力されます。
はじめに
Playwrightを使えば、note.comへの自動投稿が簡単に実現できます。
手順
ライブラリをインストールする
playwright installを実行する
スクリプトを書く
print("Hello, note!")
古い方法ではなく新しいアプローチを使いましょう。
詳細は公式ドキュメントを参照してください。
**や##がきれいに除去され、プレーンテキストになっていることが確認できます。日本語テキストも問題なく処理されています。
ステップ3:Playwrightを使ったnote自動投稿スクリプト(完全版)
ログインからクリーニング済みテキストの入力、公開ボタンのクリックまでを一連のフローで実装します。NOTE_EMAILとNOTE_PASSWORDは環境変数から取得する構成にしています。
import os
import re
import time
from playwright.sync_api import sync_playwright
def clean_markdown_for_note(text: str) -> str:
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'`(.+?)`', r'\1', text)
text = re.sub(r'```[\w]*\n(.*?)```', r'\1', text, flags=re.DOTALL)
text = re.sub(r'~~(.+?)~~', r'\1', text)
text = re.sub(r'\[(.+?)\]\(https?://[^\)]+\)', r'\1', text)
text = re.sub(r'^\s*[-\*]\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
def post_to_note(title: str, body: str):
email = os.environ["NOTE_EMAIL"]
password = os.environ["NOTE_PASSWORD"]
clean_body = clean_markdown_for_note(body)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
# ログイン
page.goto("https://note.com/login")
page.wait_for_selector('input[name="email"]')
page.fill('input[name="email"]', email)
page.fill('input[name="password"]', password)
page.click('button[type="submit"]')
page.wait_for_url("https://note.com/", timeout=15000)
# 新規記事作成ページへ
page.goto("https://note.com/notes/new")
page.wait_for_selector('.o-editable__title', timeout=15000)
# タイトル入力
page.click('.o-editable__title')
page.keyboard.type(title)
# 本文入力(ProseMirrorエディタ)
page.click('.ProseMirror')
time.sleep(0.5)
# 改行を含むテキストを1行ずつ入力
lines = clean_body.split('\n')
for i, line in enumerate(lines):
page.keyboard.type(line)
if i < len(lines) - 1:
page.keyboard.press('Enter')
time.sleep(1)
# 公開設定ボタンをクリック
page.click('button[data-type="publish"]')
page.wait_for_selector('.m-publishModal', timeout=10000)
# 公開範囲:全員に公開を選択(デフォルトのまま)
# 公開ボタンをクリック
page.click('.m-publishModal__submit')
page.wait_for_url(re.compile(r'https://note\.com/.+/n/.+'), timeout=15000)
published_url = page.url
print(f"公開完了: {published_url}")
browser.close()
return published_url
このスクリプトのポイントは、post_to_note()関数の冒頭でclean_markdown_for_note()を呼び出している点です。Playwrightがエディタにテキストを流し込む前に、必ずMarkdown記法が除去されたクリーンなテキストが用意されます。これにより、どんなAI出力が来ても**が残るリスクをほぼゼロにできます。
また、本文入力をsplit('\n')で1行ずつ処理してEnterキーを押す実装にしているのは、ProseMirrorエディタが改行を正しく認識するためです。一括でfill()するよりも安定した動作が期待できます。
フェーズ4:動作確認・エッジケース対策と応用
実装が完成したら、本番投稿の前に必ずテストを行いましょう。また、実運用で遭遇しやすいエッジケース(特殊なケース)への対策も重要です。
ユニットテストで前処理関数を検証する
Pythonのunittestまたはpytestを使って、clean_markdown_for_note()の動作をテストします。以下のようなテストケースを用意しておくと、将来的に正規表現を修正した際のリグレッション(デグレ:修正によって既存の動作が壊れること)を防げます。
import unittest
from your_module import clean_markdown_for_note
class TestCleanMarkdown(unittest.TestCase):
def test_bold(self):
self.assertEqual(clean_markdown_for_note("**太字**です"), "太字です")
def test_italic(self):
self.assertEqual(clean_markdown_for_note("*斜体*です"), "斜体です")
def test_heading(self):
self.assertEqual(clean_markdown_for_note("## 見出し"), "見出し")
def test_inline_code(self):
self.assertEqual(clean_markdown_for_note("`code`を実行"), "codeを実行")
def test_strikethrough(self):
self.assertEqual(clean_markdown_for_note("~~古い~~方法"), "古い方法")
def test_link(self):
self.assertEqual(
clean_markdown_for_note("[公式](https://example.com)"),
"公式"
)
def test_list_hyphen(self):
self.assertEqual(clean_markdown_for_note("- 項目A"), "項目A")
def test_list_number(self):
self.assertEqual(clean_markdown_for_note("1. 手順A"), "手順A")
def test_multiple_blank_lines(self):
result = clean_markdown_for_note("段落1\n\n\n\n段落2")
self.assertEqual(result, "段落1\n\n段落2")
if __name__ == "__main__":
unittest.main()
このテストをpython -m pytest test_clean_markdown.pyで実行し、全件パスすることを確認してから本番投稿に移りましょう。
エッジケース:誤検知・誤変換を防ぐ調整ポイント
正規表現は強力ですが、意図しないパターンにマッチしてしまう「誤検知」が発生することがあります。実運用で注意すべきエッジケースをまとめます。
① URLに含まれる * の誤検知
URLに*が含まれるケース(例:ワイルドカードを含むURL)では、\*(.+?)\*パターンが誤マッチすることがあります。対策として、Markdown記法の処理順序を「コードブロック→インラインコード→太字→斜体」の順にし、URLはリンク記法の処理で先に除去しておくことが重要です。現在のコードはこの順序を守っています。
② ネストした ** パターン
**外側**と**内側**のように同一行に複数の太字パターンがある場合、.+?(最短マッチ)を使っているため正しく処理されます。ただし、***太字かつ斜体***のような複合パターンは現在の実装では完全には処理できません。AIへのプロンプトで***を使わないよう指示するか、追加のパターンを実装してください。
# ***太字かつ斜体*** の対応を追加する場合
text = re.sub(r'\*\*\*(.+?)\*\*\*', r'\1', text)
# ↑ この行を **太字** の処理より前に追加する
③ 日本語の句読点・記号との混在
日本語テキストでは この記事はハナ編集部が執筆しました。**重要な点:**のように、Markdown記法の内側に全角コロンや句読点が含まれることがあります。

コメント