| 
									
										
										
										
											2024-01-09 03:49:58 +01:00
										 |  |  | # SPDX-License-Identifier: AGPL-3.0-or-later | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-03-10 18:08:25 +01:00
										 |  |  | """This is the implementation of the Mullvad-Leta meta-search engine.
 | 
					
						
							| 
									
										
										
										
											2024-01-09 03:49:58 +01:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-03-10 18:08:25 +01:00
										 |  |  | This engine **REQUIRES** that searxng operate within a Mullvad VPN | 
					
						
							| 
									
										
										
										
											2024-01-09 03:49:58 +01:00
										 |  |  | 
 | 
					
						
							|  |  |  | If using docker, consider using gluetun for easily connecting to the Mullvad | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | - https://github.com/qdm12/gluetun | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | Otherwise, follow instructions provided by Mullvad for enabling the VPN on Linux | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | - https://mullvad.net/en/help/install-mullvad-app-linux | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-03-10 18:08:25 +01:00
										 |  |  | .. hint:: | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |    The :py:obj:`EngineTraits` is empty by default.  Maintainers have to run | 
					
						
							|  |  |  |    ``make data.traits`` (in the Mullvad VPN / :py:obj:`fetch_traits`) and rebase | 
					
						
							|  |  |  |    the modified JSON file ``searx/data/engine_traits.json`` on every single | 
					
						
							|  |  |  |    update of SearXNG! | 
					
						
							| 
									
										
										
										
											2024-01-09 03:49:58 +01:00
										 |  |  | """
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | from typing import TYPE_CHECKING | 
					
						
							|  |  |  | from httpx import Response | 
					
						
							|  |  |  | from lxml import html | 
					
						
							|  |  |  | from searx.enginelib.traits import EngineTraits | 
					
						
							|  |  |  | from searx.locales import region_tag, get_official_locales | 
					
						
							|  |  |  | from searx.utils import eval_xpath, extract_text, eval_xpath_list | 
					
						
							|  |  |  | from searx.exceptions import SearxEngineResponseException | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | if TYPE_CHECKING: | 
					
						
							|  |  |  |     import logging | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     logger = logging.getLogger() | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | traits: EngineTraits | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | use_cache: bool = True  # non-cache use only has 100 searches per day! | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | search_url = "https://leta.mullvad.net" | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | # about | 
					
						
							|  |  |  | about = { | 
					
						
							|  |  |  |     "website": search_url, | 
					
						
							|  |  |  |     "wikidata_id": 'Q47008412',  # the Mullvad id - not leta, but related | 
					
						
							|  |  |  |     "official_api_documentation": 'https://leta.mullvad.net/faq', | 
					
						
							|  |  |  |     "use_official_api": False, | 
					
						
							|  |  |  |     "require_api_key": False, | 
					
						
							|  |  |  |     "results": 'HTML', | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | # engine dependent config | 
					
						
							|  |  |  | categories = ['general', 'web'] | 
					
						
							|  |  |  | paging = True | 
					
						
							|  |  |  | max_page = 50 | 
					
						
							|  |  |  | time_range_support = True | 
					
						
							|  |  |  | time_range_dict = { | 
					
						
							|  |  |  |     "day": "d1", | 
					
						
							|  |  |  |     "week": "w1", | 
					
						
							|  |  |  |     "month": "m1", | 
					
						
							|  |  |  |     "year": "y1", | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def is_vpn_connected(dom: html.HtmlElement) -> bool: | 
					
						
							|  |  |  |     """Returns true if the VPN is connected, False otherwise""" | 
					
						
							|  |  |  |     connected_text = extract_text(eval_xpath(dom, '//main/div/p[1]')) | 
					
						
							|  |  |  |     return connected_text != 'You are not connected to Mullvad VPN.' | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def assign_headers(headers: dict) -> dict: | 
					
						
							|  |  |  |     """Assigns the headers to make a request to Mullvad Leta""" | 
					
						
							|  |  |  |     headers['Accept'] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8" | 
					
						
							|  |  |  |     headers['Content-Type'] = "application/x-www-form-urlencoded" | 
					
						
							|  |  |  |     headers['Host'] = "leta.mullvad.net" | 
					
						
							|  |  |  |     headers['Origin'] = "https://leta.mullvad.net" | 
					
						
							|  |  |  |     return headers | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def request(query: str, params: dict): | 
					
						
							|  |  |  |     country = traits.get_region(params.get('searxng_locale', 'all'), traits.all_locale)  # type: ignore | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     params['url'] = search_url | 
					
						
							|  |  |  |     params['method'] = 'POST' | 
					
						
							|  |  |  |     params['data'] = { | 
					
						
							|  |  |  |         "q": query, | 
					
						
							|  |  |  |         "gl": country if country is str else '', | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     # pylint: disable=undefined-variable | 
					
						
							|  |  |  |     if use_cache: | 
					
						
							|  |  |  |         params['data']['oc'] = "on" | 
					
						
							|  |  |  |     # pylint: enable=undefined-variable | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if params['time_range'] in time_range_dict: | 
					
						
							|  |  |  |         params['dateRestrict'] = time_range_dict[params['time_range']] | 
					
						
							|  |  |  |     else: | 
					
						
							|  |  |  |         params['dateRestrict'] = '' | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if params['pageno'] > 1: | 
					
						
							|  |  |  |         #  Page 1 is n/a, Page 2 is 11, page 3 is 21, ... | 
					
						
							|  |  |  |         params['data']['start'] = ''.join([str(params['pageno'] - 1), "1"]) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if params['headers'] is None: | 
					
						
							|  |  |  |         params['headers'] = {} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     assign_headers(params['headers']) | 
					
						
							|  |  |  |     return params | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def extract_result(dom_result: html.HtmlElement): | 
					
						
							|  |  |  |     [a_elem, h3_elem, p_elem] = eval_xpath_list(dom_result, 'div/div/*') | 
					
						
							|  |  |  |     return { | 
					
						
							|  |  |  |         'url': extract_text(a_elem.text), | 
					
						
							|  |  |  |         'title': extract_text(h3_elem), | 
					
						
							|  |  |  |         'content': extract_text(p_elem), | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def response(resp: Response): | 
					
						
							|  |  |  |     """Checks if connected to Mullvad VPN, then extracts the search results from
 | 
					
						
							|  |  |  |     the DOM resp: requests response object"""
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     dom = html.fromstring(resp.text) | 
					
						
							|  |  |  |     if not is_vpn_connected(dom): | 
					
						
							|  |  |  |         raise SearxEngineResponseException('Not connected to Mullvad VPN') | 
					
						
							|  |  |  |     search_results = eval_xpath(dom.body, '//main/div[2]/div') | 
					
						
							|  |  |  |     return [extract_result(sr) for sr in search_results] | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | def fetch_traits(engine_traits: EngineTraits): | 
					
						
							|  |  |  |     """Fetch languages and regions from Mullvad-Leta
 | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-03-10 18:08:25 +01:00
										 |  |  |     .. warning:: | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2024-01-09 03:49:58 +01:00
										 |  |  |         Fetching the engine traits also requires a Mullvad VPN connection. If | 
					
						
							|  |  |  |         not connected, then an error message will print and no traits will be | 
					
						
							|  |  |  |         updated. | 
					
						
							|  |  |  |     """
 | 
					
						
							|  |  |  |     # pylint: disable=import-outside-toplevel | 
					
						
							|  |  |  |     # see https://github.com/searxng/searxng/issues/762 | 
					
						
							|  |  |  |     from searx.network import post as http_post | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     # pylint: enable=import-outside-toplevel | 
					
						
							|  |  |  |     resp = http_post(search_url, headers=assign_headers({})) | 
					
						
							|  |  |  |     if not isinstance(resp, Response): | 
					
						
							|  |  |  |         print("ERROR: failed to get response from mullvad-leta. Are you connected to the VPN?") | 
					
						
							|  |  |  |         return | 
					
						
							|  |  |  |     if not resp.ok: | 
					
						
							|  |  |  |         print("ERROR: response from mullvad-leta is not OK. Are you connected to the VPN?") | 
					
						
							|  |  |  |         return | 
					
						
							|  |  |  |     dom = html.fromstring(resp.text) | 
					
						
							|  |  |  |     if not is_vpn_connected(dom): | 
					
						
							|  |  |  |         print('ERROR: Not connected to Mullvad VPN') | 
					
						
							|  |  |  |         return | 
					
						
							|  |  |  |     # supported region codes | 
					
						
							|  |  |  |     options = eval_xpath_list(dom.body, '//main/div/form/div[2]/div/select[1]/option') | 
					
						
							|  |  |  |     if options is None or len(options) <= 0: | 
					
						
							|  |  |  |         print('ERROR: could not find any results. Are you connected to the VPN?') | 
					
						
							|  |  |  |     for x in options: | 
					
						
							|  |  |  |         eng_country = x.get("value") | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         sxng_locales = get_official_locales(eng_country, engine_traits.languages.keys(), regional=True) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         if not sxng_locales: | 
					
						
							|  |  |  |             print( | 
					
						
							|  |  |  |                 "ERROR: can't map from Mullvad-Leta country %s (%s) to a babel region." | 
					
						
							|  |  |  |                 % (x.get('data-name'), eng_country) | 
					
						
							|  |  |  |             ) | 
					
						
							|  |  |  |             continue | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |         for sxng_locale in sxng_locales: | 
					
						
							|  |  |  |             engine_traits.regions[region_tag(sxng_locale)] = eng_country |