Skip to content

Tags¤

A set of functions to replace or remove HTML/XML tags using Beautiful Soup.

_compile_regex(pattern: str) -> re.Pattern[str] cached ¤

Source code in lexos/scrubber/tags.py
@lru_cache(maxsize=128)
def _compile_regex(pattern: str) -> re.Pattern[str]:
    return re.compile(pattern)

_get_parser(mode: str) -> str ¤

Return the appropriate BeautifulSoup parser for the given mode.

Source code in lexos/scrubber/tags.py
def _get_parser(mode: str) -> str:
    """Return the appropriate BeautifulSoup parser for the given mode."""
    if mode == "html":
        return "html.parser"
    if mode == "xml":
        if importlib.util.find_spec("lxml") is not None:
            return "lxml-xml"
        return "xml"
    raise LexosException("Mode must be either 'html' or 'xml'.")

_parse_document(text: str, mode: str) -> BeautifulSoup ¤

Parse HTML or XML text using the appropriate parser.

For XML mode, BeautifulSoup prefers 'lxml-xml' when available. If parser initialization fails, this function falls back to the built-in 'xml' parser before raising the original exception.

Source code in lexos/scrubber/tags.py
def _parse_document(text: str, mode: str) -> BeautifulSoup:
    """Parse HTML or XML text using the appropriate parser.

    For XML mode, BeautifulSoup prefers 'lxml-xml' when available. If
    parser initialization fails, this function falls back to the built-in
    'xml' parser before raising the original exception.
    """
    parser = _get_parser(mode)
    try:
        return BeautifulSoup(text, parser)
    except Exception:
        if mode == "xml" and parser == "lxml-xml":
            return BeautifulSoup(text, "xml")
        raise

_filter_elements_by_attribute(elements: list, attribute_name: Optional[str], attribute_value: Optional[str], matcher_type: str) -> list ¤

Filter a list of elements by an attribute name and optional value.

Source code in lexos/scrubber/tags.py
def _filter_elements_by_attribute(
    elements: list,
    attribute_name: Optional[str],
    attribute_value: Optional[str],
    matcher_type: str,
) -> list:
    """Filter a list of elements by an attribute name and optional value."""
    if not attribute_name:
        return elements

    if attribute_value is not None:
        return [
            el
            for el in elements
            if el.has_attr(attribute_name)
            and _match_value(el[attribute_name], attribute_value, matcher_type)
        ]

    return [el for el in elements if el.has_attr(attribute_name)]

_match_elements(selector: Optional[str], text: str, mode: str = 'html', matcher_type: Optional[str] = 'exact', attribute: Optional[str | list[str]] = None, attribute_value: Optional[str] = None, attribute_filter: Optional[str] = None) -> tuple[BeautifulSoup, list] ¤

Finds HTML/XML elements matching a selector.

Parameters:

Name Type Description Default
selector Optional[str]

Tag name or CSS selector to match elements

required
text str

HTML or XML text to process

required
mode str

Parser mode, either "html" or "xml"

'html'
matcher_type Optional[str]

Type of match to perform, either "exact", "contains", or "regex"

'exact'
attribute Optional[str | list[str]]

Optional attribute name to filter elements

None
attribute_value Optional[str]

Optional value for the attribute filter

None
attribute_filter Optional[str]

Optional attribute name to filter elements

None

Returns:

Type Description
tuple[BeautifulSoup, list]

A BeautifulSoup object and a list of matching elements.

