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()
[docs] class COMPASSCrawler: """A simple website crawler to search for ordinance documents""" 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, ): """ 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``. """ 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.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
# 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 link in self._already_visited: return if on_new_page_visit_hook: await on_new_page_visit_hook(link) self._already_visited[link] = (depth, score) logger.trace("self._already_visited=%r", self._already_visited) if await self._website_link_is_doc(link, depth, score): return page_links = await self._get_links_from_page(link, base_url) 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!") 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 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 self._out_docs.append(doc) return True async def _get_links_from_page(self, link, base_url): """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=base_url, 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( score for __, score 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([d for d, __ 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()