API自動化 Python

Markdown原稿をnote用HTMLに変換して投稿するPythonスクリプト

変換処理

noteに送れるタグは、驚くほど少ない。だからこそ、変換には「捨てる設計」が要る。

Markdownで書いて、noteへ流し込むまでを実装します

📋 この記事でわかること

  • noteで生き残るタグと、消えるタグの一覧
  • markdownライブラリを使った変換+サニタイズ
  • 追加インストール不要の自前パーサ実装
  • 見出しレベルの調整(h1をh2に落とす処理)
  • 表やコードブロックなど、非対応要素の代替策
  • フロントマター付き原稿を一括で下書き化する流れ

Markdownで原稿を書く人にとって、noteへの移行はひと手間です。noteのエディタはMarkdown記法を直接受け付けないため、コピーして貼り付けると記号がそのまま文字として表示されます。結局、見出しを付け直し、リストを作り直すことになります。APIを使えば、この変換をプログラムに任せられます。ただし、noteが受け付けるHTMLタグは限られており、変換したものをそのまま送っても意図した形にはなりません。この記事では、noteの制限に合わせた変換処理を実装します。下書き作成の記事と組み合わせると、Markdownファイルから下書きまで一気通貫で処理できます。

この記事で作るもの

  1. Markdown → note用HTMLの変換関数(2種類の実装)
  2. 非対応タグを除去するサニタイザ
  3. 変換前後を比較して問題を検出する検証ツール
  4. フォルダ内のMarkdownを一括で下書き化するスクリプト

noteで生き残るタグ・消えるタグ

変換処理を書く前に、送り先の制限を正確に把握しておきます。ここを理解していないと、変換の精度を上げても無駄になります。

Markdown記法 変換後のHTML noteでの扱い
# 見出し1 h1 h2に落とすのが無難
## 見出し2 h2 そのまま使える
### 見出し3 h3 そのまま使える
#### 見出し4以降 h4〜 h3に丸める
**太字** strong そのまま使える
*斜体* em そのまま使える
~~打消~~ s / del sに変換すれば使える
`コード` code そのまま使える
- リスト ul / li そのまま使える
1. 番号リスト ol / li そのまま使える
> 引用 blockquote そのまま使える
[リンク](url) a href属性のみ残る
| 表 | table 非対応。消える
```コードブロック``` pre / code note側の埋め込み機能が別にある
--- hr 環境により消える

特に注意すべきは表(table)が使えない点です。Markdownで表を書いていると、変換後に丸ごと消えます。代替としてリスト形式に展開する処理を後述します。

装飾系はすべて落ちる

spandivstyle属性、クラス指定——これらはすべて除去されます。Markdownの中にHTMLを直接書いて装飾している場合、その部分は失われると考えてください。noteは意図的に見た目の自由度を制限しており、これは仕様です。装飾で伝えるのではなく、構成と言葉で伝える設計に切り替える必要があります。

markdownライブラリを使った変換

もっとも簡単なのは、既存のライブラリで変換してからサニタイズする方法です。

md_converter.py
"""
インストール:
    pip install markdown

Markdown → note用HTML の変換。
変換後にサニタイズして、noteが受け付ける形に整える。
"""
import re

import markdown

from sanitize import sanitize   # 下書きの記事で作成したサニタイザ

# 有効にする拡張機能
EXTENSIONS = [
    "extra",        # 表・定義リストなど
    "sane_lists",   # リストの解釈を厳密に
    "nl2br",        # 改行をbrに変換
]


def md_to_html(md_text: str) -> str:
    """Markdownを素のHTMLに変換する"""
    return markdown.markdown(md_text, extensions=EXTENSIONS)


def adjust_headings(html: str) -> str:
    """
    見出しレベルをnote向けに調整する。
    h1 → h2、h4以降 → h3 に丸める。
    """
    html = re.sub(r"<(/?)h1>", r"<\1h2>", html)
    html = re.sub(r"<(/?)h[4-6]>", r"<\1h3>", html)
    return html


def convert_del_to_s(html: str) -> str:
    """del タグを s に置き換える"""
    return re.sub(r"<(/?)del>", r"<\1s>", html)


