生成AIに日本語の文章を書かせていて、こういうものが出てきました。
개발の記録
устройства の使い方
안心してください
文字化けではありません。 「개발」は韓国語で「開発」、「устройства」はロシア語で「機器」、「안심」は韓国語で「安心」です。
意味は合っています。文字だけが別の言語になっている。翻訳が漏れたような形です。
そして、文脈にきれいに埋まっているので、読み流すと気づきません。 自分は実際に、公開直前まで気づかなかったものがあります。
機械的に検出するスクリプトを書いたので、置いておきます。標準ライブラリだけで動きます。
先に、判定方法
- ハングル・キリル文字・タイ文字など → Unicode の文字ブロックで判定。確実に拾える
- 簡体字・繁体字 → 日本語の漢字と同じ領域なので、ブロックでは区別できない
- そこで、CP932 で表現できるかどうかで判定する
この2つ目が、この記事の中身です。 最初は手書きのリストで作って、失敗しました。
文字化けとは違う
まず、区別しておきます。
文字化けは、エンコーディングの解釈がずれて起きます。縺薙l縺ッ のような、意味をなさない文字列になります。見ればすぐ分かる。
今回のものは、文字として正しい。 そして意味も合っている。言語だけが違う。
× 文字化け : 縺薙l縺ッ髢狗匱縺ョ險倬鹸縺ァ縺
△ 今回のもの: 개발の記録
後者のほうが、はるかに見つけにくいです。
どこに出るか
自分が遭遇した範囲では、こういう場所でした。
- 本文の単語ひとつ(「開発」の位置に「개발」)
-
タグやメタ情報の中(タグの並びに
работаが1つ混ざる) - 見出しの一部
全体が別言語になるのではなく、単語単位で置き換わります。 だから見落とす。
検出方法1:文字ブロック
ハングルやキリル文字は、日本語の文章に素の状態で出てくることがまずありません。 Unicode の範囲で拾えます。
SCRIPT_BLOCKS = [
("ハングル", r"[\uac00-\ud7af\u1100-\u11ff\u3130-\u318f\uffa0-\uffdc]", "error"),
("キリル文字", r"[\u0400-\u052f]", "error"),
("アラビア文字", r"[\u0600-\u06ff\u0750-\u077f]", "error"),
("ヘブライ文字", r"[\u0590-\u05ff]", "error"),
("タイ文字", r"[\u0e00-\u0e7f]", "error"),
("デーヴァナーガリー", r"[\u0900-\u097f]", "error"),
("ベトナム語の記号付き", r"[\u1ea0-\u1ef9]", "error"),
("ギリシャ文字", r"[\u0370-\u03ff\u1f00-\u1fff]", "warn"),
]
ギリシャ文字だけ警告扱いにしています。α β θ は数式や単位で正当に使うので、エラーにすると誤検出になります。
検出方法2:漢字が難しい
問題はこちらです。
簡体字も繁体字も、日本語の漢字と同じ CJK 統合漢字の領域にあります。
这 (U+8FD9) ← 簡体字
書 (U+66F8) ← 日本語
範囲で区切れません。 同じブロックの中に混在しています。
最初は、リストを書きました
日本語では使わない漢字を並べて、照合する方式です。
CHINESE_ONLY = set(
"这们说见经语边过东车马鸟鱼贝页风飞长门问间闻开关闭"
"習鄉書買賣頭實寶寫軍農動務勝勞勢單衛劉齊產親"
# ... 570字ほど
)
動かしてみたら、誤検出だらけでした。
NG test.md
△ 3行27列 「書」 中国語の漢字 U+66F8
これは開発の話です。устройства の使い方も書きます。
「書」が引っかかっています。 日本語で普通に使う字です。
何が間違っていたか
自分が「繁体字」として並べた字の多くが、日本語の旧字体と同じでした。
書 見 関 東 発 実 写 単 売 体 験 区 医
繁体字=日本語にない字、ではありません。 日本語の旧字体・正字と、かなり重なります。
数えたら、570字のうち164字が誤検出でした。3割近くです。
# 検証コード
overlap = CHINESE_ONLY & 日本語でも使う字
print(len(overlap)) # → 164
リストを手で直しても、また漏れます。 方式そのものが間違っていました。
CP932 で判定する
発想を変えました。
CP932(Shift_JIS)は、日本語のためのエンコーディングです。日本語で使う漢字は入っていて、使わない漢字は入っていません。
つまり、CP932 に変換できるかどうかで判定できます。
def is_japanese_kanji(ch: str) -> bool:
"""CP932 で表現できる漢字か。できないものは日本語では使わない字とみなす"""
try:
ch.encode("cp932")
return True
except UnicodeEncodeError:
return False
これだけです。 リストは要りません。
試してみる
for ch in "这们说见经语边过東書見関發關這們說":
print(ch, "日本語にある" if is_japanese_kanji(ch) else "★日本語にない")
結果です。
这 ★日本語にない
们 ★日本語にない
说 ★日本語にない
见 ★日本語にない
经 ★日本語にない
语 ★日本語にない
边 ★日本語にない
过 ★日本語にない
東 日本語にある
書 日本語にある
見 日本語にある
関 日本語にある
發 日本語にある ← 旧字体も通る
關 日本語にある ← 旧字体も通る
這 日本語にある ← ※後述
們 日本語にある ← ※後述
說 ★日本語にない
簡体字は全部拾えて、日本語の漢字と旧字体は通ります。 手書きのリストより正確でした。
拾えないもの
「這」「們」「當」「會」「來」は検出できません。
日本語にも存在する字なので、CP932 を通ってしまいます。
ここは目視に頼ることになります。完全ではありません。
スクリプト全文
lint_script.py として保存します。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""日本語の文章に紛れ込んだ別言語の文字を検出する"""
import sys
import re
import json
import argparse
import unicodedata
SCRIPT_BLOCKS = [
("ハングル", r"[\uac00-\ud7af\u1100-\u11ff\u3130-\u318f\uffa0-\uffdc]", "error"),
("キリル文字", r"[\u0400-\u052f]", "error"),
("アラビア文字", r"[\u0600-\u06ff\u0750-\u077f]", "error"),
("ヘブライ文字", r"[\u0590-\u05ff]", "error"),
("タイ文字", r"[\u0e00-\u0e7f]", "error"),
("デーヴァナーガリー", r"[\u0900-\u097f]", "error"),
("ベトナム語の記号付き", r"[\u1ea0-\u1ef9]", "error"),
("ギリシャ文字", r"[\u0370-\u03ff\u1f00-\u1fff]", "warn"),
]
KANJI_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
DEFAULT_ALLOW = set(
"αβγδεζηθικλμνξοπρστυφχψω"
"ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ"
"µΩ"
)
# CP932 に入っていないが、日本語で使う漢字
# CP932 は1990年代の規格。2010年の常用漢字表で追加された字が入っていない。
JIS2004_ADDITIONS = set("𠮟塡剝頰𠮷噓瘦")
def is_japanese_kanji(ch: str) -> bool:
if ch in JIS2004_ADDITIONS:
return True
try:
ch.encode("cp932")
return True
except UnicodeEncodeError:
return False
def blank_out(m):
"""マッチした範囲を空白にする。改行は残して行番号を保つ"""
return re.sub(r"[^\n]", " ", m.group(0))
def strip_code_blocks(text: str) -> str:
text = re.sub(r"```.*?```", blank_out, text, flags=re.S)
text = re.sub(r"~~~.*?~~~", blank_out, text, flags=re.S)
text = re.sub(r"<pre.*?</pre>", blank_out, text, flags=re.S)
text = re.sub(r"<code.*?</code>", blank_out, text, flags=re.S)
text = re.sub(r"`[^`\n]+`", blank_out, text)
return text
def strip_urls(text: str) -> str:
text = re.sub(r"https?://\S+", blank_out, text)
text = re.sub(r'(?:src|href|url)="[^"]*"', blank_out, text)
return text
def char_name(ch: str) -> str:
try:
return unicodedata.name(ch)
except ValueError:
return "UNKNOWN"
def scan(text: str, check_code: bool, allow: set):
target = text if check_code else strip_code_blocks(text)
target = strip_urls(target)
lines = target.split("\n")
orig_lines = text.split("\n")
hits = []
for i, line in enumerate(lines, start=1):
context = orig_lines[i - 1].strip()[:80] if i <= len(orig_lines) else ""
for name, pattern, level in SCRIPT_BLOCKS:
for m in re.finditer(pattern, line):
ch = m.group(0)
if ch in allow:
continue
hits.append({
"line": i, "col": m.start() + 1, "char": ch,
"codepoint": f"U+{ord(ch):04X}", "script": name,
"level": level, "unicode_name": char_name(ch),
"context": context,
})
for m in KANJI_RE.finditer(line):
ch = m.group(0)
if ch in allow or is_japanese_kanji(ch):
continue
hits.append({
"line": i, "col": m.start() + 1, "char": ch,
"codepoint": f"U+{ord(ch):04X}", "script": "日本語にない漢字",
"level": "error", "unicode_name": char_name(ch),
"context": context,
})
hits.sort(key=lambda h: (h["line"], h["col"]))
return hits
def group_runs(hits):
"""連続する同種の検出をまとめる。「устройства」を1件として扱う"""
if not hits:
return []
runs, cur = [], [hits[0]]
for h in hits[1:]:
prev = cur[-1]
if (h["line"] == prev["line"] and h["script"] == prev["script"]
and h["col"] == prev["col"] + 1):
cur.append(h)
else:
runs.append(cur)
cur = [h]
runs.append(cur)
out = []
for run in runs:
first = run[0]
out.append({
"line": first["line"], "col": first["col"],
"text": "".join(h["char"] for h in run),
"script": first["script"], "level": first["level"],
"codepoints": " ".join(h["codepoint"] for h in run[:5])
+ (" ..." if len(run) > 5 else ""),
"context": first["context"], "count": len(run),
})
return out
def render(path: str, hits):
runs = group_runs(hits)
if not runs:
return f"OK {path} 異言語の混入なし"
errors = [r for r in runs if r["level"] == "error"]
warns = [r for r in runs if r["level"] == "warn"]
out = [f"NG {path}",
f" エラー {len(errors)}箇所 / 警告 {len(warns)}箇所", ""]
for r in runs:
mark = "×" if r["level"] == "error" else "△"
out.append(f" {mark} {r['line']}行{r['col']}列 「{r['text']}」 {r['script']}")
out.append(f" {r['codepoints']}")
out.append(f" {r['context']}")
out.append("")
if errors:
out.append(" × 日本語の文章に出ることがまずない文字です。直してください。")
if warns:
out.append(" △ 正当に使われることもあります。意図したものか確認してください。")
return "\n".join(out)
def read_input(path: str) -> str:
if path == "-":
return sys.stdin.read()
with open(path, encoding="utf-8") as f:
return f.read()
def main():
p = argparse.ArgumentParser(description="異言語混入チェック")
p.add_argument("files", nargs="+", help="対象ファイル(- で標準入力)")
p.add_argument("--json", action="store_true", help="JSONで出力")
p.add_argument("--code", action="store_true", help="コードブロックの中も検査する")
p.add_argument("--allow", default="", help="許可する文字を並べて指定")
p.add_argument("--strict", action="store_true", help="警告もエラー扱いにする")
args = p.parse_args()
allow = DEFAULT_ALLOW | set(args.allow)
results = []
for path in args.files:
try:
text = read_input(path)
except OSError as e:
print(f"読めません: {path} ({e})", file=sys.stderr)
sys.exit(2)
results.append((path, scan(text, args.code, allow)))
if args.json:
print(json.dumps({p: group_runs(h) for p, h in results},
ensure_ascii=False, indent=2))
else:
for path, hits in results:
print(render(path, hits))
print()
if args.strict:
bad = any(h for _, hits in results for h in hits)
else:
bad = any(h for _, hits in results for h in hits if h["level"] == "error")
sys.exit(1 if bad else 0)
if __name__ == "__main__":
main()
使い方
python3 lint_script.py article.md # 1ファイル
python3 lint_script.py *.md # まとめて
python3 lint_script.py article.md --json # JSON
python3 lint_script.py article.md --code # コードの中も検査
python3 lint_script.py article.md --allow αβθ # 特定の文字を許可
cat article.md | python3 lint_script.py - # 標準入力
出力
NG article.md
エラー 2箇所 / 警告 0箇所
× 3行4列 「개발」 ハングル
U+AC1C U+BC1C
これは개발の話です。устройства の使い方も書きます。
× 3行11列 「устройства」 キリル文字
U+0443 U+0441 U+0442 U+0440 U+043E ...
これは개발の話です。устройства の使い方も書きます。
連続する文字は、まとめて1箇所として表示します。 そうしないと「устройства」が10件になって、出力が埋まります。
実装で気をつけたところ
コードブロックは、既定で除外
サンプルコードに、他言語の文字列が正当に入ることがあります。
print("개발") # これは検出しない
除外するとき、行番号がずれると使えません。 なので、改行だけ残して空白に置き換える方式にしました。
def blank_out(m):
return re.sub(r"[^\n]", " ", m.group(0))
URLも除外
https:// で始まるものと、src= href= の値を除外しています。URLに他言語が入るのは正当なので。
終了コードを返す
0 = 検出なし 1 = 検出あり 2 = 引数エラー
CI に入れると、混入したまま公開されるのを止められます。
- name: 異言語混入チェック
run: python3 lint_script.py articles/*.md
CP932 方式の弱点
この方式にも、穴があります。 実際に踏んだので書いておきます。
CP932 は1990年代の規格です。 2010年の常用漢字表で追加された字が、入っていません。
for ch in "𠮟塡剝頰":
print(ch, is_japanese_kanji(ch))
𠮟 False ← 「𠮟る」。常用漢字
塡 False ← 「補塡」。常用漢字
剝 False ← 「剝がす」。常用漢字
頰 False ← 「頰」。常用漢字
全部、日本語です。 それが「日本語にない漢字」として検出されます。
なので、例外の集合を持たせました。
JIS2004_ADDITIONS = set("𠮟塡剝頰𠮷噓瘦")
def is_japanese_kanji(ch: str) -> bool:
if ch in JIS2004_ADDITIONS:
return True
try:
ch.encode("cp932")
return True
except UnicodeEncodeError:
return False
リストを捨てたはずが、小さいリストが戻ってきました。
ただ、570字ではなく数文字です。そして根拠がはっきりしています(常用漢字表の追加分)。手で並べた570字とは、性質が違います。
「髙」「﨑」「濵」のような人名用の異体字は、CP932 に入っているので通ります。 そこは問題ありませんでした。
拾えないもの
正直に書いておきます。
日本語にも同形がある繁体字。 「這」「們」「當」「會」「來」。CP932 を通ってしまいます。
英語。 アルファベットは日本語の文章でも普通に使うので、対象外にしています。「これは important な話です」は拾えません。
誤変換。 日本語として正しい文字なら、意味が違っていても通ります。別の問題です。
まとめ
- AIが書いた日本語に、意味は合っているが文字だけ別言語のものが混ざることがある
- 文字化けと違い、文脈に埋まっているので見つけにくい
- ハングル・キリル文字などは、Unicode の文字ブロックで確実に拾える
- 簡体字・繁体字は、日本語の漢字と同じ領域にあるので、ブロックでは区別できない
- 手書きのリストは失敗した。 570字のうち164字が誤検出。旧字体と重なるため
- CP932 で表現できるかで判定する。 リスト不要で、旧字体も通る
- ただし「這」「們」など、日本語にもある繁体字は拾えない
- CP932 は1990年代の規格。 「𠮟」「塡」「剝」「頰」など常用漢字の一部が入っていない
- そこは数文字の例外リストで救済する。570字のリストとは性質が違う
- コードブロックを除外するときは、改行を残して空白に置換して行番号を保つ
- 終了コードを返して、CI で止める
自分は、リストを書いた時間を丸ごと無駄にしました。 3割が誤検出だと分かった時点で捨てています。
エンコーディングに判定を任せるという発想に切り替えてから、10行で済みました。
役に立ったらストックして、原稿を出す前に回してみてください。
ふだんはraplsworks.comで、WordPressプラグイン開発やClaude Codeまわりのことを書いています。