From c53260865e9c34b9d71b0f32be09f9c9278a2fe0 Mon Sep 17 00:00:00 2001 From: "Gareth.Y" <91742523+linjiaye929@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:03:05 -0700 Subject: [PATCH] fix: match standard metadata names case-insensitively --- crawl4ai/utils.py | 11 ++- tests/unit/test_metadata_name_case.py | 123 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_metadata_name_case.py diff --git a/crawl4ai/utils.py b/crawl4ai/utils.py index b2e2cba7c..7468d946d 100644 --- a/crawl4ai/utils.py +++ b/crawl4ai/utils.py @@ -1584,16 +1584,21 @@ def extract_metadata_using_lxml(html, doc=None): # Strip and assign title metadata["title"] = title.strip() if title else None + # Standard metadata names are ASCII case-insensitive in HTML. + lowercase_name = ( + 'translate(@name, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")' + ) + # Meta description - using XPath with multiple attribute conditions - description = head.xpath('.//meta[@name="description"]/@content') + description = head.xpath(f'.//meta[{lowercase_name}="description"]/@content') metadata["description"] = description[0].strip() if description else None # Meta keywords - keywords = head.xpath('.//meta[@name="keywords"]/@content') + keywords = head.xpath(f'.//meta[{lowercase_name}="keywords"]/@content') metadata["keywords"] = keywords[0].strip() if keywords else None # Meta author - author = head.xpath('.//meta[@name="author"]/@content') + author = head.xpath(f'.//meta[{lowercase_name}="author"]/@content') metadata["author"] = author[0].strip() if author else None # Open Graph metadata - using starts-with() for performance diff --git a/tests/unit/test_metadata_name_case.py b/tests/unit/test_metadata_name_case.py new file mode 100644 index 000000000..a85dca628 --- /dev/null +++ b/tests/unit/test_metadata_name_case.py @@ -0,0 +1,123 @@ +"""Standard metadata names are ASCII case-insensitive in HTML.""" + +import pytest + +from crawl4ai.content_scraping_strategy import LXMLWebScrapingStrategy + + +def scrape(head): + html = ( + "
Meaningful article content remains here.
" + "" + ) + result = LXMLWebScrapingStrategy().scrap("https://example.test/article", html) + assert result.success + assert result.metadata["title"] == "Keep title" + return result.metadata + + +@pytest.mark.parametrize("field", ["description", "keywords", "author"]) +@pytest.mark.parametrize("name_case", ["mixed", "upper", "lower"]) +def test_standard_metadata_names(field, name_case): + name = {"mixed": field.title(), "upper": field.upper(), "lower": field}[name_case] + content = " Preserve MiXeD CASE and Café 中文 " + metadata = scrape(f'') + assert metadata[field] == content.strip() + + +def test_missing_metadata_fields(): + metadata = scrape("") + assert { + field: metadata[field] for field in ["description", "keywords", "author"] + } == {"description": None, "keywords": None, "author": None} + + +@pytest.mark.parametrize("name", ["Keywords", "keywordſ", "x-description"]) +def test_nonstandard_names_remain_unmatched(name): + metadata = scrape(f'') + assert metadata["description"] is None + assert metadata["keywords"] is None + assert metadata["author"] is None + + +def test_duplicate_lowercase_names_keep_first_content(): + metadata = scrape( + '' + '' + ) + assert metadata["description"] == "First VALUE" + + +def test_other_metadata_is_preserved(): + metadata = scrape( + '' + '' + '' + ) + assert metadata["og:description"] == "OG Summary" + assert metadata["twitter:description"] == "Twitter Summary" + assert metadata["article:author"] == "Article Author" + assert metadata["description"] is None + assert metadata["author"] is None + + +@pytest.mark.parametrize("field", ["description", "keywords", "author"]) +@pytest.mark.parametrize("first_upper", [False, True]) +def test_duplicate_mixed_case_names_keep_first_content(field, first_upper): + first, second = (field.upper(), field) if first_upper else (field, field.upper()) + metadata = scrape( + f'' + f'' + ) + assert metadata[field] == "First VALUE" + + +@pytest.mark.parametrize("field", ["description", "keywords", "author"]) +@pytest.mark.parametrize("name_case", ["lower", "upper"]) +def test_missing_content_is_skipped(field, name_case): + name = field if name_case == "lower" else field.upper() + metadata = scrape( + f'' f'' + ) + assert metadata[field] == "Available VALUE" + + +@pytest.mark.parametrize("field", ["description", "keywords", "author"]) +def test_no_content_attribute_remains_none(field): + metadata = scrape(f'') + assert metadata[field] is None + + +@pytest.mark.parametrize("field", ["description", "keywords", "author"]) +def test_empty_content_remains_first_value(field): + metadata = scrape( + f'' + f'' + ) + assert metadata[field] == "" + + +def test_social_names_keep_existing_case_matching(): + metadata = scrape( + '' + '' + '' + ) + assert "OG:description" not in metadata + assert "Twitter:description" not in metadata + assert "Article:author" not in metadata + + +@pytest.mark.parametrize( + "head, expected", + [ + ('', "OpenGraph Title"), + ('', "Twitter Title"), + ], +) +def test_existing_social_title_fallback_is_unchanged(head, expected): + html = f"{head}Article text is here.
" + result = LXMLWebScrapingStrategy().scrap("https://example.test/article", html) + assert result.success + assert result.metadata["title"] == expected