def to_note_html(md_text: str, verbose: bool = True) -> str:
    """Markdown原稿をnoteに投稿できるHTMLへ変換する"""
    html = md_to_html(md_text)
    html = adjust_headings(html)
    html = convert_del_to_s(html)
    html = table_to_list(html)
    return sanitize(html, verbose=verbose)


def table_to_list(html: str) -> str:
    """
    表はnoteで消えるため、リスト形式に展開する。
    ヘッダ行を項目名として、各行を「項目: 値」の形にする。
    """
    def replace(match):
        table_html = match.group(0)

        rows = re.findall(r"<tr>(.*?)</tr>", table_html, re.S)
        if not rows:
            return ""

        headers = re.findall(r"<th[^>]*>(.*?)</th>", rows[0], re.S)
        headers = [strip_tags(h) for h in headers]

        items = []
        for row in rows[1:]:
            cells = re.findall(r"<td[^>]*>(.*?)</td>", row, re.S)
            cells = [strip_tags(c) for c in cells]
            if not cells:
                continue

            if headers and len(headers) == len(cells):
                text = " / ".join(
                    f"{h}: {c}" for h, c in zip(headers, cells) if c
                )
            else:
                text = " / ".join(c for c in cells if c)

            if text:
                items.append(f"<li>{text}</li>")

        return f"<ul>{''.join(items)}</ul>" if items else ""

    return re.sub(r"<table.*?</table>", replace, html, flags=re.S)


def strip_tags(text: str) -> str:
    """タグを除去して中身のテキストだけにする"""
    return re.sub(r"<[^>]+>", "", text).strip()


if __name__ == "__main__":
    sample = """# タイトル見出し

これは**太字**と*斜体*と`コード`を含む段落です。

## セクション

- 項目1
- 項目2

> 引用文です。

| 名前 | 価格 |
|------|------|
| A商品 | 500円 |
| B商品 | 800円 |

[リンク](https://example.com)です。
"""
    print(to_note_html(sample))

表をリストに展開する処理を入れているのがポイントです。消えるくらいなら、情報が残る形に落とすという判断です。「A商品 / 500円」のような形になり、見た目は劣りますが内容は伝わります。

依存なしで変換する自前パーサ

ライブラリを入れられない環境や、変換の挙動を完全に制御したい場合のために、標準ライブラリだけで動く実装も用意します。

simple_md.py
"""
外部ライブラリを使わないMarkdown変換。
noteで使えるタグだけを出力するので、サニタイズ不要。
"""
import re
from html import escape


def inline(text: str) -> str:
    """インライン記法を変換する(エスケープ済みの文字列に対して適用)"""
    # コードは最初に処理して、中身を保護する
    codes = []

    def stash_code(m):
        codes.append(m.group(1))
        return f"\x00CODE{len(codes) - 1}\x00"

    text = re.sub(r"`([^`]+)`", stash_code, text)

    # エスケープ
    text = escape(text)

    # 太字 → strong
    text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
    text = re.sub(r"__([^_]+)__", r"<strong>\1</strong>", text)

    # 斜体 → em
    text = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", text)

    # 打ち消し → s
    text = re.sub(r"~~([^~]+)~~", r"<s>\1</s>", text)

    # リンク(画像記法は別扱いなので先に除外)
    text = re.sub(
        r"\[([^\]]+)\]\(([^)]+)\)",
        lambda m: f'<a href="{escape(m.group(2))}">{m.group(1)}</a>',
        text,
    )

    # 退避したコードを戻す
    for i, code in enumerate(codes):
        text = text.replace(
            f"\x00CODE{i}\x00",
            f"<code>{escape(code)}</code>",
        )

    return text