Source code in lexos/scrubber/tags.py
def _match_elements(
    selector: Optional[str],
    text: str,
    mode: str = "html",
    matcher_type: Optional[str] = "exact",
    attribute: Optional[str | list[str]] = None,
    attribute_value: Optional[str] = None,
    attribute_filter: Optional[str] = None,
) -> tuple[BeautifulSoup, list]:
    """Finds HTML/XML elements matching a selector.

    Args:
        selector: Tag name or CSS selector to match elements
        text: HTML or XML text to process
        mode: Parser mode, either "html" or "xml"
        matcher_type: Type of match to perform, either "exact", "contains", or "regex"
        attribute: Optional attribute name to filter elements
        attribute_value: Optional value for the attribute filter
        attribute_filter: Optional attribute name to filter elements

    Returns:
        A BeautifulSoup object and a list of matching elements.
    """
    # Validate mode
    if mode not in ["html", "xml"]:
        raise LexosException("Mode must be either 'html' or 'xml'.")

    # Parse the document
    soup = _parse_document(text, mode)

    # Find elements matching the selector
    if not selector:
        elements = soup.find_all()  # Select all elements
    else:
        elements = (
            soup.select(selector)
            if selector.startswith(".") or selector.startswith("#")
            else soup.find_all(selector)
        )

    # Filter by attribute or attribute_filter
    if attribute_filter:
        elements = _filter_elements_by_attribute(
            elements, attribute_filter, attribute_value, matcher_type
        )
    else:
        elements = _filter_elements_by_attribute(
            elements, attribute, attribute_value, matcher_type
        )

    return soup, elements

_compute_replacement_value(old_attribute_value: str | list[str], matcher_type: str, attribute_value: Optional[str], replace_value: Optional[str | list[str]]) -> str ¤

Source code in lexos/scrubber/tags.py
def _compute_replacement_value(
    old_attribute_value: str | list[str],
    matcher_type: str,
    attribute_value: Optional[str],
    replace_value: Optional[str | list[str]],
) -> str:
    values = (
        old_attribute_value
        if isinstance(old_attribute_value, list)
        else [old_attribute_value]
    )
    values = [str(v) for v in values if v is not None]

    if replace_value is None:
        return " ".join(values)

    if isinstance(replace_value, list):
        replacement = " ".join(str(v) for v in replace_value if v)
    else:
        replacement = replace_value

    if matcher_type == "regex":
        pattern = _compile_regex(attribute_value or "")
        replaced_values = [
            replacement if pattern.search(value) else value for value in values
        ]
        return " ".join(replaced_values)

    if attribute_value is None:
        return replacement

    return " ".join(
        part
        for part in " ".join(values).replace(attribute_value, replacement).split(" ")
        if part
    )

_match_value(attribute_value: str | list[str], pattern: str, match_type: str = 'exact') -> bool ¤

Match attribute values using different matching strategies.

Parameters:

Name Type Description Default
attribute_value str | list[str]

The attribute value(s) to match against (string or list)

required
pattern str

The pattern/string to match

required
match_type str

Type of matching - "exact", "contains", or "regex"

'exact'

Returns:

Type Description
bool

True if the pattern matches according to the specified type, False otherwise.

Raises:

Type Description
LexosException

If match_type is not one of the valid options.

Source code in lexos/scrubber/tags.py
def _match_value(
    attribute_value: str | list[str], pattern: str, match_type: str = "exact"
) -> bool:
    """Match attribute values using different matching strategies.

    Args:
        attribute_value: The attribute value(s) to match against (string or list)
        pattern: The pattern/string to match
        match_type: Type of matching - "exact", "contains", or "regex"

    Returns:
        True if the pattern matches according to the specified type, False otherwise.

    Raises:
        LexosException: If match_type is not one of the valid options.
    """
    # Convert list to space-separated string if needed
    if isinstance(attribute_value, list):
        attribute_value = " ".join(attribute_value)

    if match_type == "exact":
        # Exact match - pattern must match the entire attribute value
        return pattern == attribute_value

    elif match_type == "contains":
        # Includes match - pattern must exactly match one of the space-separated values
        values = attribute_value.split()
        return pattern in values

    elif match_type == "regex":
        # Regex match - pattern is treated as a regex
        try:
            return _compile_regex(pattern).search(attribute_value) is not None
        except re.error:
            # Invalid regex pattern
            return False

    else:
        raise LexosException(
            f"match_type must be 'exact', 'contains', or 'regex', got '{match_type}'"
        )

