fuseji 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fuseji/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """fuseji — 日本語特化の PII 検出・マスキングミドルウェア."""
2
+
3
+ from . import entity_types
4
+ from .engine import Masker
5
+ from .exceptions import FusejiError, InvalidConfigError, InvalidEntityError
6
+ from .strategies import Hash, MaskStrategy, Placeholder, Redact, VaultStrategy
7
+ from .types import Entity, MaskResult
8
+ from .vault import InMemoryVault, Vault
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "Entity",
14
+ "FusejiError",
15
+ "Hash",
16
+ "InMemoryVault",
17
+ "InvalidConfigError",
18
+ "InvalidEntityError",
19
+ "MaskResult",
20
+ "MaskStrategy",
21
+ "Masker",
22
+ "Placeholder",
23
+ "Redact",
24
+ "Vault",
25
+ "VaultStrategy",
26
+ "__version__",
27
+ "entity_types",
28
+ ]
fuseji/engine.py ADDED
@@ -0,0 +1,176 @@
1
+ """Masker エンジン — 認識器・NER を統合し、戦略でテキストをマスクする."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from collections.abc import Sequence
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from .recognizers.base import default_recognizers
10
+ from .strategies import Placeholder, VaultStrategy
11
+ from .types import Entity, MaskResult
12
+
13
+ if TYPE_CHECKING:
14
+ from .ner.base import NerBackend
15
+ from .recognizers.base import Recognizer
16
+ from .strategies import MaskStrategy
17
+ from .vault import Vault
18
+
19
+ # mask_json の再帰深度制限。深いネストでスタック消費を防ぐための fail-closed 値。
20
+ DEFAULT_MAX_JSON_DEPTH: int = 100
21
+ # 深度超過時に返す固定 placeholder。fail-closed として原データを返さない。
22
+ _TOO_DEEP_PLACEHOLDER: str = "[fuseji: too deep]"
23
+
24
+
25
+ class Masker:
26
+ """fuseji の中核クラス。
27
+
28
+ 認識器(正規表現/checksum)と NER バックエンドを統合し、検出した
29
+ PII エンティティを戦略でマスクする。
30
+
31
+ Args:
32
+ recognizers: 使用する認識器。`None` で v0.1 のデフォルトセット。
33
+ ner: NER バックエンド(GiNZA 等)。`None` で NER 無効。
34
+ strategy: マスキング戦略(Placeholder/Redact/Hash)。Vault 指定時は
35
+ `VaultStrategy` で自動的に置き換えられ、本引数は無視される
36
+ (両者を同時指定すると `UserWarning` が発行される)。
37
+ threshold: このスコア未満のエンティティは除外する。recall 重視で 0.4。
38
+ vault: 仮名化バウルト。指定時は `VaultStrategy(vault=vault)` が
39
+ 内部戦略として使われ、同一表層形は同一 placeholder。excluded type
40
+ は番号なし `<TYPE>` 形式でマスクし、mapping に残らない。
41
+ max_json_depth: `mask_json` の再帰深度上限。超過時は fail-closed で
42
+ `"[fuseji: too deep]"` に置換される。
43
+
44
+ Example:
45
+ 基本的な使い方:
46
+
47
+ >>> from fuseji import Masker
48
+ >>> masker = Masker()
49
+ >>> result = masker.mask("メール: taro@example.co.jp、電話 090-1234-5678")
50
+ >>> print(result.text)
51
+ メール: <EMAIL_1>、電話 <JP_PHONE_NUMBER_1>
52
+
53
+ Redact 戦略で固定文字列に:
54
+
55
+ >>> from fuseji import Masker, Redact
56
+ >>> masker = Masker(strategy=Redact())
57
+ >>> masker.mask("a@b.com").text
58
+ '[REDACTED]'
59
+
60
+ Vault で復元可能なマスキング:
61
+
62
+ >>> from fuseji import Masker, InMemoryVault
63
+ >>> vault = InMemoryVault()
64
+ >>> masker = Masker(vault=vault)
65
+ >>> r = masker.mask("a@b.com への返信")
66
+ >>> vault.restore(r.text)
67
+ 'a@b.com への返信'
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ recognizers: Sequence[Recognizer] | None = None,
73
+ ner: NerBackend | None = None,
74
+ strategy: MaskStrategy | None = None,
75
+ threshold: float = 0.4,
76
+ vault: Vault | None = None,
77
+ max_json_depth: int = DEFAULT_MAX_JSON_DEPTH,
78
+ ) -> None:
79
+ self._recognizers: tuple[Recognizer, ...] = (
80
+ tuple(recognizers) if recognizers is not None else default_recognizers()
81
+ )
82
+ self._ner = ner
83
+ # vault があれば VaultStrategy で吸収し、戦略経路を単一化する。
84
+ # strategy 引数は vault と排他(vault 優先、strategy 無視)。
85
+ if vault is not None:
86
+ if strategy is not None:
87
+ warnings.warn(
88
+ "Masker: strategy と vault が同時指定されました。"
89
+ "vault を優先して strategy は無視されます。"
90
+ "両者を明確に分離するには Masker(vault=...) または "
91
+ "Masker(strategy=...) のいずれか一方のみ指定してください。",
92
+ UserWarning,
93
+ stacklevel=2,
94
+ )
95
+ self._strategy: MaskStrategy = VaultStrategy(vault=vault)
96
+ else:
97
+ self._strategy = strategy if strategy is not None else Placeholder()
98
+ self._threshold = threshold
99
+ self._max_json_depth = max_json_depth
100
+
101
+ def detect(self, text: str) -> tuple[Entity, ...]:
102
+ """テキストから PII エンティティを検出し、threshold で絞り込んだ後、
103
+ オーバーラップをスコア優先で解決して返す。
104
+
105
+ Example:
106
+ >>> from fuseji import Masker
107
+ >>> entities = Masker().detect("メール a@b.com 電話 090-1234-5678")
108
+ >>> sorted({e.type for e in entities})
109
+ ['EMAIL', 'JP_PHONE_NUMBER']
110
+ """
111
+ raw: list[Entity] = []
112
+ for r in self._recognizers:
113
+ raw.extend(r.analyze(text))
114
+ if self._ner is not None:
115
+ raw.extend(self._ner.analyze(text))
116
+ filtered = [e for e in raw if e.score >= self._threshold]
117
+ return tuple(_resolve_overlaps(filtered))
118
+
119
+ def mask(self, text: str) -> MaskResult:
120
+ """テキストをマスクして MaskResult を返す。"""
121
+ entities = self.detect(text)
122
+ masked_text, mapping = self._strategy.mask(text, entities)
123
+ return MaskResult(text=masked_text, entities=entities, mapping=mapping)
124
+
125
+ def mask_json(self, data: Any) -> Any:
126
+ """JSON 互換のデータ構造を再帰的にマスクして返す。
127
+
128
+ 対象: str(mask() を適用), dict(値のみ再帰), list/tuple(要素を再帰)。
129
+ その他の型(int, float, bool, None など)は素通し。
130
+
131
+ 辞書のキーは PII を含まない前提で、値のみマスクする。
132
+
133
+ ネスト深度が `max_json_depth`(デフォルト 100)を超えた要素は
134
+ fail-closed で固定文字列 `"[fuseji: too deep]"` に置換される。
135
+ スタック消費や無限再帰由来の DoS を抑止する。
136
+
137
+ Example:
138
+ >>> from fuseji import Masker
139
+ >>> result = Masker().mask_json({"email": "a@b.com", "user": "山田"})
140
+ >>> result["email"]
141
+ '<EMAIL_1>'
142
+ >>> result["user"]
143
+ '山田'
144
+ """
145
+ return self._mask_value(data, depth=0)
146
+
147
+ def _mask_value(self, data: Any, *, depth: int) -> Any:
148
+ if depth > self._max_json_depth:
149
+ return _TOO_DEEP_PLACEHOLDER
150
+ if isinstance(data, str):
151
+ return self.mask(data).text
152
+ if isinstance(data, dict):
153
+ return {k: self._mask_value(v, depth=depth + 1) for k, v in data.items()}
154
+ if isinstance(data, list):
155
+ return [self._mask_value(v, depth=depth + 1) for v in data]
156
+ if isinstance(data, tuple):
157
+ return tuple(self._mask_value(v, depth=depth + 1) for v in data)
158
+ return data
159
+
160
+
161
+ def _resolve_overlaps(entities: Sequence[Entity]) -> list[Entity]:
162
+ """オーバーラップするエンティティをスコア優先で解決する。
163
+
164
+ 優先順位: スコア降順 → 長い span 優先 → 開始位置昇順。
165
+ 採用済み span と重ならないものから順に採用し、最後に元テキスト位置順で
166
+ 並べ直して返す。
167
+ """
168
+ ordered = sorted(entities, key=lambda e: (-e.score, -(e.end - e.start), e.start))
169
+ accepted: list[Entity] = []
170
+ spans: list[tuple[int, int]] = []
171
+ for e in ordered:
172
+ if any(not (e.end <= s or e.start >= ee) for s, ee in spans):
173
+ continue
174
+ accepted.append(e)
175
+ spans.append((e.start, e.end))
176
+ return sorted(accepted, key=lambda e: e.start)
fuseji/entity_types.py ADDED
@@ -0,0 +1,46 @@
1
+ """エンティティ種別の定数モジュール。
2
+
3
+ ハードコードされた文字列リテラル(``"EMAIL"`` 等)の代わりに、
4
+ タイプセーフな定数として利用できる。
5
+
6
+ Example:
7
+ >>> from fuseji import InMemoryVault, entity_types
8
+ >>> vault = InMemoryVault(excluded_types=[entity_types.MY_NUMBER, entity_types.EMAIL])
9
+ >>> sorted(vault.excluded_types)
10
+ ['EMAIL', 'MY_NUMBER']
11
+
12
+ 定数は文字列そのもの(``str``)。Entity.type と直接比較できる。
13
+
14
+ v0.2 以降で追加されるエンティティ種別(JP_ADDRESS、CORPORATE_NUMBER 等)も
15
+ 本モジュールに集約する予定。
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ #: メールアドレス
21
+ EMAIL: str = "EMAIL"
22
+ #: クレジットカード番号(Luhn 検証通過)
23
+ CREDIT_CARD: str = "CREDIT_CARD"
24
+ #: マイナンバー(個人番号 12 桁)
25
+ MY_NUMBER: str = "MY_NUMBER"
26
+ #: 日本の電話番号(携帯・固定・フリーダイヤル・ナビダイヤル)
27
+ JP_PHONE_NUMBER: str = "JP_PHONE_NUMBER"
28
+ #: 日本の郵便番号
29
+ JP_POSTAL_CODE: str = "JP_POSTAL_CODE"
30
+ #: 人名(GiNZA バックエンドが出力)
31
+ PERSON: str = "PERSON"
32
+
33
+ #: v0.1 で組み込み recognizers が出力する種別の全集合(NER 含む)
34
+ V0_1_TYPES: frozenset[str] = frozenset(
35
+ {EMAIL, CREDIT_CARD, MY_NUMBER, JP_PHONE_NUMBER, JP_POSTAL_CODE, PERSON}
36
+ )
37
+
38
+ __all__ = [
39
+ "CREDIT_CARD",
40
+ "EMAIL",
41
+ "JP_PHONE_NUMBER",
42
+ "JP_POSTAL_CODE",
43
+ "MY_NUMBER",
44
+ "PERSON",
45
+ "V0_1_TYPES",
46
+ ]
fuseji/exceptions.py ADDED
@@ -0,0 +1,32 @@
1
+ """fuseji 例外階層。
2
+
3
+ 利用側で `except FusejiError` だけ書けば fuseji 由来の例外を一括で
4
+ キャッチでき、spaCy ロード失敗や FastAPI フレームワーク例外と区別できる。
5
+
6
+ `InvalidEntityError` と `InvalidConfigError` は `FusejiError` 階層に属しつつ
7
+ `ValueError` も継承する多重継承で、既存の `except ValueError` も従来通り
8
+ 拾える非破壊的設計。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ class FusejiError(Exception):
15
+ """fuseji が発生させる例外の基底クラス。
16
+
17
+ `except FusejiError` で fuseji 由来の例外のみをキャッチできる。
18
+ """
19
+
20
+
21
+ class InvalidEntityError(FusejiError, ValueError):
22
+ """Entity の構築時にフィールドが不正な場合の例外。
23
+
24
+ 既存コードとの互換性のため `ValueError` も多重継承している。
25
+ """
26
+
27
+
28
+ class InvalidConfigError(FusejiError, ValueError):
29
+ """戦略・Vault・Masker 等の設定が不正な場合の例外。
30
+
31
+ 既存コードとの互換性のため `ValueError` も多重継承している。
32
+ """
@@ -0,0 +1,5 @@
1
+ """外部サービス連携サブパッケージ."""
2
+
3
+ from .langfuse import make_mask_fn
4
+
5
+ __all__ = ["make_mask_fn"]
@@ -0,0 +1,76 @@
1
+ """Langfuse SDK の mask フック用アダプター."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ from ..engine import Masker
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # 例外時に返す fail-closed なプレースホルダー
15
+ _FAIL_PLACEHOLDER = "[fuseji: masking failed]"
16
+
17
+ # 環境変数で「フルトレースバックをログに出すか」を切り替える。デフォルトは
18
+ # off で、例外型名のみログする(PII を含むトレースバックを残さないため)。
19
+ # デバッグ目的で詳細が必要なときは FUSEJI_LANGFUSE_LOG_TRACEBACK=1 を設定する。
20
+ _LOG_TRACEBACK_ENV = "FUSEJI_LANGFUSE_LOG_TRACEBACK"
21
+
22
+
23
+ def _should_log_traceback() -> bool:
24
+ return os.environ.get(_LOG_TRACEBACK_ENV, "0") == "1"
25
+
26
+
27
+ def make_mask_fn(masker: Masker | None = None) -> Callable[[Any], Any]:
28
+ """Langfuse SDK の ``mask`` パラメータに渡せるマスキング関数を生成する。
29
+
30
+ Args:
31
+ masker: 使用する Masker インスタンス。``None`` のとき新規に
32
+ ``Masker()`` を作る(v0.1 デフォルト認識器セット + Placeholder 戦略)。
33
+
34
+ Returns:
35
+ Langfuse SDK の ``mask=`` に渡せる callable。任意のデータ構造
36
+ (str / dict / list / tuple 等)を受け取り、マスク済みの同型
37
+ データを返す。
38
+
39
+ 例外ハンドリング:
40
+ マスキング処理が例外で失敗した場合は fail-closed の方針で
41
+ ``"[fuseji: masking failed]"`` 文字列を返す。PII 漏洩を避けるため
42
+ 原データはそのまま返さない。
43
+
44
+ ログ出力は **デフォルトで例外型名のみ**(トレースバックなし)。
45
+ トレースバックには元の PII を含む文字列が含まれる可能性があるため、
46
+ ログ集約基盤への漏洩を防ぐ。デバッグ目的で完全なトレースバックが
47
+ 必要な場合は環境変数 ``FUSEJI_LANGFUSE_LOG_TRACEBACK=1`` を設定する。
48
+
49
+ Example:
50
+ Langfuse SDK と統合:
51
+
52
+ >>> from langfuse import Langfuse # doctest: +SKIP
53
+ >>> from fuseji.integrations.langfuse import make_mask_fn
54
+ >>> langfuse = Langfuse(mask=make_mask_fn()) # doctest: +SKIP
55
+
56
+ スタンドアロンで動作確認:
57
+
58
+ >>> from fuseji.integrations.langfuse import make_mask_fn
59
+ >>> fn = make_mask_fn()
60
+ >>> "<EMAIL_1>" in fn({"data": "メール a@b.com"})["data"]
61
+ True
62
+ """
63
+ actual_masker: Masker = masker if masker is not None else Masker()
64
+
65
+ def _mask(data: Any) -> Any:
66
+ try:
67
+ return actual_masker.mask_json(data)
68
+ except Exception as e:
69
+ if _should_log_traceback():
70
+ logger.exception("fuseji: マスキング処理が例外で失敗")
71
+ else:
72
+ # デフォルト: 例外型のみログ。トレースバック内の PII 漏洩を防ぐ。
73
+ logger.warning("fuseji: マスキング処理が例外で失敗 (%s)", type(e).__name__)
74
+ return _FAIL_PLACEHOLDER
75
+
76
+ return _mask
fuseji/ner/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """NER バックエンドサブパッケージ."""
2
+
3
+ from .base import NerBackend
4
+
5
+ __all__ = ["NerBackend"]
fuseji/ner/base.py ADDED
@@ -0,0 +1,19 @@
1
+ """NER バックエンドのプロトコル定義."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import Protocol
7
+
8
+ from ..types import Entity
9
+
10
+
11
+ class NerBackend(Protocol):
12
+ """NER バックエンドのプロトコル。
13
+
14
+ 認識器(regex/checksum)が拾えない、文中の自然な名詞句(人名・地名・組織名等)を
15
+ 機械学習モデルで検出するためのインタフェース。Recognizer プロトコルと互換だが、
16
+ モデル依存のため optional extra として分離する。
17
+ """
18
+
19
+ def analyze(self, text: str) -> Iterable[Entity]: ...
fuseji/ner/ginza.py ADDED
@@ -0,0 +1,70 @@
1
+ """GiNZA バックエンド(PERSON 検出)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ..entity_types import PERSON
9
+ from ..types import Entity
10
+
11
+ if TYPE_CHECKING:
12
+ from spacy.language import Language
13
+
14
+ # GiNZA は OntoNotes ではなく GSK 風の独自ラベルを返す。
15
+ # v0.1 では「Person」だけを採用し、他のラベル(Title_Other, Province 等)は
16
+ # 利用側で明示的に指定したいときのみ拾う。
17
+ _DEFAULT_LABELS: tuple[str, ...] = ("Person",)
18
+
19
+
20
+ class GinzaBackend:
21
+ """GiNZA (spaCy + ja_ginza) ベースの NER バックエンド。
22
+
23
+ `labels` で抽出する GiNZA ラベルを指定する。デフォルトは ``("Person",)``。
24
+ 抽出した Entity の type フィールドは ``Person`` を慣用名 ``PERSON`` に
25
+ マップし、その他のラベルは大文字化してそのまま使う。
26
+
27
+ `[ginza]` extra 経由でのみインストール可能(``pip install fuseji[ginza]``)。
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ labels: Iterable[str] | None = None,
33
+ model_name: str = "ja_ginza",
34
+ score: float = 0.85,
35
+ ) -> None:
36
+ try:
37
+ import spacy
38
+ except ImportError as e:
39
+ msg = (
40
+ "GinzaBackend を使うには spaCy と ja_ginza が必要です。"
41
+ "`pip install fuseji[ginza]` でインストールしてください。"
42
+ )
43
+ raise ImportError(msg) from e
44
+
45
+ self._labels: set[str] = set(labels) if labels is not None else set(_DEFAULT_LABELS)
46
+ self._score = score
47
+ # GiNZA 5.2 系の compound_splitter は新版 spaCy で設定不整合を起こすため除外
48
+ self._nlp: Language = spacy.load(model_name, exclude=["compound_splitter"])
49
+
50
+ @property
51
+ def labels(self) -> frozenset[str]:
52
+ return frozenset(self._labels)
53
+
54
+ def analyze(self, text: str) -> Iterable[Entity]:
55
+ if not text:
56
+ return
57
+ doc = self._nlp(text)
58
+ for ent in doc.ents:
59
+ if ent.label_ not in self._labels:
60
+ continue
61
+ # GiNZA の "Person" → 慣用名 PERSON 定数に正規化
62
+ type_ = PERSON if ent.label_ == "Person" else ent.label_.upper()
63
+ yield Entity(
64
+ type=type_,
65
+ text=ent.text,
66
+ start=ent.start_char,
67
+ end=ent.end_char,
68
+ score=self._score,
69
+ recognizer="ginza",
70
+ )
fuseji/py.typed ADDED
File without changes
@@ -0,0 +1,17 @@
1
+ """PII 認識器サブパッケージ."""
2
+
3
+ from .base import (
4
+ Recognizer,
5
+ default_recognizers,
6
+ normalize,
7
+ normalize_digits,
8
+ normalize_hyphens,
9
+ )
10
+
11
+ __all__ = [
12
+ "Recognizer",
13
+ "default_recognizers",
14
+ "normalize",
15
+ "normalize_digits",
16
+ "normalize_hyphens",
17
+ ]
@@ -0,0 +1,117 @@
1
+ """Recognizer プロトコルと共通の正規化・ヘルパユーティリティ."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from typing import Protocol
8
+
9
+ from ..types import Entity
10
+
11
+ # 認識器間で共有するセパレーターパターン(ハイフン/空白)。各認識器が
12
+ # digits-only に正規化する際に使う。
13
+ SEPARATOR_PATTERN: re.Pattern[str] = re.compile(r"[-\s]")
14
+
15
+
16
+ class Recognizer(Protocol):
17
+ """PII 認識器のプロトコル。
18
+
19
+ 属性:
20
+ entity_type: 認識する種別名(例: ``"EMAIL"``, ``"JP_PHONE_NUMBER"``)
21
+
22
+ メソッド:
23
+ analyze: テキストを走査し検出した `Entity` を返す。
24
+ """
25
+
26
+ entity_type: str
27
+
28
+ def analyze(self, text: str) -> Iterable[Entity]: ...
29
+
30
+
31
+ # --- 文字正規化テーブル ---
32
+ # いずれも 1 文字 ↔ 1 文字。codepoint 長が変わらないため、
33
+ # 正規化後オフセットは元テキストに対して維持される。
34
+
35
+ _DIGIT_TRANSLATION = str.maketrans("0123456789", "0123456789")
36
+
37
+ _HYPHEN_TRANSLATION = str.maketrans(
38
+ {
39
+ "‐": "-", # U+2010 HYPHEN
40
+ "‑": "-", # U+2011 NON-BREAKING HYPHEN
41
+ "‒": "-", # U+2012 FIGURE DASH
42
+ "–": "-", # U+2013 EN DASH
43
+ "—": "-", # U+2014 EM DASH
44
+ "―": "-", # U+2015 HORIZONTAL BAR
45
+ "−": "-", # U+2212 MINUS SIGN
46
+ "ー": "-", # U+30FC KATAKANA-HIRAGANA PROLONGED SOUND MARK
47
+ "-": "-", # U+FF0D FULLWIDTH HYPHEN-MINUS
48
+ }
49
+ )
50
+
51
+ # normalize() 用に digits + hyphens を 1 つの translate テーブルへマージ。
52
+ # 両者のキーは disjoint(数字と記号)なので衝突なし。値は数字側が int、
53
+ # ハイフン側が str(str.translate はどちらも受ける)。
54
+ _NORMALIZE_TRANSLATION: dict[int, str | int] = {
55
+ **_DIGIT_TRANSLATION,
56
+ **_HYPHEN_TRANSLATION,
57
+ }
58
+
59
+
60
+ def normalize_digits(text: str) -> str:
61
+ """全角数字(0-9)を半角に変換。文字数は維持される。"""
62
+ return text.translate(_DIGIT_TRANSLATION)
63
+
64
+
65
+ def normalize_hyphens(text: str) -> str:
66
+ """各種ハイフン類を ASCII ハイフン `-` に変換。文字数は維持される。
67
+
68
+ 含まれる: U+2010 HYPHEN, U+2011 NON-BREAKING HYPHEN, U+2012 FIGURE DASH,
69
+ U+2013 EN DASH, U+2014 EM DASH, U+2015 HORIZONTAL BAR, U+2212 MINUS SIGN,
70
+ U+30FC KATAKANA-HIRAGANA PROLONGED SOUND MARK, U+FF0D FULLWIDTH HYPHEN-MINUS。
71
+ """
72
+ return text.translate(_HYPHEN_TRANSLATION)
73
+
74
+
75
+ def normalize(text: str) -> str:
76
+ """数字とハイフンの両方を 1 パスで正規化。"""
77
+ return text.translate(_NORMALIZE_TRANSLATION)
78
+
79
+
80
+ def has_digit_boundary(text: str, start: int, end: int) -> bool:
81
+ """マッチ位置 [start, end) の直前または直後が数字なら True を返す。
82
+
83
+ 認識器がマッチ範囲を別 ID(より長い番号列)の一部と切り分けるためのヘルパ。
84
+ True を返した場合、その候補は除外すべき。
85
+
86
+ Args:
87
+ text: 正規化後のテキスト(半角数字に統一されている前提)。
88
+ start: マッチ開始位置(包含)。
89
+ end: マッチ終端位置(除外)。
90
+ """
91
+ if start > 0 and text[start - 1].isdigit():
92
+ return True
93
+ if end < len(text) and text[end].isdigit(): # noqa: SIM103
94
+ return True
95
+ return False
96
+
97
+
98
+ def default_recognizers() -> tuple[Recognizer, ...]:
99
+ """v0.1 のデフォルト認識器セット。
100
+
101
+ EMAIL, CREDIT_CARD, MY_NUMBER, JP_PHONE_NUMBER, JP_POSTAL_CODE の順で返す。
102
+ 順序は Masker エンジン側のオーバーラップ解決には影響しない(スコア優先)。
103
+ """
104
+ # 循環インポート回避のため遅延 import
105
+ from .credit_card import CreditCardRecognizer
106
+ from .email import EmailRecognizer
107
+ from .jp_phone import JpPhoneRecognizer
108
+ from .jp_postal import JpPostalRecognizer
109
+ from .my_number import MyNumberRecognizer
110
+
111
+ return (
112
+ EmailRecognizer(),
113
+ CreditCardRecognizer(),
114
+ MyNumberRecognizer(),
115
+ JpPhoneRecognizer(),
116
+ JpPostalRecognizer(),
117
+ )
@@ -0,0 +1,55 @@
1
+ """クレジットカード認識器(Luhn チェック付き)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+
8
+ from ..entity_types import CREDIT_CARD
9
+ from ..types import Entity
10
+ from .base import SEPARATOR_PATTERN, normalize
11
+
12
+ # 13-19 桁の数字、間に任意のハイフン or 空白を許容
13
+ _CC_PATTERN = re.compile(r"\d(?:[-\s]?\d){12,18}")
14
+
15
+
16
+ def _luhn(digits: str) -> bool:
17
+ """Luhn チェックディジット検証。digits は数字のみ。"""
18
+ total = 0
19
+ for i, ch in enumerate(reversed(digits)):
20
+ d = int(ch)
21
+ if i % 2 == 1:
22
+ d *= 2
23
+ if d > 9:
24
+ d -= 9
25
+ total += d
26
+ return total % 10 == 0
27
+
28
+
29
+ class CreditCardRecognizer:
30
+ """クレジットカード番号認識器。
31
+
32
+ 13-19 桁の数字列(ハイフン/空白セパレーター可、全角数字・全角ハイフンも対応)を
33
+ 候補とし、Luhn 検証に通過したもののみ Entity として返す。
34
+ Luhn 失敗は credit card ではないことが確実なので除外する(偽陽性抑制)。
35
+ """
36
+
37
+ entity_type = CREDIT_CARD
38
+
39
+ def analyze(self, text: str) -> Iterable[Entity]:
40
+ # 全角数字・全角ハイフンを正規化(1 文字 ↔ 1 文字なのでオフセット維持)
41
+ normalized = normalize(text)
42
+ for m in _CC_PATTERN.finditer(normalized):
43
+ digits = SEPARATOR_PATTERN.sub("", m.group())
44
+ if not 13 <= len(digits) <= 19:
45
+ continue
46
+ if not _luhn(digits):
47
+ continue
48
+ yield Entity(
49
+ type=self.entity_type,
50
+ text=text[m.start() : m.end()], # 元テキストの表層を返す
51
+ start=m.start(),
52
+ end=m.end(),
53
+ score=0.95,
54
+ recognizer="credit_card",
55
+ )