def convert(md_text: str) -> str:
    """Markdown原稿をnote用HTMLに変換する"""
    lines = md_text.split("\n")
    out = []
    i = 0
    n = len(lines)

    while i < n:
        line = lines[i]
        stripped = line.strip()

        # 空行
        if not stripped:
            i += 1
            continue

        # コードブロック(``` で囲まれた部分)
        if stripped.startswith("```"):
            i += 1
            buf = []
            while i < n and not lines[i].strip().startswith("```"):
                buf.append(lines[i])
                i += 1
            i += 1
            body = escape("\n".join(buf))
            # noteにpreは無いので、コード表記の段落として出す
            out.append(f"<p><code>{body}</code></p>")
            continue

        # 見出し
        m = re.match(r"^(#{1,6})\s+(.+)$", stripped)
        if m:
            level = len(m.group(1))
            tag = "h2" if level <= 2 else "h3"
            out.append(f"<{tag}>{inline(m.group(2))}</{tag}>")
            i += 1
            continue

        # 引用(連続する行をまとめる)
        if stripped.startswith(">"):
            buf = []
            while i < n and lines[i].strip().startswith(">"):
                buf.append(lines[i].strip().lstrip(">").strip())
                i += 1
            body = "<br>".join(inline(b) for b in buf if b)
            out.append(f"<blockquote>{body}</blockquote>")
            continue

        # 箇条書き
        if re.match(r"^[-*+]\s+", stripped):
            items = []
            while i < n and re.match(r"^[-*+]\s+", lines[i].strip()):
                content = re.sub(r"^[-*+]\s+", "", lines[i].strip())
                items.append(f"<li>{inline(content)}</li>")
                i += 1
            out.append(f"<ul>{''.join(items)}</ul>")
            continue

        # 番号付きリスト
        if re.match(r"^\d+\.\s+", stripped):
            items = []
            while i < n and re.match(r"^\d+\.\s+", lines[i].strip()):
                content = re.sub(r"^\d+\.\s+", "", lines[i].strip())
                items.append(f"<li>{inline(content)}</li>")
                i += 1
            out.append(f"<ol>{''.join(items)}</ol>")
            continue

        # 水平線はスキップ
        if re.match(r"^(-{3,}|\*{3,}|_{3,})$", stripped):
            i += 1
            continue

        # 表(連続する | の行をリストに変換)
        if stripped.startswith("|"):
            rows = []
            while i < n and lines[i].strip().startswith("|"):
                rows.append(lines[i].strip())
                i += 1
            out.append(table_lines_to_list(rows))
            continue

        # 通常の段落(連続する行を1段落にまとめる)
        buf = []
        while i < n and lines[i].strip() and not is_block_start(lines[i]):
            buf.append(lines[i].strip())
            i += 1
        if buf:
            body = "<br>".join(inline(b) for b in buf)
            out.append(f"<p>{body}</p>")

    return "".join(out)


def is_block_start(line: str) -> bool:
    """その行が新しいブロックの開始かを判定する"""
    s = line.strip()
    return bool(
        re.match(r"^#{1,6}\s", s)
        or re.match(r"^[-*+]\s", s)
        or re.match(r"^\d+\.\s", s)
        or s.startswith(">")
        or s.startswith("|")
        or s.startswith("```")
    )


def table_lines_to_list(rows: list) -> str:
    """Markdownの表をリストに変換する"""
    def cells(row: str) -> list:
        return [c.strip() for c in row.strip("|").split("|")]

    # 区切り行(|---|---|)を除去
    body = [r for r in rows if not re.match(r"^\|[\s:|-]+\|$", r)]
    if not body:
        return ""

    headers = cells(body[0])
    items = []

    for row in body[1:]:
        values = cells(row)
        if headers and len(headers) == len(values):
            text = " / ".join(
                f"{h}: {v}" for h, v in zip(headers, values) if v
            )
        else:
            text = " / ".join(v for v in values if v)
        if text:
            items.append(f"<li>{inline(text)}</li>")

    return f"<ul>{''.join(items)}</ul>" if items else ""


if __name__ == "__main__":
    sample = """## テスト見出し

これは**太字**と`コード`を含む段落です。
2行目もここに続きます。

- 項目A
- 項目B

> 引用です
> 2行目

| 名前 | 価格 |
|------|------|
| A | 500円 |
"""
    print(convert(sample))

コードを最初に退避してからエスケープしている点が実装上の要点です。この順序を逆にすると、コード内の記号が変換されてしまいます

