Source code for compass.web.website_crawl

"""Custom COMPASS website crawler

Much more simplistic than the Crawl4AI crawler, but designed to access
some links that Crawl4AI cannot (such as those behind a button
interface).
"""

import math
import asyncio
import logging
import operator
from functools import cached_property
from collections import Counter
from contextlib import AsyncExitStack
from urllib.parse import urljoin, urlsplit

from crawl4ai.models import Link as c4AILink
from bs4 import BeautifulSoup
from rebrowser_playwright.async_api import async_playwright
from rebrowser_playwright.async_api import Error as RBPlaywrightError
from playwright._impl._errors import Error as PlaywrightError  # ruff:ignore[import-private-name]
from elm.web.utilities import pw_page, PWKwargs, get_redirected_url
from elm.web.document import HTMLDocument
from elm.web.file_loader import AsyncWebFileLoader
from elm.web.website_crawl import ELMLinkScorer, _SCORE_KEY  # ruff:ignore[import-private-name]

from compass.utilities.url import URLPartFilter, normalize_domain, sanitize_url
from compass.services.threaded import TempFileCache
from compass.web.file_loader import COMPASSWebFileLoader
from compass.utilities.parsing import is_pdf_doc


logger = logging.getLogger(__name__)
_DEPTH_KEY = "web_crawl_depth"
_CLICKABLE_SELECTORS = [
    "button",  # "a", "p"
]
_BLACKLIST_SUBSTRINGS = [
    "facebook",
    "twitter",
    "linkedin",
    "instagram",
    "youtube",
    "instagram",
    "pinterest",  # cspell: disable-line
    "tiktok",  # cspell: disable-line
    "x.com",
    "snapchat",
    "reddit",
    "mailto:",
    "tel:",
    "javascript:",
    "login",
    "signup",
    "sign up",
    "signin",
    "sign in",
    "register",
    "subscribe",
    "donate",
    "shop",
    "cart",
    "careers",
    "event",
    "events",
    "calendar",
]
DOC_THRESHOLD = 5
"""Default max documents to collect before terminating COMPASS crawl"""


