TextNormalizer / rules /cards.py
frznkshn's picture
Upload 13 files
ed76d6e verified
Raw History Blame Contribute Delete
4.21 kB
import random
import re
from num2words import num2words
from rules.base import BaseProcessor
from rules.phone import PhoneNumberProcessor
from utils import NO_LETTERS, detect_digit_language
# ASCII, Persian (۰-۹) and Arabic-Indic (٠-٩) digits.
DIGIT = "0-9۰-۹٠-٩"
TO_ASCII = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
GROUP = rf"[{DIGIT}]{{4}}"
SEPARATOR = r"(?:[ \t]*[-–][ \t]*|[ \t]+)"
# Four groups of four digits (`6219 - 8765 - 2345 - 9031`, `6219-8765-2345-9031`,
# `6219 8765 2345 9031`) or sixteen digits in a row. It must not be glued to a
# word, a longer number, a decimal or a URL path.
CARD_PATTERN = re.compile(
rf"(?<![\w.,/+\-])"
rf"(?<![{DIGIT}][ \t])(?<![{DIGIT}][-–])(?<![{DIGIT}][-–][ \t])(?<![{DIGIT}][ \t][-–])(?<![{DIGIT}][ \t][-–][ \t])"
rf"(?:{GROUP}(?:{SEPARATOR}{GROUP}){{3}}|[{DIGIT}]{{16}})"
rf"(?![\w/]|[-–.,][{DIGIT}]|{SEPARATOR}[{DIGIT}])"
)
# English reads a pair that starts with 0 as "oh five" and `00` as "double zero".
ENGLISH_ZERO = "oh"
ENGLISH_DOUBLE_ZERO = "double zero"
GROUP_JOINER = " - "
PAIR_JOINER = {"fa": "، ", "en": ", "}
class CardNumberProcessor(BaseProcessor):
"""Reads 16-digit bank card numbers in pairs of digits.
Persian: `۶۲۱۹ - ۸۷۶۵ - ۲۳۴۵ - ۹۰۳۱`
-> `شصت و دو، نوزده - هشتاد و هفت، شصت و پنج - بیست و سه، چهل و پنج - نود، سی و یک`
English: `4111 1111 1111 1111`
-> `forty-one, eleven - eleven, eleven - eleven, eleven - eleven, eleven`
`5105-1051-0510-5100`
-> `fifty-one, oh five - ten, fifty-one - oh five, ten - fifty-one, double zero`
Each group of four digits is said as two numbers separated by a pause
(comma), and the groups are separated by ` - `. A pair that starts with `0`
keeps its zero: Persian `05` -> `صفر پنج`, `00` -> `دو صفر`; English `05` ->
`oh five`, `00` -> `double zero`.
Recognised: four groups of four digits separated by spaces or hyphens (or
sixteen digits in a row), written with ASCII, Persian or Arabic-Indic digits,
with any first digit. Left alone: other lengths (15-digit Amex, 19-digit),
masked numbers (`6219-****-****-9031`), numbers glued to words, and decimals.
With no letters in the text, the digits pick the language: Persian digits ->
Persian, ASCII digits -> English. This processor must run before
`PhoneNumberProcessor`, because a word like `شماره` ("number") before a card
would otherwise make the phone processor read it.
"""
def __init__(self, epsilon: float = 1.0):
super(CardNumberProcessor, self).__init__(epsilon=epsilon)
def process(self, text: str, language: str = None, *args, **kwargs) -> str:
if random.random() > self.epsilon:
return text
if re.search(rf"[{DIGIT}]", text) is None:
return text
if language in (None, NO_LETTERS):
language = detect_digit_language(text)
if language not in ("fa", "en"):
return text
return CARD_PATTERN.sub(lambda match: self._convert(match, language), text)
def _convert(self, match: re.Match, language: str) -> str:
digits = re.sub(rf"[^{DIGIT}]", "", match.group(0)).translate(TO_ASCII)
if len(digits) != 16:
return match.group(0)
groups = [digits[index:index + 4] for index in range(0, 16, 4)]
return GROUP_JOINER.join(
PAIR_JOINER[language].join(
self._pair_to_words(group[start:start + 2], language) for start in (0, 2)
)
for group in groups
)
@staticmethod
def _pair_to_words(pair: str, language: str) -> str:
if language == "fa":
# Same reading as inside a phone number: `62` -> `شصت و دو`,
# `05` -> `صفر پنج`, `00` -> `دو صفر`.
return PhoneNumberProcessor._persian_number(pair)
if pair == "00":
return ENGLISH_DOUBLE_ZERO
if pair.startswith("0"):
return f"{ENGLISH_ZERO} {num2words(int(pair))}"
return num2words(int(pair))