▶ 変換したHTMLを実際にnoteへ送る処理は、下書き作成の記事にあります。
2段階のAPI仕様から解説しています。

下書き自動作成を見る

フロントマター付き原稿に対応する

静的サイトジェネレーターで使われるフロントマター形式で原稿を管理していると、タイトルやタグをファイル内に持たせられます。

frontmatter.py
"""
YAMLフロントマター付きMarkdownを解析する。
PyYAMLに依存しない簡易パーサ。

---
title: 記事のタイトル
tags: [Python, note, 自動化]
price: 0
---
本文...
"""
import re


def parse(content: str) -> tuple:
    """
    フロントマターと本文を分離する。
    戻り値: (メタ情報のdict, 本文のstr)
    """
    if not content.startswith("---"):
        return {}, content

    parts = content.split("---", 2)
    if len(parts) < 3:
        return {}, content

    meta_text = parts[1]
    body = parts[2].lstrip("\n")

    meta = {}
    for line in meta_text.split("\n"):
        line = line.strip()
        if not line or line.startswith("#") or ":" not in line:
            continue

        key, value = line.split(":", 1)
        key = key.strip()
        value = value.strip()

        # クォートを外す
        if value and value[0] in "\"'" and value[-1] == value[0]:
            value = value[1:-1]

        # 配列表記 [a, b, c]
        if value.startswith("[") and value.endswith("]"):
            items = [
                v.strip().strip("\"'")
                for v in value[1:-1].split(",")
            ]
            meta[key] = [v for v in items if v]
            continue

        # 数値
        if re.fullmatch(r"-?\d+", value):
            meta[key] = int(value)
            continue

        # 真偽値
        if value.lower() in ("true", "false"):
            meta[key] = value.lower() == "true"
            continue

        meta[key] = value

    return meta, body


def extract_title(meta: dict, body: str, fallback: str = "") -> str:
    """タイトルを決める。frontmatter → 本文のh1 → ファイル名の順"""
    if meta.get("title"):
        return str(meta["title"])

    m = re.search(r"^#\s+(.+)$", body, re.MULTILINE)
    if m:
        return m.group(1).strip()

    return fallback


def strip_title_heading(body: str) -> str:
    """本文冒頭のh1見出しを取り除く(タイトルと重複するため)"""
    return re.sub(r"^#\s+.+\n+", "", body, count=1)


if __name__ == "__main__":
    sample = """---
title: "テスト記事のタイトル"
tags: [Python, 自動化]
price: 500
draft: false
---

# 本文の見出し

ここから本文です。
"""
    meta, body = parse(sample)
    print("メタ情報:", meta)
    print("タイトル:", extract_title(meta, body))
    print("本文:", strip_title_heading(body)[:40])

変換結果を検証する

変換して送る前に、何が失われるかを確認できると安心です。検証専用のスクリプトを用意します。

validate.py
"""
変換結果を検証し、note投稿時に問題になる箇所を報告する。
実際に投稿する前に必ず通す。
"""
import re

ALLOWED_TAGS = {
    "p", "h2", "h3", "ul", "ol", "li",
    "blockquote", "strong", "em", "s", "code", "a", "br",
}

WARN_PATTERNS = [
    (r"<table", "表が残っています。noteでは表示されません"),
    (r"<img", "画像タグがあります。先にアップロードが必要です"),
    (r"<pre", "preタグは非対応です"),
    (r"<h1", "h1は使えません。h2に変換してください"),
    (r"<h[4-6]", "h4以降は使えません。h3に丸めてください"),
    (r'style\s*=', "style属性は除去されます"),
    (r"<div", "divは除去されます"),
    (r"<span", "spanは除去されます"),
]


def used_tags(html: str) -> set:
    return set(re.findall(r"<(\w+)", html))