remove_attribute(text: str, selector: str, attribute: str = None, mode: str = 'html', matcher_type: str = 'exact', attribute_value: Optional[str] = None, attribute_filter: Optional[str] = None) -> str ¤

Removes attributes from HTML/XML elements.

Removes specified attributes from elements matching the selector. Can filter elements by specific attribute or attribute value.

Parameters:

Name Type Description Default
text str

HTML or XML text to process

required
selector str

Tag name or CSS selector to match elements

required
attribute str

Attribute name to remove.

None
mode str

Parser mode, either "html" or "xml"

'html'
matcher_type str

Type of match to perform, either "exact", "contains", or "regex"

'exact'
attribute_value Optional[str]

Optional value for the attribute filter

None
attribute_filter Optional[str]

Optional attribute name to filter elements

None

Returns:

Type Description
str

Processed text with attributes removed from matching elements

Raises:

Type Description
LexosException

If mode is not "html" or "xml"

Examples:

>>> text = '<div class="main" id="content">Text</div>'
>>> remove_attributes(text, "div", "class")
'<div id="content">Text</div>'
>>> text = '<p class="a">Keep</p><p class="b" id="x">Remove attrs</p>'
>>> remove_attributes(text, "p", attribute_filter="class", attribute_value="b")
'<p class="a">Keep</p><p>Remove attrs</p>'
Source code in lexos/scrubber/tags.py
def remove_attribute(
    text: str,
    selector: str,
    attribute: str = None,
    mode: str = "html",
    matcher_type: str = "exact",
    attribute_value: Optional[str] = None,
    attribute_filter: Optional[str] = None,
) -> str:
    """Removes attributes from HTML/XML elements.

    Removes specified attributes from elements matching the selector.
    Can filter elements by specific attribute or attribute value.

    Args:
        text: HTML or XML text to process
        selector: Tag name or CSS selector to match elements
        attribute: Attribute name to remove.
        mode: Parser mode, either "html" or "xml"
        matcher_type: Type of match to perform, either "exact", "contains", or "regex"
        attribute_value: Optional value for the attribute filter
        attribute_filter: Optional attribute name to filter elements

    Returns:
        Processed text with attributes removed from matching elements

    Raises:
        LexosException: If mode is not "html" or "xml"

    Examples:
        >>> text = '<div class="main" id="content">Text</div>'
        >>> remove_attributes(text, "div", "class")
        '<div id="content">Text</div>'

        >>> text = '<p class="a">Keep</p><p class="b" id="x">Remove attrs</p>'
        >>> remove_attributes(text, "p", attribute_filter="class", attribute_value="b")
        '<p class="a">Keep</p><p>Remove attrs</p>'
    """
    # Get matching elements
    soup, elements = _match_elements(
        selector, text, mode, matcher_type, attribute, attribute_value, attribute_filter
    )

    # Filter by attribute if specified
    # if attribute_filter:
    #     if attribute_value:
    #         elements = [
    #             el
    #             for el in elements
    #             if el.has_attr(attribute_filter)
    #             and _match_value(el[attribute_filter], attribute_value, matcher_type)
    #         ]
    #     else:
    #         # Filter elements that have the attribute regardless of value
    #         elements = [el for el in elements if el.has_attr(attribute_filter)]

    # Remove specified attributes from matching elements
    for element in elements:
        if attribute:
            # Remove only the specified attribute
            if element.has_attr(attribute):
                del element[attribute]
        else:
            # Remove all attributes
            element.attrs = {}

    # Return the processed document
    return str(soup)

remove_comments(text: str, mode: str = 'html') -> str ¤

Removes comments from HTML or XML text.

Uses BeautifulSoup to find and remove all comments from HTML or XML content.

Parameters:

Name Type Description Default
text str

HTML or XML text to process

required
mode str

Parser mode, either "html" or "xml"

'html'

Returns:

Type Description
str

String containing the HTML/XML content with all comments removed

Raises:

