on master help sign in
29 files changed+539−176
since splitsnapshots
unifiedsplit
.github/workflows/data-update.yml+1−1 .github/workflows/integration.yml+32−3 .gitignore+2 .pylintrc+1−1 Dockerfile+5−4 docs/dev/engine_overview.rst+1−1 manage+4−4 requirements-dev.txt+13−12 requirements.txt+5−5 searx/autocomplete.py+6−6 searx/engines/__init__.py+1−1 searx/engines/command.py+1−1 searx/engines/gigablast.py+24−13 searx/engines/google.py+20−11 searx/engines/prowlarr.py+80 searx/engines/startpage.py+100−20 searx/engines/tineye.py+86 searx/engines/yggtorrent.py+2−2 searx/network/__init__.py+26−27 searx/network/client.py+14−23 searx/network/network.py+8 searx/results.py+8−2 searx/search/checker/impl.py+1−1 searx/search/processors/online.py+4−3 searx/settings.yml+39−8 searx/utils.py+12−6 searx/webapp.py+42−14 tests/unit/test_external_bangs.py−6 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
.gitignore+2
@@ -26,3 +26,5 @@ dist/
2626local/
2727gh-pages/
2828searx.egg-info/
29+.env
30+geckodriver.log
.pylintrc+1−1
@@ -59,7 +59,7 @@ confidence=
5959# --enable=similarities". If you want to run only the classes checker, but have
6060# no Warning level messages displayed, use"--disable=all --enable=classes
6161# --disable=W"
62−disable=bad-whitespace, duplicate-code
62+disable=bad-whitespace, duplicate-code, consider-using-f-string
6363
6464# Enable the message, report, category or checker with the given id(s). You can
6565# either give multiple identifier separated by comma (,) or put this option
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\
docs/dev/engine_overview.rst+1−1
@@ -171,7 +171,7 @@ headers set HTTP header information
171171data set HTTP data information
172172cookies set HTTP cookies
173173verify bool Performing SSL-Validity check
174−allow_redirects bool Follow redirects
174+follow_redirects bool Follow redirects
175175max_redirects int maximum redirects, hard limit
176176soft_max_redirects int maximum redirects, soft limit. Record an error but don't stop the engine
177177raise_for_httperror bool True by default: raise an exception if the HTTP code of response is >= 300
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+13−12
@@ -1,19 +1,20 @@
11mock==4.0.3
22nose2[coverage_plugin]==0.10.0
33cov-core==1.15.0
4−pycodestyle==2.7.0
5−pylint==2.10.2
6−splinter==0.15.0
7−transifex-client==0.14.3
8−selenium==3.141.0
9−twine==3.4.2
10−Pallets-Sphinx-Themes==2.0.1
4+pycodestyle==2.8.0
5+pylint==2.12.2
6+splinter==0.17.0
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−aiounittest==1.4.0
19+linuxdoc==20211220
20+aiounittest==1.4.1
requirements.txt+5−5
@@ -1,16 +1,16 @@
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
10−httpx[http2]==0.19.0
10+httpx[http2]==0.21.3
1111Brotli==1.0.9
1212uvloop==0.16.0; python_version >= '3.7'
1313uvloop==0.14.0; python_version < '3.7'
14−httpx-socks[asyncio]==0.4.1
14+httpx-socks[asyncio]==0.7.2
1515langdetect==1.0.9
1616setproctitle==1.2.2
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/__init__.py+1−1
@@ -275,7 +275,7 @@ def get_engines_stats(preferences):
275275
276276
277277def load_engines(engine_list):
278− global engines, engine_shortcuts
278+ global engines, engine_shortcuts # pylint: disable=global-variable-not-assigned
279279 engines.clear()
280280 engine_shortcuts.clear()
281281 for engine_data in engine_list:
searx/engines/command.py+1−1
@@ -35,7 +35,7 @@ def init(engine_settings):
3535 if 'command' not in engine_settings:
3636 raise ValueError('engine command : missing configuration key: command')
3737
38− global command, working_dir, result_template, delimiter, parse_regex, timeout, environment_variables
38+ global command, working_dir, delimiter, parse_regex, environment_variables
3939
4040 command = engine_settings['command']
4141
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/prowlarr.pynew file+80
@@ -0,0 +1,80 @@
1+# SPDX-License-Identifier: AGPL-3.0-or-later
2+"""
3+
4+ Prowlarr search (Files)
5+
6+"""
7+
8+from json import loads
9+from urllib.parse import urlencode
10+from searx.exceptions import SearxEngineAPIException
11+
12+categories = ''
13+
14+paging = False
15+api_key = ''
16+indexer_ids = ''
17+search_type = 'search'
18+search_categories = ''
19+base_url = ''
20+
21+
22+def request(query, params):
23+ if not base_url:
24+ raise SearxEngineAPIException('missing prowlarr base url')
25+
26+ if not api_key:
27+ raise SearxEngineAPIException('missing prowlarr API key')
28+
29+ query_args = {
30+ 'query': query,
31+ 'apikey': api_key,
32+ 'type': search_type
33+ }
34+
35+ if indexer_ids:
36+ query_args['indexerIds'] = indexer_ids
37+
38+ if search_categories:
39+ query_args['categories'] = search_categories
40+
41+ params['url'] = base_url + urlencode(query_args)
42+
43+ return params
44+
45+
46+def response(resp):
47+ results = []
48+
49+ json_data = loads(resp.text)
50+
51+ for result in json_data:
52+
53+ new_result = {
54+ 'title': result['title'],
55+ 'url': result['infoUrl'],
56+ 'template': 'torrent.html'
57+ }
58+
59+ if 'files' in result:
60+ new_result['files'] = result['files']
61+
62+ if 'size' in result:
63+ new_result['filesize'] = result['size']
64+
65+ if 'seeders' in result:
66+ new_result['seed'] = result['seeders']
67+
68+ if 'leechers' in result:
69+ new_result['leech'] = result['leechers']
70+
71+ if 'downloadUrl' in result:
72+ new_result['torrentfile'] = result['downloadUrl']
73+
74+ # magnet link *may* be in guid, but it may be also idential to infoUrl
75+ if 'guid' in result and isinstance(result['guid'], str) and result['guid'].startswith('magnet'):
76+ new_result['magnetlink'] = result['guid']
77+
78+ results.append(new_result)
79+
80+ return results
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/engines/yggtorrent.py+2−2
@@ -37,9 +37,9 @@ cookies = dict()
3737
3838
3939def init(engine_settings=None):
40− global cookies
40+ global cookies # pylint: disable=global-variable-not-assigned
4141 # initial cookies
42− resp = http_get(url, allow_redirects=False)
42+ resp = http_get(url, follow_redirects=False)
4343 if resp.ok:
4444 for r in resp.history:
4545 cookies.update(r.cookies)
searx/network/__init__.py+26−27
@@ -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
@@ -122,17 +101,17 @@ def request(method, url, **kwargs):
122101
123102
124103def get(url, **kwargs):
125− kwargs.setdefault('allow_redirects', True)
104+ kwargs.setdefault('follow_redirects', True)
126105 return request('get', url, **kwargs)
127106
128107
129108def options(url, **kwargs):
130− kwargs.setdefault('allow_redirects', True)
109+ kwargs.setdefault('follow_redirects', True)
131110 return request('options', url, **kwargs)
132111
133112
134113def head(url, **kwargs):
135− kwargs.setdefault('allow_redirects', False)
114+ kwargs.setdefault('follow_redirects', False)
136115 return request('head', url, **kwargs)
137116
138117
@@ -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+14−23
@@ -3,6 +3,7 @@
33import asyncio
44import logging
55import threading
6+import uvloop
67
78import httpcore
89import httpx
@@ -17,30 +18,20 @@ 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
3124SSLCONTEXTS = {}
3225TRANSPORT_KWARGS = {
33− 'backend': 'asyncio',
3426 'trust_env': False,
3527}
3628
3729
38−async def close_connections_for_url(connection_pool: httpcore.AsyncConnectionPool, url: httpcore._utils.URL):
39− origin = httpcore._utils.url_to_origin(url)
40− logger.debug('Drop connections for %r', origin)
41− connections_to_close = connection_pool._connections_for_origin(origin)
30+async def close_connections_for_url(connection_pool: httpcore.AsyncConnectionPool, url: httpx._models.URL):
31+ logger.debug('Drop connections for %r', url.host)
32+ connections_to_close = [conn for conn in connection_pool._pool if conn._origin == url.host]
4233 for connection in connections_to_close:
43− await connection_pool._remove_from_pool(connection)
34+ connection_pool._pool.remove(connection)
4435 try:
4536 await connection.aclose()
4637 except httpx.NetworkError as e:
@@ -76,12 +67,12 @@ class AsyncProxyTransportFixed(AsyncProxyTransport):
7667 Note: AsyncProxyTransport inherit from AsyncConnectionPool
7768 """
7869
79− async def handle_async_request(self, method, url, headers=None, stream=None, extensions=None):
70+ async def handle_async_request(self, request: httpx.Request):
8071 retry = 2
8172 while retry > 0:
8273 retry -= 1
8374 try:
84− return await super().handle_async_request(method, url, headers, stream, extensions)
75+ return await super().handle_async_request(request)
8576 except (ProxyConnectionError, ProxyTimeoutError, ProxyError) as e:
8677 raise httpx.ProxyError(e)
8778 except OSError as e:
@@ -89,25 +80,25 @@ class AsyncProxyTransportFixed(AsyncProxyTransport):
8980 raise httpx.NetworkError(e)
9081 except httpx.RemoteProtocolError as e:
9182 # in case of httpx.RemoteProtocolError: Server disconnected
92− await close_connections_for_url(self, url)
83+ await close_connections_for_url(self, request.url)
9384 logger.warning('httpx.RemoteProtocolError: retry', exc_info=e)
9485 # retry
9586 except (httpx.NetworkError, httpx.ProtocolError) as e:
9687 # httpx.WriteError on HTTP/2 connection leaves a new opened stream
9788 # then each new request creates a new stream and raise the same WriteError
98− await close_connections_for_url(self, url)
89+ await close_connections_for_url(self, request.url)
9990 raise e
10091
10192
10293class AsyncHTTPTransportFixed(httpx.AsyncHTTPTransport):
10394 """Fix httpx.AsyncHTTPTransport"""
10495
105− async def handle_async_request(self, method, url, headers=None, stream=None, extensions=None):
96+ async def handle_async_request(self, request: httpx.Request):
10697 retry = 2
10798 while retry > 0:
10899 retry -= 1
109100 try:
110− return await super().handle_async_request(method, url, headers, stream, extensions)
101+ return await super().handle_async_request(request)
111102 except OSError as e:
112103 # socket.gaierror when DNS resolution fails
113104 raise httpx.ConnectError(e)
@@ -115,16 +106,16 @@ class AsyncHTTPTransportFixed(httpx.AsyncHTTPTransport):
115106 # httpx.CloseError: [Errno 104] Connection reset by peer
116107 # raised by _keepalive_sweep()
117108 # from https://github.com/encode/httpcore/blob/4b662b5c42378a61e54d673b4c949420102379f5/httpcore/_backends/asyncio.py#L198 # noqa
118− await close_connections_for_url(self._pool, url)
109+ await close_connections_for_url(self._pool, request.url)
119110 logger.warning('httpx.CloseError: retry', exc_info=e)
120111 # retry
121112 except httpx.RemoteProtocolError as e:
122113 # in case of httpx.RemoteProtocolError: Server disconnected
123− await close_connections_for_url(self._pool, url)
114+ await close_connections_for_url(self._pool, request.url)
124115 logger.warning('httpx.RemoteProtocolError: retry', exc_info=e)
125116 # retry
126117 except (httpx.ProtocolError, httpx.NetworkError) as e:
127− await close_connections_for_url(self._pool, url)
118+ await close_connections_for_url(self._pool, request.url)
128119 raise e
129120
130121
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/search/checker/impl.py+1−1
@@ -76,7 +76,7 @@ def _is_url_image(image_url):
7676 a = time()
7777 try:
7878 network.set_timeout_for_thread(10.0, time())
79− r = network.get(image_url, timeout=10.0, allow_redirects=True, headers={
79+ r = network.get(image_url, timeout=10.0, follow_redirects=True, headers={
8080 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:84.0) Gecko/20100101 Firefox/84.0',
8181 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
8282 'Accept-Language': 'en-US;q=0.5,en;q=0.3',
searx/search/processors/online.py+4−3
@@ -69,9 +69,10 @@ class OnlineProcessor(EngineProcessor):
6969 if max_redirects:
7070 request_args['max_redirects'] = max_redirects
7171
72− # allow_redirects
73− if 'allow_redirects' in params:
74− request_args['allow_redirects'] = params['allow_redirects']
72+ # follow_redirects
73+ if 'follow_redirects' in params:
74+ # httpx has renamed this parameter to 'follow_redirects'
75+ request_args['follow_redirects'] = params['follow_redirects']
7576
7677 # soft_max_redirects
7778 soft_max_redirects = params.get('soft_max_redirects', max_redirects or 0)
searx/settings.yml+39−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
@@ -908,6 +919,26 @@ engines:
908919 engine : openstreetmap
909920 shortcut : osm
910921
922+# - name : prowlarr
923+# engine : prowlarr
924+# shortcut : prow
925+# categories : files
926+# enable_http : True
927+# api_key : ''
928+# indexer_ids : 5 # comma separated list of indexer ids
929+# search_categories : '' # comma separated list of categories
930+# search_type : search
931+# base_url: http://localhost:9696/api/v1/search?
932+# timeout : 50.0
933+# disabled : True,
934+# about :
935+# website : https://wiki.servarr.com/prowlarr
936+# wikidata_id : None
937+# official_api_documentation : https://wiki.servarr.com/prowlarr/search
938+# use_official_api : true
939+# require_api_key : true
940+# results : JSON
941+
911942 - name : openrepos
912943 engine : xpath
913944 paging : True
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+42−14
@@ -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,16 +934,23 @@ 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'],
944− allow_redirects=True,
953+ follow_redirects=True,
945954 max_redirects=20)
946955
947956 resp = next(stream)
@@ -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
tests/unit/test_external_bangs.py−6
@@ -72,31 +72,26 @@ class TestResolveBangDefinition(SearxTestCase):
7272class TestGetBangDefinitionAndAutocomplete(SearxTestCase):
7373
7474 def test_found(self):
75− global TEST_DB
7675 bang_definition, new_autocomplete = get_bang_definition_and_autocomplete('exam', external_bangs_db=TEST_DB)
7776 self.assertEqual(bang_definition, TEST_DB['trie']['exam']['*'])
7877 self.assertEqual(new_autocomplete, ['example'])
7978
8079 def test_found_optimized(self):
81− global TEST_DB
8280 bang_definition, new_autocomplete = get_bang_definition_and_autocomplete('example', external_bangs_db=TEST_DB)
8381 self.assertEqual(bang_definition, TEST_DB['trie']['exam']['ple'])
8482 self.assertEqual(new_autocomplete, [])
8583
8684 def test_partial(self):
87− global TEST_DB
8885 bang_definition, new_autocomplete = get_bang_definition_and_autocomplete('examp', external_bangs_db=TEST_DB)
8986 self.assertEqual(bang_definition, None)
9087 self.assertEqual(new_autocomplete, ['example'])
9188
9289 def test_partial2(self):
93− global TEST_DB
9490 bang_definition, new_autocomplete = get_bang_definition_and_autocomplete('sea', external_bangs_db=TEST_DB)
9591 self.assertEqual(bang_definition, TEST_DB['trie']['sea']['*'])
9692 self.assertEqual(new_autocomplete, ['search', 'searching', 'seascapes', 'season'])
9793
9894 def test_error(self):
99− global TEST_DB
10095 bang_definition, new_autocomplete = get_bang_definition_and_autocomplete('error', external_bangs_db=TEST_DB)
10196 self.assertEqual(bang_definition, None)
10297 self.assertEqual(new_autocomplete, [])
@@ -114,7 +109,6 @@ class TestExternalBangJson(SearxTestCase):
114109 self.assertEqual(result, None)
115110
116111 def test_get_bang_url(self):
117− global TEST_DB
118112 url = get_bang_url(SearchQuery('test', engineref_list=[], external_bang='example'), external_bangs_db=TEST_DB)
119113 self.assertEqual(url, 'https://example.com/test')
120114
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}"