Files
upstream-mirror db69de1c11
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:36:01 +05:00

468 lines
20 KiB
Python

# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Pure unit tests for the crawler mechanics (no network, no DB).
Covers URL normalization, the same-site/registrable-domain check, link-priority
ordering (config-driven keywords), the crawlable filter (asset/scheme/skip-pattern
rejection) and ``extract_content`` (title/headings/visible text).
"""
from __future__ import annotations
from unittest import mock
from frappe.tests import UnitTestCase
from crm.domain_enrichment import crawler
from crm.domain_enrichment.crawler import (
_is_crawlable,
_link_priority,
extract_content,
normalize_url,
registrable_domain,
same_site,
)
from crm.domain_enrichment.tests import fixtures
from crm.domain_enrichment.tests.fixtures import make_config
# The default BFS priority keywords used by the engine when Settings has none.
PRIORITY = [
("about", 1.0),
("contact", 1.0),
("team", 1.0),
("careers", 1.0),
("company", 1.0),
]
SKIP_PATTERNS = [r"/wp-admin", r"\?replytocom="]
class NormalizeUrlTest(UnitTestCase):
def test_strips_fragment(self):
self.assertEqual(normalize_url("https://x.com/about/#team"), "https://x.com/about")
def test_strips_trailing_slash_on_path(self):
self.assertEqual(normalize_url("https://x.com/about/"), "https://x.com/about")
def test_keeps_root_slash(self):
# A bare root "/" must not be stripped (path length 1).
self.assertEqual(normalize_url("https://x.com/"), "https://x.com/")
def test_strips_query_string(self):
# Query-string variants of the same page (tracking params, product-variant
# params) must collapse to one dedupe key -- otherwise the same page gets
# queued and fetched multiple times under different query strings.
self.assertEqual(normalize_url("https://x.com/w/book/1?ean=123"), "https://x.com/w/book/1")
self.assertEqual(
normalize_url("https://x.com/w/book/1?terms=free-shipping"), "https://x.com/w/book/1"
)
self.assertEqual(normalize_url("https://x.com/w/book/1"), "https://x.com/w/book/1")
class RegistrableDomainTest(UnitTestCase):
def test_strips_www_and_port(self):
self.assertEqual(registrable_domain("www.example.com:443"), "example.com")
def test_deep_subdomains_collapse_to_registrable(self):
self.assertEqual(registrable_domain("blog.shop.example.com"), "example.com")
def test_single_label(self):
self.assertEqual(registrable_domain("localhost"), "localhost")
def test_bare_ip_returns_itself(self):
self.assertEqual(registrable_domain("10.0.0.5"), "10.0.0.5")
def test_multi_label_public_suffix_keeps_registrable_label(self):
# The bug this guards: a last-two-labels rule returns "co.uk" here, so every
# *.co.uk site collapses into one same-site bucket. The PSL keeps the SLD.
self.assertEqual(registrable_domain("acme.co.uk"), "acme.co.uk")
self.assertEqual(registrable_domain("www.shop.acme.com.au"), "acme.com.au")
def test_distinct_orgs_under_a_public_suffix_are_distinct(self):
self.assertNotEqual(registrable_domain("acme.co.uk"), registrable_domain("evil.co.uk"))
def test_private_hosting_suffix_is_registrable(self):
# github.io is a PSL *private* suffix: two Pages tenants are different sites.
self.assertEqual(registrable_domain("acme.github.io"), "acme.github.io")
self.assertNotEqual(registrable_domain("acme.github.io"), registrable_domain("other.github.io"))
class SameSiteTest(UnitTestCase):
def test_ignores_www_and_subdomains(self):
self.assertTrue(same_site("https://www.x.com/a", "x.com"))
self.assertTrue(same_site("https://blog.x.com/a", "x.com"))
def test_rejects_unrelated_org_sharing_a_public_suffix(self):
# Would be a false "same site" under the naive last-two-labels rule.
self.assertFalse(same_site("https://evil.co.uk/x", "acme.co.uk"))
self.assertFalse(same_site("https://other.github.io/x", "acme.github.io"))
def test_rejects_other_domain(self):
self.assertFalse(same_site("https://other.com/a", "x.com"))
class LinkPriorityTest(UnitTestCase):
def test_priority_pages_rank_before_generic(self):
about = _link_priority("https://x.com/about", "About", PRIORITY)
blog = _link_priority("https://x.com/blog/post", "Read the post", PRIORITY)
self.assertLess(about, blog)
def test_anchor_text_counts(self):
# A generic URL with a priority keyword in the anchor still ranks high.
hit = _link_priority("https://x.com/p/42", "Our team", PRIORITY)
miss = _link_priority("https://x.com/p/43", "Read more", PRIORITY)
self.assertLess(hit, miss)
def test_non_priority_sorts_last(self):
generic = _link_priority("https://x.com/random", "", PRIORITY)
self.assertEqual(generic, len(PRIORITY) + 1)
def test_ancestor_segment_does_not_leak_priority_to_unrelated_leaf(self):
# A news article nested under "/about-us/..." must not inherit "about-us"'s
# priority from an ancestor path segment alone -- only the leaf segment counts.
news_article = _link_priority("https://x.com/about-us/corporate-news/2023/some-award", "", PRIORITY)
generic = _link_priority("https://x.com/blog/post", "", PRIORITY)
about_page = _link_priority("https://x.com/about-us", "", PRIORITY)
self.assertEqual(news_article, generic)
self.assertLess(about_page, news_article)
class IsCrawlableTest(UnitTestCase):
def test_rejects_non_http_schemes(self):
self.assertFalse(_is_crawlable("mailto:a@b.com", []))
self.assertFalse(_is_crawlable("tel:+15555550142", []))
self.assertFalse(_is_crawlable("javascript:void(0)", []))
def test_rejects_asset_extensions(self):
self.assertFalse(_is_crawlable("https://x.com/file.pdf", []))
self.assertFalse(_is_crawlable("https://x.com/logo.png", []))
self.assertFalse(_is_crawlable("https://x.com/app.js", []))
def test_allows_normal_page(self):
self.assertTrue(_is_crawlable("https://x.com/about", []))
def test_rejects_configured_skip_pattern(self):
self.assertFalse(_is_crawlable("https://x.com/wp-admin/edit", SKIP_PATTERNS))
self.assertFalse(_is_crawlable("https://x.com/post?replytocom=5", SKIP_PATTERNS))
self.assertTrue(_is_crawlable("https://x.com/about", SKIP_PATTERNS))
def test_malformed_skip_pattern_treated_as_substring(self):
# An invalid regex must not crash; it falls back to substring match.
self.assertFalse(_is_crawlable("https://x.com/[bad", ["[bad"]))
class ExtractContentTest(UnitTestCase):
def test_pulls_title_headings_and_text(self):
_page, soup = fixtures.make_page("https://acme.example", fixtures.HOMEPAGE)
content = extract_content(soup)
self.assertEqual(content["title"], "Acme Analytics | AI powered SaaS")
self.assertIn("AI powered analytics", content["headings"])
self.assertIn("hello@acme.example", content["text"])
def test_strips_script_and_style(self):
html = (
"<html><head><style>.x{}</style></head><body><script>var x=1</script><p>Visible</p></body></html>"
)
_page, soup = fixtures.make_page("https://x.com", html)
content = extract_content(soup)
self.assertIn("Visible", content["text"])
self.assertNotIn("var x", content["text"])
class ParseSitemapTest(UnitTestCase):
def test_parses_urlset(self):
kind, locs = crawler._parse_sitemap(fixtures.URLSET_XML)
self.assertEqual(kind, "urlset")
self.assertIn("https://acme.example/about", locs)
self.assertIn("https://acme.example/contact", locs)
self.assertIn("https://acme.example/blog/post-1", locs)
def test_parses_sitemapindex(self):
kind, locs = crawler._parse_sitemap(fixtures.SITEMAP_INDEX_XML)
self.assertEqual(kind, "sitemapindex")
self.assertEqual(
locs,
["https://acme.example/sitemap-pages.xml", "https://acme.example/sitemap-blog.xml"],
)
def test_malformed_xml_returns_none_kind(self):
kind, locs = crawler._parse_sitemap("<urlset><url><loc>unclosed")
self.assertIsNone(kind)
self.assertEqual(locs, [])
def test_doctype_rejected_before_parsing(self):
# Must never reach ET.fromstring with a DTD payload.
kind, locs = crawler._parse_sitemap(fixtures.MALICIOUS_DOCTYPE_XML)
self.assertIsNone(kind)
self.assertEqual(locs, [])
def test_unrelated_root_element_ignored(self):
kind, locs = crawler._parse_sitemap("<rss><channel></channel></rss>")
self.assertIsNone(kind)
self.assertEqual(locs, [])
class DiscoverSitemapUrlsTest(UnitTestCase):
def setUp(self):
self.cfg = make_config()
def test_uses_robots_declared_sitemap(self):
robots = mock.Mock()
robots.site_maps.return_value = ["https://acme.example/my-sitemap.xml"]
responses = {"https://acme.example/my-sitemap.xml": (200, fixtures.URLSET_XML)}
with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)):
urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, robots)
self.assertIn("https://acme.example/about", urls)
self.assertIn("https://acme.example/contact", urls)
def test_falls_back_to_conventional_path_when_robots_declares_none(self):
robots = mock.Mock()
robots.site_maps.return_value = None
responses = {"https://acme.example/sitemap.xml": (200, fixtures.URLSET_XML)}
with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)):
urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, robots)
self.assertIn("https://acme.example/about", urls)
def test_falls_back_when_robots_is_none(self):
responses = {"https://acme.example/sitemap.xml": (200, fixtures.URLSET_XML)}
with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)):
urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, None)
self.assertIn("https://acme.example/about", urls)
def test_missing_sitemap_returns_empty_without_raising(self):
with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch({})):
urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, None)
self.assertEqual(urls, [])
def test_malformed_xml_returns_empty(self):
responses = {"https://acme.example/sitemap.xml": (200, "not xml at all")}
with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)):
urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, None)
self.assertEqual(urls, [])
def test_follows_one_level_of_sitemap_index(self):
responses = {
"https://acme.example/sitemap.xml": (200, fixtures.SITEMAP_INDEX_XML),
"https://acme.example/sitemap-pages.xml": (200, fixtures.URLSET_XML),
"https://acme.example/sitemap-blog.xml": (
200,
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
"<url><loc>https://acme.example/blog/hello</loc></url></urlset>",
),
}
with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)):
urls = crawler.discover_sitemap_urls("https://acme.example", self.cfg, None, None)
self.assertIn("https://acme.example/about", urls)
self.assertIn("https://acme.example/blog/hello", urls)
def test_rejects_cross_site_sitemap(self):
# The sitemap FILE lives on a different site than the one we're enriching --
# must never be fetched, regardless of what its <loc> entries claim. Uses
# real-style ".com" hosts (not "*.example") so registrable_domain() actually
# differentiates them -- ".example" isn't in the bundled Public Suffix List,
# so acme.example/evil.example would collapse to the same registrable domain.
robots = mock.Mock()
robots.site_maps.return_value = ["https://evil-corp.com/sitemap.xml"]
responses = {
"https://evil-corp.com/sitemap.xml": (
200,
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
"<url><loc>https://evil-corp.com/about</loc></url></urlset>",
)
}
with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)):
urls = crawler.discover_sitemap_urls("https://acme-corp.com", self.cfg, None, robots)
self.assertEqual(urls, [])
class CrawlSitemapSeedingTest(UnitTestCase):
"""End-to-end: crawl() seeded from a robots.txt-declared sitemap.
Sitemap discovery must be a pure addition on top of the ordinary BFS: it should
surface a page that isn't linked from the homepage, without displacing the
homepage as the first crawled result, and it must disappear cleanly (no error)
when disabled or when the site has no sitemap at all.
"""
HOMEPAGE_HTML = '<html><head><title>Acme</title></head><body><a href="/contact">Contact</a></body></html>'
CONTACT_HTML = "<html><body>Contact us</body></html>"
HIDDEN_ABOUT_HTML = "<html><head><title>Hidden About</title></head><body><h1>About</h1></body></html>"
ROBOTS_TXT = "Sitemap: https://acme.example/sitemap.xml"
SITEMAP_XML = (
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
"<url><loc>https://acme.example/hidden-about</loc></url></urlset>"
)
def _responses(self, **overrides):
base = {
"https://acme.example": (200, self.HOMEPAGE_HTML),
"https://acme.example/robots.txt": (200, self.ROBOTS_TXT),
"https://acme.example/sitemap.xml": (200, self.SITEMAP_XML),
"https://acme.example/contact": (200, self.CONTACT_HTML),
"https://acme.example/hidden-about": (200, self.HIDDEN_ABOUT_HTML),
}
base.update(overrides)
return base
def test_sitemap_only_page_is_crawled_homepage_stays_first(self):
cfg = make_config(
settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 1},
link_priority=PRIORITY,
skip_patterns=[],
)
with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(self._responses())):
results = crawler.crawl("https://acme.example", cfg)
urls = [page.url for page, _soup in results]
self.assertEqual(urls[0], "https://acme.example")
self.assertIn("https://acme.example/hidden-about", urls)
self.assertIn("https://acme.example/contact", urls)
def test_use_sitemap_disabled_never_fetches_sitemap(self):
cfg = make_config(
settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 0},
link_priority=PRIORITY,
skip_patterns=[],
)
with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(self._responses())):
results = crawler.crawl("https://acme.example", cfg)
urls = [page.url for page, _soup in results]
self.assertEqual(urls[0], "https://acme.example")
self.assertNotIn("https://acme.example/hidden-about", urls)
self.assertIn("https://acme.example/contact", urls)
def test_no_sitemap_falls_through_to_plain_link_following(self):
# robots.txt exists but declares no sitemap, and /sitemap.xml 404s -- the
# crawl must proceed exactly as ordinary link-following, no error surfaced.
responses = self._responses(**{"https://acme.example/robots.txt": (200, "User-agent: *")})
del responses["https://acme.example/sitemap.xml"]
cfg = make_config(
settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 1},
link_priority=PRIORITY,
skip_patterns=[],
)
with mock.patch.object(crawler, "fetch", side_effect=fixtures.fake_fetch(responses)):
results = crawler.crawl("https://acme.example", cfg)
urls = [page.url for page, _soup in results]
self.assertEqual(urls[0], "https://acme.example")
self.assertIn("https://acme.example/contact", urls)
self.assertNotIn("https://acme.example/hidden-about", urls)
self.assertEqual(len(results), 2) # homepage + contact only, no crash, no phantom pages
class RedirectDedupTest(UnitTestCase):
"""A redirect can land two different *requested* URLs on the same resolved page
(e.g. /contact.html -> /contact) -- the pre-fetch `visited` check can't catch
this since the requested URLs genuinely differ. crawl() must collapse the
duplicate after the fact and not let it consume a page-budget slot.
"""
def test_redirect_collapsed_duplicate_does_not_consume_budget(self):
homepage_html = (
'<html><body><a href="/contact">Contact</a>'
'<a href="/contact.html">Contact (alt)</a>'
'<a href="/team">Team</a></body></html>'
)
responses = {
"https://acme.example": (200, homepage_html),
"https://acme.example/contact": (200, "<html><body>Contact page</body></html>"),
"https://acme.example/contact.html": (200, "<html><body>Contact page</body></html>"),
"https://acme.example/team": (200, "<html><body>Team page</body></html>"),
}
# /contact.html resolves (redirects) to the same page as /contact.
redirects = {"https://acme.example/contact.html": "https://acme.example/contact"}
cfg = make_config(
settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 0},
link_priority=PRIORITY,
skip_patterns=[],
)
with mock.patch.object(
crawler, "fetch", side_effect=fixtures.fake_fetch_with_redirects(responses, redirects)
):
results = crawler.crawl("https://acme.example", cfg)
urls = [page.url for page, _soup in results]
self.assertEqual(urls.count("https://acme.example/contact"), 1)
# The freed budget slot means /team -- otherwise starved by the wasted
# duplicate fetch -- still gets crawled.
self.assertIn("https://acme.example/team", urls)
class OffSiteRedirectScopeTest(UnitTestCase):
"""A same-site link that redirects off-domain must not be treated as an
in-scope crawled page: fetch()'s redirect loop only re-validates SSRF safety
per hop, never registrable-domain scope.
"""
def test_offsite_redirect_marked_out_of_scope(self):
homepage_html = (
'<html><body><a href="/investor-relations">IR</a><a href="/team">Team</a></body></html>'
)
responses = {
"https://jpm-test.com": (200, homepage_html),
"https://jpm-test.com/investor-relations": (200, "<html><body>redirected content</body></html>"),
"https://jpm-test.com/team": (200, "<html><body>Team page</body></html>"),
}
redirects = {
"https://jpm-test.com/investor-relations": "https://reports.jpmchase-test.com/ir/2022.htm"
}
cfg = make_config(
settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 0},
link_priority=PRIORITY,
skip_patterns=[],
)
with mock.patch.object(
crawler, "fetch", side_effect=fixtures.fake_fetch_with_redirects(responses, redirects)
):
results = crawler.crawl("https://jpm-test.com", cfg)
offsite = next(
page for page, _soup in results if page.url == "https://reports.jpmchase-test.com/ir/2022.htm"
)
self.assertTrue(offsite.error)
self.assertEqual(offsite.html, "")
self.assertEqual(offsite.text, "")
def test_offsite_redirect_clears_title_and_headings(self):
# title/headings are parsed by crawl_page() before the off-site check runs,
# so clearing html/text/soup alone isn't enough -- extractors that read
# title/headings straight off the CrawledPage (e.g. classify_industry's
# About-page lookup) would otherwise still see off-site content.
homepage_html = '<html><body><a href="/about">About</a></body></html>'
responses = {
"https://jpm-test.com": (200, homepage_html),
"https://jpm-test.com/about": (
200,
"<html><head><title>Global Widget Manufacturing Co</title></head>"
"<body><h1>Leading widget manufacturer since 1900</h1></body></html>",
),
}
redirects = {"https://jpm-test.com/about": "https://widgetco-test.com/about"}
cfg = make_config(
settings={"max_pages": 10, "max_depth": 1, "use_sitemap": 0},
link_priority=PRIORITY,
skip_patterns=[],
)
with mock.patch.object(
crawler, "fetch", side_effect=fixtures.fake_fetch_with_redirects(responses, redirects)
):
results = crawler.crawl("https://jpm-test.com", cfg)
offsite = next(page for page, _soup in results if page.url == "https://widgetco-test.com/about")
self.assertTrue(offsite.error)
self.assertEqual(offsite.title, "")
self.assertEqual(offsite.headings, [])
def test_homepage_redirect_is_still_adopted_not_flagged(self):
# The existing "homepage moved to a new host" behavior must be unaffected --
# only NON-homepage pages get the off-site-scope check.
responses = {"https://old-home-test.com": (200, "<html><body>hi</body></html>")}
redirects = {"https://old-home-test.com": "https://new-home-test.com"}
cfg = make_config(
settings={"max_pages": 1, "max_depth": 0, "use_sitemap": 0},
link_priority=PRIORITY,
skip_patterns=[],
)
with mock.patch.object(
crawler, "fetch", side_effect=fixtures.fake_fetch_with_redirects(responses, redirects)
):
results = crawler.crawl("https://old-home-test.com", cfg)
self.assertEqual(results[0][0].url, "https://new-home-test.com")
self.assertFalse(results[0][0].error)