4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIが書いた日本語に、ハングルやキリル文字が紛れ込む。CP932で検出する【コピペ用スクリプト】

4
Posted at

生成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まわりのことを書いています。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?