def validate(html: str, verbose: bool = True) -> dict:
    """HTMLを検証して結果を返す"""
    tags = used_tags(html)
    disallowed = tags - ALLOWED_TAGS

    warnings = []
    for pattern, message in WARN_PATTERNS:
        if re.search(pattern, html, re.IGNORECASE):
            warnings.append(message)

    text_only = re.sub(r"<[^>]+>", "", html)
    text_length = len(re.sub(r"\s", "", text_only))

    result = {
        "ok": not disallowed and not warnings,
        "tags": sorted(tags),
        "disallowed": sorted(disallowed),
        "warnings": warnings,
        "text_length": text_length,
        "html_length": len(html),
    }

    if verbose:
        print("=== 変換結果の検証 ===")
        print(f"  本文の文字数 : {text_length:,} 文字")
        print(f"  HTMLの長さ   : {len(html):,} 文字")
        print(f"  使用タグ     : {', '.join(result['tags'])}")

        if disallowed:
            print(f"  非対応タグ   : {', '.join(result['disallowed'])}")

        if warnings:
            print("  警告:")
            for w in warnings:
                print(f"    - {w}")

        if result["ok"]:
            print("  → 問題ありません")

    return result


def preview(html: str, chars: int = 300):
    """変換後のテキストを目視確認する"""
    text = re.sub(r"<br>", "\n", html)
    text = re.sub(r"</(p|h2|h3|li|blockquote)>", "\n", text)
    text = re.sub(r"<[^>]+>", "", text)
    text = re.sub(r"\n{3,}", "\n\n", text)

    print("\n=== 本文プレビュー ===")
    print(text[:chars].strip())
    if len(text) > chars:
        print("...")


if __name__ == "__main__":
    from simple_md import convert

    sample = """## 見出し

本文です。**太字**もあります。

| A | B |
|---|---|
| 1 | 2 |
"""
    html = convert(sample)
    validate(html)
    preview(html)

▼ RECOMMENDATION ▼

書く環境を整えても、読まれる仕組みは別に要る

Markdownで書いて自動で流し込めるようになれば、執筆の効率は上がります。しかし記事を読む人を増やす部分は、別の仕組みが必要です。アメブロであれば、アメプレスProがいいね・フォロー・アクセス獲得を自動で回します。コードを書く必要はありません。noteと違ってASPアフィリエイトも使えるため、書いた記事を収益につなげる設計もしやすくなります。月額2,980円、365日LINEサポート付きです。

✦ プログラミング不要 ✦ 自動いいね・フォロー ✦ WordPress連携 ✦ 365日LINEサポート
アメプレスPro 公式ページを確認する →

※ 当サイトはアフィリエイト広告を含みます。

Markdownファイルを一括で下書き化する

ここまでの部品を組み合わせて、実用的なスクリプトにします。フォルダ内のMarkdownを読み、変換・検証してからnoteの下書きにします。

md_to_note.py
"""
posts/ フォルダのMarkdownを、noteの下書きとして一括作成する。

    posts/
      2024-01-15-first.md
      2024-01-22-second.md

各ファイルはフロントマター付きでもよい。
処理済みは posts_done/ へ移動する。
"""
import os
import shutil
import sys
import time

from note_auth_client import NoteAuthClient, CookieExpiredError
from note_draft import NoteDraft
from simple_md import convert
from frontmatter import parse, extract_title, strip_title_heading
from validate import validate

SOURCE_DIR = "posts"
DONE_DIR = "posts_done"
SLEEP_SEC = 3.0
MAX_PER_RUN = 8
MIN_TEXT_LENGTH = 200


def process_file(path: str) -> dict:
    """1ファイルを読み込んで、投稿用のデータに変換する"""
    with open(path, encoding="utf-8") as f:
        content = f.read()

    meta, body = parse(content)

    fallback = os.path.splitext(os.path.basename(path))[0]
    title = extract_title(meta, body, fallback)
    body = strip_title_heading(body)

    html = convert(body)
    result = validate(html, verbose=False)

    return {
        "title": title,
        "html": html,
        "meta": meta,
        "validation": result,
    }


