class Filter: def __init__(self, user_keys: dict, mobile=False, config=None): if config is None: config = {} self.near = config['near'] if 'near' in config else '' self.dark = config['dark'] if 'dark' in config else False self.nojs = config['nojs'] if 'nojs' in config else False self.new_tab = config['new_tab'] if 'new_tab' in config else False self.alt_redirect = config['alts'] if 'alts' in config else False self.mobile = mobile self.user_keys = user_keys self.main_divs = ResultSet('') self._elements = 0 def __getitem__(self, name): return getattr(self, name) @property def elements(self): return self._elements def reskin(self, page): # Aesthetic only re-skinning if self.dark: page = page.replace('fff', '000').replace('202124', 'ddd').replace('1967D2', '3b85ea') return page def encrypt_path(self, msg, is_element=False): # Encrypts path to avoid plaintext results in logs if is_element: # Element paths are tracked differently in order for the element key to be regenerated # once all elements have been loaded enc_path = Fernet(self.user_keys['element_key']).encrypt(msg.encode()).decode() self._elements += 1 return enc_path return Fernet(self.user_keys['text_key']).encrypt(msg.encode()).decode() def clean(self, soup): self.main_divs = soup.find('div', {'id': 'main'}) self.remove_ads() self.fix_question_section() self.update_styling(soup) for img in [_ for _ in soup.find_all('img') if 'src' in _.attrs]: self.update_element_src(img, 'image/png') for audio in [_ for _ in soup.find_all('audio') if 'src' in _.attrs]: self.update_element_src(audio, 'audio/mpeg') for link in soup.find_all('a', href=True): self.update_link(link) input_form = soup.find('form') if input_form is not None: input_form['method'] = 'POST' # Ensure no extra scripts passed through for script in soup('script'): script.decompose() # Update default footer and header footer = 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)) > 2] header = soup.find('header') if header: header.decompose() return soup def remove_ads(self): if not self.main_divs: return for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]: has_ad = len([_ for _ in div.find_all('span', recursive=True) if has_ad_content(_.text)]) _ = div.decompose() if has_ad else None def fix_question_section(self): if not self.main_divs: return question_divs = [_ for _ in self.main_divs.find_all('div', recursive=False) if len(_.find_all('h2')) > 0] for question_div in question_divs: questions = [_ for _ in question_div.find_all('div', recursive=True) if _.text.endswith('?')] for question in questions: question['style'] = 'padding: 10px; font-style: italic;' def update_element_src(self, element, mime): element_src = element['src'] if element_src.startswith('//'): element_src = 'https:' + element_src elif element_src.startswith(LOGO_URL): # Re-brand with Whoogle logo element['src'] = '/static/img/logo.png' element['style'] = 'height:40px;width:162px' return elif element_src.startswith(GOOG_IMG): element['src'] = BLANK_B64 return element['src'] = '/element?url=' + self.encrypt_path(element_src, is_element=True) + \ '&type=' + urlparse.quote(mime) # TODO: Non-mobile image results link to website instead of image # if not self.mobile: # img.append(BeautifulSoup(FULL_RES_IMG.format(element_src), 'html.parser')) def update_styling(self, soup): # Remove unnecessary button(s) for button in soup.find_all('button'): button.decompose() # Remove svg logos for svg in soup.find_all('svg'): svg.decompose() # Update logo logo = soup.find('a', {'class': 'l'}) if logo and self.mobile: logo['style'] = 'display:flex; justify-content:center; align-items:center; color:#685e79; ' \ 'font-size:18px; ' # Fix search bar length on mobile try: search_bar = soup.find('header').find('form').find('div') search_bar['style'] = 'width: 100%;' except AttributeError: pass def update_link(self, link): # Replace href with only the intended destination (no "utm" type tags) href = link['href'].replace('https://www.google.com', '') if '/advanced_search' in href or 'tbm=shop' in href: # TODO: The "Shopping" tab requires further filtering (see #136) # Temporarily removing all links to that tab for now. link.decompose() return elif self.new_tab: link['target'] = '_blank' result_link = urlparse.urlparse(href) query_link = parse_qs(result_link.query)['q'][0] if '?q=' in href else '' if query_link.startswith('/'): # Internal google links (i.e. mail, maps, etc) should still be forwarded to Google link['href'] = 'https://google.com' + query_link elif '/search?q=' in href: # "li:1" implies the query should be interpreted verbatim, so we wrap it in double quotes if 'li:1' in href: query_link = '"' + query_link + '"' new_search = '/search?q=' + self.encrypt_path(query_link) query_params = parse_qs(urlparse.urlparse(href).query) for param in VALID_PARAMS: param_val = query_params[param][0] if param in query_params else '' new_search += '&' + param + '=' + param_val link['href'] = new_search elif 'url?q=' in href: # Strip unneeded arguments link['href'] = filter_link_args(query_link) # Add no-js option if self.nojs: gen_nojs(link) else: link['href'] = href # Replace link location if "alts" config is enabled if self.alt_redirect: # Search and replace all link descriptions with alternative location link['href'] = get_site_alt(link['href']) link_desc = link.find_all(text=re.compile('|'.join(SITE_ALTS.keys()))) if len(link_desc) == 0: return # Replace link destination link_desc[0].replace_with(get_site_alt(link_desc[0]))
class Filter: def __init__(self, user_keys: dict, mobile=False, config=None): if config is None: config = {} self.near = config['near'] if 'near' in config else '' self.dark = config['dark'] if 'dark' in config else False self.nojs = config['nojs'] if 'nojs' in config else False self.new_tab = config['new_tab'] if 'new_tab' in config else False self.mobile = mobile self.user_keys = user_keys self.main_divs = ResultSet('') self._elements = 0 def __getitem__(self, name): return getattr(self, name) @property def elements(self): return self._elements def reskin(self, page): # Aesthetic only re-skinning page = page.replace('>G<', '>Wh<') pattern = re.compile('4285f4|ea4335|fbcc05|34a853|fbbc05', re.IGNORECASE) page = pattern.sub('685e79', page) if self.dark: page = page.replace('fff', '000').replace('202124', 'ddd').replace( '1967D2', '3b85ea') return page def encrypt_path(self, msg, is_element=False): # Encrypts path to avoid plaintext results in logs if is_element: # Element paths are tracked differently in order for the element key to be regenerated # once all elements have been loaded enc_path = Fernet(self.user_keys['element_key']).encrypt( msg.encode()).decode() self._elements += 1 return enc_path return Fernet(self.user_keys['text_key']).encrypt( msg.encode()).decode() def clean(self, soup): self.main_divs = soup.find('div', {'id': 'main'}) self.remove_ads() self.fix_question_section() self.update_styling(soup) for img in [_ for _ in soup.find_all('img') if 'src' in _.attrs]: self.update_element_src(img, 'image/png') for audio in [_ for _ in soup.find_all('audio') if 'src' in _.attrs]: self.update_element_src(audio, 'audio/mpeg') for link in soup.find_all('a', href=True): self.update_link(link) input_form = soup.find('form') if input_form is not None: input_form['method'] = 'POST' # Ensure no extra scripts passed through for script in soup('script'): script.decompose() # Update default footer and header footer = 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)) > 2 ] header = soup.find('header') if header: header.decompose() return soup def remove_ads(self): if not self.main_divs: return for div in [_ for _ in self.main_divs.find_all('div', recursive=True)]: has_ad = len([ _ for _ in div.find_all('span', recursive=True) if has_ad_content(_.text) ]) _ = div.decompose() if has_ad else None def fix_question_section(self): if not self.main_divs: return question_divs = [ _ for _ in self.main_divs.find_all('div', recursive=False) if len(_.find_all('h2')) > 0 ] for question_div in question_divs: questions = [ _ for _ in question_div.find_all('div', recursive=True) if _.text.endswith('?') ] for question in questions: question['style'] = 'padding: 10px; font-style: italic;' def update_element_src(self, element, mime): element_src = element['src'] if element_src.startswith('//'): element_src = 'https:' + element_src elif element_src.startswith(LOGO_URL): # Re-brand with Whoogle logo element['src'] = '/static/img/logo.png' element['style'] = 'height:40px;width:162px' return elif element_src.startswith(GOOG_IMG): element['src'] = BLANK_B64 return element['src'] = '/element?url=' + self.encrypt_path(element_src, is_element=True) + \ '&type=' + urlparse.quote(mime) # TODO: Non-mobile image results link to website instead of image # if not self.mobile: # img.append(BeautifulSoup(FULL_RES_IMG.format(element_src), 'html.parser')) def update_styling(self, soup): # Remove unnecessary button(s) for button in soup.find_all('button'): button.decompose() # Remove svg logos for svg in soup.find_all('svg'): svg.decompose() # Update logo logo = soup.find('a', {'class': 'l'}) if logo and self.mobile: logo['style'] = 'display:flex; justify-content:center; align-items:center; color:#685e79; ' \ 'font-size:18px; ' # Fix search bar length on mobile try: search_bar = soup.find('header').find('form').find('div') search_bar['style'] = 'width: 100%;' except AttributeError: pass # Set up dark mode if active if self.dark: soup.find( 'html' )['style'] = 'scrollbar-color: #333 #111;color:#fff !important;background:#000 !important' for input_element in soup.findAll('input'): input_element['style'] = 'color:#fff;background:#000;' for span_element in soup.findAll('span'): span_element['style'] = 'color: white;' for href_element in soup.findAll('a'): href_element['style'] = 'color: white' if href_element[ 'href'].startswith('/search') else '' def update_link(self, link): # Replace href with only the intended destination (no "utm" type tags) href = link['href'].replace('https://www.google.com', '') if '/advanced_search' in href: link.decompose() return elif self.new_tab: link['target'] = '_blank' result_link = urlparse.urlparse(href) query_link = parse_qs( result_link.query)['q'][0] if '?q=' in href else '' if query_link.startswith('/'): link['href'] = 'https://google.com' + query_link elif '/search?q=' in href: new_search = '/search?q=' + self.encrypt_path(query_link) query_params = parse_qs(urlparse.urlparse(href).query) for param in VALID_PARAMS: param_val = query_params[param][ 0] if param in query_params else '' new_search += '&' + param + '=' + param_val link['href'] = new_search elif 'url?q=' in href: # Strip unneeded arguments link['href'] = filter_link_args(query_link) # Add no-js option if self.nojs: gen_nojs(link) else: link['href'] = href
def get_cells(row: element.ResultSet) -> List[str]: """ Gets all th and tr elements within a single tr element """ return [el.text for el in row.find_all(['th', 'td'])]
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, mobile=False) -> None: self.config = config self.mobile = mobile self.user_key = user_key self.main_divs = ResultSet('') self._elements = 0 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.main_divs = soup.find('div', {'id': 'main'}) self.remove_ads() self.remove_block_titles() self.remove_block_url() self.collapse_sections() self.update_styling(soup) for img in [_ for _ in soup.find_all('img') if 'src' in _.attrs]: self.update_element_src(img, 'image/png') for audio in [_ for _ in soup.find_all('audio') if 'src' in _.attrs]: self.update_element_src(audio, 'audio/mpeg') for link in soup.find_all('a', href=True): self.update_link(link) input_form = soup.find('form') if input_form is not None: input_form['method'] = 'GET' if self.config.get_only else 'POST' # Ensure no extra scripts passed through for script in soup('script'): script.decompose() # Update default footer and header footer = 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 ] header = soup.find('header') if header: header.decompose() return soup 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.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.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 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: result_children = pull_child_divs(result) if minimal_mode: if len(result_children) in (1, 3): continue else: if len(result_children) < self.RESULT_CHILD_LIMIT: continue # Find and decompose the first element with an inner HTML text val. # This typically extracts the title of the section (i.e. "Related # Searches", "People also ask", etc) label = 'Collapsed Results' for elem in result_children: if elem.text: label = elem.text 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 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) -> 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['src'] if src.startswith('//'): src = 'https:' + src 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(GOOG_IMG) or GOOG_STATIC in src: element['src'] = BLANK_B64 return element['src'] = f'{Endpoint.element}?url=' + self.encrypt_path( src, is_element=True) + '&type=' + urlparse.quote(mime) def update_styling(self, soup) -> None: # Remove unnecessary button(s) for button in soup.find_all('button'): button.decompose() # Remove svg logos for svg in soup.find_all('svg'): svg.decompose() # Update logo logo = soup.find('a', {'class': 'l'}) if logo and self.mobile: logo['style'] = ('display:flex; justify-content:center; ' 'align-items:center; color:#685e79; ' 'font-size:18px; ') # Fix search bar length on mobile try: search_bar = soup.find('header').find('form').find('div') search_bar['style'] = 'width: 100%;' except AttributeError: pass def update_link(self, link: Tag) -> None: """Update internal link paths with encrypted path, otherwise remove unnecessary redirects and/or marketing params from the url Args: link: A bs4 Tag element to inspect and update Returns: None (the tag is updated directly) """ # Replace href with only the intended destination (no "utm" type tags) href = link['href'].replace('https://www.google.com', '') if 'advanced_search' in href or 'tbm=shop' in href: # FIXME: The "Shopping" tab requires further filtering (see #136) # Temporarily removing all links to that tab for now. link.decompose() return result_link = urlparse.urlparse(href) q = extract_q(result_link.query, href) if q.startswith('/'): # Internal google links (i.e. mail, maps, etc) should still # be forwarded to Google link['href'] = 'https://google.com' + q elif '/search?q=' in href: # "li:1" implies the query should be interpreted verbatim, # which is accomplished by wrapping the query in double quotes if 'li:1' in href: q = '"' + q + '"' new_search = 'search?q=' + self.encrypt_path(q) query_params = parse_qs(urlparse.urlparse(href).query) for param in VALID_PARAMS: if param not in query_params: continue param_val = query_params[param][0] new_search += '&' + param + '=' + param_val link['href'] = new_search elif 'url?q=' in href: # Strip unneeded arguments link['href'] = filter_link_args(q) # Add no-js option if self.config.nojs: append_nojs(link) if self.config.new_tab: link['target'] = '_blank' else: if href.startswith(MAPS_URL): # Maps links don't work if a site filter is applied link['href'] = MAPS_URL + "?q=" + clean_query(q) else: link['href'] = href # Replace link location if "alts" config is enabled if self.config.alts: # Search and replace all link descriptions # with alternative location link['href'] = get_site_alt(link['href']) link_desc = link.find_all( text=re.compile('|'.join(SITE_ALTS.keys()))) if len(link_desc) == 0: return # Replace link description link_desc = link_desc[0] for site, alt in SITE_ALTS.items(): if site not in link_desc: continue new_desc = BeautifulSoup(features='html.parser').new_tag('div') new_desc.string = str(link_desc).replace(site, alt) link_desc.replace_with(new_desc) break def view_image(self, soup) -> BeautifulSoup: """Replaces the soup with a new one that handles mobile results and adds the link of the image full res to the results. Args: soup: A BeautifulSoup object containing the image mobile results. Returns: BeautifulSoup: The new BeautifulSoup object """ # get some tags that are unchanged between mobile and pc versions search_input = soup.find_all('td', attrs={'class': "O4cRJf"})[0] search_options = soup.find_all('div', attrs={'class': "M7pB2"})[0] cor_suggested = soup.find_all('table', attrs={'class': "By0U9"}) next_pages = soup.find_all('table', attrs={'class': "uZgmoc"})[0] information = soup.find_all('div', attrs={'class': "TuS8Ad"})[0] results = [] # find results div results_div = soup.find_all('div', attrs={'class': "nQvrDb"})[0] # find all the results results_all = results_div.find_all('div', attrs={'class': "lIMUZd"}) for item in results_all: urls = item.find('a')['href'].split('&imgrefurl=') # Skip urls that are not two-element lists if len(urls) != 2: continue img_url = urlparse.unquote(urls[0].replace( f'/{Endpoint.imgres}?imgurl=', '')) try: # Try to strip out only the necessary part of the web page link web_page = urlparse.unquote(urls[1].split('&')[0]) except IndexError: web_page = urlparse.unquote(urls[1]) img_tbn = urlparse.unquote(item.find('a').find('img')['src']) results.append({ 'domain': urlparse.urlparse(web_page).netloc, 'img_url': img_url, 'web_page': web_page, 'img_tbn': img_tbn }) soup = BeautifulSoup(render_template('imageresults.html', length=len(results), results=results, view_label="View Image"), features='html.parser') # replace search input object soup.find_all('td', attrs={'class': "O4cRJf"})[0].replaceWith(search_input) # replace search options object (All, Images, Videos, etc.) soup.find_all('div', attrs={'class': "M7pB2"})[0].replaceWith(search_options) # replace correction suggested by google object if exists if len(cor_suggested): soup.find_all('table', attrs={'class': "By0U9"})[0].replaceWith(cor_suggested[0]) # replace next page object at the bottom of the page soup.find_all('table', attrs={'class': "uZgmoc"})[0].replaceWith(next_pages) # replace information about user connection at the bottom of the page soup.find_all('div', attrs={'class': "TuS8Ad"})[0].replaceWith(information) return soup