Type Description
LexosException

If mode is not "html" or "xml"

Examples:

>>> html = '<!-- Header comment --><div>Content</div><!-- Footer -->'
>>> remove_comments(html)
'<div>Content</div>'
>>> xml = '<?xml version="1.0"?><!-- Config --><root>Data</root>'
>>> remove_comments(xml, mode="xml")
'<?xml version="1.0"?><root>Data</root>'
Source code in lexos/scrubber/tags.py
def remove_comments(text: str, mode: str = "html") -> str:
    """Removes comments from HTML or XML text.

    Uses BeautifulSoup to find and remove all comments from HTML or XML content.

    Args:
        text: HTML or XML text to process
        mode: Parser mode, either "html" or "xml"

    Returns:
        String containing the HTML/XML content with all comments removed

    Raises:
        LexosException: If mode is not "html" or "xml"

    Examples:
        >>> html = '<!-- Header comment --><div>Content</div><!-- Footer -->'
        >>> remove_comments(html)
        '<div>Content</div>'

        >>> xml = '<?xml version="1.0"?><!-- Config --><root>Data</root>'
        >>> remove_comments(xml, mode="xml")
        '<?xml version="1.0"?><root>Data</root>'
    """
    # Parse the document
    soup = _parse_document(text, mode)

    # Find all comment nodes
    comments = soup.find_all(string=lambda text: isinstance(text, Comment))

    # Remove each comment
    for comment in comments:
        comment.extract()

    # Return the processed document
    return str(soup)

remove_doctype(text: str) -> str ¤

Removes a document type declaration from HTML or XML text.

Parameters:

Name Type Description Default
text str

HTML or XML text to process

required

Returns:

Type Description
str

String containing the HTML/XML content with document type declaration removed

Source code in lexos/scrubber/tags.py
def remove_doctype(text: str) -> str:
    """Removes a document type declaration from HTML or XML text.

    Args:
        text: HTML or XML text to process

    Returns:
        String containing the HTML/XML content with document type declaration removed
    """
    # Remove HTML and XML doctype declarations
    html_doctype_pattern = re.compile(r"<!DOCTYPE[^>]*>", re.IGNORECASE | re.DOTALL)
    text = re.sub(html_doctype_pattern, "", text)

    xml_doctype_pattern = re.compile(r"<?xml[^>]*>", re.IGNORECASE | re.DOTALL)
    text = re.sub(xml_doctype_pattern, "", text)

    # Return the processed document
    return text

remove_element(text: str, selector: str, mode: str = 'html', matcher_type: str = 'exact', attribute: str = None, attribute_value: str = None) -> str ¤

Removes HTML/XML elements using BeautifulSoup.

Removes elements that match the given selector from HTML or XML text. Can further filter elements by specific attribute or attribute value.

Parameters:

Name Type Description Default
text str

HTML or XML text to process

required
selector str

Tag name or CSS selector to match elements for removal

required
mode str

Parser mode, either "html" or "xml"

'html'
matcher_type str

Type of match to perform, either "exact", "contains", or "regex"

'exact'
attribute str

Optional attribute name to filter elements

None
attribute_value str

Optional value for the attribute filter

None

Returns:

Type Description
str

Processed text with matching elements removed

Raises:

Type Description
LexosException

If mode is not "html" or "xml"

Examples:

