import cssutils from bs4 import BeautifulSoup from bs4.element import ResultSet, Tag from cryptography.fernet import Fernet from flask import render_template import html import urllib.parse as urlparse from urllib.parse import parse_qs import re from app.models.g_classes import GClasses from app.request import VALID_PARAMS, MAPS_URL from app.utils.misc import get_abs_url, read_config_bool from app.utils.results import ( BLANK_B64, GOOG_IMG, GOOG_STATIC, G_M_LOGO_URL, LOGO_URL, SITE_ALTS, has_ad_content, filter_link_args, append_anon_view, get_site_alt, ) from app.models.endpoint import Endpoint from app.models.config import Config MAPS_ARGS = ['q', 'daddr'] minimal_mode_sections = ['Top stories', 'Images', 'People also ask'] unsupported_g_pages = [ 'support.google.com', 'accounts.google.com', 'policies.google.com', 'google.com/preferences', 'google.com/intl', 'advanced_search', 'tbm=shop' ] def extract_q(q_str: str, href: str) -> str: """Extracts the 'q' element from a result link. This is typically either the link to a result's website, or a string. Args: q_str: The result link to parse href: The full url to check for standalone 'q' elements first, rather than parsing the whole query string and then checking. Returns: str: The 'q' element of the link, or an empty string """ return parse_qs(q_str, keep_blank_values=True)['q'][0] if ('&q=' in href or '?q=' in href) else '' def build_map_url(href: str) -> str: """Tries to extract known args that explain the location in the url. If a location is found, returns the default url with it. Otherwise, returns the url unchanged. Args: href: The full url to check. Returns: str: The parsed url, or the url unchanged. """ # parse the url parsed_url = parse_qs(href) # iterate through the known parameters and try build the url for param in MAPS_ARGS: if param in parsed_url: return MAPS_URL + "?q=" + parsed_url[param][0] # query could not be extracted returning unchanged url return href def clean_query(query: str) -> str: """Strips the blocked site list from the query, if one is being used. Args: query: The query string Returns: str: The query string without any "-site:..." filters """ return query[:query.find('-site:')] if '-site:' in query else query def clean_css(css: str, page_url: str) -> str: """Removes all remote URLs from a CSS string. Args: css: The CSS string Returns: str: The filtered CSS, with URLs proxied through Whoogle """ sheet = cssutils.parseString(css) urls = cssutils.getUrls(sheet) for url in urls: abs_url = get_abs_url(url, page_url) if abs_url.startswith('data:'): continue css = css.replace( url, f'{Endpoint.element}?type=image/png&url={abs_url}' ) return css class Filter: # Limit used for determining if a result is a "regular" result or a list # type result (such as "people also asked", "related searches", etc) RESULT_CHILD_LIMIT = 7 def __init__( self, user_key: str, config: Config, root_url='', page_url='', query='', mobile=False) -> None: self.soup = None self.config = config self.mobile = mobile self.user_key = user_key self.page_url = page_url self.query = query self.main_divs = ResultSet('') self._elements = 0 self._av = set() self.root_url = root_url[:-1] if root_url.endswith('/') else root_url def __getitem__(self, name): return getattr(self, name) @property def elements(self): return self._elements def encrypt_path(self, path, is_element=False) -> str: # Encrypts path to avoid plaintext results in logs if is_element: # Element paths are encrypted separately from text, to allow key # regeneration once all items have been served to the user enc_path = Fernet(self.user_key).encrypt(path.encode()).decode() self._elements += 1 return enc_path return Fernet(self.user_key).encrypt(path.encode()).decode() def clean(self, soup) -> BeautifulSoup: self.soup = soup self.main_divs = self.soup.find('div', {'id': 'main'}) self.remove_ads() self.remove_block_titles() self.remove_block_url() self.collapse_sections() self.update_css() self.update_styling() self.remove_block_tabs() # self.main_divs is only populated for the main page of search results # (i.e. not images/news/etc). if self.main_divs: for div in self.main_divs: self.sanitize_div(div) for img in [_ for _ in self.soup.find_all('img') if 'src' in _.attrs]: self.update_element_src(img, 'image/png') for audio in [_ for _ in self.soup.find_all('audio') if 'src' in _.attrs]: self.update_element_src(audio, 'audio/mpeg') audio['controls'] = '' for link in self.soup.find_all('a', href=True): self.update_link(link) self.add_favicon(link) if self.config.alts: self.site_alt_swap() input_form = self.soup.find('form') if input_form is not None: input_form['method'] = 'GET' if self.config.get_only else 'POST' # Use a relative URI for submissions input_form['action'] = 'search' # Ensure no extra scripts passed through for script in self.soup('script'): script.decompose() # Update default footer and header footer = self.soup.find('footer') if footer: # Remove divs that have multiple links beyond just page navigation [_.decompose() for _ in footer.find_all('div', recursive=False) if len(_.find_all('a', href=True)) > 3] for link in footer.find_all('a', href=True): link['href'] = f'{link["href"]}&preferences={self.config.preferences}' header = self.soup.find('header') if header: header.decompose() self.remove_site_blocks(self.soup) return self.soup def sanitize_div(self, div) -> None: """Removes escaped script and iframe tags from results Returns: None (The soup object is modified directly) """ if not div: return for d in div.find_all('div', recursive=True): d_text = d.find(text=True, recursive=False) # Ensure we're working with tags that contain text content if not d_text or not d.string: continue d.string = html.unescape(d_text) div_soup = BeautifulSoup(d.string, 'html.parser') # Remove all valid script or iframe tags in the div for script in div_soup.find_all('script'): script.decompose() for iframe in div_soup.find_all('iframe'): iframe.decompose() d.string = str(div_soup) def add_favicon(self, link) -> None: """Adds icons for each returned result, using the result site's favicon Returns: None (The soup object is modified directly) """ # Skip empty, parentless, or internal links if not link or not link.parent or not link['href'].startswith('http'): return parent = link.parent is_result_div = False # Check each parent to make sure that the div doesn't already have a # favicon attached, and that the div is a result div while parent: p_cls = parent.attrs.get('class') or [] if 'has-favicon' in p_cls or GClasses.scroller_class in p_cls: return elif GClasses.result_class_a not in p_cls: parent = parent.parent else: is_result_div = True break if not is_result_div: return # Construct the html for inserting the icon into the parent div parsed = urlparse.urlparse(link['href']) favicon = self.encrypt_path( f'{parsed.scheme}://{parsed.netloc}/favicon.ico', is_element=True) src = f'{self.root_url}/{Endpoint.element}?url={favicon}' + \ '&type=image/x-icon' html = f'' favicon = BeautifulSoup(html, 'html.parser') link.parent.insert(0, favicon) # Update all parents to indicate that a favicon has been attached parent = link.parent while parent: p_cls = parent.get('class') or [] p_cls.append('has-favicon') parent['class'] = p_cls parent = parent.parent if GClasses.result_class_a in p_cls: break def remove_site_blocks(self, soup) -> None: if not self.config.block or not soup.body: return search_string = ' '.join(['-site:' + _ for _ in self.config.block.split(',')]) selected = soup.body.findAll(text=re.compile(search_string)) for result in selected: result.string.replace_with(result.string.replace( search_string, '')) def remove_ads(self) -> None: """Removes ads found in the list of search result divs Returns: None (The soup object is modified directly) """ if not self.main_divs: return for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]: div_ads = [_ for _ in div.find_all('span', recursive=True) if has_ad_content(_.text)] _ = div.decompose() if len(div_ads) else None def remove_block_titles(self) -> None: if not self.main_divs or not self.config.block_title: return block_title = re.compile(self.config.block_title) for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]: block_divs = [_ for _ in div.find_all('h3', recursive=True) if block_title.search(_.text) is not None] _ = div.decompose() if len(block_divs) else None def remove_block_url(self) -> None: if not self.main_divs or not self.config.block_url: return block_url = re.compile(self.config.block_url) for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]: block_divs = [_ for _ in div.find_all('a', recursive=True) if block_url.search(_.attrs['href']) is not None] _ = div.decompose() if len(block_divs) else None def remove_block_tabs(self) -> None: if self.main_divs: for div in self.main_divs.find_all( 'div', attrs={'class': f'{GClasses.main_tbm_tab}'} ): _ = div.decompose() else: # when in images tab for div in self.soup.find_all( 'div', attrs={'class': f'{GClasses.images_tbm_tab}'} ): _ = div.decompose() def collapse_sections(self) -> None: """Collapses long result sections ("people also asked", "related searches", etc) into "details" elements These sections are typically the only sections in the results page that have more than ~5 child divs within a primary result div. Returns: None (The soup object is modified directly) """ minimal_mode = read_config_bool('WHOOGLE_MINIMAL') def pull_child_divs(result_div: BeautifulSoup): try: return result_div.findChildren( 'div', recursive=False )[0].findChildren( 'div', recursive=False) except IndexError: return [] if not self.main_divs: return # Loop through results and check for the number of child divs in each for result in self.main_divs.find_all(): result_children = pull_child_divs(result) if minimal_mode: if any(f">{x} 1: subtitle = ' (' + \ ''.join(content[1:]) + ')' elem.decompose() break # Create the new details element to wrap around the result's # first parent parent = None idx = 0 while not parent and idx < len(result_children): parent = result_children[idx].parent idx += 1 details = BeautifulSoup(features='html.parser').new_tag('details') summary = BeautifulSoup(features='html.parser').new_tag('summary') summary.string = label if subtitle: soup = BeautifulSoup(subtitle, 'html.parser') summary.append(soup) details.append(summary) if parent and not minimal_mode: parent.wrap(details) elif parent and minimal_mode: # Remove parent element from document if "minimal mode" is # enabled parent.decompose() def update_element_src(self, element: Tag, mime: str, attr='src') -> None: """Encrypts the original src of an element and rewrites the element src to use the "/element?src=" pass-through. Returns: None (The soup element is modified directly) """ src = element[attr].split(' ')[0] if src.startswith('//'): src = 'https:' + src elif src.startswith('data:'): return if src.startswith(LOGO_URL): # Re-brand with Whoogle logo element.replace_with(BeautifulSoup( render_template('logo.html'), features='html.parser')) return elif src.startswith(G_M_LOGO_URL): # Re-brand with single-letter Whoogle logo element['src'] = 'static/img/favicon/apple-icon.png' element.parent['href'] = 'home' return elif src.startswith(GOOG_IMG) or GOOG_STATIC in src: element['src'] = BLANK_B64 return element[attr] = f'{self.root_url}/{Endpoint.element}?url=' + ( self.encrypt_path( src, is_element=True ) + '&type=' + urlparse.quote(mime) ) def update_css(self) -> None: """Updates URLs used in inline styles to be proxied by Whoogle using the /element endpoint. Returns: None (The soup element is modified directly) """ # Filter all