def main(dry_run: bool = False):
    if not os.path.isdir(SOURCE_DIR):
        print(f"{SOURCE_DIR}/ を作成して、Markdownを置いてください")
        return

    files = sorted(
        f for f in os.listdir(SOURCE_DIR) if f.endswith(".md")
    )
    if not files:
        print("処理対象のファイルがありません")
        return

    print(f"対象: {len(files)} ファイル"
          f"{'(確認のみ)' if dry_run else ''}\n")

    client = None
    draft = None

    if not dry_run:
        try:
            client = NoteAuthClient()
            client.verify()
            draft = NoteDraft(client)
        except CookieExpiredError as e:
            print("認証エラー:", e)
            return
        os.makedirs(DONE_DIR, exist_ok=True)

    created = 0

    for filename in files[:MAX_PER_RUN]:
        path = os.path.join(SOURCE_DIR, filename)
        print(f"--- {filename}")

        try:
            data = process_file(path)
        except Exception as e:
            print(f"  読み込みエラー: {e}")
            continue

        v = data["validation"]
        print(f"  タイトル: {data['title'][:44]}")
        print(f"  本文    : {v['text_length']:,} 文字")

        if v["warnings"]:
            for w in v["warnings"]:
                print(f"  警告: {w}")

        if v["text_length"] < MIN_TEXT_LENGTH:
            print(f"  スキップ: 本文が短すぎます")
            continue

        if dry_run:
            continue

        # 重複チェック
        if draft.find_by_title(data["title"]):
            print("  スキップ: 同名の下書きが既にあります")
            continue

        try:
            result = draft.create(data["title"], data["html"])
            print(f"  作成: {result['edit_url']}")
            created += 1
            shutil.move(path, os.path.join(DONE_DIR, filename))
        except Exception as e:
            print(f"  作成エラー: {e}")

        time.sleep(SLEEP_SEC)

    if dry_run:
        print("\n確認のみで終了しました。"
              "実際に作成するには --run を付けてください")
    else:
        print(f"\n完了: {created} 件の下書きを作成しました")


if __name__ == "__main__":
    # 既定は確認のみ。--run を付けたときだけ実際に作成する
    main(dry_run="--run" not in sys.argv)

既定を「確認のみ」にしている理由

投稿系のスクリプトは、うっかり実行すると取り返しがつきません。--run を明示的に付けたときだけ実際に作成する設計にしておくと、事故を防げます。まず確認モードで実行して、タイトルと文字数、警告の有無を確認してから本実行する——この2段階を習慣にしてください。

変換で困りやすいケース

困りごと 原因 対処
改行が反映されない 1行の改行が無視される nl2br拡張を使う、またはbrを挿入
表が消えた noteに表機能がない リストに展開する
コードブロックが崩れる preタグが非対応 codeを含む段落にする
画像が表示されない ローカルパスを参照している 先にアップロードしてURLを得る
記号が文字化けする エスケープの順序ミス コードを退避してからエスケープ
見出しが大きすぎる h1のまま送っている h2に変換する
リンクが効かない href以外の属性を付けている hrefのみ残す

変換前後を並べて確認するCLI

変換処理を調整していると、「どこがどう変わったか」を素早く確認したくなります。差分を表示するツールを用意しておくと作業が速くなります。

diff_check.py
"""
Markdown変換の前後を比較して、失われた情報を検出する。

    python diff_check.py article.md

- 元の原稿と変換後のテキストを行単位で比較
- 消えた要素を警告として表示
"""
import difflib
import re
import sys

from simple_md import convert
from frontmatter import parse, strip_title_heading
from validate import validate


def html_to_plain(html: str) -> str:
    """変換後のHTMLからテキストだけを取り出す"""
    text = re.sub(r"<br\s*/?>", "\n", html)
    text = re.sub(r"</(p|h2|h3|li|blockquote)>", "\n", text)
    text = re.sub(r"<li>", "- ", text)
    text = re.sub(r"<[^>]+>", "", text)

    # HTMLエンティティを戻す
    for entity, char in [
        ("&lt;", "<"), ("&gt;", ">"),
        ("&quot;", '"'), ("&amp;", "&"),
    ]:
        text = text.replace(entity, char)

    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()