>>> text = "<p class='a'>Keep</p><p class='b'>Remove</p><div>Remove</div>"
>>> remove_element(text, "div")
'<p>Keep</p>'
>>> remove_element("text", "p", attribute="class", attribute_value="b")
"<p class='a'>Keep</p><div>Remove</div>"
Source code in lexos/scrubber/tags.py
def remove_element(
    text: str,
    selector: str,
    mode: str = "html",
    matcher_type: str = "exact",
    attribute: str = None,
    attribute_value: str = None,
) -> str:
    """Removes HTML/XML elements using BeautifulSoup.

    Removes elements that match the given selector from HTML or XML text.
    Can further filter elements by specific attribute or attribute value.

    Args:
        text: HTML or XML text to process
        selector: Tag name or CSS selector to match elements for removal
        mode: Parser mode, either "html" or "xml"
        matcher_type: Type of match to perform, either "exact", "contains", or "regex"
        attribute: Optional attribute name to filter elements
        attribute_value: Optional value for the attribute filter

    Returns:
        Processed text with matching elements removed

    Raises:
        LexosException: If mode is not "html" or "xml"

    Examples:
        >>> text = "<p class='a'>Keep</p><p class='b'>Remove</p><div>Remove</div>"
        >>> remove_element(text, "div")
        '<p>Keep</p>'
        >>> remove_element("text", "p", attribute="class", attribute_value="b")
        "<p class='a'>Keep</p><div>Remove</div>"
    """
    # Get matching elements
    soup, elements = _match_elements(
        selector, text, mode, matcher_type, attribute, attribute_value
    )

    # Remove matching elements
    for element in elements:
        element.decompose()

    # Return the processed document
    return str(soup)

remove_tag(text: str, selector: str, mode: str = 'html', matcher_type: str = 'exact', attribute: str = None, attribute_value: str = None) -> str ¤

Removes HTML/XML tags but keeps their inner content.

Removes tags matching the selector while preserving their inner content. Can filter elements by specific attribute or attribute value.

Parameters:

Name Type Description Default
text str

HTML or XML text to process

required
selector str

Tag name or CSS selector to match elements for unwrapping

required
mode str

Parser mode, either "html" or "xml"

'html'
matcher_type str

Type of match to perform, either "exact", "contains", or "regex"

'exact'
attribute str

Optional attribute name to filter elements

None
attribute_value str

Optional value for the attribute filter

None

Returns:

Type Description
str

Processed text with matching tags unwrapped but content preserved

Raises:

Type Description
LexosException

If mode is not "html" or "xml"

Examples:

>>> text = "<div><p>Keep this</p></div><span>And this</span>"
>>> remove_tag(text, "div")
'<p>Keep this</p><span>And this</span>'
>>> text = "<p class='a'>Keep tag</p><p class='b'>Remove tag only</p>"
>>> remove_tag(text, "p", attribute="class", attribute_value="b")
"<p class='a'>Keep tag</p>Remove tag only"
Source code in lexos/scrubber/tags.py
def remove_tag(
    text: str,
    selector: str,
    mode: str = "html",
    matcher_type: str = "exact",
    attribute: str = None,
    attribute_value: str = None,
) -> str:
    """Removes HTML/XML tags but keeps their inner content.

    Removes tags matching the selector while preserving their inner content.
    Can filter elements by specific attribute or attribute value.

    Args:
        text: HTML or XML text to process
        selector: Tag name or CSS selector to match elements for unwrapping
        mode: Parser mode, either "html" or "xml"
        matcher_type: Type of match to perform, either "exact", "contains", or "regex"
        attribute: Optional attribute name to filter elements
        attribute_value: Optional value for the attribute filter

    Returns:
        Processed text with matching tags unwrapped but content preserved

    Raises:
        LexosException: If mode is not "html" or "xml"

    Examples:
        >>> text = "<div><p>Keep this</p></div><span>And this</span>"
        >>> remove_tag(text, "div")
        '<p>Keep this</p><span>And this</span>'
        >>> text = "<p class='a'>Keep tag</p><p class='b'>Remove tag only</p>"
        >>> remove_tag(text, "p", attribute="class", attribute_value="b")
        "<p class='a'>Keep tag</p>Remove tag only"
    """
    # Get matching elements
    soup, elements = _match_elements(
        selector, text, mode, matcher_type, attribute, attribute_value
    )

    # Unwrap matching elements (remove tag but keep content)
    for element in elements:
        element.unwrap()

    # Return the processed document
    return str(soup)

