on master help sign in
[enh] record details exception per engine

add an new API /stats/errors

commit1d0c368746e0ae28ea042edaf4c75ee3a2b738c2 authorAlexandre Flament <alex@al-f.net> dateThu 26 Nov 2020, 15:12 (6 years ago) parent6b5a578
10 files changed+495−39
docs/dev/engine_overview.rst+12−10 searx/engines/__init__.py+2−1 searx/exceptions.py+37 searx/metrology/__init__.py searx/metrology/error_recorder.py+142 searx/results.py+9 searx/search.py+49−5 searx/utils.py+125−23 searx/webapp.py+29 tests/unit/test_utils.py+90
docs/dev/engine_overview.rst+12−10
@@ -134,16 +134,18 @@ The function ``def request(query, params):`` always returns the ``params``
134134variable. Inside searx, the following paramters can be used to specify a search
135135request:
136136
137−============ =========== =========================================================
138−argument type information
139−============ =========== =========================================================
140−url string requested url
141−method string HTTP request method
142−headers set HTTP header information
143−data set HTTP data information (parsed if ``method != 'GET'``)
144−cookies set HTTP cookies
145−verify boolean Performing SSL-Validity check
146−============ =========== =========================================================
137+================== =========== ========================================================================
138+argument type information
139+================== =========== ========================================================================
140+url string requested url
141+method string HTTP request method
142+headers set HTTP header information
143+data set HTTP data information (parsed if ``method != 'GET'``)
144+cookies set HTTP cookies
145+verify boolean Performing SSL-Validity check
146+max_redirects int maximum redirects, hard limit
147+soft_max_redirects int maximum redirects, soft limit. Record an error but don't stop the engine
148+================== =========== ========================================================================
147149
148150
149151example code
searx/engines/__init__.py+2−1
@@ -132,8 +132,9 @@ def load_engine(engine_data):
132132 lambda: engine._fetch_supported_languages(get(engine.supported_languages_url)))
133133
134134 engine.stats = {
135+ 'sent_search_count': 0, # sent search
136+ 'search_count': 0, # succesful search
135137 'result_count': 0,
136− 'search_count': 0,
137138 'engine_time': 0,
138139 'engine_time_count': 0,
139140 'score_count': 0,
searx/exceptions.py+37
@@ -34,8 +34,45 @@ class SearxParameterException(SearxException):
3434
3535
3636class SearxSettingsException(SearxException):
37+ """Error while loading the settings"""
3738
3839 def __init__(self, message, filename):
3940 super().__init__(message)
4041 self.message = message
4142 self.filename = filename
43+
44+
45+class SearxEngineException(SearxException):
46+ """Error inside an engine"""
47+
48+
49+class SearxXPathSyntaxException(SearxEngineException):
50+ """Syntax error in a XPATH"""
51+
52+ def __init__(self, xpath_spec, message):
53+ super().__init__(str(xpath_spec) + " " + message)
54+ self.message = message
55+ # str(xpath_spec) to deal with str and XPath instance
56+ self.xpath_str = str(xpath_spec)
57+
58+
59+class SearxEngineResponseException(SearxEngineException):
60+ """Impossible to parse the result of an engine"""
61+
62+
63+class SearxEngineAPIException(SearxEngineResponseException):
64+ """The website has returned an application error"""
65+
66+
67+class SearxEngineCaptchaException(SearxEngineResponseException):
68+ """The website has returned a CAPTCHA"""
69+
70+
71+class SearxEngineXPathException(SearxEngineResponseException):
72+ """Error while getting the result of an XPath expression"""
73+
74+ def __init__(self, xpath_spec, message):
75+ super().__init__(str(xpath_spec) + " " + message)
76+ self.message = message
77+ # str(xpath_spec) to deal with str and XPath instance
78+ self.xpath_str = str(xpath_spec)
searx/metrology/__init__.pynew file

No changes to the content.

searx/metrology/error_recorder.pynew file+142
@@ -0,0 +1,142 @@
1+import typing
2+import inspect
3+import logging
4+from json import JSONDecodeError
5+from urllib.parse import urlparse
6+from requests.exceptions import RequestException
7+from searx.exceptions import SearxXPathSyntaxException, SearxEngineXPathException
8+from searx import logger
9+
10+
11+logging.basicConfig(level=logging.INFO)
12+
13+errors_per_engines = {}
14+
15+
16+class ErrorContext:
17+
18+ __slots__ = 'filename', 'function', 'line_no', 'code', 'exception_classname', 'log_message', 'log_parameters'
19+
20+ def __init__(self, filename, function, line_no, code, exception_classname, log_message, log_parameters):
21+ self.filename = filename
22+ self.function = function
23+ self.line_no = line_no
24+ self.code = code
25+ self.exception_classname = exception_classname
26+ self.log_message = log_message
27+ self.log_parameters = log_parameters
28+
29+ def __eq__(self, o) -> bool:
30+ if not isinstance(o, ErrorContext):
31+ return False
32+ return self.filename == o.filename and self.function == o.function and self.line_no == o.line_no\
33+ and self.code == o.code and self.exception_classname == o.exception_classname\
34+ and self.log_message == o.log_message and self.log_parameters == o.log_parameters
35+
36+ def __hash__(self):
37+ return hash((self.filename, self.function, self.line_no, self.code, self.exception_classname, self.log_message,
38+ self.log_parameters))
39+
40+ def __repr__(self):
41+ return "ErrorContext({!r}, {!r}, {!r}, {!r}, {!r}, {!r})".\
42+ format(self.filename, self.line_no, self.code, self.exception_classname, self.log_message,
43+ self.log_parameters)
44+
45+
46+def add_error_context(engine_name: str, error_context: ErrorContext) -> None:
47+ errors_for_engine = errors_per_engines.setdefault(engine_name, {})
48+ errors_for_engine[error_context] = errors_for_engine.get(error_context, 0) + 1
49+ logger.debug('⚠️ %s: %s', engine_name, str(error_context))
50+
51+
52+def get_trace(traces):
53+ previous_trace = traces[-1]
54+ for trace in reversed(traces):
55+ if trace.filename.endswith('searx/search.py'):
56+ if previous_trace.filename.endswith('searx/poolrequests.py'):
57+ return trace
58+ if previous_trace.filename.endswith('requests/models.py'):
59+ return trace
60+ return previous_trace
61+ previous_trace = trace
62+ return traces[-1]
63+
64+
65+def get_hostname(exc: RequestException) -> typing.Optional[None]:
66+ url = exc.request.url
67+ if url is None and exc.response is not None:
68+ url = exc.response.url
69+ return urlparse(url).netloc
70+
71+
72+def get_request_exception_messages(exc: RequestException)\
73+ -> typing.Tuple[typing.Optional[str], typing.Optional[str], typing.Optional[str]]:
74+ url = None
75+ status_code = None
76+ reason = None
77+ hostname = None
78+ if exc.request is not None:
79+ url = exc.request.url
80+ if url is None and exc.response is not None:
81+ url = exc.response.url
82+ if url is not None:
83+ hostname = str(urlparse(url).netloc)
84+ if exc.response is not None:
85+ status_code = str(exc.response.status_code)
86+ reason = exc.response.reason
87+ return (status_code, reason, hostname)
88+
89+
90+def get_messages(exc, filename) -> typing.Tuple:
91+ if isinstance(exc, JSONDecodeError):
92+ return (exc.msg, )
93+ if isinstance(exc, TypeError):
94+ return (str(exc), )
95+ if isinstance(exc, ValueError) and 'lxml' in filename:
96+ return (str(exc), )
97+ if isinstance(exc, RequestException):
98+ return get_request_exception_messages(exc)
99+ if isinstance(exc, SearxXPathSyntaxException):
100+ return (exc.xpath_str, exc.message)
101+ if isinstance(exc, SearxEngineXPathException):
102+ return (exc.xpath_str, exc.message)
103+ return ()
104+
105+
106+def get_exception_classname(exc: Exception) -> str:
107+ exc_class = exc.__class__
108+ exc_name = exc_class.__qualname__
109+ exc_module = exc_class.__module__
110+ if exc_module is None or exc_module == str.__class__.__module__:
111+ return exc_name
112+ return exc_module + '.' + exc_name
113+
114+
115+def get_error_context(framerecords, exception_classname, log_message, log_parameters) -> ErrorContext:
116+ searx_frame = get_trace(framerecords)
117+ filename = searx_frame.filename
118+ function = searx_frame.function
119+ line_no = searx_frame.lineno
120+ code = searx_frame.code_context[0].strip()
121+ del framerecords
122+ return ErrorContext(filename, function, line_no, code, exception_classname, log_message, log_parameters)
123+
124+
125+def record_exception(engine_name: str, exc: Exception) -> None:
126+ framerecords = inspect.trace()
127+ try:
128+ exception_classname = get_exception_classname(exc)
129+ log_parameters = get_messages(exc, framerecords[-1][1])
130+ error_context = get_error_context(framerecords, exception_classname, None, log_parameters)
131+ add_error_context(engine_name, error_context)
132+ finally:
133+ del framerecords
134+
135+
136+def record_error(engine_name: str, log_message: str, log_parameters: typing.Optional[typing.Tuple] = None) -> None:
137+ framerecords = list(reversed(inspect.stack()[1:]))
138+ try:
139+ error_context = get_error_context(framerecords, None, log_message, log_parameters or ())
140+ add_error_context(engine_name, error_context)
141+ finally:
142+ del framerecords
searx/results.py+9
@@ -4,6 +4,7 @@ from threading import RLock
44from urllib.parse import urlparse, unquote
55from searx import logger
66from searx.engines import engines
7+from searx.metrology.error_recorder import record_error
78
89
910CONTENT_LEN_IGNORED_CHARS_REGEX = re.compile(r'[,;:!?\./\\\\ ()-_]', re.M | re.U)
@@ -161,6 +162,7 @@ class ResultContainer:
161162
162163 def extend(self, engine_name, results):
163164 standard_result_count = 0
165+ error_msgs = set()
164166 for result in list(results):
165167 result['engine'] = engine_name
166168 if 'suggestion' in result:
@@ -177,14 +179,21 @@ class ResultContainer:
177179 # standard result (url, title, content)
178180 if 'url' in result and not isinstance(result['url'], str):
179181 logger.debug('result: invalid URL: %s', str(result))
182+ error_msgs.add('invalid URL')
180183 elif 'title' in result and not isinstance(result['title'], str):
181184 logger.debug('result: invalid title: %s', str(result))
185+ error_msgs.add('invalid title')
182186 elif 'content' in result and not isinstance(result['content'], str):
183187 logger.debug('result: invalid content: %s', str(result))
188+ error_msgs.add('invalid content')
184189 else:
185190 self._merge_result(result, standard_result_count + 1)
186191 standard_result_count += 1
187192
193+ if len(error_msgs) > 0:
194+ for msg in error_msgs:
195+ record_error(engine_name, 'some results are invalids: ' + msg)
196+
188197 if engine_name in engines:
189198 with RLock():
190199 engines[engine_name].stats['search_count'] += 1
searx/search.py+49−5
@@ -20,6 +20,7 @@ import gc
2020import threading
2121from time import time
2222from uuid import uuid4
23+from urllib.parse import urlparse
2324from _thread import start_new_thread
2425
2526import requests.exceptions
@@ -31,6 +32,8 @@ from searx.utils import gen_useragent
3132from searx.results import ResultContainer
3233from searx import logger
3334from searx.plugins import plugins
35+from searx.exceptions import SearxEngineCaptchaException
36+from searx.metrology.error_recorder import record_exception, record_error
3437
3538
3639logger = logger.getChild('search')
@@ -120,6 +123,14 @@ def send_http_request(engine, request_params):
120123 if hasattr(engine, 'proxies'):
121124 request_args['proxies'] = requests_lib.get_proxies(engine.proxies)
122125
126+ # max_redirects
127+ max_redirects = request_params.get('max_redirects')
128+ if max_redirects:
129+ request_args['max_redirects'] = max_redirects
130+
131+ # soft_max_redirects
132+ soft_max_redirects = request_params.get('soft_max_redirects', max_redirects or 0)
133+
123134 # specific type of request (GET or POST)
124135 if request_params['method'] == 'GET':
125136 req = requests_lib.get
@@ -129,7 +140,23 @@ def send_http_request(engine, request_params):
129140 request_args['data'] = request_params['data']
130141
131142 # send the request
132− return req(request_params['url'], **request_args)
143+ response = req(request_params['url'], **request_args)
144+
145+ # check HTTP status
146+ response.raise_for_status()
147+
148+ # check soft limit of the redirect count
149+ if len(response.history) > soft_max_redirects:
150+ # unexpected redirect : record an error
151+ # but the engine might still return valid results.
152+ status_code = str(response.status_code or '')
153+ reason = response.reason or ''
154+ hostname = str(urlparse(response.url or '').netloc)
155+ record_error(engine.name,
156+ '{} redirects, maximum: {}'.format(len(response.history), soft_max_redirects),
157+ (status_code, reason, hostname))
158+
159+ return response
133160
134161
135162def search_one_http_request(engine, query, request_params):
@@ -183,8 +210,9 @@ def search_one_http_request_safe(engine_name, query, request_params, result_cont
183210 # update stats with the total HTTP time
184211 engine.stats['page_load_time'] += page_load_time
185212 engine.stats['page_load_count'] += 1
186−
187213 except Exception as e:
214+ record_exception(engine_name, e)
215+
188216 # Timing
189217 engine_time = time() - start_time
190218 page_load_time = requests_lib.get_time_for_thread()
@@ -195,23 +223,29 @@ def search_one_http_request_safe(engine_name, query, request_params, result_cont
195223 engine.stats['errors'] += 1
196224
197225 if (issubclass(e.__class__, requests.exceptions.Timeout)):
198− result_container.add_unresponsive_engine(engine_name, 'timeout')
226+ result_container.add_unresponsive_engine(engine_name, 'HTTP timeout')
199227 # requests timeout (connect or read)
200228 logger.error("engine {0} : HTTP requests timeout"
201229 "(search duration : {1} s, timeout: {2} s) : {3}"
202230 .format(engine_name, engine_time, timeout_limit, e.__class__.__name__))
203231 requests_exception = True
204232 elif (issubclass(e.__class__, requests.exceptions.RequestException)):
205− result_container.add_unresponsive_engine(engine_name, 'request exception')
233+ result_container.add_unresponsive_engine(engine_name, 'HTTP error')
206234 # other requests exception
207235 logger.exception("engine {0} : requests exception"
208236 "(search duration : {1} s, timeout: {2} s) : {3}"
209237 .format(engine_name, engine_time, timeout_limit, e))
210238 requests_exception = True
239+ elif (issubclass(e.__class__, SearxEngineCaptchaException)):
240+ result_container.add_unresponsive_engine(engine_name, 'CAPTCHA required')
241+ logger.exception('engine {0} : CAPTCHA')
211242 else:
212− result_container.add_unresponsive_engine(engine_name, 'unexpected crash', str(e))
243+ result_container.add_unresponsive_engine(engine_name, 'unexpected crash')
213244 # others errors
214245 logger.exception('engine {0} : exception : {1}'.format(engine_name, e))
246+ else:
247+ if getattr(threading.current_thread(), '_timeout', False):
248+ record_error(engine_name, 'Timeout')
215249
216250 # suspend or not the engine if there are HTTP errors
217251 with threading.RLock():
@@ -255,12 +289,17 @@ def search_one_offline_request_safe(engine_name, query, request_params, result_c
255289 engine.stats['engine_time_count'] += 1
256290
257291 except ValueError as e:
292+ record_exception(engine_name, e)
258293 record_offline_engine_stats_on_error(engine, result_container, start_time)
259294 logger.exception('engine {0} : invalid input : {1}'.format(engine_name, e))
260295 except Exception as e:
296+ record_exception(engine_name, e)
261297 record_offline_engine_stats_on_error(engine, result_container, start_time)
262298 result_container.add_unresponsive_engine(engine_name, 'unexpected crash', str(e))
263299 logger.exception('engine {0} : exception : {1}'.format(engine_name, e))
300+ else:
301+ if getattr(threading.current_thread(), '_timeout', False):
302+ record_error(engine_name, 'Timeout')
264303
265304
266305def search_one_request_safe(engine_name, query, request_params, result_container, start_time, timeout_limit):
@@ -278,6 +317,7 @@ def search_multiple_requests(requests, result_container, start_time, timeout_lim
278317 args=(engine_name, query, request_params, result_container, start_time, timeout_limit),
279318 name=search_id,
280319 )
320+ th._timeout = False
281321 th._engine_name = engine_name
282322 th.start()
283323
@@ -286,6 +326,7 @@ def search_multiple_requests(requests, result_container, start_time, timeout_lim
286326 remaining_time = max(0.0, timeout_limit - (time() - start_time))
287327 th.join(remaining_time)
288328 if th.is_alive():
329+ th._timeout = True
289330 result_container.add_unresponsive_engine(th._engine_name, 'timeout')
290331 logger.warning('engine timeout: {0}'.format(th._engine_name))
291332
@@ -385,6 +426,9 @@ class Search:
385426 request_params['category'] = engineref.category
386427 request_params['pageno'] = self.search_query.pageno
387428
429+ with threading.RLock():
430+ engine.stats['sent_search_count'] += 1
431+
388432 return request_params, engine.timeout
389433
390434 # do search-request
searx/utils.py+125−23
@@ -10,7 +10,7 @@ from html.parser import HTMLParser
1010from urllib.parse import urljoin, urlparse
1111
1212from lxml import html
13−from lxml.etree import XPath, _ElementStringResult, _ElementUnicodeResult
13+from lxml.etree import ElementBase, XPath, XPathError, XPathSyntaxError, _ElementStringResult, _ElementUnicodeResult
1414from babel.core import get_global
1515
1616
@@ -18,6 +18,7 @@ from searx import settings
1818from searx.data import USER_AGENTS
1919from searx.version import VERSION_STRING
2020from searx.languages import language_codes
21+from searx.exceptions import SearxXPathSyntaxException, SearxEngineXPathException
2122from searx import logger
2223
2324
@@ -33,6 +34,13 @@ xpath_cache = dict()
3334lang_to_lc_cache = dict()
3435
3536
37+class NotSetClass:
38+ pass
39+
40+
41+NOTSET = NotSetClass()
42+
43+
3644def searx_useragent():
3745 """Return the searx User Agent"""
3846 return 'searx/{searx_version} {suffix}'.format(
@@ -125,7 +133,7 @@ def html_to_text(html_str):
125133 return s.get_text()
126134
127135
128−def extract_text(xpath_results):
136+def extract_text(xpath_results, allow_none=False):
129137 """Extract text from a lxml result
130138
131139 * if xpath_results is list, extract the text from each result and concat the list
@@ -133,22 +141,27 @@ def extract_text(xpath_results):
133141 ( text_content() method from lxml )
134142 * if xpath_results is a string element, then it's already done
135143 """
136− if type(xpath_results) == list:
144+ if isinstance(xpath_results, list):
137145 # it's list of result : concat everything using recursive call
138146 result = ''
139147 for e in xpath_results:
140148 result = result + extract_text(e)
141149 return result.strip()
142− elif type(xpath_results) in [_ElementStringResult, _ElementUnicodeResult]:
143− # it's a string
144− return ''.join(xpath_results)
145− else:
150+ elif isinstance(xpath_results, ElementBase):
146151 # it's a element
147152 text = html.tostring(
148153 xpath_results, encoding='unicode', method='text', with_tail=False
149154 )
150155 text = text.strip().replace('\n', ' ')
151156 return ' '.join(text.split())
157+ elif isinstance(xpath_results, (_ElementStringResult, _ElementUnicodeResult, str, Number, bool)):
158+ return str(xpath_results)
159+ elif xpath_results is None and allow_none:
160+ return None
161+ elif xpath_results is None and not allow_none:
162+ raise ValueError('extract_text(None, allow_none=False)')
163+ else:
164+ raise ValueError('unsupported type')
152165
153166
154167def normalize_url(url, base_url):
@@ -170,7 +183,7 @@ def normalize_url(url, base_url):
170183 >>> normalize_url('', 'https://example.com')
171184 'https://example.com/'
172185 >>> normalize_url('/test', '/path')
173− raise Exception
186+ raise ValueError
174187
175188 Raises:
176189 * lxml.etree.ParserError
@@ -194,7 +207,7 @@ def normalize_url(url, base_url):
194207
195208 # add a / at this end of the url if there is no path
196209 if not parsed_url.netloc:
197− raise Exception('Cannot parse url')
210+ raise ValueError('Cannot parse url')
198211 if not parsed_url.path:
199212 url += '/'
200213
@@ -224,17 +237,17 @@ def extract_url(xpath_results, base_url):
224237 >>> f('', 'https://example.com')
225238 raise lxml.etree.ParserError
226239 >>> searx.utils.extract_url([], 'https://example.com')
227− raise Exception
240+ raise ValueError
228241
229242 Raises:
230− * Exception
243+ * ValueError
231244 * lxml.etree.ParserError
232245
233246 Returns:
234247 * str: normalized URL
235248 """
236249 if xpath_results == []:
237− raise Exception('Empty url resultset')
250+ raise ValueError('Empty url resultset')
238251
239252 url = extract_text(xpath_results)
240253 return normalize_url(url, base_url)
@@ -258,7 +271,6 @@ def dict_subset(d, properties):
258271
259272def list_get(a_list, index, default=None):
260273 """Get element in list or default value
261−
262274 Examples:
263275 >>> list_get(['A', 'B', 'C'], 0)
264276 'A'
@@ -310,7 +322,7 @@ def get_torrent_size(filesize, filesize_multiplier):
310322 filesize = int(filesize * 1000 * 1000)
311323 elif filesize_multiplier == 'KiB':
312324 filesize = int(filesize * 1000)
313− except:
325+ except ValueError:
314326 filesize = None
315327
316328 return filesize
@@ -506,20 +518,110 @@ def get_engine_from_settings(name):
506518 return {}
507519
508520
509−def get_xpath(xpath_str):
521+def get_xpath(xpath_spec):
510522 """Return cached compiled XPath
511523
512524 There is no thread lock.
513525 Worst case scenario, xpath_str is compiled more than one time.
526+
527+ Args:
528+ * xpath_spec (str|lxml.etree.XPath): XPath as a str or lxml.etree.XPath
529+
530+ Returns:
531+ * result (bool, float, list, str): Results.
532+
533+ Raises:
534+ * TypeError: Raise when xpath_spec is neither a str nor a lxml.etree.XPath
535+ * SearxXPathSyntaxException: Raise when there is a syntax error in the XPath
536+ """
537+ if isinstance(xpath_spec, str):
538+ result = xpath_cache.get(xpath_spec, None)
539+ if result is None:
540+ try:
541+ result = XPath(xpath_spec)
542+ except XPathSyntaxError as e:
543+ raise SearxXPathSyntaxException(xpath_spec, str(e.msg))
544+ xpath_cache[xpath_spec] = result
545+ return result
546+
547+ if isinstance(xpath_spec, XPath):
548+ return xpath_spec
549+
550+ raise TypeError('xpath_spec must be either a str or a lxml.etree.XPath')
551+
552+
553+def eval_xpath(element, xpath_spec):
554+ """Equivalent of element.xpath(xpath_str) but compile xpath_str once for all.
555+ See https://lxml.de/xpathxslt.html#xpath-return-values
556+
557+ Args:
558+ * element (ElementBase): [description]
559+ * xpath_spec (str|lxml.etree.XPath): XPath as a str or lxml.etree.XPath
560+
561+ Returns:
562+ * result (bool, float, list, str): Results.
563+
564+ Raises:
565+ * TypeError: Raise when xpath_spec is neither a str nor a lxml.etree.XPath
566+ * SearxXPathSyntaxException: Raise when there is a syntax error in the XPath
567+ * SearxEngineXPathException: Raise when the XPath can't be evaluated.
514568 """
515− result = xpath_cache.get(xpath_str, None)
516− if result is None:
517− result = XPath(xpath_str)
518− xpath_cache[xpath_str] = result
569+ xpath = get_xpath(xpath_spec)
570+ try:
571+ return xpath(element)
572+ except XPathError as e:
573+ arg = ' '.join([str(i) for i in e.args])
574+ raise SearxEngineXPathException(xpath_spec, arg)
575+
576+
577+def eval_xpath_list(element, xpath_spec, min_len=None):
578+ """Same as eval_xpath, check if the result is a list
579+
580+ Args:
581+ * element (ElementBase): [description]
582+ * xpath_spec (str|lxml.etree.XPath): XPath as a str or lxml.etree.XPath
583+ * min_len (int, optional): [description]. Defaults to None.
584+
585+ Raises:
586+ * TypeError: Raise when xpath_spec is neither a str nor a lxml.etree.XPath
587+ * SearxXPathSyntaxException: Raise when there is a syntax error in the XPath
588+ * SearxEngineXPathException: raise if the result is not a list
589+
590+ Returns:
591+ * result (bool, float, list, str): Results.
592+ """
593+ result = eval_xpath(element, xpath_spec)
594+ if not isinstance(result, list):
595+ raise SearxEngineXPathException(xpath_spec, 'the result is not a list')
596+ if min_len is not None and min_len > len(result):
597+ raise SearxEngineXPathException(xpath_spec, 'len(xpath_str) < ' + str(min_len))
519598 return result
520599
521600
522−def eval_xpath(element, xpath_str):
523− """Equivalent of element.xpath(xpath_str) but compile xpath_str once for all."""
524− xpath = get_xpath(xpath_str)
525− return xpath(element)
601+def eval_xpath_getindex(elements, xpath_spec, index, default=NOTSET):
602+ """Call eval_xpath_list then get one element using the index parameter.
603+ If the index does not exist, either aise an exception is default is not set,
604+ other return the default value (can be None).
605+
606+ Args:
607+ * elements (ElementBase): lxml element to apply the xpath.
608+ * xpath_spec (str|lxml.etree.XPath): XPath as a str or lxml.etree.XPath.
609+ * index (int): index to get
610+ * default (Object, optional): Defaults if index doesn't exist.
611+
612+ Raises:
613+ * TypeError: Raise when xpath_spec is neither a str nor a lxml.etree.XPath
614+ * SearxXPathSyntaxException: Raise when there is a syntax error in the XPath
615+ * SearxEngineXPathException: if the index is not found. Also see eval_xpath.
616+
617+ Returns:
618+ * result (bool, float, list, str): Results.
619+ """
620+ result = eval_xpath_list(elements, xpath_spec)
621+ if index >= -len(result) and index < len(result):
622+ return result[index]
623+ if default == NOTSET:
624+ # raise an SearxEngineXPathException instead of IndexError
625+ # to record xpath_spec
626+ raise SearxEngineXPathException(xpath_spec, 'index ' + str(index) + ' not found')
627+ return default
searx/webapp.py+29
@@ -79,6 +79,7 @@ from searx.plugins.oa_doi_rewrite import get_doi_resolver
7979from searx.preferences import Preferences, ValidationException, LANGUAGE_CODES
8080from searx.answerers import answerers
8181from searx.poolrequests import get_global_proxies
82+from searx.metrology.error_recorder import errors_per_engines
8283
8384
8485# serve pages with HTTP/1.1
@@ -943,6 +944,34 @@ def stats():
943944 )
944945
945946
947+@app.route('/stats/errors', methods=['GET'])
948+def stats_errors():
949+ result = {}
950+ engine_names = list(errors_per_engines.keys())
951+ engine_names.sort()
952+ for engine_name in engine_names:
953+ error_stats = errors_per_engines[engine_name]
954+ sent_search_count = max(engines[engine_name].stats['sent_search_count'], 1)
955+ sorted_context_count_list = sorted(error_stats.items(), key=lambda context_count: context_count[1])
956+ r = []
957+ percentage_sum = 0
958+ for context, count in sorted_context_count_list:
959+ percentage = round(20 * count / sent_search_count) * 5
960+ percentage_sum += percentage
961+ r.append({
962+ 'filename': context.filename,
963+ 'function': context.function,
964+ 'line_no': context.line_no,
965+ 'code': context.code,
966+ 'exception_classname': context.exception_classname,
967+ 'log_message': context.log_message,
968+ 'log_parameters': context.log_parameters,
969+ 'percentage': percentage,
970+ })
971+ result[engine_name] = sorted(r, reverse=True, key=lambda d: d['percentage'])
972+ return jsonify(result)
973+
974+
946975@app.route('/robots.txt', methods=['GET'])
947976def robots():
948977 return Response("""User-agent: *
tests/unit/test_utils.py+90
@@ -3,6 +3,7 @@ import lxml.etree
33from lxml import html
44
55from searx.testing import SearxTestCase
6+from searx.exceptions import SearxXPathSyntaxException, SearxEngineXPathException
67from searx import utils
78
89
@@ -57,8 +58,16 @@ class TestUtils(SearxTestCase):
5758 dom = html.fromstring(html_str)
5859 self.assertEqual(utils.extract_text(dom), 'Test text')
5960 self.assertEqual(utils.extract_text(dom.xpath('//span')), 'Test text')
61+ self.assertEqual(utils.extract_text(dom.xpath('//span/text()')), 'Test text')
62+ self.assertEqual(utils.extract_text(dom.xpath('count(//span)')), '3.0')
63+ self.assertEqual(utils.extract_text(dom.xpath('boolean(//span)')), 'True')
6064 self.assertEqual(utils.extract_text(dom.xpath('//img/@src')), 'test.jpg')
6165 self.assertEqual(utils.extract_text(dom.xpath('//unexistingtag')), '')
66+ self.assertEqual(utils.extract_text(None, allow_none=True), None)
67+ with self.assertRaises(ValueError):
68+ utils.extract_text(None)
69+ with self.assertRaises(ValueError):
70+ utils.extract_text({})
6271
6372 def test_extract_url(self):
6473 def f(html_str, search_url):
@@ -136,3 +145,84 @@ class TestHTMLTextExtractor(SearxTestCase):
136145 text = '<p><b>Lorem ipsum</i>dolor sit amet</p>'
137146 with self.assertRaises(utils.HTMLTextExtractorException):
138147 self.html_text_extractor.feed(text)
148+
149+
150+class TestXPathUtils(SearxTestCase):
151+
152+ TEST_DOC = """<ul>
153+ <li>Text in <b>bold</b> and <i>italic</i> </li>
154+ <li>Another <b>text</b> <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="></li>
155+ </ul>"""
156+
157+ def test_get_xpath_cache(self):
158+ xp1 = utils.get_xpath('//a')
159+ xp2 = utils.get_xpath('//div')
160+ xp3 = utils.get_xpath('//a')
161+
162+ self.assertEqual(id(xp1), id(xp3))
163+ self.assertNotEqual(id(xp1), id(xp2))
164+
165+ def test_get_xpath_type(self):
166+ utils.get_xpath(lxml.etree.XPath('//a'))
167+
168+ with self.assertRaises(TypeError):
169+ utils.get_xpath([])
170+
171+ def test_get_xpath_invalid(self):
172+ invalid_xpath = '//a[0].text'
173+ with self.assertRaises(SearxXPathSyntaxException) as context:
174+ utils.get_xpath(invalid_xpath)
175+
176+ self.assertEqual(context.exception.message, 'Invalid expression')
177+ self.assertEqual(context.exception.xpath_str, invalid_xpath)
178+
179+ def test_eval_xpath_unregistered_function(self):
180+ doc = html.fromstring(TestXPathUtils.TEST_DOC)
181+
182+ invalid_function_xpath = 'int(//a)'
183+ with self.assertRaises(SearxEngineXPathException) as context:
184+ utils.eval_xpath(doc, invalid_function_xpath)
185+
186+ self.assertEqual(context.exception.message, 'Unregistered function')
187+ self.assertEqual(context.exception.xpath_str, invalid_function_xpath)
188+
189+ def test_eval_xpath(self):
190+ doc = html.fromstring(TestXPathUtils.TEST_DOC)
191+
192+ self.assertEqual(utils.eval_xpath(doc, '//p'), [])
193+ self.assertEqual(utils.eval_xpath(doc, '//i/text()'), ['italic'])
194+ self.assertEqual(utils.eval_xpath(doc, 'count(//i)'), 1.0)
195+
196+ def test_eval_xpath_list(self):
197+ doc = html.fromstring(TestXPathUtils.TEST_DOC)
198+
199+ # check a not empty list
200+ self.assertEqual(utils.eval_xpath_list(doc, '//i/text()'), ['italic'])
201+
202+ # check min_len parameter
203+ with self.assertRaises(SearxEngineXPathException) as context:
204+ utils.eval_xpath_list(doc, '//p', min_len=1)
205+ self.assertEqual(context.exception.message, 'len(xpath_str) < 1')
206+ self.assertEqual(context.exception.xpath_str, '//p')
207+
208+ def test_eval_xpath_getindex(self):
209+ doc = html.fromstring(TestXPathUtils.TEST_DOC)
210+
211+ # check index 0
212+ self.assertEqual(utils.eval_xpath_getindex(doc, '//i/text()', 0), 'italic')
213+
214+ # default is 'something'
215+ self.assertEqual(utils.eval_xpath_getindex(doc, '//i/text()', 1, default='something'), 'something')
216+
217+ # default is None
218+ self.assertEqual(utils.eval_xpath_getindex(doc, '//i/text()', 1, default=None), None)
219+
220+ # index not found
221+ with self.assertRaises(SearxEngineXPathException) as context:
222+ utils.eval_xpath_getindex(doc, '//i/text()', 1)
223+ self.assertEqual(context.exception.message, 'index 1 not found')
224+
225+ # not a list
226+ with self.assertRaises(SearxEngineXPathException) as context:
227+ utils.eval_xpath_getindex(doc, 'count(//i)', 1)
228+ self.assertEqual(context.exception.message, 'the result is not a list')