Source code for xpark.dataset.processors.text_cleaner
from __future__ import annotations
import re
from typing import TYPE_CHECKING
from xpark.dataset.datatype import DataType
from xpark.dataset.expressions import BatchColumnClassProtocol, udf
from xpark.dataset.import_utils import lazy_import
if TYPE_CHECKING:
import pyarrow as pa
else:
pa = lazy_import("pyarrow", rename="pa")
#: Matches URLs (any scheme), ``www.`` prefixed hosts, and bare domains with a path.
#:
#: Adapted from data-juicer's ``clean_links_mapper``:
#: https://github.com/modelscope/data-juicer/blob/main/data_juicer/ops/mapper/clean_links_mapper.py
#:
#: Key properties:
#: - Supports balanced parentheses inside the URL (one level of nesting).
#: - The trailing character must NOT be sentence punctuation (ASCII or CJK),
#: so punctuation that follows a URL in natural-language text is preserved.
PATTERN_URI = (
r"\b("
r"(?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])"
r"|www\d{0,3}[.]"
r"|[a-z0-9.\-]+[.][a-z]{2,4}\/)"
r"(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+"
r"(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)"
r"|[^\s`!()\[\]{};:\'\".,<>?\u00ab\u00bb\u201c\u201d\u2018\u2019])"
r")"
)
#: Matches common email addresses
PATTERN_EMAIL = r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b"
#: Matches common phone numbers (international +xx format and 11-digit mobile numbers)
PATTERN_PHONE = (
r"\b(?:\+?\d{1,3}[\s\-]?)?"
r"(?:\(?\d{2,4}\)?[\s\-]?)?"
r"\d{3,4}[\s\-]?\d{4}\b"
)
#: Matches IPv4 addresses with each octet in 0-255
PATTERN_IPV4 = (
r"\b(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}"
r"(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\b"
)
# Mapping from built-in pattern name to regex string
_BUILTIN_PATTERNS: dict[str, str] = {
"uri": PATTERN_URI,
"email": PATTERN_EMAIL,
"phone": PATTERN_PHONE,
"ipv4": PATTERN_IPV4,
}
[docs]
@udf(return_dtype=DataType.string())
class TextPatternCleaner(BatchColumnClassProtocol):
"""
Supports two usage modes:
1. **Built-in patterns**: Pass a built-in pattern name (string); use a list for multiple.
Available names: ``"uri"``, ``"email"``, ``"phone"``, ``"ipv4"``.
2. **Custom regex**: Pass a regex string or list directly. All patterns are merged
into a single ``|``-joined regex and applied in one substitution pass.
Both modes can be mixed together.
Args:
patterns: Built-in pattern name(s) or custom regex string(s); accepts a single
string or a list.
replacement: Replacement string. Defaults to ``""`` (delete matched text).
flags: Flags passed to :func:`re.compile`. Defaults to ``re.IGNORECASE``.
Examples:
.. code-block:: python
from xpark.dataset.expressions import col
from xpark.dataset import from_items
from xpark.dataset.processors.text_cleaner import TextPatternCleaner
ds = from_items(["Visit https://example.com, email: foo@bar.com"])
ds = ds.with_column(
"cleaned",
TextPatternCleaner(patterns=["uri", "email"], replacement="[REDACTED]")
.options(num_workers={"CPU": 1}, batch_size=10)
.with_column(col("item")),
)
ds = ds.with_column(
"cleaned",
TextPatternCleaner(patterns=r"\\d{4}-\\d{4}-\\d{4}-\\d{4}", replacement="[CARD]")
.options(num_workers={"CPU": 1}, batch_size=10)
.with_column(col("item")),
)
print(ds.take(1))
"""
def __init__(
self,
patterns: str | list[str],
replacement: str = "",
flags: int = re.IGNORECASE,
):
if isinstance(patterns, str):
patterns = [patterns]
# Resolve built-in names; pass through unknown names as raw regex
resolved = [_BUILTIN_PATTERNS.get(p, p) for p in patterns]
merged = "|".join(f"(?:{p})" for p in resolved)
self.pattern = merged
self._pattern = re.compile(merged, flags)
self.replacement = replacement
def __call__(self, batch: pa.ChunkedArray) -> pa.Array:
texts = batch.to_pylist()
cleaned = [self._pattern.sub(self.replacement, t) if t is not None else None for t in texts]
return pa.array(cleaned, type=pa.string())