on master help sign in
Merge remote-tracking branch 'remotes/origin/result-handling-refactor'
commitd8ef98371bd599be28307778d85dcee549d99f74 authorAdam Tauber <adam.tauber@balabit.com> dateFri 16 Oct 2015, 12:27 (11 years ago) parentf1ac79466a70c6
9 files changed+419−294
searx/results.py+239 searx/search.py+16−257 searx/static/themes/oscar/css/oscar.min.css+88−1 searx/static/themes/oscar/less/oscar/results.less+5 searx/templates/oscar/macros.html+5−1 searx/tests/test_results.py+41 searx/tests/test_search.py+4−19 searx/tests/test_webapp.py+7−1 searx/webapp.py+14−15
searx/results.pynew file+239
@@ -0,0 +1,239 @@
1+import re
2+from collections import defaultdict
3+from operator import itemgetter
4+from threading import RLock
5+from urlparse import urlparse, unquote
6+from searx.engines import engines
7+
8+CONTENT_LEN_IGNORED_CHARS_REGEX = re.compile('[,;:!?\./\\\\ ()-_]', re.M | re.U)
9+WHITESPACE_REGEX = re.compile('( |\t|\n)+', re.M | re.U)
10+
11+
12+# return the meaningful length of the content for a result
13+def result_content_len(content):
14+ if isinstance(content, basestring):
15+ return len(CONTENT_LEN_IGNORED_CHARS_REGEX.sub('', content))
16+ else:
17+ return 0
18+
19+
20+def compare_urls(url_a, url_b):
21+ if url_a.netloc != url_b.netloc or url_a.query != url_b.query:
22+ return False
23+
24+ # remove / from the end of the url if required
25+ path_a = url_a.path[:-1]\
26+ if url_a.path.endswith('/')\
27+ else url_a.path
28+ path_b = url_b.path[:-1]\
29+ if url_b.path.endswith('/')\
30+ else url_b.path
31+
32+ return unquote(path_a) == unquote(path_b)
33+
34+
35+def merge_two_infoboxes(infobox1, infobox2):
36+ if 'urls' in infobox2:
37+ urls1 = infobox1.get('urls', None)
38+ if urls1 is None:
39+ urls1 = []
40+ infobox1.set('urls', urls1)
41+
42+ urlSet = set()
43+ for url in infobox1.get('urls', []):
44+ urlSet.add(url.get('url', None))
45+
46+ for url in infobox2.get('urls', []):
47+ if url.get('url', None) not in urlSet:
48+ urls1.append(url)
49+
50+ if 'attributes' in infobox2:
51+ attributes1 = infobox1.get('attributes', None)
52+ if attributes1 is None:
53+ attributes1 = []
54+ infobox1.set('attributes', attributes1)
55+
56+ attributeSet = set()
57+ for attribute in infobox1.get('attributes', []):
58+ if attribute.get('label', None) not in attributeSet:
59+ attributeSet.add(attribute.get('label', None))
60+
61+ for attribute in infobox2.get('attributes', []):
62+ attributes1.append(attribute)
63+
64+ if 'content' in infobox2:
65+ content1 = infobox1.get('content', None)
66+ content2 = infobox2.get('content', '')
67+ if content1 is not None:
68+ if result_content_len(content2) > result_content_len(content1):
69+ infobox1['content'] = content2
70+ else:
71+ infobox1.set('content', content2)
72+
73+
74+def result_score(result):
75+ weight = 1.0
76+
77+ for result_engine in result['engines']:
78+ if hasattr(engines[result_engine], 'weight'):
79+ weight *= float(engines[result_engine].weight)
80+
81+ occurences = len(result['positions'])
82+
83+ return sum((occurences * weight) / position for position in result['positions'])
84+
85+
86+class ResultContainer(object):
87+ """docstring for ResultContainer"""
88+ def __init__(self):
89+ super(ResultContainer, self).__init__()
90+ self.results = defaultdict(list)
91+ self._merged_results = []
92+ self.infoboxes = []
93+ self._infobox_ids = {}
94+ self.suggestions = set()
95+ self.answers = set()
96+
97+ def extend(self, engine_name, results):
98+ for result in list(results):
99+ if 'suggestion' in result:
100+ self.suggestions.add(result['suggestion'])
101+ results.remove(result)
102+ elif 'answer' in result:
103+ self.answers.add(result['suggestion'])
104+ results.remove(result)
105+ elif 'infobox' in result:
106+ self._merge_infobox(result)
107+ results.remove(result)
108+
109+ with RLock():
110+ engines[engine_name].stats['search_count'] += 1
111+ engines[engine_name].stats['result_count'] += len(results)
112+
113+ if not results:
114+ return
115+
116+ self.results[engine_name].extend(results)
117+
118+ for i, result in enumerate(results):
119+ position = i + 1
120+ self._merge_result(result, position)
121+
122+ def _merge_infobox(self, infobox):
123+ add_infobox = True
124+ infobox_id = infobox.get('id', None)
125+ if infobox_id is not None:
126+ existingIndex = self._infobox_ids.get(infobox_id, None)
127+ if existingIndex is not None:
128+ merge_two_infoboxes(self.infoboxes[existingIndex], infobox)
129+ add_infobox = False
130+
131+ if add_infobox:
132+ self.infoboxes.append(infobox)
133+ self._infobox_ids[infobox_id] = len(self.infoboxes) - 1
134+
135+ def _merge_result(self, result, position):
136+ result['parsed_url'] = urlparse(result['url'])
137+
138+ # if the result has no scheme, use http as default
139+ if not result['parsed_url'].scheme:
140+ result['parsed_url'] = result['parsed_url']._replace(scheme="http")
141+
142+ result['host'] = result['parsed_url'].netloc
143+
144+ if result['host'].startswith('www.'):
145+ result['host'] = result['host'].replace('www.', '', 1)
146+
147+ result['engines'] = [result['engine']]
148+
149+ # strip multiple spaces and cariage returns from content
150+ if result.get('content'):
151+ result['content'] = WHITESPACE_REGEX.sub(' ', result['content'])
152+
153+ # check for duplicates
154+ duplicated = False
155+ for merged_result in self._merged_results:
156+ if compare_urls(result['parsed_url'], merged_result['parsed_url'])\
157+ and result.get('template') == merged_result.get('template'):
158+ duplicated = merged_result
159+ break
160+
161+ # merge duplicates together
162+ if duplicated:
163+ # using content with more text
164+ if result_content_len(result.get('content', '')) >\
165+ result_content_len(duplicated.get('content', '')):
166+ duplicated['content'] = result['content']
167+
168+ # add the new position
169+ duplicated['positions'].append(position)
170+
171+ # add engine to list of result-engines
172+ duplicated['engines'].append(result['engine'])
173+
174+ # using https if possible
175+ if duplicated['parsed_url'].scheme != 'https' and result['parsed_url'].scheme == 'https':
176+ duplicated['url'] = result['parsed_url'].geturl()
177+ duplicated['parsed_url'] = result['parsed_url']
178+
179+ # if there is no duplicate found, append result
180+ else:
181+ result['positions'] = [position]
182+ with RLock():
183+ self._merged_results.append(result)
184+
185+ def get_ordered_results(self):
186+ for result in self._merged_results:
187+ score = result_score(result)
188+ result['score'] = score
189+ with RLock():
190+ for result_engine in result['engines']:
191+ engines[result_engine].stats['score_count'] += score
192+
193+ results = sorted(self._merged_results, key=itemgetter('score'), reverse=True)
194+
195+ # pass 2 : group results by category and template
196+ gresults = []
197+ categoryPositions = {}
198+
199+ for i, res in enumerate(results):
200+ # FIXME : handle more than one category per engine
201+ category = engines[res['engine']].categories[0] + ':' + ''\
202+ if 'template' not in res\
203+ else res['template']
204+
205+ current = None if category not in categoryPositions\
206+ else categoryPositions[category]
207+
208+ # group with previous results using the same category
209+ # if the group can accept more result and is not too far
210+ # from the current position
211+ if current is not None and (current['count'] > 0)\
212+ and (len(gresults) - current['index'] < 20):
213+ # group with the previous results using
214+ # the same category with this one
215+ index = current['index']
216+ gresults.insert(index, res)
217+
218+ # update every index after the current one
219+ # (including the current one)
220+ for k in categoryPositions:
221+ v = categoryPositions[k]['index']
222+ if v >= index:
223+ categoryPositions[k]['index'] = v + 1
224+
225+ # update this category
226+ current['count'] -= 1
227+
228+ else:
229+ # same category
230+ gresults.append(res)
231+
232+ # update categoryIndex
233+ categoryPositions[category] = {'index': len(gresults), 'count': 8}
234+
235+ # return gresults
236+ return gresults
237+
238+ def results_length(self):
239+ return len(self._merged_results)
searx/search.py+16−257
@@ -16,13 +16,8 @@ along with searx. If not, see < http://www.gnu.org/licenses/ >.
1616'''
1717
1818import threading
19−import re
2019import searx.poolrequests as requests_lib
21−from itertools import izip_longest, chain
22−from operator import itemgetter
23−from Queue import Queue
2420from time import time
25−from urlparse import urlparse, unquote
2621from searx import settings
2722from searx.engines import (
2823 categories, engines
@@ -30,6 +25,7 @@ from searx.engines import (
3025from searx.languages import language_codes
3126from searx.utils import gen_useragent, get_blocked_engines
3227from searx.query import Query
28+from searx.results import ResultContainer
3329from searx import logger
3430
3531logger = logger.getChild('search')
@@ -42,7 +38,8 @@ def search_request_wrapper(fn, url, engine_name, **kwargs):
4238 return fn(url, **kwargs)
4339 except:
4440 # increase errors stats
45− engines[engine_name].stats['errors'] += 1
41+ with threading.RLock():
42+ engines[engine_name].stats['errors'] += 1
4643
4744 # print engine name and specific error message
4845 logger.exception('engine crash: {0}'.format(engine_name))
@@ -84,7 +81,7 @@ def default_request_params():
8481
8582
8683# create a callback wrapper for the search engine results
87−def make_callback(engine_name, results_queue, callback, params):
84+def make_callback(engine_name, callback, params, result_container):
8885
8986 # creating a callback wrapper for the search engine results
9087 def process_callback(response, **kwargs):
@@ -96,12 +93,17 @@ def make_callback(engine_name, results_queue, callback, params):
9693
9794 response.search_params = params
9895
99− timeout_overhead = 0.2 # seconds
10096 search_duration = time() - params['started']
97+ # update stats with current page-load-time
98+ with threading.RLock():
99+ engines[engine_name].stats['page_load_time'] += search_duration
100+
101+ timeout_overhead = 0.2 # seconds
101102 timeout_limit = engines[engine_name].timeout + timeout_overhead
103+
102104 if search_duration > timeout_limit:
103− engines[engine_name].stats['page_load_time'] += timeout_limit
104− engines[engine_name].stats['errors'] += 1
105+ with threading.RLock():
106+ engines[engine_name].stats['errors'] += 1
105107 return
106108
107109 # callback
@@ -111,212 +113,11 @@ def make_callback(engine_name, results_queue, callback, params):
111113 for result in search_results:
112114 result['engine'] = engine_name
113115
114− results_queue.put_nowait((engine_name, search_results))
115−
116− # update stats with current page-load-time
117− engines[engine_name].stats['page_load_time'] += search_duration
116+ result_container.extend(engine_name, search_results)
118117
119118 return process_callback
120119
121120
122−# return the meaningful length of the content for a result
123−def content_result_len(content):
124− if isinstance(content, basestring):
125− content = re.sub('[,;:!?\./\\\\ ()-_]', '', content)
126− return len(content)
127− else:
128− return 0
129−
130−
131−# score results and remove duplications
132−def score_results(results):
133− # calculate scoring parameters
134− flat_res = filter(
135− None, chain.from_iterable(izip_longest(*results.values())))
136− flat_len = len(flat_res)
137− engines_len = len(results)
138−
139− results = []
140−
141− # pass 1: deduplication + scoring
142− for i, res in enumerate(flat_res):
143−
144− res['parsed_url'] = urlparse(res['url'])
145−
146− # if the result has no scheme, use http as default
147− if not res['parsed_url'].scheme:
148− res['parsed_url'] = res['parsed_url']._replace(scheme="http")
149−
150− res['host'] = res['parsed_url'].netloc
151−
152− if res['host'].startswith('www.'):
153− res['host'] = res['host'].replace('www.', '', 1)
154−
155− res['engines'] = [res['engine']]
156−
157− weight = 1.0
158−
159− # strip multiple spaces and cariage returns from content
160− if res.get('content'):
161− res['content'] = re.sub(' +', ' ',
162− res['content'].strip().replace('\n', ''))
163−
164− # get weight of this engine if possible
165− if hasattr(engines[res['engine']], 'weight'):
166− weight = float(engines[res['engine']].weight)
167−
168− # calculate score for that engine
169− score = int((flat_len - i) / engines_len) * weight + 1
170−
171− # check for duplicates
172− duplicated = False
173− for new_res in results:
174− # remove / from the end of the url if required
175− p1 = res['parsed_url'].path[:-1]\
176− if res['parsed_url'].path.endswith('/')\
177− else res['parsed_url'].path
178− p2 = new_res['parsed_url'].path[:-1]\
179− if new_res['parsed_url'].path.endswith('/')\
180− else new_res['parsed_url'].path
181−
182− # check if that result is a duplicate
183− if res['host'] == new_res['host'] and\
184− unquote(p1) == unquote(p2) and\
185− res['parsed_url'].query == new_res['parsed_url'].query and\
186− res.get('template') == new_res.get('template'):
187− duplicated = new_res
188− break
189−
190− # merge duplicates together
191− if duplicated:
192− # using content with more text
193− if content_result_len(res.get('content', '')) >\
194− content_result_len(duplicated.get('content', '')):
195− duplicated['content'] = res['content']
196−
197− # increase result-score
198− duplicated['score'] += score
199−
200− # add engine to list of result-engines
201− duplicated['engines'].append(res['engine'])
202−
203− # using https if possible
204− if duplicated['parsed_url'].scheme == 'https':
205− continue
206− elif res['parsed_url'].scheme == 'https':
207− duplicated['url'] = res['parsed_url'].geturl()
208− duplicated['parsed_url'] = res['parsed_url']
209−
210− # if there is no duplicate found, append result
211− else:
212− res['score'] = score
213−
214− results.append(res)
215−
216− results = sorted(results, key=itemgetter('score'), reverse=True)
217−
218− # pass 2 : group results by category and template
219− gresults = []
220− categoryPositions = {}
221−
222− for i, res in enumerate(results):
223− # FIXME : handle more than one category per engine
224− category = engines[res['engine']].categories[0] + ':' + ''\
225− if 'template' not in res\
226− else res['template']
227−
228− current = None if category not in categoryPositions\
229− else categoryPositions[category]
230−
231− # group with previous results using the same category
232− # if the group can accept more result and is not too far
233− # from the current position
234− if current is not None and (current['count'] > 0)\
235− and (len(gresults) - current['index'] < 20):
236− # group with the previous results using
237− # the same category with this one
238− index = current['index']
239− gresults.insert(index, res)
240−
241− # update every index after the current one
242− # (including the current one)
243− for k in categoryPositions:
244− v = categoryPositions[k]['index']
245− if v >= index:
246− categoryPositions[k]['index'] = v + 1
247−
248− # update this category
249− current['count'] -= 1
250−
251− else:
252− # same category
253− gresults.append(res)
254−
255− # update categoryIndex
256− categoryPositions[category] = {'index': len(gresults), 'count': 8}
257−
258− # return gresults
259− return gresults
260−
261−
262−def merge_two_infoboxes(infobox1, infobox2):
263− if 'urls' in infobox2:
264− urls1 = infobox1.get('urls', None)
265− if urls1 is None:
266− urls1 = []
267− infobox1.set('urls', urls1)
268−
269− urlSet = set()
270− for url in infobox1.get('urls', []):
271− urlSet.add(url.get('url', None))
272−
273− for url in infobox2.get('urls', []):
274− if url.get('url', None) not in urlSet:
275− urls1.append(url)
276−
277− if 'attributes' in infobox2:
278− attributes1 = infobox1.get('attributes', None)
279− if attributes1 is None:
280− attributes1 = []
281− infobox1.set('attributes', attributes1)
282−
283− attributeSet = set()
284− for attribute in infobox1.get('attributes', []):
285− if attribute.get('label', None) not in attributeSet:
286− attributeSet.add(attribute.get('label', None))
287−
288− for attribute in infobox2.get('attributes', []):
289− attributes1.append(attribute)
290−
291− if 'content' in infobox2:
292− content1 = infobox1.get('content', None)
293− content2 = infobox2.get('content', '')
294− if content1 is not None:
295− if content_result_len(content2) > content_result_len(content1):
296− infobox1['content'] = content2
297− else:
298− infobox1.set('content', content2)
299−
300−
301−def merge_infoboxes(infoboxes):
302− results = []
303− infoboxes_id = {}
304− for infobox in infoboxes:
305− add_infobox = True
306− infobox_id = infobox.get('id', None)
307− if infobox_id is not None:
308− existingIndex = infoboxes_id.get(infobox_id, None)
309− if existingIndex is not None:
310− merge_two_infoboxes(results[existingIndex], infobox)
311− add_infobox = False
312−
313− if add_infobox:
314− results.append(infobox)
315− infoboxes_id[infobox_id] = len(results) - 1
316−
317− return results
318−
319−
320121class Search(object):
321122
322123 """Search information container"""
@@ -334,10 +135,7 @@ class Search(object):
334135 # set blocked engines
335136 self.blocked_engines = get_blocked_engines(engines, request.cookies)
336137
337− self.results = []
338− self.suggestions = set()
339− self.answers = set()
340− self.infoboxes = []
138+ self.result_container = ResultContainer()
341139 self.request_data = {}
342140
343141 # set specific language if set
@@ -449,8 +247,6 @@ class Search(object):
449247
450248 # init vars
451249 requests = []
452− results_queue = Queue()
453− results = {}
454250
455251 # increase number of searches
456252 number_of_searches += 1
@@ -504,9 +300,9 @@ class Search(object):
504300 # create a callback wrapper for the search engine results
505301 callback = make_callback(
506302 selected_engine['name'],
507− results_queue,
508303 engine.response,
509− request_params)
304+ request_params,
305+ self.result_container)
510306
511307 # create dictionary which contain all
512308 # informations about the request
@@ -539,42 +335,5 @@ class Search(object):
539335 # send all search-request
540336 threaded_requests(requests)
541337
542− while not results_queue.empty():
543− engine_name, engine_results = results_queue.get_nowait()
544−
545− # TODO type checks
546− [self.suggestions.add(x['suggestion'])
547− for x in list(engine_results)
548− if 'suggestion' in x
549− and engine_results.remove(x) is None]
550−
551− [self.answers.add(x['answer'])
552− for x in list(engine_results)
553− if 'answer' in x
554− and engine_results.remove(x) is None]
555−
556− self.infoboxes.extend(x for x in list(engine_results)
557− if 'infobox' in x
558− and engine_results.remove(x) is None)
559−
560− results[engine_name] = engine_results
561−
562− # update engine-specific stats
563− for engine_name, engine_results in results.items():
564− engines[engine_name].stats['search_count'] += 1
565− engines[engine_name].stats['result_count'] += len(engine_results)
566−
567− # score results and remove duplications
568− self.results = score_results(results)
569−
570− # merge infoboxes according to their ids
571− self.infoboxes = merge_infoboxes(self.infoboxes)
572−
573− # update engine stats, using calculated score
574− for result in self.results:
575− for res_engine in result['engines']:
576− engines[result['engine']]\
577− .stats['score_count'] += result['score']
578−
579338 # return results, suggestions, answers and infoboxes
580339 return self
searx/static/themes/oscar/css/oscar.min.css+88−1
@@ -1 +1,88 @@
1−html{position:relative;min-height:100%}body{margin-bottom:80px}.footer{position:absolute;bottom:0;width:100%;height:60px}input[type=checkbox]:checked+.label_hide_if_checked,input[type=checkbox]:checked+.label_hide_if_not_checked+.label_hide_if_checked{display:none}input[type=checkbox]:not(:checked)+.label_hide_if_not_checked,input[type=checkbox]:not(:checked)+.label_hide_if_checked+.label_hide_if_not_checked{display:none}.result_header{margin-bottom:5px;margin-top:20px}.result_header .favicon{margin-bottom:-3px}.result_header a{vertical-align:bottom}.result_header a .highlight{font-weight:bold}.result-content{margin-top:5px;word-wrap:break-word}.result-content .highlight{font-weight:bold}.result-default{clear:both}.result-images{float:left !important}.img-thumbnail{margin:5px;max-height:128px;min-height:128px}.result-videos{clear:both}.result-torrents{clear:both}.result-map{clear:both}.result-code{clear:both}.suggestion_item{margin:2px 5px}.result_download{margin-right:5px}#pagination{margin-top:30px;padding-bottom:50px}.infobox .infobox_part{margin-bottom:20px;word-wrap:break-word}.infobox .infobox_part:last-child{margin-bottom:0}.search_categories{margin:10px 0;text-transform:capitalize}.cursor-text{cursor:text !important}.cursor-pointer{cursor:pointer !important}.highlight .hll{background-color:#ffc}.highlight{background:#f8f8f8}.highlight .c{color:#408080;font-style:italic}.highlight .err{border:1px solid #f00}.highlight .k{color:#008000;font-weight:bold}.highlight .o{color:#666}.highlight .cm{color:#408080;font-style:italic}.highlight .cp{color:#bc7a00}.highlight .c1{color:#408080;font-style:italic}.highlight .cs{color:#408080;font-style:italic}.highlight .gd{color:#a00000}.highlight .ge{font-style:italic}.highlight .gr{color:#f00}.highlight .gh{color:#000080;font-weight:bold}.highlight .gi{color:#00a000}.highlight .go{color:#888}.highlight .gp{color:#000080;font-weight:bold}.highlight .gs{font-weight:bold}.highlight .gu{color:#800080;font-weight:bold}.highlight .gt{color:#04d}.highlight .kc{color:#008000;font-weight:bold}.highlight .kd{color:#008000;font-weight:bold}.highlight .kn{color:#008000;font-weight:bold}.highlight .kp{color:#008000}.highlight .kr{color:#008000;font-weight:bold}.highlight .kt{color:#b00040}.highlight .m{color:#666}.highlight .s{color:#ba2121}.highlight .na{color:#7d9029}.highlight .nb{color:#008000}.highlight .nc{color:#00f;font-weight:bold}.highlight .no{color:#800}.highlight .nd{color:#a2f}.highlight .ni{color:#999;font-weight:bold}.highlight .ne{color:#d2413a;font-weight:bold}.highlight .nf{color:#00f}.highlight .nl{color:#a0a000}.highlight .nn{color:#00f;font-weight:bold}.highlight .nt{color:#008000;font-weight:bold}.highlight .nv{color:#19177c}.highlight .ow{color:#a2f;font-weight:bold}.highlight .w{color:#bbb}.highlight .mf{color:#666}.highlight .mh{color:#666}.highlight .mi{color:#666}.highlight .mo{color:#666}.highlight .sb{color:#ba2121}.highlight .sc{color:#ba2121}.highlight .sd{color:#ba2121;font-style:italic}.highlight .s2{color:#ba2121}.highlight .se{color:#b62;font-weight:bold}.highlight .sh{color:#ba2121}.highlight .si{color:#b68;font-weight:bold}.highlight .sx{color:#008000}.highlight .sr{color:#b68}.highlight .s1{color:#ba2121}.highlight .ss{color:#19177c}.highlight .bp{color:#008000}.highlight .vc{color:#19177c}.highlight .vg{color:#19177c}.highlight .vi{color:#19177c}.highlight .il{color:#666}.highlight .lineno{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;cursor:default}.highlight .lineno::selection{background:transparent}.highlight .lineno::-moz-selection{background:transparent}
1+html{position:relative;min-height:100%}
2+body{margin-bottom:80px}
3+.footer{position:absolute;bottom:0;width:100%;height:60px}
4+input[type=checkbox]:checked+.label_hide_if_checked,input[type=checkbox]:checked+.label_hide_if_not_checked+.label_hide_if_checked{display:none}
5+input[type=checkbox]:not(:checked)+.label_hide_if_not_checked,input[type=checkbox]:not(:checked)+.label_hide_if_checked+.label_hide_if_not_checked{display:none}
6+.result_header{margin-bottom:5px;margin-top:20px}.result_header .favicon{margin-bottom:-3px}
7+.result_header a{vertical-align:bottom}.result_header a .highlight{font-weight:bold}
8+.result-content{margin-top:5px;word-wrap:break-word}.result-content .highlight{font-weight:bold}
9+.result-default{clear:both}
10+.result-images{float:left !important}
11+.img-thumbnail{margin:5px;max-height:128px;min-height:128px}
12+.result-videos{clear:both}
13+.result-torrents{clear:both}
14+.result-map{clear:both}
15+.result-code{clear:both}
16+.suggestion_item{margin:2px 5px}
17+.result_download{margin-right:5px}
18+#pagination{margin-top:30px;padding-bottom:50px}
19+.label-default{color:#aaa;background:#fff}
20+.infobox .infobox_part{margin-bottom:20px;word-wrap:break-word}
21+.infobox .infobox_part:last-child{margin-bottom:0}
22+.search_categories{margin:10px 0;text-transform:capitalize}
23+.cursor-text{cursor:text !important}
24+.cursor-pointer{cursor:pointer !important}
25+.highlight .hll{background-color:#ffc}
26+.highlight{background:#f8f8f8}
27+.highlight .c{color:#408080;font-style:italic}
28+.highlight .err{border:1px solid #f00}
29+.highlight .k{color:#008000;font-weight:bold}
30+.highlight .o{color:#666}
31+.highlight .cm{color:#408080;font-style:italic}
32+.highlight .cp{color:#bc7a00}
33+.highlight .c1{color:#408080;font-style:italic}
34+.highlight .cs{color:#408080;font-style:italic}
35+.highlight .gd{color:#a00000}
36+.highlight .ge{font-style:italic}
37+.highlight .gr{color:#f00}
38+.highlight .gh{color:#000080;font-weight:bold}
39+.highlight .gi{color:#00a000}
40+.highlight .go{color:#888}
41+.highlight .gp{color:#000080;font-weight:bold}
42+.highlight .gs{font-weight:bold}
43+.highlight .gu{color:#800080;font-weight:bold}
44+.highlight .gt{color:#04d}
45+.highlight .kc{color:#008000;font-weight:bold}
46+.highlight .kd{color:#008000;font-weight:bold}
47+.highlight .kn{color:#008000;font-weight:bold}
48+.highlight .kp{color:#008000}
49+.highlight .kr{color:#008000;font-weight:bold}
50+.highlight .kt{color:#b00040}
51+.highlight .m{color:#666}
52+.highlight .s{color:#ba2121}
53+.highlight .na{color:#7d9029}
54+.highlight .nb{color:#008000}
55+.highlight .nc{color:#00f;font-weight:bold}
56+.highlight .no{color:#800}
57+.highlight .nd{color:#a2f}
58+.highlight .ni{color:#999;font-weight:bold}
59+.highlight .ne{color:#d2413a;font-weight:bold}
60+.highlight .nf{color:#00f}
61+.highlight .nl{color:#a0a000}
62+.highlight .nn{color:#00f;font-weight:bold}
63+.highlight .nt{color:#008000;font-weight:bold}
64+.highlight .nv{color:#19177c}
65+.highlight .ow{color:#a2f;font-weight:bold}
66+.highlight .w{color:#bbb}
67+.highlight .mf{color:#666}
68+.highlight .mh{color:#666}
69+.highlight .mi{color:#666}
70+.highlight .mo{color:#666}
71+.highlight .sb{color:#ba2121}
72+.highlight .sc{color:#ba2121}
73+.highlight .sd{color:#ba2121;font-style:italic}
74+.highlight .s2{color:#ba2121}
75+.highlight .se{color:#b62;font-weight:bold}
76+.highlight .sh{color:#ba2121}
77+.highlight .si{color:#b68;font-weight:bold}
78+.highlight .sx{color:#008000}
79+.highlight .sr{color:#b68}
80+.highlight .s1{color:#ba2121}
81+.highlight .ss{color:#19177c}
82+.highlight .bp{color:#008000}
83+.highlight .vc{color:#19177c}
84+.highlight .vg{color:#19177c}
85+.highlight .vi{color:#19177c}
86+.highlight .il{color:#666}
87+.highlight .lineno{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;cursor:default}.highlight .lineno::selection{background:transparent;}
88+.highlight .lineno::-moz-selection{background:transparent;}
searx/static/themes/oscar/less/oscar/results.less+5
@@ -76,3 +76,8 @@
7676 margin-top: 30px;
7777 padding-bottom: 50px;
7878}
79+
80+.label-default {
81+ color: #AAA;
82+ background: #FFF;
83+}
searx/templates/oscar/macros.html+5−1
@@ -25,7 +25,11 @@
2525<!-- Draw result footer -->
2626{% macro result_footer(result) -%}
2727 <div class="clearfix"></div>
28− <span class="label label-default pull-right">{{ result.engine }}</span>
28+ <div class="pull-right">
29+ {% for engine in result.engines %}
30+ <span class="label label-default">{{ engine }}</span>
31+ {% endfor %}
32+</div>
2933 <p class="text-muted">{{ result.pretty_url }}</p>
3034{%- endmacro %}
3135
searx/tests/test_results.pynew file+41
@@ -0,0 +1,41 @@
1+# -*- coding: utf-8 -*-
2+
3+from searx.results import ResultContainer
4+from searx.testing import SearxTestCase
5+
6+
7+def fake_result(url='https://aa.bb/cc?dd=ee#ff',
8+ title='aaa',
9+ content='bbb',
10+ engine='wikipedia', **kwargs):
11+ result = {'url': url,
12+ 'title': title,
13+ 'content': content,
14+ 'engine': engine}
15+ result.update(kwargs)
16+ return result
17+
18+
19+# TODO
20+class ResultContainerTestCase(SearxTestCase):
21+
22+ def test_empty(self):
23+ c = ResultContainer()
24+ self.assertEqual(c.get_ordered_results(), [])
25+
26+ def test_one_result(self):
27+ c = ResultContainer()
28+ c.extend('wikipedia', [fake_result()])
29+ self.assertEqual(c.results_length(), 1)
30+
31+ def test_one_suggestion(self):
32+ c = ResultContainer()
33+ c.extend('wikipedia', [fake_result(suggestion=True)])
34+ self.assertEqual(len(c.suggestions), 1)
35+ self.assertEqual(c.results_length(), 0)
36+
37+ def test_result_merge(self):
38+ c = ResultContainer()
39+ c.extend('wikipedia', [fake_result()])
40+ c.extend('wikidata', [fake_result(), fake_result(url='https://example.com/')])
41+ self.assertEqual(c.results_length(), 2)
searx/tests/test_search.py+4−19
@@ -1,25 +1,10 @@
11# -*- coding: utf-8 -*-
22
3−from searx.search import score_results
43from searx.testing import SearxTestCase
54
65
7−def fake_result(url='https://aa.bb/cc?dd=ee#ff',
8− title='aaa',
9− content='bbb',
10− engine='wikipedia'):
11− return {'url': url,
12− 'title': title,
13− 'content': content,
14− 'engine': engine}
6+# TODO
7+class SearchTestCase(SearxTestCase):
158
16−
17−class ScoreResultsTestCase(SearxTestCase):
18−
19− def test_empty(self):
20− self.assertEqual(score_results(dict()), [])
21−
22− def test_urlparse(self):
23− results = score_results(dict(a=[fake_result(url='https://aa.bb/cc?dd=ee#ff')]))
24− parsed_url = results[0]['parsed_url']
25− self.assertEqual(parsed_url.query, 'dd=ee')
9+ def test_(self):
10+ pass
searx/tests/test_webapp.py+7−1
@@ -1,6 +1,7 @@
11# -*- coding: utf-8 -*-
22
33import json
4+from mock import Mock
45from urlparse import ParseResult
56from searx import webapp
67from searx.testing import SearxTestCase
@@ -33,7 +34,12 @@ class ViewsTestCase(SearxTestCase):
3334 ]
3435
3536 def search_mock(search_self, *args):
36− search_self.results = self.test_results
37+ search_self.result_container = Mock(get_ordered_results=lambda: self.test_results,
38+ answers=set(),
39+ suggestions=set(),
40+ infoboxes=[],
41+ results=self.test_results,
42+ results_length=lambda: len(self.test_results))
3743
3844 webapp.Search.search = search_mock
3945
searx/webapp.py+14−15
@@ -383,7 +383,7 @@ def index():
383383
384384 plugins.call('post_search', request, locals())
385385
386− for result in search.results:
386+ for result in search.result_container.get_ordered_results():
387387
388388 plugins.call('on_result', request, locals())
389389 if not search.paging and engines[result['engine']].paging:
@@ -411,7 +411,7 @@ def index():
411411 minutes = int((timedifference.seconds / 60) % 60)
412412 hours = int(timedifference.seconds / 60 / 60)
413413 if hours == 0:
414− result['publishedDate'] = gettext(u'{minutes} minute(s) ago').format(minutes=minutes) # noqa
414+ result['publishedDate'] = gettext(u'{minutes} minute(s) ago').format(minutes=minutes)
415415 else:
416416 result['publishedDate'] = gettext(u'{hours} hour(s), {minutes} minute(s) ago').format(hours=hours, minutes=minutes) # noqa
417417 else:
@@ -419,17 +419,16 @@ def index():
419419
420420 if search.request_data.get('format') == 'json':
421421 return Response(json.dumps({'query': search.query,
422− 'results': search.results}),
422+ 'results': search.result_container.get_ordered_results()}),
423423 mimetype='application/json')
424424 elif search.request_data.get('format') == 'csv':
425425 csv = UnicodeWriter(cStringIO.StringIO())
426426 keys = ('title', 'url', 'content', 'host', 'engine', 'score')
427− if search.results:
428− csv.writerow(keys)
429− for row in search.results:
430− row['host'] = row['parsed_url'].netloc
431− csv.writerow([row.get(key, '') for key in keys])
432− csv.stream.seek(0)
427+ csv.writerow(keys)
428+ for row in search.result_container.get_ordered_results():
429+ row['host'] = row['parsed_url'].netloc
430+ csv.writerow([row.get(key, '') for key in keys])
431+ csv.stream.seek(0)
433432 response = Response(csv.stream.read(), mimetype='application/csv')
434433 cont_disp = 'attachment;Filename=searx_-_{0}.csv'.format(search.query)
435434 response.headers.add('Content-Disposition', cont_disp)
@@ -437,24 +436,24 @@ def index():
437436 elif search.request_data.get('format') == 'rss':
438437 response_rss = render(
439438 'opensearch_response_rss.xml',
440− results=search.results,
439+ results=search.result_container.get_ordered_results(),
441440 q=search.request_data['q'],
442− number_of_results=len(search.results),
441+ number_of_results=search.result_container.results_length(),
443442 base_url=get_base_url()
444443 )
445444 return Response(response_rss, mimetype='text/xml')
446445
447446 return render(
448447 'results.html',
449− results=search.results,
448+ results=search.result_container.get_ordered_results(),
450449 q=search.request_data['q'],
451450 selected_categories=search.categories,
452451 paging=search.paging,
453452 pageno=search.pageno,
454453 base_url=get_base_url(),
455− suggestions=search.suggestions,
456− answers=search.answers,
457− infoboxes=search.infoboxes,
454+ suggestions=search.result_container.suggestions,
455+ answers=search.result_container.answers,
456+ infoboxes=search.result_container.infoboxes,
458457 theme=get_current_theme_name(),
459458 favicons=global_favicons[themes.index(get_current_theme_name())]
460459 )