def md_to_plain(md: str) -> str:
    """元の原稿からも記法を落として比較用にする"""
    text = md

    text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
    text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
    text = re.sub(r"\*([^*]+)\*", r"\1", text)
    text = re.sub(r"~~([^~]+)~~", r"\1", text)
    text = re.sub(r"`([^`]+)`", r"\1", text)
    text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
    text = re.sub(r"^>\s*", "", text, flags=re.MULTILINE)
    text = re.sub(r"^[-*+]\s+", "- ", text, flags=re.MULTILINE)
    text = re.sub(r"^(-{3,}|\*{3,})$", "", text, flags=re.MULTILINE)

    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()


def show_diff(original: str, converted: str, context: int = 1):
    """行単位の差分を表示する"""
    before = md_to_plain(original).split("\n")
    after = html_to_plain(converted).split("\n")

    diff = difflib.unified_diff(
        before, after,
        fromfile="元の原稿", tofile="変換後",
        lineterm="", n=context,
    )

    lines = list(diff)

    if len(lines) <= 2:
        print("  内容の差分はありません")
        return

    print("=== 差分 ===")
    for line in lines[:80]:
        if line.startswith("-") and not line.startswith("---"):
            print(f"  消えた: {line[1:][:64]}")
        elif line.startswith("+") and not line.startswith("+++"):
            print(f"  増えた: {line[1:][:64]}")


def count_elements(md: str) -> dict:
    """原稿内の要素数を数える"""
    return {
        "見出し": len(re.findall(r"^#{1,6}\s", md, re.MULTILINE)),
        "リスト項目": len(re.findall(r"^[-*+]\s", md, re.MULTILINE)),
        "番号リスト": len(re.findall(r"^\d+\.\s", md, re.MULTILINE)),
        "引用": len(re.findall(r"^>", md, re.MULTILINE)),
        "リンク": len(re.findall(r"\[[^\]]+\]\([^)]+\)", md)),
        "画像": len(re.findall(r"!\[[^\]]*\]\([^)]+\)", md)),
        "表の行": len(re.findall(r"^\|", md, re.MULTILINE)),
        "コードブロック": md.count("```") // 2,
    }


def main(path: str):
    with open(path, encoding="utf-8") as f:
        content = f.read()

    _, body = parse(content)
    body = strip_title_heading(body)

    html = convert(body)

    print("=== 原稿の要素 ===")
    for label, count in count_elements(body).items():
        if count:
            print(f"  {label}: {count}")

    print()
    validate(html)

    print()
    show_diff(body, html)

    print(f"\n=== 文字数 ===")
    print(f"  原稿   : {len(md_to_plain(body))} 文字")
    print(f"  変換後 : {len(html_to_plain(html))} 文字")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("使い方: python diff_check.py article.md")
        sys.exit(1)

    main(sys.argv[1])

文字数の比較が意外と役に立ちます。変換後に大きく減っていれば、表やコードブロックなどが落ちている可能性が高いと分かります。

まとめ:捨てるものを決めてから変換する

Markdown変換の要点は、変換の精度を上げることより「noteで使えないものをどう処理するか」を決めることにあります。表を消すのか、リストに落とすのか。この判断を先に済ませておけば、実装は機械的な作業になります。

この記事の要点

  1. noteで使えるタグは p / h2 / h3 / ul / ol / li / blockquote / strong / em / s / code / a / br
  2. span・div・style属性・table は除去される
  3. h1はh2に、h4以降はh3に丸める
  4. 表は消えるので、リスト形式に展開する
  5. コードは退避してからエスケープする(順序が重要)
  6. フロントマターでタイトルやタグを管理できる
  7. 送る前に検証スクリプトで失われる要素を確認する
  8. 投稿系スクリプトは既定を「確認のみ」にする

ここまでできれば、書く環境をnoteのエディタに縛られなくなります。使い慣れたエディタで書き、コマンド1つでnoteに反映する。この状態を作ると、書くことそのものに集中しやすくなります。

FAQ|Markdown変換についてよくある質問

Q. noteはMarkdown記法に対応していないのですか?

エディタ上では、一部の記法が入力補助として機能します。行頭に # を入力すると見出しになる、といった挙動です。ただし、Markdownテキストを丸ごと貼り付けても記法として解釈されるわけではありません。APIから送る場合も同様で、本文はHTMLとして扱われます。そのため変換処理が必要になります。

