diff --git a/packages/markitdown/src/markitdown/converters/_bing_serp_converter.py b/packages/markitdown/src/markitdown/converters/_bing_serp_converter.py index fd00a70ab..c58e35949 100644 --- a/packages/markitdown/src/markitdown/converters/_bing_serp_converter.py +++ b/packages/markitdown/src/markitdown/converters/_bing_serp_converter.py @@ -1,7 +1,7 @@ import re import base64 import binascii -from urllib.parse import parse_qs, urlparse +from urllib.parse import ParseResult, parse_qs, urlparse from typing import Any, BinaryIO from bs4 import BeautifulSoup @@ -20,6 +20,18 @@ ] +def _is_bing_redirect(parsed_href: ParseResult) -> bool: + """Return True only for Bing's own `/ck/a` redirect links. + + A normal result may use `u=` for its own purpose. So the base64 payload is + decoded only when the link is one that Bing wrapped. + """ + host = (parsed_href.netloc or "").lower() + if host != "bing.com" and not host.endswith(".bing.com"): + return False + return parsed_href.path == "/ck/a" + + class BingSerpConverter(DocumentConverter): """ Handle Bing results pages (only the organic search results). @@ -87,6 +99,12 @@ def convert( # Rewrite redirect urls for a in result.find_all("a", href=True): parsed_href = urlparse(a["href"]) + + # Keep normal result URLs as they are. Only Bing's redirect + # links carry the base64 `u` payload. + if not _is_bing_redirect(parsed_href): + continue + qs = parse_qs(parsed_href.query) # The destination is contained in the u parameter, diff --git a/packages/markitdown/tests/test_bing_serp_converter.py b/packages/markitdown/tests/test_bing_serp_converter.py new file mode 100644 index 000000000..9d80589bc --- /dev/null +++ b/packages/markitdown/tests/test_bing_serp_converter.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 -m pytest +"""A result URL that uses `u=` for its own purpose must survive conversion. + +Bing puts each search result behind a `/ck/a` redirect link. The real +destination sits in a base64 `u` query parameter. The converter decodes that +parameter, but it did so for every link that has `u=`. A normal result that +uses `u=` for its own purpose was therefore rewritten into garbage. +""" + +import io + +from markitdown import MarkItDown, StreamInfo + +SERP_URL = "https://www.bing.com/search?q=python" + +REDIRECT_TARGET = "https://docs.python.org/3/" +REDIRECT_HREF = ( + "https://www.bing.com/ck/a?!&&p=xyz" + "&u=a1aHR0cHM6Ly9kb2NzLnB5dGhvbi5vcmcvMy8=" +) + +NORMAL_HREF = "https://example.com/profile?u=abcdef" + +LOOKALIKE_HREF = "https://not-bing.com/ck/a?u=a1aHR0cHM6Ly9kb2NzLnB5dGhvbi5vcmcvMy8=" + +LOOKALIKE_PATH_HREF = ( + "https://www.bing.com/ck/abc?u=a1aHR0cHM6Ly9kb2NzLnB5dGhvbi5vcmcvMy8=" +) + + +def _serp(*hrefs: str) -> io.BytesIO: + items = "".join( + '
  • Result %d

    Snippet %d.

  • ' + % (href, i, i) + for i, href in enumerate(hrefs, start=1) + ) + html = ( + "python - Search" + '
      %s
    ' % items + ) + return io.BytesIO(html.encode("utf-8")) + + +def _convert(*hrefs: str) -> str: + return ( + MarkItDown() + .convert_stream( + _serp(*hrefs), + stream_info=StreamInfo( + url=SERP_URL, + mimetype="text/html", + extension=".html", + charset="utf-8", + ), + ) + .markdown + ) + + +def test_bing_redirect_is_decoded_to_its_destination() -> None: + markdown = _convert(REDIRECT_HREF) + + assert "](%s)" % REDIRECT_TARGET in markdown + assert "ck/a" not in markdown + + +def test_normal_url_keeps_its_own_u_parameter() -> None: + markdown = _convert(NORMAL_HREF) + + assert "](%s)" % NORMAL_HREF in markdown + + +def test_normal_url_survives_next_to_a_redirect() -> None: + markdown = _convert(REDIRECT_HREF, NORMAL_HREF) + + assert "](%s)" % REDIRECT_TARGET in markdown + assert "](%s)" % NORMAL_HREF in markdown + + +def test_lookalike_host_is_not_treated_as_a_bing_redirect() -> None: + markdown = _convert(LOOKALIKE_HREF) + + assert "](%s)" % LOOKALIKE_HREF in markdown + + +def test_lookalike_path_is_not_treated_as_a_bing_redirect() -> None: + markdown = _convert(LOOKALIKE_PATH_HREF) + + assert "](%s)" % LOOKALIKE_PATH_HREF in markdown