Skip to main content

AI の回答にある markdown の不完全な強調表示を hack

· 8 min read

前置き

以下、誰も興味を持てない内容なので skip 推奨。この手の小手先の回避策は直ぐに忘れるので記録しておく。

目次

  1. 何か問題か
  2. 対処方法
  3. スクリプト

何か問題か

既に、AI が生成した文章を Blog などに埋め込むのは当たり前となってきたが、次のような問題がある。

ChatGPT も Gemini も NotebookLM も、生成した回答文は markdown 書式を用いている。 markdown 書式では **ここは強調箇所** というように** で挟みこむことで、特定の箇所のみ強調表示するようになっており、AI が生成する回答文でも多用されている。(*は本来は半角)

この ** の記法には条件があって **『 のような記号が直後に続く場合、正常に強調表示されないことになっている。だが、AI はそんなことはお構いなしに、直後に記号を連続させてしまう。これにより、本来なら強調表示される箇所が **『とそのまま表示されて、強調表示されないだけではなく、文章に混じった**が見苦しい。AI の開発者は英語圏なので、このような不具合は当面、放置される筈。

対処方法

AI の尻拭いは当然、AI にさせる…というわけで上記の問題を AI と対話しながら hack したのが下のスクリプト。根本から解決するには markdown の構文解釈に踏み込む必要があるが、そこには泥沼が待っているで踏み込まない。

以下、ChatGPT に作成させたスクリプト。GUI 版とコマンドライン版がある。

落とし穴

この記事に含まれるスクリプトには ** が含まれる。それゆえ、この記事自体を以下のスクリプトで変換することはまずい。これを回避するには本気で構文解析(のライブラリ)に踏み込む必要がある。

スクリプト

以下のスクリプトはざっとしかチェックしていない。AI との対話スレッドが長くなり過ぎたためか、AI のコード生成能力がスレッド長に反比例して低下しているような感触。

コマンドライン版

#!/usr/bin/env python
# -*- coding: utf-8 -*-

r"""
md_2astr_wrapper.py

複数の Markdown ファイルに対して、ゼロ幅非接合子 (U+200C) を用いた ** マーカーのラップ
およびバックアップ作成を行うコマンドラインツール。

使い方:
md_2astr_wrapper.py [-h] file [file ...]

file 処理対象の Markdown ファイル (拡張子 .md/.mdx 推奨)

オプション:
-h, --help このヘルプメッセージを表示して終了

処理内容:
1. <file> のコピーを <file>_bk に作成
2. <file> を UTF-8 で読み込み
3. 全ての "**" の前後にゼロ幅非接合子を挿入
4. <file> に UTF-8 で上書き出力

複数ファイルを指定すると、順に処理を行います。
どれか一つでエラーが生じた場合、そのファイル名を stderr に出力し、即時終了します。


"""
import argparse
import sys
import os
import shutil
import re

ZWNJ = '\u200C'
PLAIN = '**'
WRAPPED = f'{ZWNJ}{PLAIN}{ZWNJ}'


# === 変換除外リスト(basename+拡張子) ===
IGNORE_LIST = ['2025-07-14-hack_ai_generated_md_text.md', 'skip_this.md', 'ignore_sample.mdx']

def wrap_zwnj_once(text: str) -> str:
# 既に ZWNJ に囲まれていない ** のみをラップ
pattern = re.compile(rf'(?<!{ZWNJ}){re.escape(PLAIN)}(?!{ZWNJ})')
return pattern.sub(WRAPPED, text)


def process_file(path: str) -> None:
# バックアップを作成
bak = path + '_bk'
shutil.copy2(path, bak)

# ファイル読み込み
with open(path, 'r', encoding='utf-8') as f:
raw = f.read()

# マーカーをラップ
result = wrap_zwnj_once(raw)

# 上書き出力
with open(path, 'w', encoding='utf-8') as f:
f.write(result)