[docs] class COMPASSLinkScorer(ELMLinkScorer): """Custom URL scorer for COMPASS website crawling""" def _assign_value(self, text): """Score based on the presence of keywords in link text""" score = 0 text = text.casefold().replace("plant", "") for kw, kw_score in self.keyword_points.items(): if kw in text: score += kw_score return score
class _Link(c4AILink): """Crawl4AI Link subclass with a few utilities""" def __hash__(self): return hash(self.href.casefold()) def __repr__(self): return ( f"Link(title={self.title!r}, href={self.href!r}, " f"base_domain={self.base_domain!r})" ) def __str__(self): return f"{self.title} ({self.href})" def __eq__(self, other): if isinstance(other, str): return self.href.casefold() == other.casefold() if not isinstance(other, c4AILink): return NotImplemented return self.href.casefold() == other.href.casefold() @cached_property def consistent_domain(self): """bool: ``True`` if the link is from the base domain""" norm_b = normalize_domain(self.base_domain) norm_href = normalize_domain(self.href) is_consistent = norm_href == norm_b or ( bool(norm_b) and norm_href.endswith(f".{norm_b}") ) logger.trace( "Consistent domain between %s and %s: %r", norm_href, norm_b, is_consistent, ) return is_consistent @property def resembles_pdf(self): """bool: ``True`` if the link has "pdf" in title or href""" return "pdf" in self.title.casefold() or "pdf" in self.href.casefold() class _PageVisit: """Track traversal metadata and document identity for a visited URL Navigation pages may be revisited at a shallower depth when crawling with a depth limit. Documents have no descendants to explore, so they never need a second visit. Document identity is retained even when validation rejects the document, preventing repeated work. The source identifies the fetched document, which may differ from the visited URL, and is stored case-folded for matching. Source aliases do not create additional visits. """ def __init__(self, depth, score, is_document=False, source=None): """ Parameters ---------- depth : int Link depth of this visit, with the landing page at zero. score : float Relevance score assigned to the link for this visit. is_document : bool, optional Whether this visit corresponds to a document rather than a navigation page. By default, ``False``. source : str, optional The source URL of the document, which may differ from the visited URL. By default, ``None``. """ self.depth = depth self.score = score self.is_document = is_document self.source = source def can_revisit(self, depth): """Check whether a navigation-page visit is shallower Parameters ---------- depth : int Link depth of the proposed visit. Returns ------- bool Whether this is a shallower visit to a navigation page. """ return not self.is_document and depth < self.depth
[docs] class COMPASSCrawler: """A simple website crawler to search for ordinance documents""" # ruff:ignore[too-many-arguments,too-many-positional-arguments] def __init__( self, validator, url_scorer, file_loader_kwargs=None, already_visited=None, browser_semaphore=None, num_link_scores_to_check_per_page=4, max_pages=100, max_same_score_links_per_page=20, url_ignore_substrings=None, url_keep_substrings=None, max_depth=None, ): """ Parameters ---------- validator : callable An async callable that takes a document instance (containing the text from a PDF or a webpage) and returns a boolean indicating whether the text passes the validation check. This is used to determine whether or not to keep (i.e. return) the document. url_scorer : callable An async callable that takes a list of dictionaries containing URL information and assigns each dictionary a `score` key representing the score for that URL. The input URL dictionaries will each have at least one key: "href". This key will contain the URL of the link. The dictionary may also have other attributes such as "title", which contains the link title text. file_loader_kwargs : dict, optional Additional keyword-value argument pairs to pass to the :class:`~elm.web.file_loader.AsyncWebFileLoader` class. If this dictionary contains the ``pw_launch_kwargs`` key, it's value (assumes to be another dictionary) will be used to initialize the playwright instances used for the crawl. By default, ``None``. already_visited : set, optional A set of URLs (either strings or ``Link`` objects) that have already been visited. This is used to avoid re-visiting links that have already been checked. By default, ``None``. browser_semaphore : :class:`asyncio.Semaphore`, optional Semaphore instance that can be used to limit the number of playwright browsers open concurrently. If ``None``, no limits are applied. By default, ``None``. num_link_scores_to_check_per_page : int, default=4 Number of top unique-scoring links per page to use for recursive crawling. This helps the crawl stay focused on the most likely links to contain documents of interest. max_pages : int, default=100 Maximum number of pages to crawl before terminating, regardless of whether the document was found or not. By default, ``100``. max_same_score_links_per_page : int, default=20 Maximum number of links with the same score to check per page. This helps prevent the crawler from spending too much time on links that have identical scores. By default, ``20``. url_ignore_substrings : iterable of str, optional URL parts that exclude matching crawl candidates. By default, ``None``. url_keep_substrings : iterable of str, optional URL parts that override all crawl blacklist matches. By default, ``None``. max_depth : int, optional Maximum link depth to check, including the starting page at depth zero. By default, ``None`` (unlimited). """ self.validator = validator self.url_scorer = url_scorer self.num_scores_to_check_per_page = num_link_scores_to_check_per_page self.checked_previously = already_visited or set() self.max_pages = max_pages self.max_same_score_links_per_page = max_same_score_links_per_page self.max_depth = max_depth self.url_filter = URLPartFilter( [*_BLACKLIST_SUBSTRINGS, *(url_ignore_substrings or [])], url_keep_substrings, ) file_loader_kwargs = file_loader_kwargs or {} flk = {"verify_ssl": False} flk.update(file_loader_kwargs or {}) # Fast file loader that always uses poppler self.fast_afl = AsyncWebFileLoader(**flk) # best parsing file loader selected by user self.final_afl = COMPASSWebFileLoader(**flk) self.pw_launch_kwargs = PWKwargs.launch_kwargs() self.pw_launch_kwargs.update( file_loader_kwargs.get("pw_launch_kwargs") or {} ) self.browser_semaphore = ( AsyncExitStack() if browser_semaphore is None else browser_semaphore ) self._out_docs = [] self._already_visited = {} self._failed_external_domains = set() self._should_stop = None
[docs] async def run( self, base_url, crawl_timeout_s, termination_callback=None, on_new_page_visit_hook=None, ): """Run the COMPASS website crawler Parameters ---------- base_url : str URL of the website to start crawling from. crawl_timeout_s : float, optional A float representing the maximum time in seconds to allow the crawl to run. termination_callback : callable, optional An async callable that takes a list of documents and returns a boolean indicating whether to stop crawling. If ``None``, the crawl will simply terminates when :obj:`DOC_THRESHOLD` number of documents have been found. By default, ``None``. on_new_page_visit_hook : callable, optional An async callable that is called every time a new page is found during the crawl. The callable should accept a single argument, which is the page ``Link`` instance. If ``None``, no additional processing is done on new pages. By default, ``None``. Returns ------- list List of document instances that passed the validation check. Each document contains the text from a PDF and has an attribute `source` that contains the URL of the document. This list may be empty if no documents are found. """ self._should_stop = termination_callback or _default_found_enough_docs try: async with asyncio.timeout(crawl_timeout_s): await self._run( base_url, on_new_page_visit_hook=on_new_page_visit_hook ) except TimeoutError: logger.exception( "COMPASS Website crawl deadline (%s) exceeded for %r; " "returning %d collected crawl docs", f"{int(crawl_timeout_s):,d}s", base_url, len(self._out_docs), ) except Exception: logger.exception( "Error collecting documents using COMPASS web crawl for %r; " "returning %d collected crawl docs", base_url, len(self._out_docs), ) self._should_stop = None self._log_crawl_stats() self._out_docs.sort(key=lambda x: -1 * x.attrs[_SCORE_KEY]) return self._out_docs
# ruff:ignore[complex-structure] # complexipy: ignore async def _run( self, base_url, link=None, depth=0, score=0, on_new_page_visit_hook=None, ): """Recursive web crawl function""" if link is None: base_url, link = await self._reset_crawl(base_url) logger.debug( "Starting COMPASS crawl for base URL: %s\nLink: %r", base_url, link, ) if self.url_filter.blacklist_match(link.href) is not None: logger.debug("Skipping blacklisted URL: %s", link.href) return if self._document_source_seen(link.href): logger.debug( "Skipping previously collected document URL: %s", link.href ) return if not self._should_continue_page_crawl(link, depth): return if on_new_page_visit_hook: await on_new_page_visit_hook(link) visit = self._already_visited[link] = _PageVisit(depth, score) logger.trace("self._already_visited=%r", self._already_visited) if await self._website_link_is_doc(link, depth, score): visit.is_document = True return if self.max_depth is not None and depth >= self.max_depth: return page_links = await self._get_links_from_page(link) for next_link in self._top_scored_links(page_links, link): prev_len = len(self._out_docs) next_href = await get_redirected_url( next_link["href"], verify=False ) await self._run( base_url, link=_Link( title=next_link["title"], href=next_href, base_domain=base_url, ), depth=depth + 1, score=next_link["score"], on_new_page_visit_hook=on_new_page_visit_hook, ) doc_was_just_found = ( # fmt: off len(self._out_docs) == (prev_len + 1) and ( self._out_docs[-1].attrs.get(_DEPTH_KEY, -1) == (depth + 1) ) ) if doc_was_just_found: if await self.validator(self._out_docs[-1]): logger.debug(" - Document passed validation check!") await self._load_last_doc_with_final_afl(next_link["href"]) else: self._out_docs = self._out_docs[:-1] if await self._should_terminate_crawl(): break return async def _load_last_doc_with_final_afl(self, link): """Load the last document with the final file loader""" old_doc = self._out_docs[-1] link = old_doc.attrs.get("source", link) try: doc = await self.final_afl.fetch(link) doc.attrs[_DEPTH_KEY] = old_doc.attrs[_DEPTH_KEY] doc.attrs[_SCORE_KEY] = old_doc.attrs[_SCORE_KEY] if not doc.empty: self._out_docs[-1] = doc except KeyboardInterrupt: raise except Exception as e: msg = ( "Encountered error of type %r while trying to fetch " "content from %s" ) err_type = type(e) logger.exception(msg, err_type, link) async def _reset_crawl(self, base_url): """Reset crawl state and initialize crawling link""" self._out_docs = [] self._already_visited = {} self._failed_external_domains = set() base_url = sanitize_url(base_url) base_url = await get_redirected_url(base_url, verify=False) return base_url, _Link( title="Landing Page", href=sanitize_url(urljoin(base_url, base_url.split(" ")[0])), base_domain=base_url, ) async def _website_link_is_doc(self, link, depth, score): """Check if website link contains doc""" if link in self.checked_previously and link.consistent_domain: # Don't re-check pages on main website return False if await self._website_link_is_pdf(link, depth, score): return True # at this point the page is NOT a PDF. However, it could still # just be a normal webpage on the main domain that we haven't # visited before. In that case, just return False if link.consistent_domain: return False # now we are on an external page that we either have not visited # before or that we have seen but determined is NOT a PDF file return await self._website_link_as_html_doc(link, depth, score) async def _website_link_is_pdf(self, link, depth, score): """Fetch page content; keep only PDFs""" if not link.consistent_domain and not link.resembles_pdf: logger.debug( "Skipping external non-PDF candidate: %s", link.href, ) return False parsed = urlsplit(link.href) if parsed.scheme not in {"http", "https"}: logger.debug("Skipping non-http URL: %s", link.href) return False if ( not link.consistent_domain and parsed.netloc.casefold() in self._failed_external_domains ): logger.debug( "Skipping external domain after previous fetch failure: %s", parsed.netloc, ) return False logger.debug("Loading Link: %s", link) return await self._loaded_link_is_pdf(link, depth, score, parsed) async def _loaded_link_is_pdf(self, link, depth, score, parsed): """Check if the loaded link is a PDF document""" try: doc = await self.fast_afl.fetch(link.href) except KeyboardInterrupt: raise except Exception as e: msg = ( "Encountered error of type %r while trying to fetch " "content from %s" ) err_type = type(e) logger.exception(msg, err_type, link) if not link.consistent_domain and parsed.netloc: self._failed_external_domains.add(parsed.netloc.casefold()) return False if is_pdf_doc(doc): logger.debug(" - Found PDF!") doc.attrs[_DEPTH_KEY] = depth doc.attrs[_SCORE_KEY] = score source = doc.attrs.get("source", link.href).casefold() if self._record_document(link, depth, score, source): self._out_docs.append(doc) return True return False async def _website_link_as_html_doc(self, link, depth, score): """Fetch page content as HTML doc""" logger.debug("Loading Link as HTML: %s", link) html_text = await self._get_text_no_err(link.href) attrs = {_DEPTH_KEY: depth, _SCORE_KEY: score, "source": link.href} doc = HTMLDocument([html_text], attrs=attrs) cache_fn = await TempFileCache.call(doc, doc.text) if cache_fn is not None: doc.attrs["cache_fn"] = cache_fn if self._record_document(link, depth, score, link.href): self._out_docs.append(doc) return True def _should_continue_page_crawl(self, link, depth): """Determine whether the crawler should continue crawling""" previous_visit = self._already_visited.get(link) if previous_visit is None: return True if self.max_depth is None: # We visited this page with no depth restriction, so we've # already seen all other possible links - no need to recheck return False # We visited this page but potentially didn't check links on it # due to depth restriction, so see if we are now visiting it # with a shallower depth, which allows further exploration return previous_visit.can_revisit(depth) def _document_source_seen(self, source): """Check known document URLs and reported sources""" source_key = str(source).casefold() return any( visit.is_document and ( link.href.casefold() == source_key or visit.source == source_key ) for link, visit in self._already_visited.items() ) def _record_document(self, link, depth, score, source): """Record a document and return whether its source is new""" is_new = not self._document_source_seen(source) visit = self._already_visited.setdefault( link, _PageVisit(depth, score) ) visit.is_document = True visit.source = str(source).casefold() return is_new async def _get_links_from_page(self, link): """Get all links from a page sorted by relevance score""" if not link.consistent_domain: logger.debug("Detected new domain, stopping link discovery") return [] html_text = await self._get_text_no_err(link.href) page_links = [] if html_text: page_links = _extract_links_from_html( html_text, base_url=link.href, url_filter=self.url_filter ) page_links = await self.url_scorer( [dict(link) for link in page_links] ) page_links = sorted( page_links, key=operator.itemgetter("score"), reverse=True ) _debug_info_on_links(page_links) return page_links def _top_scored_links(self, page_links, link): """Yield the configured number of links from each top score""" num_link_scores_checked = 0 num_same_score_links_checked = 0 has_warned = False curr_url_score = None for page_link in page_links: page_link_score = page_link["score"] if page_link_score == 0: continue if curr_url_score != page_link_score: if self._max_score_cats_checked(num_link_scores_checked, link): return curr_url_score = page_link_score num_link_scores_checked += 1 num_same_score_links_checked = 0 has_warned = False if self._max_same_score_links_checked( num_same_score_links_checked ): has_warned = self._warn_about_max_links( has_warned, curr_url_score, link ) continue num_same_score_links_checked += 1 yield page_link def _max_score_cats_checked(self, num_link_scores_checked, link): """Determine if the max score categories limit is reached""" if num_link_scores_checked >= self.num_scores_to_check_per_page: logger.debug( "Reached max score categories (%d) to check for page: %s", self.num_scores_to_check_per_page, link.href, ) return True return False def _max_same_score_links_checked(self, num_same_score_links_checked): """Determine if the same-score-link limit has been reached""" return ( num_same_score_links_checked >= self.max_same_score_links_per_page ) def _warn_about_max_links(self, has_warned, curr_url_score, link): """Log a warning for max links for the current score""" if not has_warned: logger.warning( "Reached max links (%d) to check for score %d for page: %s", self.max_same_score_links_per_page, curr_url_score, link.href, ) return True async def _get_text_no_err(self, url): """Get all text from a page; return empty string if pw error""" try: text = await self._get_text(url) except (PlaywrightError, RBPlaywrightError): text = "" return text async def _get_text(self, url): """Get all html text from a page""" all_text = [] timeout_ms = 180_000 # milliseconds pw_page_kwargs = { "intercept_routes": True, "ignore_https_errors": True, "timeout": timeout_ms, } async with async_playwright() as p, self.browser_semaphore: browser = await p.chromium.launch(**self.pw_launch_kwargs) async with pw_page(browser, **pw_page_kwargs) as page: await page.goto(url) logger.debug( "Waiting up to %d min for '%s' to load...", math.ceil(timeout_ms / 60_000), url, ) await page.wait_for_load_state( "networkidle", timeout=timeout_ms ) all_text.append(await page.content()) all_text += await _get_text_from_all_locators(page) return "\n".join(all_text) async def _should_terminate_crawl(self): """Check if crawl should terminate""" if await self._should_stop(self._out_docs): logger.debug("Exiting crawl early due to user condition") return True if len(self._already_visited) >= self.max_pages: logger.debug(" - Too many links visited, stopping recursion") return True logger.trace( "Only checked %d pages, continuing crawl...", len(self._already_visited), ) return False def _log_crawl_stats(self): """Log statistics about crawled pages and depths""" logger.info("Crawled %d pages", len(self._already_visited)) logger.info("Found %d potential document(s)", len(self._out_docs)) logger.debug("Average score: %.2f", self._compute_avg_link_score()) logger.debug("Pages crawled by depth:") for depth, count in sorted(self._crawl_depth_counts().items()): logger.debug(" Depth %d: %d pages", depth, count) def _compute_avg_link_score(self): """Compute the average score of the crawled results""" if not self._already_visited: return 0 return sum( visit.score for visit in self._already_visited.values() ) / len(self._already_visited) def _crawl_depth_counts(self): """Compute number of pages per depth""" depth_counts = Counter() depth_counts.update( visit.depth for visit in self._already_visited.values() ) return depth_counts
async def _default_found_enough_docs(out_docs): # ruff:ignore[unused-async] """Check if a predetermined # of documents has been found The number to check is set by the module-level constant :obj:`DOC_THRESHOLD`. """ return len(out_docs) >= DOC_THRESHOLD def _debug_info_on_links(links): """Send debug info on links to logger""" num_links = len(links) if num_links <= 0: logger.debug("Found no links!") return logger.debug("Found %d links:", len(links)) for link in links[:3]: logger.debug( " - %d: %s (%s)", link["score"], link["title"], link["href"] ) if num_links > 3: # ruff:ignore[magic-value-comparison] logger.debug(" ...") def _extract_links_from_html(text, base_url, url_filter=None): """Parse HTML and extract all links""" soup = BeautifulSoup(text, "html.parser") links = [ (a.get_text().strip(), a["href"]) for a in soup.find_all("a", href=True) ] return set(_sanitized_links(links, base_url, url_filter=url_filter)) def _sanitized_links(links, base_url, url_filter=None): """Sanitized links from the given list of (title, path) tuples""" url_filter = url_filter or URLPartFilter(_BLACKLIST_SUBSTRINGS) for title, path in links: if not title or not path: continue href = sanitize_url(urljoin(base_url, path)) if urlsplit(href).scheme not in {"http", "https"}: continue if _is_blacklisted(title, path, href, url_filter): continue yield _Link(title=title, href=href, base_domain=base_url) def _is_blacklisted(title, path, url, url_filter): """Check whether a link title or URL is blacklisted""" if url_filter.is_whitelisted(url): return False title_and_path = f"{title}\n{path}".casefold() if any(substr in title_and_path for substr in _BLACKLIST_SUBSTRINGS): return True return url_filter.blacklist_match(url) is not None async def _get_text_from_all_locators(page): """Go through locators on page and get text behind them""" all_text = [] for selector in _CLICKABLE_SELECTORS: logger.trace("Checking selector %r", selector) locators = page.locator(selector) locator_count = await locators.count() logger.trace(" - Found %d instances", locator_count) for index in range(locator_count): try: text = await _get_locator_text(locators, index, page) except (PlaywrightError, RBPlaywrightError): continue if text: all_text.append(text) return all_text async def _get_locator_text(locators, index, page): """Get text after clicking on one of the page locators""" locator = locators.nth(index) if not await locator.is_visible(): return None if not await locator.is_enabled(): return None await locator.click(timeout=10_000) return await page.content()