on master help sign in
.github/workflows/data-update.yml+1−1
since splitsnapshots
unifiedsplit
@@ -29,7 +29,7 @@ jobs:
29 - name: Set up Python
29 - name: Set up Python
30 uses: actions/setup-python@v2
30 uses: actions/setup-python@v2
31 with:
31 with:
32− python-version: '3.9'
32+ python-version: '3.10'
33 architecture: 'x64'
33 architecture: 'x64'
34
34
35 - name: Install Python dependencies
35 - name: Install Python dependencies
.github/workflows/integration.yml+32−3
@@ -13,7 +13,7 @@ jobs:
13 strategy:
13 strategy:
14 matrix:
14 matrix:
15 os: [ubuntu-20.04]
15 os: [ubuntu-20.04]
16− python-version: [3.7, 3.8, 3.9]
16+ python-version: [3.7, 3.8, 3.9, "3.10"]
17 steps:
17 steps:
18 - name: Checkout
18 - name: Checkout
19 uses: actions/checkout@v2
19 uses: actions/checkout@v2
@@ -56,6 +56,17 @@ jobs:
56 uses: actions/checkout@v2
56 uses: actions/checkout@v2
57 - name: Install Ubuntu packages
57 - name: Install Ubuntu packages
58 run: sudo ./utils/searx.sh install packages
58 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') }}
59 - name: Install node dependencies
70 - name: Install node dependencies
60 run: make V=1 node.env
71 run: make V=1 node.env
61 - name: Build themes
72 - name: Build themes
@@ -68,14 +79,21 @@ jobs:
68 - name: Checkout
79 - name: Checkout
69 uses: actions/checkout@v2
80 uses: actions/checkout@v2
70 with:
81 with:
82+ fetch-depth: '0'
71 persist-credentials: false
83 persist-credentials: false
72 - name: Install Ubuntu packages
84 - name: Install Ubuntu packages
73 run: sudo ./utils/searx.sh install buildhost
85 run: sudo ./utils/searx.sh install buildhost
74 - name: Set up Python
86 - name: Set up Python
75 uses: actions/setup-python@v2
87 uses: actions/setup-python@v2
76 with:
88 with:
77− python-version: '3.9'
89+ python-version: '3.10'
78 architecture: 'x64'
90 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') }}
79 - name: Build documentation
97 - name: Build documentation
80 run: |
98 run: |
81 make V=1 docs.clean docs.html
99 make V=1 docs.clean docs.html
@@ -97,7 +115,7 @@ jobs:
97 - documentation
115 - documentation
98 env:
116 env:
99 DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
117 DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
100− runs-on: ubuntu-18.04
118+ runs-on: ubuntu-20.04
101 steps:
119 steps:
102 - name: Checkout
120 - name: Checkout
103 if: env.DOCKERHUB_USERNAME != null
121 if: env.DOCKERHUB_USERNAME != null
@@ -105,6 +123,17 @@ jobs:
105 with:
123 with:
106 # make sure "make docker.push" can get the git history
124 # make sure "make docker.push" can get the git history
107 fetch-depth: '0'
125 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') }}
108 - name: Set up QEMU
137 - name: Set up QEMU
109 if: env.DOCKERHUB_USERNAME != null
138 if: env.DOCKERHUB_USERNAME != null
110 uses: docker/setup-qemu-action@v1
139 uses: docker/setup-qemu-action@v1
Dockerfile+3−2
@@ -51,13 +51,14 @@ RUN apk upgrade --no-cache \
51 && apk del build-dependencies \
51 && apk del build-dependencies \
52 && rm -rf /root/.cache
52 && rm -rf /root/.cache
53
53
54−COPY --chown=searx:searx . .
54+COPY searx ./searx
55+COPY dockerfiles ./dockerfiles
55
56
56ARG TIMESTAMP_SETTINGS=0
57ARG TIMESTAMP_SETTINGS=0
57ARG TIMESTAMP_UWSGI=0
58ARG TIMESTAMP_UWSGI=0
58ARG VERSION_GITCOMMIT=unknown
59ARG VERSION_GITCOMMIT=unknown
59
60
60−RUN su searx -c "/usr/bin/python3 -m compileall -q searx"; \
61+RUN /usr/bin/python3 -m compileall -q searx; \
61 touch -c --date=@${TIMESTAMP_SETTINGS} searx/settings.yml; \
62 touch -c --date=@${TIMESTAMP_SETTINGS} searx/settings.yml; \
62 touch -c --date=@${TIMESTAMP_UWSGI} dockerfiles/uwsgi.ini; \
63 touch -c --date=@${TIMESTAMP_UWSGI} dockerfiles/uwsgi.ini; \
63 if [ ! -z $VERSION_GITCOMMIT ]; then\
64 if [ ! -z $VERSION_GITCOMMIT ]; then\
manage+4−4
@@ -13,7 +13,7 @@ source_dot_config
13
13
14PY_SETUP_EXTRAS='[test]'
14PY_SETUP_EXTRAS='[test]'
15NPM_PACKAGES="less@2.7 less-plugin-clean-css grunt-cli"
15NPM_PACKAGES="less@2.7 less-plugin-clean-css grunt-cli"
16−GECKODRIVER_VERSION="v0.28.0"
16+GECKODRIVER_VERSION="v0.30.0"
17# SPHINXOPTS=
17# SPHINXOPTS=
18
18
19# These py files are linted by test.pylint(), all other files are linted by
19# These py files are linted by test.pylint(), all other files are linted by
@@ -224,7 +224,7 @@ docker.build() {
224
224
225 # build Docker image
225 # build Docker image
226 build_msg DOCKER "Building image ${SEARX_IMAGE_NAME}:${SEARX_GIT_VERSION}"
226 build_msg DOCKER "Building image ${SEARX_IMAGE_NAME}:${SEARX_GIT_VERSION}"
227− sudo docker build \
227+ docker build \
228 --build-arg GIT_URL="${GIT_URL}" \
228 --build-arg GIT_URL="${GIT_URL}" \
229 --build-arg SEARX_GIT_VERSION="${SEARX_GIT_VERSION}" \
229 --build-arg SEARX_GIT_VERSION="${SEARX_GIT_VERSION}" \
230 --build-arg VERSION_GITCOMMIT="${VERSION_GITCOMMIT}" \
230 --build-arg VERSION_GITCOMMIT="${VERSION_GITCOMMIT}" \
@@ -236,8 +236,8 @@ docker.build() {
236 -t "${SEARX_IMAGE_NAME}:latest" -t "${SEARX_IMAGE_NAME}:${SEARX_GIT_VERSION}" .
236 -t "${SEARX_IMAGE_NAME}:latest" -t "${SEARX_IMAGE_NAME}:${SEARX_GIT_VERSION}" .
237
237
238 if [ "$1" = "push" ]; then
238 if [ "$1" = "push" ]; then
239− sudo docker push "${SEARX_IMAGE_NAME}:latest"
239+ docker push "${SEARX_IMAGE_NAME}:latest"
240− sudo docker push "${SEARX_IMAGE_NAME}:${SEARX_GIT_VERSION}"
240+ docker push "${SEARX_IMAGE_NAME}:${SEARX_GIT_VERSION}"
241 fi
241 fi
242 )
242 )
243 dump_return $?
243 dump_return $?
requirements-dev.txt+5−4
@@ -4,16 +4,17 @@ cov-core==1.15.0
4pycodestyle==2.8.0
4pycodestyle==2.8.0
5pylint==2.12.2
5pylint==2.12.2
6splinter==0.17.0
6splinter==0.17.0
7−transifex-client==0.14.3
7+transifex-client==0.14.3; python_version < '3.10'
8+transifex-client==0.12.1; python_version == '3.10'
8selenium==4.1.0
9selenium==4.1.0
9twine==3.7.1
10twine==3.7.1
10−Pallets-Sphinx-Themes==2.0.1
11+Pallets-Sphinx-Themes==2.0.2
11docutils==0.16
12docutils==0.16
12−Sphinx==4.3.2
13+Sphinx==4.4.0
13sphinx-issues==3.0.1
14sphinx-issues==3.0.1
14sphinx-jinja==1.4.0
15sphinx-jinja==1.4.0
15sphinx-tabs==3.2.0
16sphinx-tabs==3.2.0
16sphinxcontrib-programoutput==0.17
17sphinxcontrib-programoutput==0.17
17sphinx-autobuild==2021.3.14
18sphinx-autobuild==2021.3.14
18−linuxdoc==20210324
19+linuxdoc==20211220
19aiounittest==1.4.1
20aiounittest==1.4.1
requirements.txt+1−1
@@ -1,7 +1,7 @@
1certifi==2021.10.8
1certifi==2021.10.8
2babel==2.9.1
2babel==2.9.1
3flask-babel==2.0.0
3flask-babel==2.0.0
4−flask==1.1.2
4+flask==2.0.2
5jinja2==3.0.3
5jinja2==3.0.3
6lxml==4.7.1
6lxml==4.7.1
7pygments==2.8.0
7pygments==2.8.0
searx/autocomplete.py+6−6
@@ -24,6 +24,7 @@ from httpx import HTTPError
24
24
25
25
26from searx import settings
26from searx import settings
27+from searx.data import ENGINES_LANGUAGES
27from searx.network import get as http_get
28from searx.network import get as http_get
28from searx.exceptions import SearxEngineResponseException
29from searx.exceptions import SearxEngineResponseException
29
30
@@ -93,12 +94,11 @@ def google(query, lang):
93
94
94def startpage(query, lang):
95def startpage(query, lang):
95 # startpage autocompleter
96 # startpage autocompleter
97+ lui = ENGINES_LANGUAGES['startpage'].get(lang, 'english')
96− url = 'https://startpage.com/do/suggest?{query}'
98+ url = 'https://startpage.com/suggestions?{query}'
97−
98− resp = get(url.format(query=urlencode({'query': query}))).text.split('\n')
99+ resp = get(url.format(query=urlencode({'q': query, 'segment': 'startpage.udog', 'lui': lui})))
99− if len(resp) > 1:
100+ data = resp.json()
100− return resp
101+ return [e['text'] for e in data.get('suggestions', []) if 'text' in e]
101− return []
102
102
103
103
104def swisscows(query, lang):
104def swisscows(query, lang):
searx/engines/gigablast.py+24−13
@@ -5,10 +5,10 @@
5# pylint: disable=missing-function-docstring, invalid-name
5# pylint: disable=missing-function-docstring, invalid-name
6
6
7import re
7import re
8−from json import loads
8+from json import loads, JSONDecodeError
9from urllib.parse import urlencode
9from urllib.parse import urlencode
10−# from searx import logger
11from searx.network import get
10from searx.network import get
11+from searx.exceptions import SearxEngineResponseException
12
12
13# about
13# about
14about = {
14about = {
@@ -22,6 +22,9 @@ about = {
22
22
23# engine dependent config
23# engine dependent config
24categories = ['general']
24categories = ['general']
25+collections = 'main'
26+search_type = ''
27+fast = 0
25# gigablast's pagination is totally damaged, don't use it
28# gigablast's pagination is totally damaged, don't use it
26paging = False
29paging = False
27safesearch = True
30safesearch = True
@@ -34,6 +37,8 @@ base_url = 'https://gigablast.com'
34extra_param = ''
37extra_param = ''
35extra_param_path='/search?c=main&qlangcountry=en-us&q=south&s=10'
38extra_param_path='/search?c=main&qlangcountry=en-us&q=south&s=10'
36
39
40+_wait_for_results_msg = 'Loading results takes too long. Please enable fast option in gigablast engine.'
41+
37def parse_extra_param(text):
42def parse_extra_param(text):
38
43
39 # example:
44 # example:
@@ -54,7 +59,6 @@ def parse_extra_param(text):
54 if re_var is not None and re_var.search(line):
59 if re_var is not None and re_var.search(line):
55 extra_param += re_var.search(line).group(1)
60 extra_param += re_var.search(line).group(1)
56 break
61 break
57− # logger.debug('gigablast extra_param="%s"', extra_param)
58
62
59def init(engine_settings=None): # pylint: disable=unused-argument
63def init(engine_settings=None): # pylint: disable=unused-argument
60 parse_extra_param(get(base_url + extra_param_path).text)
64 parse_extra_param(get(base_url + extra_param_path).text)
@@ -65,14 +69,17 @@ def request(query, params): # pylint: disable=unused-argument
65
69
66 # see API http://www.gigablast.com/api.html#/search
70 # see API http://www.gigablast.com/api.html#/search
67 # Take into account, that the API has some quirks ..
71 # Take into account, that the API has some quirks ..
68−
69− query_args = dict(
72+ query_args = {
70− c = 'main'
73+ 'c': collections,
71− , format = 'json'
74+ 'format': 'json',
72− , q = query
75+ 'q': query,
73− , dr = 1
76+ 'dr': 1 ,
74− , showgoodimages = 0
77+ 'showgoodimages': 0,
75− )
78+ 'fast': fast,
79+ }
80+
81+ if search_type != '':
82+ query_args['searchtype'] = search_type
76
83
77 if params['language'] and params['language'] != 'all':
84 if params['language'] and params['language'] != 'all':
78 query_args['qlangcountry'] = params['language']
85 query_args['qlangcountry'] = params['language']
@@ -90,9 +97,13 @@ def request(query, params): # pylint: disable=unused-argument
90def response(resp):
97def response(resp):
91 results = []
98 results = []
92
99
100+ try:
93− response_json = loads(resp.text)
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
94
106
95− # logger.debug('gigablast returns %s results', len(response_json['results']))
96
107
97 for result in response_json['results']:
108 for result in response_json['results']:
98 # see "Example JSON Output (&format=json)"
109 # see "Example JSON Output (&format=json)"
searx/engines/google.py+20−11
@@ -8,7 +8,7 @@ Definitions`_.
8 https://developers.google.com/custom-search/docs/xml_results#WebSearch_Query_Parameter_Definitions
8 https://developers.google.com/custom-search/docs/xml_results#WebSearch_Query_Parameter_Definitions
9"""
9"""
10
10
11−# pylint: disable=invalid-name, missing-function-docstring
11+# pylint: disable=invalid-name, missing-function-docstring, too-many-branches
12
12
13from urllib.parse import urlencode
13from urllib.parse import urlencode
14from lxml import html
14from lxml import html
@@ -108,8 +108,9 @@ filter_mapping = {
108# specific xpath variables
108# specific xpath variables
109# ------------------------
109# ------------------------
110
110
111−# google results are grouped into <div class="g" ../>
111+# google results are grouped into <div class="g ..." ../>
112−results_xpath = '//div[@class="g"]'
112+results_xpath = '//div[@id="search"]//div[contains(@class, "g ")]'
113+results_xpath_mobile_ui = '//div[contains(@class, "g ")]'
113
114
114# google *sections* are no usual *results*, we ignore them
115# google *sections* are no usual *results*, we ignore them
115g_section_with_header = './g-section-with-header'
116g_section_with_header = './g-section-with-header'
@@ -121,8 +122,8 @@ title_xpath = './/h3[1]'
121# href=...>
122# href=...>
122href_xpath = './/div[@class="yuRUbf"]//a/@href'
123href_xpath = './/div[@class="yuRUbf"]//a/@href'
123
124
124−# in the result group there is <div class="IsZvec" ../> containing he *content*
125+# in the result group there is <div class="VwiC3b ..." ../> containing the *content*
125−content_xpath = './/div[@class="IsZvec"]'
126+content_xpath = './/div[contains(@class, "VwiC3b")]'
126
127
127# Suggestions are links placed in a *card-section*, we extract only the text
128# Suggestions are links placed in a *card-section*, we extract only the text
128# from the links not the links itself.
129# from the links not the links itself.
@@ -274,7 +275,12 @@ def response(resp):
274 logger.error(e, exc_info=True)
275 logger.error(e, exc_info=True)
275
276
276 # parse results
277 # parse results
278+
279+ _results_xpath = results_xpath
280+ if use_mobile_ui:
281+ _results_xpath = results_xpath_mobile_ui
282+
277− for result in eval_xpath_list(dom, results_xpath):
283+ for result in eval_xpath_list(dom, _results_xpath):
278
284
279 # google *sections*
285 # google *sections*
280 if extract_text(eval_xpath(result, g_section_with_header)):
286 if extract_text(eval_xpath(result, g_section_with_header)):
@@ -285,24 +291,27 @@ def response(resp):
285 title_tag = eval_xpath_getindex(result, title_xpath, 0, default=None)
291 title_tag = eval_xpath_getindex(result, title_xpath, 0, default=None)
286 if title_tag is None:
292 if title_tag is None:
287 # this not one of the common google results *section*
293 # 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')
289 continue
295 continue
290 title = extract_text(title_tag)
296 title = extract_text(title_tag)
291 url = eval_xpath_getindex(result, href_xpath, 0, None)
297 url = eval_xpath_getindex(result, href_xpath, 0, None)
292 if url is None:
298 if url is None:
293 continue
299 continue
294 content = extract_text(eval_xpath_getindex(result, content_xpath, 0, default=None), allow_none=True)
300 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+
295 results.append({
307 results.append({
296 'url': url,
308 'url': url,
297 'title': title,
309 'title': title,
298 'content': content
310 'content': content
299 })
311 })
312+
300 except Exception as e: # pylint: disable=broad-except
313 except Exception as e: # pylint: disable=broad-except
301 logger.error(e, exc_info=True)
314 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()
306 continue
315 continue
307
316
308 # parse suggestion
317 # parse suggestion
searx/engines/startpage.py+100−20
@@ -1,16 +1,30 @@
1# SPDX-License-Identifier: AGPL-3.0-or-later
1# SPDX-License-Identifier: AGPL-3.0-or-later
2−"""
2+# lint: pylint
3− Startpage (Web)
3+"""Startpage (Web)
4+
4"""
5"""
5
6
6−from lxml import html
7−from dateutil import parser
8−from datetime import datetime, timedelta
9import re
7import re
8+from time import time
9+
10+from urllib.parse import urlencode
10from unicodedata import normalize, combining
11from unicodedata import normalize, combining
12+from datetime import datetime, timedelta
13+
14+from dateutil import parser
15+from lxml import html
11from babel import Locale
16from babel import Locale
12from babel.localedata import locale_identifiers
17from babel.localedata import locale_identifiers
18+
19+from searx import logger
20+from searx.network import get
13from searx.utils import extract_text, eval_xpath, match_language
21from 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')
14
28
15# about
29# about
16about = {
30about = {
@@ -33,7 +47,7 @@ supported_languages_url = 'https://www.startpage.com/do/settings'
33
47
34# search-url
48# search-url
35base_url = 'https://startpage.com/'
49base_url = 'https://startpage.com/'
36−search_url = base_url + 'do/search'
50+search_url = base_url + 'sp/search?'
37
51
38# specific xpath variables
52# specific xpath variables
39# ads xpath //div[@id="results"]/div[@id="sponsored"]//div[@class="result"]
53# ads xpath //div[@id="results"]/div[@id="sponsored"]//div[@class="result"]
@@ -42,18 +56,74 @@ results_xpath = '//div[@class="w-gl__result__main"]'
42link_xpath = './/a[@class="w-gl__result-title result-link"]'
56link_xpath = './/a[@class="w-gl__result-title result-link"]'
43content_xpath = './/p[@class="w-gl__description"]'
57content_xpath = './/p[@class="w-gl__description"]'
44
58
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+
45
108
46# do search-request
109# do search-request
47def request(query, params):
110def request(query, params):
48
111
49− params['url'] = search_url
112+ # pylint: disable=line-too-long
50− params['method'] = 'POST'
113+ # The format string from Startpage's FFox add-on [1]::
51− params['data'] = {
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 = {
52 'query': query,
120 'query': query,
53 'page': params['pageno'],
121 'page': params['pageno'],
54 'cat': 'web',
122 'cat': 'web',
55− 'cmd': 'process_search',
123+ # 'pl': 'ext-ff',
56− 'engine0': 'v1all',
124+ # 'extVersion': '1.3.0',
125+ # 'abp': "-1",
126+ 'sc': get_sc_code(params['headers']),
57 }
127 }
58
128
59 # set language if specified
129 # set language if specified
@@ -61,9 +131,10 @@ def request(query, params):
61 lang_code = match_language(params['language'], supported_languages, fallback=None)
131 lang_code = match_language(params['language'], supported_languages, fallback=None)
62 if lang_code:
132 if lang_code:
63 language_name = supported_languages[lang_code]['alias']
133 language_name = supported_languages[lang_code]['alias']
64− params['data']['language'] = language_name
134+ args['language'] = language_name
65− params['data']['lui'] = language_name
135+ args['lui'] = language_name
66
136
137+ params['url'] = search_url + urlencode(args)
67 return params
138 return params
68
139
69
140
@@ -139,10 +210,11 @@ def response(resp):
139
210
140# get supported languages from their site
211# get supported languages from their site
141def _fetch_supported_languages(resp):
212def _fetch_supported_languages(resp):
142− # startpage's language selector is a mess
213+ # startpage's language selector is a mess each option has a displayed name
143− # each option has a displayed name and a value, either of which may represent the language name
214+ # and a value, either of which may represent the language name in the native
144− # in the native script, the language name in English, an English transliteration of the native name,
215+ # script, the language name in English, an English transliteration of the
145− # the English name of the writing script used by the language, or occasionally something else entirely.
216+ # native name, the English name of the writing script used by the language,
217+ # or occasionally something else entirely.
146
218
147 # this cases are so special they need to be hardcoded, a couple of them are mispellings
219 # this cases are so special they need to be hardcoded, a couple of them are mispellings
148 language_names = {
220 language_names = {
@@ -156,7 +228,15 @@ def _fetch_supported_languages(resp):
156 }
228 }
157
229
158 # get the English name of every language known by babel
230 # get the English name of every language known by babel
231+ language_names.update(
232+ {
233+ # fmt: off
234+ name.lower(): lang_code
235+ # pylint: disable=protected-access
159− language_names.update({name.lower(): lang_code for lang_code, name in Locale('en')._data['languages'].items()})
236+ for lang_code, name in Locale('en')._data['languages'].items()
237+ # fmt: on
238+ }
239+ )
160
240
161 # get the native name of every language known by babel
241 # get the native name of every language known by babel
162 for lang_code in filter(lambda lang_code: lang_code.find('_') == -1, locale_identifiers()):
242 for lang_code in filter(lambda lang_code: lang_code.find('_') == -1, locale_identifiers()):
@@ -181,8 +261,8 @@ def _fetch_supported_languages(resp):
181 if isinstance(lang_code, str):
261 if isinstance(lang_code, str):
182 supported_languages[lang_code] = {'alias': sp_option_value}
262 supported_languages[lang_code] = {'alias': sp_option_value}
183 elif isinstance(lang_code, list):
263 elif isinstance(lang_code, list):
184− for lc in lang_code:
264+ for _lc in lang_code:
185− supported_languages[lc] = {'alias': sp_option_value}
265+ supported_languages[_lc] = {'alias': sp_option_value}
186 else:
266 else:
187 print('Unknown language option in Startpage: {} ({})'.format(sp_option_value, sp_option_text))
267 print('Unknown language option in Startpage: {} ({})'.format(sp_option_value, sp_option_text))
188
268
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+22−1
@@ -5,6 +5,7 @@ import threading
5import concurrent.futures
5import concurrent.futures
6from time import time
6from time import time
7from queue import SimpleQueue
7from queue import SimpleQueue
8+from types import MethodType
8
9
9import httpx
10import httpx
10import h2.exceptions
11import h2.exceptions
@@ -134,15 +135,27 @@ async def stream_chunk_to_queue(network, q, method, url, **kwargs):
134 try:
135 try:
135 async with await network.stream(method, url, **kwargs) as response:
136 async with await network.stream(method, url, **kwargs) as response:
136 q.put(response)
137 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
137 async for chunk in response.aiter_bytes(65536):
140 async for chunk in response.aiter_bytes(65536):
138 if len(chunk) > 0:
141 if len(chunk) > 0:
139 q.put(chunk)
142 q.put(chunk)
143+ except httpx.ResponseClosed as e:
144+ # the response was closed
145+ pass
140 except (httpx.HTTPError, OSError, h2.exceptions.ProtocolError) as e:
146 except (httpx.HTTPError, OSError, h2.exceptions.ProtocolError) as e:
141 q.put(e)
147 q.put(e)
142 finally:
148 finally:
143 q.put(None)
149 q.put(None)
144
150
145
151
152+def _close_response_method(self):
153+ asyncio.run_coroutine_threadsafe(
154+ self.aclose(),
155+ get_loop()
156+ )
157+
158+
146def stream(method, url, **kwargs):
159def stream(method, url, **kwargs):
147 """Replace httpx.stream.
160 """Replace httpx.stream.
148
161
@@ -158,10 +171,18 @@ def stream(method, url, **kwargs):
158 q = SimpleQueue()
171 q = SimpleQueue()
159 future = asyncio.run_coroutine_threadsafe(stream_chunk_to_queue(get_network(), q, method, url, **kwargs),
172 future = asyncio.run_coroutine_threadsafe(stream_chunk_to_queue(get_network(), q, method, url, **kwargs),
160 get_loop())
173 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
161 chunk_or_exception = q.get()
182 chunk_or_exception = q.get()
162 while chunk_or_exception is not None:
183 while chunk_or_exception is not None:
163 if isinstance(chunk_or_exception, Exception):
184 if isinstance(chunk_or_exception, Exception):
164 raise chunk_or_exception
185 raise chunk_or_exception
165 yield chunk_or_exception
186 yield chunk_or_exception
166 chunk_or_exception = q.get()
187 chunk_or_exception = q.get()
167− return future.result()
188+ future.result()
searx/network/network.py+8
@@ -326,6 +326,14 @@ def initialize(settings_engines=None, settings_outgoing=None):
326 if isinstance(network, str):
326 if isinstance(network, str):
327 NETWORKS[engine_name] = NETWORKS[network]
327 NETWORKS[engine_name] = NETWORKS[network]
328
328
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+
329
337
330@atexit.register
338@atexit.register
331def done():
339def done():
searx/results.py+8−2
@@ -6,6 +6,7 @@ from urllib.parse import urlparse, unquote
6from searx import logger
6from searx import logger
7from searx.engines import engines
7from searx.engines import engines
8from searx.metrology.error_recorder import record_error
8from searx.metrology.error_recorder import record_error
9+from searx.utils import add_scheme_to_url
9from searx import settings
10from searx import settings
10
11
11
12
@@ -240,10 +241,15 @@ class ResultContainer:
240 result['parsed_url'] = urlparse(result['url'])
241 result['parsed_url'] = urlparse(result['url'])
241
242
242 # if the result has no scheme, use http as default
243 # if the result has no scheme, use http as default
243− if not result['parsed_url'].scheme:
244+ if not result['parsed_url'].scheme or result['parsed_url'].scheme == '':
244− result['parsed_url'] = result['parsed_url']._replace(scheme="http")
245+ result['parsed_url'] = result['parsed_url']._replace(scheme='http')
245 result['url'] = result['parsed_url'].geturl()
246 result['url'] = result['parsed_url'].geturl()
246
247
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+
247 result['engines'] = set([result['engine']])
253 result['engines'] = set([result['engine']])
248
254
249 # strip multiple spaces and cariage returns from content
255 # strip multiple spaces and cariage returns from content
searx/settings.yml+19−8
@@ -410,6 +410,13 @@ engines:
410 timeout: 3.0
410 timeout: 3.0
411 disabled : True
411 disabled : True
412
412
413+ - name : tineye
414+ engine : tineye
415+ shortcut : tin
416+ paging : True
417+ timeout : 9.0
418+ disabled : True
419+
413 - name : etools
420 - name : etools
414 engine : etools
421 engine : etools
415 shortcut : eto
422 shortcut : eto
@@ -498,6 +505,13 @@ engines:
498 engine : gigablast
505 engine : gigablast
499 shortcut : gb
506 shortcut : gb
500 timeout : 3.0
507 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
501 disabled: True
515 disabled: True
502 additional_tests:
516 additional_tests:
503 rosebud: *test_rosebud
517 rosebud: *test_rosebud
@@ -683,22 +697,19 @@ engines:
683
697
684 - name : hoogle
698 - name : hoogle
685 engine : xpath
699 engine : xpath
686− paging : True
687 search_url : https://hoogle.haskell.org/?hoogle={query}&start={pageno}
700 search_url : https://hoogle.haskell.org/?hoogle={query}&start={pageno}
688 results_xpath : '//div[@class="result"]'
701 results_xpath : '//div[@class="result"]'
689− title_xpath : './/div[@class="ans"]//a'
702+ title_xpath : './div[@class="ans"]'
690− url_xpath : './/div[@class="ans"]//a/@href'
703+ url_xpath : './div[@class="ans"]//a/@href'
691− content_xpath : './/div[@class="from"]'
704+ content_xpath : './div[contains(@class, "doc")]'
692− page_size : 20
693 categories : it
705 categories : it
694 shortcut : ho
706 shortcut : ho
695 about:
707 about:
696− website: https://www.haskell.org/
708+ website: https://hoogle.haskell.org/
697 wikidata_id: Q34010
709 wikidata_id: Q34010
698− official_api_documentation: https://hackage.haskell.org/api
699 use_official_api: false
710 use_official_api: false
700 require_api_key: false
711 require_api_key: false
701− results: JSON
712+ results: HTML
702
713
703 - name : imdb
714 - name : imdb
704 engine : imdb
715 engine : imdb
searx/utils.py+12−6
@@ -7,7 +7,7 @@ from numbers import Number
7from os.path import splitext, join
7from os.path import splitext, join
8from random import choice
8from random import choice
9from html.parser import HTMLParser
9from html.parser import HTMLParser
10−from urllib.parse import urljoin, urlparse
10+from urllib.parse import urljoin, urlparse, urlunparse
11
11
12from lxml import html
12from lxml import html
13from lxml.etree import ElementBase, XPath, XPathError, XPathSyntaxError, _ElementStringResult, _ElementUnicodeResult
13from lxml.etree import ElementBase, XPath, XPathError, XPathSyntaxError, _ElementStringResult, _ElementUnicodeResult
@@ -214,6 +214,16 @@ def normalize_url(url, base_url):
214 return url
214 return url
215
215
216
216
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+
217def extract_url(xpath_results, base_url):
227def extract_url(xpath_results, base_url):
218 """Extract and normalize URL from lxml Element
228 """Extract and normalize URL from lxml Element
219
229
@@ -262,11 +272,7 @@ def dict_subset(d, properties):
262 >>> >> dict_subset({'A': 'a', 'B': 'b', 'C': 'c'}, ['A', 'D'])
272 >>> >> dict_subset({'A': 'a', 'B': 'b', 'C': 'c'}, ['A', 'D'])
263 {'A': 'a'}
273 {'A': 'a'}
264 """
274 """
265− result = {}
266− for k in properties:
275+ return {k: d[k] for k in properties if k in d}
267− if k in d:
268− result[k] = d[k]
269− return result
270
276
271
277
272def get_torrent_size(filesize, filesize_multiplier):
278def get_torrent_size(filesize, filesize_multiplier):
searx/webapp.py+41−13
@@ -94,7 +94,7 @@ from searx.plugins import plugins
94from searx.plugins.oa_doi_rewrite import get_doi_resolver
94from searx.plugins.oa_doi_rewrite import get_doi_resolver
95from searx.preferences import Preferences, ValidationException, LANGUAGE_CODES
95from searx.preferences import Preferences, ValidationException, LANGUAGE_CODES
96from searx.answerers import answerers
96from 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
98from searx.answerers import ask
98from searx.answerers import ask
99from searx.metrology.error_recorder import errors_per_engines
99from searx.metrology.error_recorder import errors_per_engines
100from searx.settings_loader import get_default_settings_path
100from searx.settings_loader import get_default_settings_path
@@ -921,6 +921,8 @@ def _is_selected_language_supported(engine, preferences):
921
921
922@app.route('/image_proxy', methods=['GET'])
922@app.route('/image_proxy', methods=['GET'])
923def image_proxy():
923def image_proxy():
924+ # pylint: disable=too-many-return-statements, too-many-branches
925+
924 url = request.args.get('url')
926 url = request.args.get('url')
925
927
926 if not url:
928 if not url:
@@ -932,14 +934,21 @@ def image_proxy():
932 return '', 400
934 return '', 400
933
935
934 maximum_size = 5 * 1024 * 1024
936 maximum_size = 5 * 1024 * 1024
935−
937+ forward_resp = False
938+ resp = None
936 try:
939 try:
937− headers = dict_subset(request.headers, {'If-Modified-Since', 'If-None-Match'})
940+ request_headers = {
938− headers['User-Agent'] = gen_useragent()
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')
939 stream = http_stream(
948 stream = http_stream(
940 method='GET',
949 method='GET',
941 url=url,
950 url=url,
942− headers=headers,
951+ headers=request_headers,
943 timeout=settings['outgoing']['request_timeout'],
952 timeout=settings['outgoing']['request_timeout'],
944 follow_redirects=True,
953 follow_redirects=True,
945 max_redirects=20)
954 max_redirects=20)
@@ -949,25 +958,37 @@ def image_proxy():
949 if content_length and content_length.isdigit() and int(content_length) > maximum_size:
958 if content_length and content_length.isdigit() and int(content_length) > maximum_size:
950 return 'Max size', 400
959 return 'Max size', 400
951
960
952− if resp.status_code == 304:
953− return '', resp.status_code
954−
955 if resp.status_code != 200:
961 if resp.status_code != 200:
956 logger.debug('image-proxy: wrong response code: {0}'.format(resp.status_code))
962 logger.debug('image-proxy: wrong response code: {0}'.format(resp.status_code))
957 if resp.status_code >= 400:
963 if resp.status_code >= 400:
958 return '', resp.status_code
964 return '', resp.status_code
959 return '', 400
965 return '', 400
960
966
961− if not resp.headers.get('content-type', '').startswith('image/'):
967+ if not resp.headers.get('Content-Type', '').startswith('image/'):
962− logger.debug('image-proxy: wrong content-type: {0}'.format(resp.headers.get('content-type')))
968+ logger.debug('image-proxy: wrong content-type: %s', resp.headers.get('Content-Type', ''))
963 return '', 400
969 return '', 400
964
970
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')
966
983
967− total_length = 0
984+ try:
985+ headers = dict_subset(
986+ resp.headers,
987+ {'Content-Type', 'Content-Encoding', 'Content-Length', 'Length'}
988+ )
968
989
969 def forward_chunk():
990 def forward_chunk():
970− nonlocal total_length
991+ total_length = 0
971 for chunk in stream:
992 for chunk in stream:
972 total_length += len(chunk)
993 total_length += len(chunk)
973 if total_length > maximum_size:
994 if total_length > maximum_size:
@@ -1148,6 +1169,13 @@ def run():
1148 )
1169 )
1149
1170
1150
1171
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+
1151class ReverseProxyPathFix:
1179class ReverseProxyPathFix:
1152 '''Wrap the application in this middleware and configure the
1180 '''Wrap the application in this middleware and configure the
1153 front-end server to add these headers, to let you quietly bind
1181 front-end server to add these headers, to let you quietly bind
utils/searx.sh+1−1
@@ -418,7 +418,7 @@ install_settings() {
418 err_msg "you have to install searx first"
418 err_msg "you have to install searx first"
419 exit 42
419 exit 42
420 fi
420 fi
421− mkdir -p "$(dirname ${SEARX_SETTINGS_PATH})"
421+ mkdir -p "$(dirname "${SEARX_SETTINGS_PATH}")"
422
422
423 if [[ ! -f ${SEARX_SETTINGS_PATH} ]]; then
423 if [[ ! -f ${SEARX_SETTINGS_PATH} ]]; then
424 info_msg "install settings ${SEARX_SETTINGS_TEMPLATE}"
424 info_msg "install settings ${SEARX_SETTINGS_TEMPLATE}"