def main():
parser = argparse.ArgumentParser(
description='Wrap Markdown ** markers with ZWNJ and backup originals',
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument(
'files', nargs='+', help='Markdown files to process'
)
args = parser.parse_args()

for filepath in args.files:
basename = os.path.basename(filepath)
# 除外リストにある場合はスキップ
if basename in IGNORE_LIST:
print(f'Skipped ignored file: {basename}', file=sys.stderr)
continue
if not os.path.isfile(filepath):
print(f'Error: file not found: {filepath}', file=sys.stderr)
sys.exit(1)
try:
process_file(filepath)
except Exception as e:
print(f'Error processing {filepath}: {e}', file=sys.stderr)
sys.exit(1)

if __name__ == '__main__':
main()

GUI 版

#!/usr/bin/env python
# -*- coding: utf-8 -*-
r"""
ZWNJ Wrapper GUI

Windows11 + PyQt5 で Markdown ファイルをドラッグ&ドロップし、
ゼロ幅非接合子(U+200C)による ** マーカーのラップとバックアップを実行します。
エラー時は一覧に「エラー: ファイル名」、正常時は「正常終了: ファイル名」と表示。
再変換時はエラー表示されたファイルのみ処理します。

依存: Python3, PyQt5, 標準ライブラリ
"""
import sys
import os
import shutil
import re
from PyQt5.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QListWidget, QLabel, QMessageBox
)

# === 変換除外リスト(basename+拡張子) ===
IGNORE_LIST = ['2025-07-14-hack_ai_generated_md_text.md', 'skip_this.md', 'ignore_sample.mdx']

ZWNJ = '\u200C'
PLAIN = '**'
WRAPPED = f'{ZWNJ}{PLAIN}{ZWNJ}'


def wrap_zwnj_once(text: str) -> str:
pattern = re.compile(rf'(?<!{ZWNJ}){re.escape(PLAIN)}(?!{ZWNJ})')
return pattern.sub(WRAPPED, text)


def process_file(path: str) -> bool:
bak = path + '_bk'
try:
shutil.copy2(path, bak)
except Exception as e:
print(f'Backup failed: {e}', file=sys.stderr)
return False
try:
raw = open(path, 'r', encoding='utf-8').read()
except Exception as e:
print(f'Read failed: {e}', file=sys.stderr)
return False
count = raw.count(PLAIN)
if count % 2 != 0:
print(f'Warning: odd PLAIN count {count}', file=sys.stderr)
result = wrap_zwnj_once(raw)
try:
open(path, 'w', encoding='utf-8').write(result)
except Exception as e:
print(f'Write failed: {e}', file=sys.stderr)
return False
return True

class ZwnjGUI(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle('ZWNJ Wrapper GUI')
self.resize(600, 400)
self.setAcceptDrops(True)

self.list_widget = QListWidget()
self.info_label = QLabel('ファイルをドラッグ&ドロップしてください')

self.convert_btn = QPushButton('変換')
self.convert_btn.clicked.connect(self.on_convert)

layout = QVBoxLayout(self)
layout.addWidget(self.info_label)
layout.addWidget(self.list_widget)
btn_layout = QHBoxLayout()
btn_layout.addWidget(self.convert_btn)
layout.addLayout(btn_layout)

def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()

def dropEvent(self, event):
for url in event.mimeData().urls():
path = url.toLocalFile()
# *.md, *.mdx のみ登録かつ除外リストにない
basename = os.path.basename(path)
if (os.path.isfile(path)
and path.lower().endswith(('.md', '.mdx'))
and basename not in IGNORE_LIST):
existing = [self.list_widget.item(i).text() for i in range(self.list_widget.count())]
if path not in existing:
self.list_widget.addItem(path)
self.info_label.setText('ファイルを登録しました')

def on_convert(self):
for i in range(self.list_widget.count()):
item = self.list_widget.item(i)
text = item.text()
# basename抽出
path = text.split(':', 1)[-1] if ':' in text else text
basename = os.path.basename(path)
# 除外ファイルはスキップ
if basename in IGNORE_LIST:
continue
# 再変換時はエラーのもののみ、初回は全て
if text.startswith('正常終了:'):
continue
success = process_file(path)
if success:
item.setText(f'正常終了:{path}')
else:
item.setText(f'エラー:{path}')

if __name__ == '__main__':
app = QApplication(sys.argv)
gui = ZwnjGUI()
gui.show()
sys.exit(app.exec_())

(2025-07-14)