replace_attribute(text: str, selector: str, old_attribute: str, new_attribute: str, mode: str = 'html', matcher_type: str = 'exact', attribute_value: Optional[str] = None, replace_value: Optional[str] = None, attribute_filter: Optional[str] = None, filter_value: Optional[str] = None) -> str ¤

Replaces HTML/XML element attributes or their values.

This function finds elements matching the selector and replaces attribute names or attribute values. It can filter elements by a specific attribute/value.

Parameters:

Name Type Description Default
text str

HTML or XML text to process

required
selector str

Tag name or CSS selector to match elements

required
old_attribute str

Name of the attribute to replace

required
new_attribute str

Name of the new attribute (or same name if only changing value)

required
mode str

Parser mode, either "html" or "xml"

'html'
matcher_type str

Type of match to perform, either "exact", "contains", or "regex"

'exact'
attribute_value Optional[str]

Only replace attributes with this specific value

None
replace_value Optional[str]

New value to use (keeps original value if None)

None
attribute_filter Optional[str]

Optional attribute name to filter elements

None
filter_value Optional[str]

Optional value for the attribute filter

None

Returns:

Type Description
str

Processed text with attributes replaced in matching elements

Raises:

Type Description
LexosException

If mode is not "html" or "xml"

Examples:

>>> # Replace class attribute with data-type, keeping the value
>>> text = '<div class="main">Text</div>'
>>> replace_attribute(text, "div", "class", "data-type")
'<div data-type="main">Text</div>'
>>> # Replace class="info" with class="highlight"
>>> text = '<p class="info">Text</p><p class="data">More</p>'
>>> replace_attribute(text, "p", "class", "class", filter_value="info", replace_value="highlight")
'<p class="highlight">Text</p><p class="data">More</p>'
>>> # Only replace attributes on elements with a specific attribute value
>>> text = '<div class="main" id="content">Text</div><div class="sidebar">Side</div>'
>>> replace_attribute(text, "div", "class", "role", attribute_filter="id", filter_value="content")
'<div role="main" id="content">Text</div><div class="sidebar">Side</div>'
Source code in lexos/scrubber/tags.py
def replace_attribute(
    text: str,
    selector: str,
    old_attribute: str,
    new_attribute: str,
    mode: str = "html",
    matcher_type: str = "exact",
    attribute_value: Optional[str] = None,
    replace_value: Optional[str] = None,
    attribute_filter: Optional[str] = None,
    filter_value: Optional[str] = None,
) -> str:
    """Replaces HTML/XML element attributes or their values.

    This function finds elements matching the selector and replaces attribute names
    or attribute values. It can filter elements by a specific attribute/value.

    Args:
        text: HTML or XML text to process
        selector: Tag name or CSS selector to match elements
        old_attribute: Name of the attribute to replace
        new_attribute: Name of the new attribute (or same name if only changing value)
        mode: Parser mode, either "html" or "xml"
        matcher_type: Type of match to perform, either "exact", "contains", or "regex"
        attribute_value: Only replace attributes with this specific value
        replace_value: New value to use (keeps original value if None)
        attribute_filter: Optional attribute name to filter elements
        filter_value: Optional value for the attribute filter

    Returns:
        Processed text with attributes replaced in matching elements

    Raises:
        LexosException: If mode is not "html" or "xml"

    Examples:
        >>> # Replace class attribute with data-type, keeping the value
        >>> text = '<div class="main">Text</div>'
        >>> replace_attribute(text, "div", "class", "data-type")
        '<div data-type="main">Text</div>'

        >>> # Replace class="info" with class="highlight"
        >>> text = '<p class="info">Text</p><p class="data">More</p>'
        >>> replace_attribute(text, "p", "class", "class", filter_value="info", replace_value="highlight")
        '<p class="highlight">Text</p><p class="data">More</p>'

        >>> # Only replace attributes on elements with a specific attribute value
        >>> text = '<div class="main" id="content">Text</div><div class="sidebar">Side</div>'
        >>> replace_attribute(text, "div", "class", "role", attribute_filter="id", filter_value="content")
        '<div role="main" id="content">Text</div><div class="sidebar">Side</div>'
    """
    # Get matching elements
    soup, elements = _match_elements(
        selector, text, mode, matcher_type, old_attribute, attribute_value
    )

    elements = _filter_elements_by_attribute(
        elements, attribute_filter, filter_value, matcher_type
    )

    for element in elements:
        if not element.has_attr(old_attribute):
            continue

        value = _compute_replacement_value(
            element[old_attribute], matcher_type, attribute_value, replace_value
        )

        if old_attribute != new_attribute:
            del element[old_attribute]

        element[new_attribute] = value

    return str(soup)

