on master help sign in
19 files changed+403−135
since splitsnapshots
unifiedsplit
.github/workflows/data-update.yml+1−1 .github/workflows/integration.yml+32−3 Dockerfile+5−4 manage+4−4 requirements-dev.txt+9−8 requirements.txt+3−3 searx/autocomplete.py+6−6 searx/engines/gigablast.py+24−13 searx/engines/google.py+20−11 searx/engines/startpage.py+100−20 searx/engines/tineye.py+86 searx/network/__init__.py+23−24 searx/network/client.py+1−8 searx/network/network.py+8 searx/results.py+8−2 searx/settings.yml+19−8 searx/utils.py+12−6 searx/webapp.py+41−13 utils/searx.sh+1−1
.github/workflows/data-update.yml+1−1
@@ -29,7 +29,7 @@ jobs:
2929 - name: Set up Python
3030 uses: actions/setup-python@v2
3131 with:
32− python-version: '3.9'
32+ python-version: '3.10'
3333 architecture: 'x64'
3434
3535 - name: Install Python dependencies
.github/workflows/integration.yml+32−3
@@ -13,7 +13,7 @@ jobs:
1313 strategy:
1414 matrix:
1515 os: [ubuntu-20.04]
16− python-version: [3.6, 3.7, 3.8, 3.9]
16+ python-version: [3.7, 3.8, 3.9, "3.10"]
1717 steps:
1818 - name: Checkout
1919 uses: actions/checkout@v2
@@ -56,6 +56,17 @@ jobs:
5656 uses: actions/checkout@v2
5757 - name: Install Ubuntu packages
5858 run: sudo ./utils/searx.sh install packages
59+ - name: Set up Python
60+ uses: actions/setup-python@v2
61+ with:
62+ python-version: '3.9'
63+ architecture: 'x64'
64+ - name: Cache Python dependencies
65+ id: cache-python
66+ uses: actions/cache@v2
67+ with:
68+ path: ./local
69+ key: python-ubuntu-20.04-3.9-${{ hashFiles('requirements*.txt', 'setup.py') }}
5970 - name: Install node dependencies
6071 run: make V=1 node.env
6172 - name: Build themes
@@ -68,14 +79,21 @@ jobs:
6879 - name: Checkout
6980 uses: actions/checkout@v2
7081 with:
82+ fetch-depth: '0'
7183 persist-credentials: false
7284 - name: Install Ubuntu packages
7385 run: sudo ./utils/searx.sh install buildhost
7486 - name: Set up Python
7587 uses: actions/setup-python@v2
7688 with:
77− python-version: '3.9'
89+ python-version: '3.10'
7890 architecture: 'x64'
91+ - name: Cache Python dependencies
92+ id: cache-python
93+ uses: actions/cache@v2
94+ with:
95+ path: ./local
96+ key: python-ubuntu-20.04-3.9-${{ hashFiles('requirements*.txt', 'setup.py') }}
7997 - name: Build documentation
8098 run: |
8199 make V=1 docs.clean docs.html
@@ -97,7 +115,7 @@ jobs:
97115 - documentation
98116 env:
99117 DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
100− runs-on: ubuntu-18.04
118+ runs-on: ubuntu-20.04
101119 steps:
102120 - name: Checkout
103121 if: env.DOCKERHUB_USERNAME != null
@@ -105,6 +123,17 @@ jobs:
105123 with:
106124 # make sure "make docker.push" can get the git history
107125 fetch-depth: '0'
126+ - name: Set up Python
127+ uses: actions/setup-python@v2
128+ with:
129+ python-version: '3.9'
130+ architecture: 'x64'
131+ - name: Cache Python dependencies
132+ id: cache-python
133+ uses: actions/cache@v2
134+ with:
135+ path: ./local
136+ key: python-ubuntu-20.04-3.9-${{ hashFiles('requirements*.txt', 'setup.py') }}
108137 - name: Set up QEMU
109138 if: env.DOCKERHUB_USERNAME != null
110139 uses: docker/setup-qemu-action@v1
Dockerfile+5−4
@@ -1,4 +1,4 @@
1−FROM alpine:3.12
1+FROM alpine:3.15
22ENTRYPOINT ["/sbin/tini","--","/usr/local/searx/dockerfiles/docker-entrypoint.sh"]
33EXPOSE 8080
44VOLUME /etc/searx
@@ -46,18 +46,19 @@ RUN apk upgrade --no-cache \
4646 uwsgi \
4747 uwsgi-python3 \
4848 brotli \
49− && pip3 install --upgrade pip \
49+ && pip3 install --upgrade pip wheel setuptools \
5050 && pip3 install --no-cache -r requirements.txt \
5151 && apk del build-dependencies \
5252 && rm -rf /root/.cache
5353
54−COPY --chown=searx:searx . .
54+COPY searx ./searx
55+COPY dockerfiles ./dockerfiles
5556
5657ARG TIMESTAMP_SETTINGS=0
5758ARG TIMESTAMP_UWSGI=0
5859ARG VERSION_GITCOMMIT=unknown
5960
60−RUN su searx -c "/usr/bin/python3 -m compileall -q searx"; \
61+RUN /usr/bin/python3 -m compileall -q searx; \
6162 touch -c --date=@${TIMESTAMP_SETTINGS} searx/settings.yml; \
6263 touch -c --date=@${TIMESTAMP_UWSGI} dockerfiles/uwsgi.ini; \
6364 if [ ! -z $VERSION_GITCOMMIT ]; then\
manage+4−4
@@ -13,7 +13,7 @@ source_dot_config
1313
1414PY_SETUP_EXTRAS='[test]'
1515NPM_PACKAGES="less@2.7 less-plugin-clean-css grunt-cli"
16−GECKODRIVER_VERSION="v0.28.0"
16+GECKODRIVER_VERSION="v0.30.0"
1717# SPHINXOPTS=
1818
1919# These py files are linted by test.pylint(), all other files are linted by
@@ -224,7 +224,7 @@ docker.build() {
224224
225225 # build Docker image
226226 build_msg DOCKER "Building image ${SEARX_IMAGE_NAME}:${SEARX_GIT_VERSION}"
227− sudo docker build \
227+ docker build \
228228 --build-arg GIT_URL="${GIT_URL}" \
229229 --build-arg SEARX_GIT_VERSION="${SEARX_GIT_VERSION}" \
230230 --build-arg VERSION_GITCOMMIT="${VERSION_GITCOMMIT}" \
@@ -236,8 +236,8 @@ docker.build() {
236236 -t "${SEARX_IMAGE_NAME}:latest" -t "${SEARX_IMAGE_NAME}:${SEARX_GIT_VERSION}" .
237237
238238 if [ "$1" = "push" ]; then
239− sudo docker push "${SEARX_IMAGE_NAME}:latest"
240− sudo docker push "${SEARX_IMAGE_NAME}:${SEARX_GIT_VERSION}"
239+ docker push "${SEARX_IMAGE_NAME}:latest"
240+ docker push "${SEARX_IMAGE_NAME}:${SEARX_GIT_VERSION}"
241241 fi
242242 )
243243 dump_return $?
requirements-dev.txt+9−8
@@ -4,16 +4,17 @@ cov-core==1.15.0
44pycodestyle==2.8.0
55pylint==2.12.2
66splinter==0.17.0
7−transifex-client==0.14.3
8−selenium==3.141.0
9−twine==3.4.2
10−Pallets-Sphinx-Themes==2.0.1
7+transifex-client==0.14.3; python_version < '3.10'
8+transifex-client==0.12.1; python_version == '3.10'
9+selenium==4.1.0
10+twine==3.7.1
11+Pallets-Sphinx-Themes==2.0.2
1112docutils==0.16
12−Sphinx==4.2.0
13−sphinx-issues==1.2.0
14−sphinx-jinja==1.1.1
13+Sphinx==4.4.0
14+sphinx-issues==3.0.1
15+sphinx-jinja==1.4.0
1516sphinx-tabs==3.2.0
1617sphinxcontrib-programoutput==0.17
1718sphinx-autobuild==2021.3.14
18−linuxdoc==20210324
19+linuxdoc==20211220
1920aiounittest==1.4.1
requirements.txt+3−3
@@ -1,9 +1,9 @@
11certifi==2021.10.8
22babel==2.9.1
33flask-babel==2.0.0
4−flask==1.1.2
5−jinja2==3.0.2
6−lxml==4.6.3
4+flask==2.0.2
5+jinja2==3.0.3
6+lxml==4.7.1
77pygments==2.8.0
88python-dateutil==2.8.2
99pyyaml==6.0
searx/autocomplete.py+6−6
@@ -24,6 +24,7 @@ from httpx import HTTPError
2424
2525
2626from searx import settings
27+from searx.data import ENGINES_LANGUAGES
2728from searx.network import get as http_get
2829from searx.exceptions import SearxEngineResponseException
2930
@@ -93,12 +94,11 @@ def google(query, lang):
9394
9495def startpage(query, lang):
9596 # startpage autocompleter
96− url = 'https://startpage.com/do/suggest?{query}'
97−
98− resp = get(url.format(query=urlencode({'query': query}))).text.split('\n')
99− if len(resp) > 1:
100− return resp
101− return []
97+ lui = ENGINES_LANGUAGES['startpage'].get(lang, 'english')
98+ url = 'https://startpage.com/suggestions?{query}'
99+ resp = get(url.format(query=urlencode({'q': query, 'segment': 'startpage.udog', 'lui': lui})))
100+ data = resp.json()
101+ return [e['text'] for e in data.get('suggestions', []) if 'text' in e]
102102
103103
104104def swisscows(query, lang):
searx/engines/gigablast.py+24−13
@@ -5,10 +5,10 @@
55# pylint: disable=missing-function-docstring, invalid-name
66
77import re
8−from json import loads
8+from json import loads, JSONDecodeError
99from urllib.parse import urlencode
10−# from searx import logger
1110from searx.network import get
11+from searx.exceptions import SearxEngineResponseException
1212
1313# about
1414about = {
@@ -22,6 +22,9 @@ about = {
2222
2323# engine dependent config
2424categories = ['general']
25+collections = 'main'
26+search_type = ''
27+fast = 0
2528# gigablast's pagination is totally damaged, don't use it
2629paging = False
2730safesearch = True
@@ -34,6 +37,8 @@ base_url = 'https://gigablast.com'
3437extra_param = ''
3538extra_param_path='/search?c=main&qlangcountry=en-us&q=south&s=10'
3639
40+_wait_for_results_msg = 'Loading results takes too long. Please enable fast option in gigablast engine.'
41+
3742def parse_extra_param(text):
3843
3944 # example:
@@ -54,7 +59,6 @@ def parse_extra_param(text):
5459 if re_var is not None and re_var.search(line):
5560 extra_param += re_var.search(line).group(1)
5661 break
57− # logger.debug('gigablast extra_param="%s"', extra_param)
5862
5963def init(engine_settings=None): # pylint: disable=unused-argument
6064 parse_extra_param(get(base_url + extra_param_path).text)
@@ -65,14 +69,17 @@ def request(query, params): # pylint: disable=unused-argument
6569
6670 # see API http://www.gigablast.com/api.html#/search
6771 # Take into account, that the API has some quirks ..
68−
69− query_args = dict(
70− c = 'main'
71− , format = 'json'
72− , q = query
73− , dr = 1
74− , showgoodimages = 0
75− )
72+ query_args = {
73+ 'c': collections,
74+ 'format': 'json',
75+ 'q': query,
76+ 'dr': 1 ,
77+ 'showgoodimages': 0,
78+ 'fast': fast,
79+ }
80+
81+ if search_type != '':
82+ query_args['searchtype'] = search_type
7683
7784 if params['language'] and params['language'] != 'all':
7885 query_args['qlangcountry'] = params['language']
@@ -90,9 +97,13 @@ def request(query, params): # pylint: disable=unused-argument
9097def response(resp):
9198 results = []
9299
93− response_json = loads(resp.text)
100+ try:
101+ response_json = loads(resp.text)
102+ except JSONDecodeError as e:
103+ if 'Waiting for results' in resp.text:
104+ raise SearxEngineResponseException(message=_wait_for_results_msg) # pylint: disable=raise-missing-from
105+ raise e
94106
95− # logger.debug('gigablast returns %s results', len(response_json['results']))
96107
97108 for result in response_json['results']:
98109 # see "Example JSON Output (&format=json)"
searx/engines/google.py+20−11
@@ -8,7 +8,7 @@ Definitions`_.
88 https://developers.google.com/custom-search/docs/xml_results#WebSearch_Query_Parameter_Definitions
99"""
1010
11−# pylint: disable=invalid-name, missing-function-docstring
11+# pylint: disable=invalid-name, missing-function-docstring, too-many-branches
1212
1313from urllib.parse import urlencode
1414from lxml import html
@@ -108,8 +108,9 @@ filter_mapping = {
108108# specific xpath variables
109109# ------------------------
110110
111−# google results are grouped into <div class="g" ../>
112−results_xpath = '//div[@class="g"]'
111+# google results are grouped into <div class="g ..." ../>
112+results_xpath = '//div[@id="search"]//div[contains(@class, "g ")]'
113+results_xpath_mobile_ui = '//div[contains(@class, "g ")]'
113114
114115# google *sections* are no usual *results*, we ignore them
115116g_section_with_header = './g-section-with-header'
@@ -121,8 +122,8 @@ title_xpath = './/h3[1]'
121122# href=...>
122123href_xpath = './/div[@class="yuRUbf"]//a/@href'
123124
124−# in the result group there is <div class="IsZvec" ../> containing he *content*
125−content_xpath = './/div[@class="IsZvec"]'
125+# in the result group there is <div class="VwiC3b ..." ../> containing the *content*
126+content_xpath = './/div[contains(@class, "VwiC3b")]'
126127
127128# Suggestions are links placed in a *card-section*, we extract only the text
128129# from the links not the links itself.
@@ -274,7 +275,12 @@ def response(resp):
274275 logger.error(e, exc_info=True)
275276
276277 # parse results
277− for result in eval_xpath_list(dom, results_xpath):
278+
279+ _results_xpath = results_xpath
280+ if use_mobile_ui:
281+ _results_xpath = results_xpath_mobile_ui
282+
283+ for result in eval_xpath_list(dom, _results_xpath):
278284
279285 # google *sections*
280286 if extract_text(eval_xpath(result, g_section_with_header)):
@@ -285,24 +291,27 @@ def response(resp):
285291 title_tag = eval_xpath_getindex(result, title_xpath, 0, default=None)
286292 if title_tag is None:
287293 # this not one of the common google results *section*
288− logger.debug('ingoring <div class="g" ../> section: missing title')
294+ logger.debug('ingoring item from the result_xpath list: missing title')
289295 continue
290296 title = extract_text(title_tag)
291297 url = eval_xpath_getindex(result, href_xpath, 0, None)
292298 if url is None:
293299 continue
294300 content = extract_text(eval_xpath_getindex(result, content_xpath, 0, default=None), allow_none=True)
301+ if content is None:
302+ logger.debug('ingoring item from the result_xpath list: missing content of title "%s"', title)
303+ continue
304+
305+ logger.debug('add link to results: %s', title)
306+
295307 results.append({
296308 'url': url,
297309 'title': title,
298310 'content': content
299311 })
312+
300313 except Exception as e: # pylint: disable=broad-except
301314 logger.error(e, exc_info=True)
302− # from lxml import etree
303− # logger.debug(etree.tostring(result, pretty_print=True))
304− # import pdb
305− # pdb.set_trace()
306315 continue
307316
308317 # parse suggestion
searx/engines/startpage.py+100−20
@@ -1,16 +1,30 @@
11# SPDX-License-Identifier: AGPL-3.0-or-later
2−"""
3− Startpage (Web)
2+# lint: pylint
3+"""Startpage (Web)
4+
45"""
56
6−from lxml import html
7−from dateutil import parser
8−from datetime import datetime, timedelta
97import re
8+from time import time
9+
10+from urllib.parse import urlencode
1011from unicodedata import normalize, combining
12+from datetime import datetime, timedelta
13+
14+from dateutil import parser
15+from lxml import html
1116from babel import Locale
1217from babel.localedata import locale_identifiers
18+
19+from searx import logger
20+from searx.network import get
1321from searx.utils import extract_text, eval_xpath, match_language
22+from searx.exceptions import (
23+ SearxEngineResponseException,
24+ SearxEngineCaptchaException,
25+)
26+
27+logger = logger.getChild('startpage')
1428
1529# about
1630about = {
@@ -33,7 +47,7 @@ supported_languages_url = 'https://www.startpage.com/do/settings'
3347
3448# search-url
3549base_url = 'https://startpage.com/'
36−search_url = base_url + 'do/search'
50+search_url = base_url + 'sp/search?'
3751
3852# specific xpath variables
3953# ads xpath //div[@id="results"]/div[@id="sponsored"]//div[@class="result"]
@@ -42,18 +56,74 @@ results_xpath = '//div[@class="w-gl__result__main"]'
4256link_xpath = './/a[@class="w-gl__result-title result-link"]'
4357content_xpath = './/p[@class="w-gl__description"]'
4458
59+# timestamp of the last fetch of 'sc' code
60+sc_code_ts = 0
61+sc_code = ''
62+
63+
64+def raise_captcha(resp):
65+
66+ if str(resp.url).startswith('https://www.startpage.com/sp/captcha'):
67+ # suspend CAPTCHA for 7 days
68+ raise SearxEngineCaptchaException(suspended_time=7 * 24 * 3600)
69+
70+
71+def get_sc_code(headers):
72+ """Get an actual `sc` argument from startpage's home page.
73+
74+ Startpage puts a `sc` argument on every link. Without this argument
75+ startpage considers the request is from a bot. We do not know what is
76+ encoded in the value of the `sc` argument, but it seems to be a kind of a
77+ *time-stamp*. This *time-stamp* is valid for a few hours.
78+
79+ This function scrap a new *time-stamp* from startpage's home page every hour
80+ (3000 sec).
81+
82+ """
83+
84+ global sc_code_ts, sc_code # pylint: disable=global-statement
85+
86+ if time() > (sc_code_ts + 3000):
87+ logger.debug("query new sc time-stamp ...")
88+
89+ resp = get(base_url, headers=headers)
90+ raise_captcha(resp)
91+ dom = html.fromstring(resp.text)
92+
93+ try:
94+ # href --> '/?sc=adrKJMgF8xwp20'
95+ href = eval_xpath(dom, '//a[@class="footer-home__logo"]')[0].get('href')
96+ except IndexError as exc:
97+ # suspend startpage API --> https://github.com/searxng/searxng/pull/695
98+ raise SearxEngineResponseException(
99+ suspended_time=7 * 24 * 3600, message="PR-695: query new sc time-stamp failed!"
100+ ) from exc
101+
102+ sc_code = href[5:]
103+ sc_code_ts = time()
104+ logger.debug("new value is: %s", sc_code)
105+
106+ return sc_code
107+
45108
46109# do search-request
47110def request(query, params):
48111
49− params['url'] = search_url
50− params['method'] = 'POST'
51− params['data'] = {
112+ # pylint: disable=line-too-long
113+ # The format string from Startpage's FFox add-on [1]::
114+ #
115+ # https://www.startpage.com/do/dsearch?query={searchTerms}&cat=web&pl=ext-ff&language=__MSG_extensionUrlLanguage__&extVersion=1.3.0
116+ #
117+ # [1] https://addons.mozilla.org/en-US/firefox/addon/startpage-private-search/
118+
119+ args = {
52120 'query': query,
53121 'page': params['pageno'],
54122 'cat': 'web',
55− 'cmd': 'process_search',
56− 'engine0': 'v1all',
123+ # 'pl': 'ext-ff',
124+ # 'extVersion': '1.3.0',
125+ # 'abp': "-1",
126+ 'sc': get_sc_code(params['headers']),
57127 }
58128
59129 # set language if specified
@@ -61,9 +131,10 @@ def request(query, params):
61131 lang_code = match_language(params['language'], supported_languages, fallback=None)
62132 if lang_code:
63133 language_name = supported_languages[lang_code]['alias']
64− params['data']['language'] = language_name
65− params['data']['lui'] = language_name
134+ args['language'] = language_name
135+ args['lui'] = language_name
66136
137+ params['url'] = search_url + urlencode(args)
67138 return params
68139
69140
@@ -139,10 +210,11 @@ def response(resp):
139210
140211# get supported languages from their site
141212def _fetch_supported_languages(resp):
142− # startpage's language selector is a mess
143− # each option has a displayed name and a value, either of which may represent the language name
144− # in the native script, the language name in English, an English transliteration of the native name,
145− # the English name of the writing script used by the language, or occasionally something else entirely.
213+ # startpage's language selector is a mess each option has a displayed name
214+ # and a value, either of which may represent the language name in the native
215+ # script, the language name in English, an English transliteration of the
216+ # native name, the English name of the writing script used by the language,
217+ # or occasionally something else entirely.
146218
147219 # this cases are so special they need to be hardcoded, a couple of them are mispellings
148220 language_names = {
@@ -156,7 +228,15 @@ def _fetch_supported_languages(resp):
156228 }
157229
158230 # get the English name of every language known by babel
159− language_names.update({name.lower(): lang_code for lang_code, name in Locale('en')._data['languages'].items()})
231+ language_names.update(
232+ {
233+ # fmt: off
234+ name.lower(): lang_code
235+ # pylint: disable=protected-access
236+ for lang_code, name in Locale('en')._data['languages'].items()
237+ # fmt: on
238+ }
239+ )
160240
161241 # get the native name of every language known by babel
162242 for lang_code in filter(lambda lang_code: lang_code.find('_') == -1, locale_identifiers()):
@@ -181,8 +261,8 @@ def _fetch_supported_languages(resp):
181261 if isinstance(lang_code, str):
182262 supported_languages[lang_code] = {'alias': sp_option_value}
183263 elif isinstance(lang_code, list):
184− for lc in lang_code:
185− supported_languages[lc] = {'alias': sp_option_value}
264+ for _lc in lang_code:
265+ supported_languages[_lc] = {'alias': sp_option_value}
186266 else:
187267 print('Unknown language option in Startpage: {} ({})'.format(sp_option_value, sp_option_text))
188268
searx/engines/tineye.pynew file+86
@@ -0,0 +1,86 @@
1+"""
2+Tineye - Reverse search images
3+"""
4+
5+from json import loads
6+from urllib.parse import urlencode
7+
8+from datetime import datetime
9+
10+about = {
11+ "website": "https://tineye.com",
12+ "wikidata_id": "Q2382535",
13+ "use_official_api": False,
14+ "require_api_key": False,
15+ "results": "JSON",
16+}
17+
18+
19+categories = ['images']
20+paging = True
21+
22+safesearch = False
23+
24+
25+base_url = 'https://tineye.com'
26+search_string = '/result_json/?page={page}&{query}'
27+
28+
29+def request(query, params):
30+ params['url'] = base_url +\
31+ search_string.format(
32+ query=urlencode({'url': query}),
33+ page=params['pageno'])
34+
35+ params['headers'].update({
36+ 'Connection': 'keep-alive',
37+ 'Accept-Encoding': 'gzip, defalte, br',
38+ 'Host': 'tineye.com',
39+ 'DNT': '1',
40+ 'TE': 'trailers',
41+ })
42+
43+ return params
44+
45+
46+def response(resp):
47+ results = []
48+ # Define wanted results
49+ json_data = loads(resp.text)
50+ number_of_results = json_data['num_matches']
51+
52+ for i in json_data['matches']:
53+ for i in json_data['matches']:
54+ image_format = i['format']
55+ width = i['width']
56+ height = i['height']
57+ thumbnail_src = i['image_url']
58+ backlink = i['domains'][0]['backlinks'][0]
59+
60+ url = backlink['backlink']
61+ source = backlink['url']
62+ title = backlink['image_name']
63+ img_src = backlink['url']
64+
65+ # Get and convert published date
66+ api_date = backlink['crawl_date'][:-3]
67+ publishedDate = datetime.fromisoformat(api_date)
68+
69+ # Append results
70+ results.append({
71+ 'template': 'images.html',
72+ 'url': url,
73+ 'thumbnail_src': thumbnail_src,
74+ 'source': source,
75+ 'title': title,
76+ 'img_src': img_src,
77+ 'format': image_format,
78+ 'widht': width,
79+ 'height': height,
80+ 'publishedDate': publishedDate,
81+ })
82+
83+ # Append number of results
84+ results.append({'number_of_results': number_of_results})
85+
86+ return results
searx/network/__init__.py+23−24
@@ -4,6 +4,8 @@ import asyncio
44import threading
55import concurrent.futures
66from time import time
7+from queue import SimpleQueue
8+from types import MethodType
79
810import httpx
911import h2.exceptions
@@ -12,29 +14,6 @@ from .network import get_network, initialize, check_network_configuration
1214from .client import get_loop
1315from .raise_for_httperror import raise_for_httperror
1416
15−# queue.SimpleQueue: Support Python 3.6
16−try:
17− from queue import SimpleQueue
18−except ImportError:
19− from queue import Empty
20− from collections import deque
21−
22− class SimpleQueue:
23− """Minimal backport of queue.SimpleQueue"""
24−
25− def __init__(self):
26− self._queue = deque()
27− self._count = threading.Semaphore(0)
28−
29− def put(self, item):
30− self._queue.append(item)
31− self._count.release()
32−
33− def get(self):
34− if not self._count.acquire(True):
35− raise Empty
36− return self._queue.popleft()
37−
3817
3918THREADLOCAL = threading.local()
4019
@@ -156,15 +135,27 @@ async def stream_chunk_to_queue(network, q, method, url, **kwargs):
156135 try:
157136 async with await network.stream(method, url, **kwargs) as response:
158137 q.put(response)
138+ # aiter_raw: access the raw bytes on the response without applying any HTTP content decoding
139+ # https://www.python-httpx.org/quickstart/#streaming-responses
159140 async for chunk in response.aiter_bytes(65536):
160141 if len(chunk) > 0:
161142 q.put(chunk)
143+ except httpx.ResponseClosed as e:
144+ # the response was closed
145+ pass
162146 except (httpx.HTTPError, OSError, h2.exceptions.ProtocolError) as e:
163147 q.put(e)
164148 finally:
165149 q.put(None)
166150
167151
152+def _close_response_method(self):
153+ asyncio.run_coroutine_threadsafe(
154+ self.aclose(),
155+ get_loop()
156+ )
157+
158+
168159def stream(method, url, **kwargs):
169160 """Replace httpx.stream.
170161
@@ -180,10 +171,18 @@ def stream(method, url, **kwargs):
180171 q = SimpleQueue()
181172 future = asyncio.run_coroutine_threadsafe(stream_chunk_to_queue(get_network(), q, method, url, **kwargs),
182173 get_loop())
174+ # yield response
175+ response = q.get()
176+ if isinstance(response, Exception):
177+ raise response
178+ response.close = MethodType(_close_response_method, response)
179+ yield response
180+
181+ # yield chunks
183182 chunk_or_exception = q.get()
184183 while chunk_or_exception is not None:
185184 if isinstance(chunk_or_exception, Exception):
186185 raise chunk_or_exception
187186 yield chunk_or_exception
188187 chunk_or_exception = q.get()
189− return future.result()
188+ future.result()
searx/network/client.py+1−8
@@ -3,6 +3,7 @@
33import asyncio
44import logging
55import threading
6+import uvloop
67
78import httpcore
89import httpx
@@ -17,14 +18,6 @@ import python_socks._errors
1718
1819from searx import logger
1920
20−# Optional uvloop (support Python 3.6)
21−try:
22− import uvloop
23−except ImportError:
24− pass
25−else:
26− uvloop.install()
27−
2821
2922logger = logger.getChild('searx.http.client')
3023LOOP = None
searx/network/network.py+8
@@ -326,6 +326,14 @@ def initialize(settings_engines=None, settings_outgoing=None):
326326 if isinstance(network, str):
327327 NETWORKS[engine_name] = NETWORKS[network]
328328
329+ # the /image_proxy endpoint has a dedicated network.
330+ # same parameters than the default network, but HTTP/2 is disabled.
331+ # It decreases the CPU load average, and the total time is more or less the same
332+ if 'image_proxy' not in NETWORKS:
333+ image_proxy_params = default_params.copy()
334+ image_proxy_params['enable_http2'] = False
335+ NETWORKS['image_proxy'] = new_network(image_proxy_params)
336+
329337
330338@atexit.register
331339def done():
searx/results.py+8−2
@@ -6,6 +6,7 @@ from urllib.parse import urlparse, unquote
66from searx import logger
77from searx.engines import engines
88from searx.metrology.error_recorder import record_error
9+from searx.utils import add_scheme_to_url
910from searx import settings
1011
1112
@@ -240,10 +241,15 @@ class ResultContainer:
240241 result['parsed_url'] = urlparse(result['url'])
241242
242243 # if the result has no scheme, use http as default
243− if not result['parsed_url'].scheme:
244− result['parsed_url'] = result['parsed_url']._replace(scheme="http")
244+ if not result['parsed_url'].scheme or result['parsed_url'].scheme == '':
245+ result['parsed_url'] = result['parsed_url']._replace(scheme='http')
245246 result['url'] = result['parsed_url'].geturl()
246247
248+ if 'thumbnail_src' in result:
249+ result['thumbnail_src'] = add_scheme_to_url(result['thumbnail_src'])
250+ if 'img_src' in result:
251+ result['img_src'] = add_scheme_to_url(result['img_src'])
252+
247253 result['engines'] = set([result['engine']])
248254
249255 # strip multiple spaces and cariage returns from content
searx/settings.yml+19−8
@@ -410,6 +410,13 @@ engines:
410410 timeout: 3.0
411411 disabled : True
412412
413+ - name : tineye
414+ engine : tineye
415+ shortcut : tin
416+ paging : True
417+ timeout : 9.0
418+ disabled : True
419+
413420 - name : etools
414421 engine : etools
415422 shortcut : eto
@@ -498,6 +505,13 @@ engines:
498505 engine : gigablast
499506 shortcut : gb
500507 timeout : 3.0
508+ # Collection names to search in, separated by whitespaces.
509+ # By default searx, queries the main collection.
510+ #collections: main
511+ # Search types, not required. possible values: images, news
512+ #search_type: images
513+ # Enable fast to get results quicker.
514+ #fast : 1
501515 disabled: True
502516 additional_tests:
503517 rosebud: *test_rosebud
@@ -683,22 +697,19 @@ engines:
683697
684698 - name : hoogle
685699 engine : xpath
686− paging : True
687700 search_url : https://hoogle.haskell.org/?hoogle={query}&start={pageno}
688701 results_xpath : '//div[@class="result"]'
689− title_xpath : './/div[@class="ans"]//a'
690− url_xpath : './/div[@class="ans"]//a/@href'
691− content_xpath : './/div[@class="from"]'
692− page_size : 20
702+ title_xpath : './div[@class="ans"]'
703+ url_xpath : './div[@class="ans"]//a/@href'
704+ content_xpath : './div[contains(@class, "doc")]'
693705 categories : it
694706 shortcut : ho
695707 about:
696− website: https://www.haskell.org/
708+ website: https://hoogle.haskell.org/
697709 wikidata_id: Q34010
698− official_api_documentation: https://hackage.haskell.org/api
699710 use_official_api: false
700711 require_api_key: false
701− results: JSON
712+ results: HTML
702713
703714 - name : imdb
704715 engine : imdb
searx/utils.py+12−6
@@ -7,7 +7,7 @@ from numbers import Number
77from os.path import splitext, join
88from random import choice
99from html.parser import HTMLParser
10−from urllib.parse import urljoin, urlparse
10+from urllib.parse import urljoin, urlparse, urlunparse
1111
1212from lxml import html
1313from lxml.etree import ElementBase, XPath, XPathError, XPathSyntaxError, _ElementStringResult, _ElementUnicodeResult
@@ -214,6 +214,16 @@ def normalize_url(url, base_url):
214214 return url
215215
216216
217+def add_scheme_to_url(url, scheme="https"):
218+ """Add schema to URL: if scheme is missing from the URL, then add it."""
219+
220+ parsed = urlparse(url)
221+ if parsed.scheme == '':
222+ parsed_with_scheme = parsed._replace(scheme=scheme)
223+ return urlunparse(parsed_with_scheme)
224+ return url
225+
226+
217227def extract_url(xpath_results, base_url):
218228 """Extract and normalize URL from lxml Element
219229
@@ -262,11 +272,7 @@ def dict_subset(d, properties):
262272 >>> >> dict_subset({'A': 'a', 'B': 'b', 'C': 'c'}, ['A', 'D'])
263273 {'A': 'a'}
264274 """
265− result = {}
266− for k in properties:
267− if k in d:
268− result[k] = d[k]
269− return result
275+ return {k: d[k] for k in properties if k in d}
270276
271277
272278def get_torrent_size(filesize, filesize_multiplier):
searx/webapp.py+41−13
@@ -94,7 +94,7 @@ from searx.plugins import plugins
9494from searx.plugins.oa_doi_rewrite import get_doi_resolver
9595from searx.preferences import Preferences, ValidationException, LANGUAGE_CODES
9696from searx.answerers import answerers
97−from searx.network import stream as http_stream
97+from searx.network import stream as http_stream, set_context_network_name
9898from searx.answerers import ask
9999from searx.metrology.error_recorder import errors_per_engines
100100from searx.settings_loader import get_default_settings_path
@@ -921,6 +921,8 @@ def _is_selected_language_supported(engine, preferences):
921921
922922@app.route('/image_proxy', methods=['GET'])
923923def image_proxy():
924+ # pylint: disable=too-many-return-statements, too-many-branches
925+
924926 url = request.args.get('url')
925927
926928 if not url:
@@ -932,14 +934,21 @@ def image_proxy():
932934 return '', 400
933935
934936 maximum_size = 5 * 1024 * 1024
935−
937+ forward_resp = False
938+ resp = None
936939 try:
937− headers = dict_subset(request.headers, {'If-Modified-Since', 'If-None-Match'})
938− headers['User-Agent'] = gen_useragent()
940+ request_headers = {
941+ 'User-Agent': gen_useragent(),
942+ 'Accept': 'image/webp,*/*',
943+ 'Accept-Encoding': 'gzip, deflate',
944+ 'Sec-GPC': '1',
945+ 'DNT': '1',
946+ }
947+ set_context_network_name('image_proxy')
939948 stream = http_stream(
940949 method='GET',
941950 url=url,
942− headers=headers,
951+ headers=request_headers,
943952 timeout=settings['outgoing']['request_timeout'],
944953 follow_redirects=True,
945954 max_redirects=20)
@@ -949,25 +958,37 @@ def image_proxy():
949958 if content_length and content_length.isdigit() and int(content_length) > maximum_size:
950959 return 'Max size', 400
951960
952− if resp.status_code == 304:
953− return '', resp.status_code
954−
955961 if resp.status_code != 200:
956962 logger.debug('image-proxy: wrong response code: {0}'.format(resp.status_code))
957963 if resp.status_code >= 400:
958964 return '', resp.status_code
959965 return '', 400
960966
961− if not resp.headers.get('content-type', '').startswith('image/'):
962− logger.debug('image-proxy: wrong content-type: {0}'.format(resp.headers.get('content-type')))
967+ if not resp.headers.get('Content-Type', '').startswith('image/'):
968+ logger.debug('image-proxy: wrong content-type: %s', resp.headers.get('Content-Type', ''))
963969 return '', 400
964970
965− headers = dict_subset(resp.headers, {'Content-Length', 'Length', 'Date', 'Last-Modified', 'Expires', 'Etag'})
971+ forward_resp = True
972+ except httpx.HTTPError:
973+ logger.exception('HTTP error')
974+ return '', 400
975+ finally:
976+ if resp and not forward_resp:
977+ # the code is about to return an HTTP 400 error to the browser
978+ # we make sure to close the response between searxng and the HTTP server
979+ try:
980+ resp.close()
981+ except httpx.HTTPError:
982+ logger.exception('HTTP error on closing')
966983
967− total_length = 0
984+ try:
985+ headers = dict_subset(
986+ resp.headers,
987+ {'Content-Type', 'Content-Encoding', 'Content-Length', 'Length'}
988+ )
968989
969990 def forward_chunk():
970− nonlocal total_length
991+ total_length = 0
971992 for chunk in stream:
972993 total_length += len(chunk)
973994 if total_length > maximum_size:
@@ -1148,6 +1169,13 @@ def run():
11481169 )
11491170
11501171
1172+def patch_application(app):
1173+ # serve pages with HTTP/1.1
1174+ WSGIRequestHandler.protocol_version = "HTTP/{}".format(settings['server']['http_protocol_version'])
1175+ # patch app to handle non root url-s behind proxy & wsgi
1176+ app.wsgi_app = ReverseProxyPathFix(ProxyFix(app.wsgi_app))
1177+
1178+
11511179class ReverseProxyPathFix:
11521180 '''Wrap the application in this middleware and configure the
11531181 front-end server to add these headers, to let you quietly bind
utils/searx.sh+1−1
@@ -418,7 +418,7 @@ install_settings() {
418418 err_msg "you have to install searx first"
419419 exit 42
420420 fi
421− mkdir -p "$(dirname ${SEARX_SETTINGS_PATH})"
421+ mkdir -p "$(dirname "${SEARX_SETTINGS_PATH}")"
422422
423423 if [[ ! -f ${SEARX_SETTINGS_PATH} ]]; then
424424 info_msg "install settings ${SEARX_SETTINGS_TEMPLATE}"