Q. markdownライブラリと自前パーサ、どちらを使うべきですか?

複雑なMarkdownを扱うならライブラリ、挙動を完全に制御したいなら自前パーサです。ライブラリは記法の網羅性が高い反面、noteで使えないタグも出力するためサニタイズが必須になります。自前パーサは最初からnoteで使えるタグしか出力しないので、後処理が不要です。原稿の書き方がある程度決まっているなら、自前パーサのほうが扱いやすいでしょう。

Q. 表をどうしても使いたいのですが

noteに表の機能がないため、直接は不可能です。実務的な代替案は3つあります。1つ目はリストに展開する方法(この記事の実装)。2つ目は表を画像として作成し、画像として貼る方法。3つ目は外部サービスの埋め込みを使う方法です。読者にとっての分かりやすさを優先するなら、項目数が少ない表はリスト展開、複雑な表は画像化という使い分けが現実的です。

Q. コードブロックはどう扱うのが最適ですか?

noteには外部サービスのコード埋め込み機能があるため、長いコードはそちらを使うのが読みやすくなります。短いコードであれば、この記事の実装のように code タグを含む段落として出力すれば最低限の体裁は保てます。ただしシンタックスハイライトは効きません。技術記事を継続的に書くなら、コード部分だけ外部サービスに置く運用を検討してください。

Q. 画像を含むMarkdownはどうなりますか?

画像記法はimgタグに変換されますが、ローカルパスを参照している場合は当然表示されません。またnote側でも、外部URLの画像がそのまま使えるとは限りません。画像を含む記事を自動投稿するには、先に画像をアップロードしてURLを取得し、そのURLで本文を組み立てる必要があります。この手順は画像アップロードの記事で扱っています。

Q. 変換すると改行がなくなります

Markdownの仕様では、1つの改行は段落内の折り返しとして扱われ、HTMLには反映されません。2つの改行(空行)で初めて段落が分かれます。1行ごとの改行を反映したい場合は、markdownライブラリなら nl2br 拡張を有効にしてください。この記事の自前パーサでは、連続する行を br で結合する実装にしています。

Q. 既存の記事をMarkdownで管理したいのですが

バックアップの記事で扱っている手順で、既存記事をMarkdownとして書き出せます。書き出したファイルを手元で編集し、この記事の変換処理でnoteへ戻す、という往復が可能です。ただし完全な往復変換はできず、埋め込みなど一部の要素は失われます。Markdownを正として管理したいなら、新しい記事から始めるほうが混乱しません。

Q. サニタイズは必ず必要ですか?

note側でも不正なタグは除去されるため、サニタイズしなくても壊れることはありません。しかし、何が除去されたか分からないまま投稿すると、意図と違う見た目になっていることに後で気づきます。手元でサニタイズして、除去されたタグを表示するようにしておけば、投稿前に問題を把握できます。安全のためというより、確認のために入れる工程です。

Q. 大量の記事を一度に移行できますか?

技術的には可能ですが、短時間に大量投稿するのは避けてください。この記事のスクリプトでは1回の実行で8件までという上限を設け、3秒の間隔を空けています。数十本を移行する場合は、日を分けて実行してください。また、機械的に大量投稿された記事は評価されにくいという側面もあります。移行後の見え方も含めて、ペースを考えることをおすすめします。

Q. 変換結果を投稿前に見る方法はありますか?

この記事の validate.py にプレビュー機能を入れてあります。タグを除去したテキストとして出力するので、本文の流れを確認できます。より正確に見た目を確認したい場合は、まず下書きとして作成し、noteの編集画面で確認してから公開するという手順が確実です。下書き作成であれば公開されないため、失敗しても影響がありません。

アメプレスラボ編集部

AMEPRESS LAB EDITORIAL TEAM

アメブロ・note・WordPressを実際に運営し、それぞれの収益構造を数字で検証しているチーム。プラットフォームごとの得意・不得意を踏まえた、宣伝色に偏らない実践情報の発信を続けています。