replace_tag(text: str, selector: str, replacement: str, mode: str = 'html', matcher_type: str = 'exact', attribute: str = None, attribute_value: str = None, preserve_attributes: bool = True) -> str ¤

Replaces HTML/XML tags with another tag while preserving content.

Parameters:

Name Type Description Default
text str

HTML or XML text to process

required
selector str

Tag name or CSS selector to match elements for replacement

required
replacement str

New tag name to replace the matched elements with

required
mode str

Parser mode, either "html" or "xml"

'html'
matcher_type str

Type of match to perform, either "exact", "contains", or "regex"

'exact'
attribute str

Optional attribute name to filter elements

None
attribute_value str

Optional value for the attribute filter

None
preserve_attributes bool

Whether to preserve original tag attributes

True

Returns:

Type Description
str

Processed text with matching tags replaced but content preserved

Raises:

Type Description
LexosException

If mode is not "html" or "xml"

Examples:

>>> text = "<div><p>Keep this</p></div>"
>>> replace_tag(text, "div", "section")
'<section><p>Keep this</p></section>'
>>> text = "<p class='a'>Keep</p><p class='b' id='x'>Replace tag</p>"
>>> replace_tag(text, "p", "span", attribute="class", attribute_value="b")
"<p class='a'>Keep</p><span class='b' id='x'>Replace tag</span>"
Source code in lexos/scrubber/tags.py
def replace_tag(
    text: str,
    selector: str,
    replacement: str,
    mode: str = "html",
    matcher_type: str = "exact",
    attribute: str = None,
    attribute_value: str = None,
    preserve_attributes: bool = True,
) -> str:
    """Replaces HTML/XML tags with another tag while preserving content.

    Args:
        text: HTML or XML text to process
        selector: Tag name or CSS selector to match elements for replacement
        replacement: New tag name to replace the matched elements with
        mode: Parser mode, either "html" or "xml"
        matcher_type: Type of match to perform, either "exact", "contains", or "regex"
        attribute: Optional attribute name to filter elements
        attribute_value: Optional value for the attribute filter
        preserve_attributes: Whether to preserve original tag attributes

    Returns:
        Processed text with matching tags replaced but content preserved

    Raises:
        LexosException: If mode is not "html" or "xml"

    Examples:
        >>> text = "<div><p>Keep this</p></div>"
        >>> replace_tag(text, "div", "section")
        '<section><p>Keep this</p></section>'

        >>> text = "<p class='a'>Keep</p><p class='b' id='x'>Replace tag</p>"
        >>> replace_tag(text, "p", "span", attribute="class", attribute_value="b")
        "<p class='a'>Keep</p><span class='b' id='x'>Replace tag</span>"
    """
    # Get matching elements
    soup, elements = _match_elements(
        selector, text, mode, matcher_type, attribute, attribute_value
    )

    # Replace matching elements with the new tag
    for element in elements:
        # Create a new tag with the same content
        new_element = soup.new_tag(replacement)

        # Copy all attributes if requested
        if preserve_attributes:
            for attr_name, attr_value in element.attrs.items():
                new_element[attr_name] = attr_value

        # Copy all child nodes
        for child in list(element.children):
            new_element.append(child)

        # Replace the old element with the new one
        element.replace_with(new_element)

    # Return the processed document
    return str(soup)