6 Commits

Author SHA1 Message Date
Zhijie He
7b4612e862 [feat] engines: add Ollama engine 2025-03-30 14:25:58 +02:00
Bnyro
9ffa9fb730 [feat] engines: add reuters news engine 2025-03-30 13:56:09 +02:00
Tommaso Colella
5daa4f0460 [feat] engine: add engine for italian online newspaper "il post" 2025-03-30 13:45:06 +02:00
Zhijie He
33661cc5c3 [feat] engines: add Quark engine
Co-authored-by: Bnyro <bnyro@tutanota.com>
2025-03-30 13:20:35 +02:00
Zhijie He
b231cb4b59 [feat] engines: add Niconico videos engine
Co-authored-by: Bnyro <bnyro@tutanota.com>
2025-03-30 12:42:31 +02:00
naughtymommy42069
c8b419fcbb [feat] engine: add bitchute 2025-03-30 12:41:43 +02:00
11 changed files with 832 additions and 9 deletions

View File

@@ -176,3 +176,4 @@ features or generally made searx better:
- Patrick Evans `https://github.com/holysoles`
- Daniel Mowitz `<https://daniel.mowitz.rocks>`
- `Bearz314 <https://github.com/bearz314>`_
- Tommaso Colella `<https://github.com/gioleppe>`

View File

@@ -0,0 +1,8 @@
.. _reuters engine:
=======
Reuters
=======
.. automodule:: searx.engines.reuters
:members:

56
searx/engines/bitchute.py Normal file
View File

@@ -0,0 +1,56 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""bitchute (Videos)"""
from json import dumps
from datetime import datetime
from searx.utils import html_to_text
about = {
"website": 'https://bitchute.com',
"wikidata_id": "Q45287179",
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": "JSON",
}
base_url = "https://api.bitchute.com/api/beta/search/videos"
categories = ['videos']
paging = True
results_per_page = 20
def request(query, params):
start_index = (params["pageno"] - 1) * results_per_page
data = {"offset": start_index, "limit": results_per_page, "query": query, "sensitivity_id": "normal", "sort": "new"}
params["url"] = base_url
params["method"] = 'POST'
params['headers']['content-type'] = "application/json"
params['data'] = dumps(data)
return params
def response(resp):
search_res = resp.json()
results = []
for item in search_res.get('videos', []):
results.append(
{
"title": item['video_name'],
"url": 'https://www.bitchute.com/video/' + item['video_id'],
"content": html_to_text(item['description']),
"author": item['channel']['channel_name'],
"publishedDate": datetime.strptime(item["date_published"], "%Y-%m-%dT%H:%M:%S.%fZ"),
"length": item['duration'],
"views": item['view_count'],
"thumbnail": item['thumbnail_url'],
"iframe_src": 'https://www.bitchute.com/embed/' + item['video_id'],
"template": "videos.html",
}
)
return results

71
searx/engines/il_post.py Normal file
View File

@@ -0,0 +1,71 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Engine for Il Post, a largely independent online Italian newspaper.
To use this engine add the following entry to your engines
list in ``settings.yml``:
.. code:: yaml
- name: il post
engine: il_post
shortcut: pst
disabled: false
"""
from urllib.parse import urlencode
from searx.result_types import EngineResults
engine_type = "online"
language_support = False
categories = ["news"]
paging = True
page_size = 10
time_range_support = True
time_range_args = {"month": "pub_date:ultimi_30_giorni", "year": "pub_date:ultimo_anno"}
search_api = "https://api.ilpost.org/search/api/site_search/?"
about = {
"website": "https://www.ilpost.it",
"wikidata_id": "Q3792882",
"official_api_documentation": None,
"use_official_api": True,
"require_api_key": False,
"results": "JSON",
"language": "it",
}
def request(query, params):
query_params = {
"qs": query,
"pg": params["pageno"],
"sort": "date_d",
"filters": "ctype:articoli",
}
if params["time_range"]:
if params["time_range"] not in time_range_args:
return None
query_params["filters"] += f";{time_range_args.get(params['time_range'], 'pub_date:da_sempre')}"
params["url"] = search_api + urlencode(query_params)
return params
def response(resp) -> EngineResults:
res = EngineResults()
json_data = resp.json()
for result in json_data["docs"]:
res.add(
res.types.MainResult(
url=result["link"],
title=result["title"],
content=result.get("summary", ""),
thumbnail=result.get("image"),
)
)
return res

90
searx/engines/niconico.py Normal file
View File

@@ -0,0 +1,90 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Niconico search engine for searxng"""
from urllib.parse import urlencode
from datetime import datetime, timedelta
from lxml import html
from searx.utils import eval_xpath_getindex, eval_xpath_list, eval_xpath, extract_text
about = {
"website": "https://www.nicovideo.jp/",
"wikidata_id": "Q697233",
"use_official_api": False,
"require_api_key": False,
"results": "HTML",
"language": "ja",
}
categories = ["videos"]
paging = True
time_range_support = True
time_range_dict = {"day": 1, "week": 7, "month": 30, "year": 365}
base_url = "https://www.nicovideo.jp"
embed_url = "https://embed.nicovideo.jp"
results_xpath = '//li[@data-video-item]'
url_xpath = './/a[@class="itemThumbWrap"]/@href'
video_length_xpath = './/span[@class="videoLength"]'
upload_time_xpath = './/p[@class="itemTime"]//span[@class="time"]/text()'
title_xpath = './/p[@class="itemTitle"]/a'
content_xpath = './/p[@class="itemDescription"]/@title'
thumbnail_xpath = './/img[@class="thumb"]/@src'
def request(query, params):
query_params = {"page": params['pageno']}
if time_range_dict.get(params['time_range']):
time_diff_days = time_range_dict[params['time_range']]
start_date = datetime.now() - timedelta(days=time_diff_days)
query_params['start'] = start_date.strftime('%Y-%m-%d')
params['url'] = f"{base_url}/search/{query}?{urlencode(query_params)}"
return params
def response(resp):
results = []
dom = html.fromstring(resp.text)
for item in eval_xpath_list(dom, results_xpath):
relative_url = eval_xpath_getindex(item, url_xpath, 0)
video_id = relative_url.rsplit('?', maxsplit=1)[0].split('/')[-1]
url = f"{base_url}/watch/{video_id}"
iframe_src = f"{embed_url}/watch/{video_id}"
length = None
video_length = eval_xpath_getindex(item, video_length_xpath, 0)
if len(video_length) > 0:
try:
timediff = datetime.strptime(video_length, "%M:%S")
length = timedelta(minutes=timediff.minute, seconds=timediff.second)
except ValueError:
pass
published_date = None
upload_time = eval_xpath_getindex(item, upload_time_xpath, 0)
if len(upload_time) > 0:
try:
published_date = datetime.strptime(upload_time, "%Y/%m/%d %H:%M")
except ValueError:
pass
results.append(
{
'template': 'videos.html',
'title': extract_text(eval_xpath(item, title_xpath)),
'content': eval_xpath_getindex(item, content_xpath, 0),
'url': url,
"iframe_src": iframe_src,
'thumbnail': eval_xpath_getindex(item, thumbnail_xpath, 0),
'length': length,
"publishedDate": published_date,
}
)
return results

60
searx/engines/ollama.py Normal file
View File

@@ -0,0 +1,60 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Ollama model search engine for searxng"""
from urllib.parse import urlencode
from datetime import datetime
from lxml import html
from searx.utils import eval_xpath_list, eval_xpath_getindex, eval_xpath, extract_text
from searx.result_types import EngineResults
about = {
"website": "https://ollama.com",
"wikidata_id": "Q124636097",
"use_official_api": False,
"require_api_key": False,
"results": "HTML",
}
categories = ["it", "repos"]
base_url = "https://ollama.com"
results_xpath = '//li[@x-test-model]'
title_xpath = './/span[@x-test-search-response-title]/text()'
content_xpath = './/p[@class="max-w-lg break-words text-neutral-800 text-md"]/text()'
url_xpath = './a/@href'
publish_date_xpath = './/span[contains(@class, "flex items-center")]/@title'
def request(query, params):
query_params = {"q": query}
params['url'] = f"{base_url}/search?{urlencode(query_params)}"
return params
def response(resp) -> EngineResults:
res = EngineResults()
dom = html.fromstring(resp.text)
for item in eval_xpath_list(dom, results_xpath):
published_date = None
try:
published_date = datetime.strptime(
extract_text(eval_xpath(item, publish_date_xpath)), "%b %d, %Y %I:%M %p %Z"
)
except ValueError:
pass
res.add(
res.types.MainResult(
title=extract_text(eval_xpath(item, title_xpath)),
content=extract_text(eval_xpath(item, content_xpath)),
url=f"{base_url}{eval_xpath_getindex(item, url_xpath, 0)}",
publishedDate=published_date,
)
)
return res

383
searx/engines/quark.py Normal file
View File

@@ -0,0 +1,383 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Quark (Shenma) search engine for searxng"""
from urllib.parse import urlencode
from datetime import datetime
import re
import json
from searx.utils import html_to_text, gen_useragent
from searx.exceptions import SearxEngineAPIException, SearxEngineCaptchaException
# Metadata
about = {
"website": "https://m.quark.cn/",
"wikidata_id": "Q48816502",
"use_official_api": False,
"require_api_key": False,
"results": "HTML",
"language": "zh",
}
# Engine Configuration
categories = []
paging = True
results_per_page = 10
quark_category = 'general'
time_range_support = True
time_range_dict = {'day': '4', 'week': '3', 'month': '2', 'year': '1'}
CAPTCHA_PATTERN = r'\{[^{]*?"action"\s*:\s*"captcha"\s*,\s*"url"\s*:\s*"([^"]+)"[^{]*?\}'
def is_alibaba_captcha(html):
"""
Detects if the response contains an Alibaba X5SEC CAPTCHA page.
Quark may return a CAPTCHA challenge after 9 requests in a short period.
Typically, the ban duration is around 15 minutes.
"""
return bool(re.search(CAPTCHA_PATTERN, html))
def init(_):
if quark_category not in ('general', 'images'):
raise SearxEngineAPIException(f"Unsupported category: {quark_category}")
def request(query, params):
page_num = params["pageno"]
category_config = {
'general': {
'endpoint': 'https://m.quark.cn/s',
'params': {
"q": query,
"layout": "html",
"page": page_num,
},
},
'images': {
'endpoint': 'https://vt.sm.cn/api/pic/list',
'params': {
"query": query,
"limit": results_per_page,
"start": (page_num - 1) * results_per_page,
},
},
}
query_params = category_config[quark_category]['params']
query_url = category_config[quark_category]['endpoint']
if time_range_dict.get(params['time_range']) and quark_category == 'general':
query_params["tl_request"] = time_range_dict.get(params['time_range'])
params["url"] = f"{query_url}?{urlencode(query_params)}"
params["headers"] = {
"User-Agent": gen_useragent(),
}
return params
def response(resp):
results = []
text = resp.text
if is_alibaba_captcha(text):
raise SearxEngineCaptchaException(
suspended_time=900, message="Alibaba CAPTCHA detected. Please try again later."
)
if quark_category == 'images':
data = json.loads(text)
for item in data.get('data', {}).get('hit', {}).get('imgInfo', {}).get('item', []):
try:
published_date = datetime.fromtimestamp(int(item.get("publish_time")))
except (ValueError, TypeError):
published_date = None
results.append(
{
"template": "images.html",
"url": item.get("imgUrl"),
"thumbnail_src": item.get("img"),
"img_src": item.get("bigPicUrl"),
"title": item.get("title"),
"source": item.get("site"),
"resolution": f"{item['width']} x {item['height']}",
"publishedDate": published_date,
}
)
if quark_category == 'general':
# Quark returns a variety of different sc values on a single page, depending on the query type.
source_category_parsers = {
'addition': parse_addition,
'ai_page': parse_ai_page,
'baike_sc': parse_baike_sc,
'finance_shuidi': parse_finance_shuidi,
'kk_yidian_all': parse_kk_yidian_all,
'life_show_general_image': parse_life_show_general_image,
'med_struct': parse_med_struct,
'music_new_song': parse_music_new_song,
'nature_result': parse_nature_result,
'news_uchq': parse_news_uchq,
'ss_note': parse_ss_note,
# ss_kv, ss_pic, ss_text, ss_video, baike, structure_web_novel use the same struct as ss_doc
'ss_doc': parse_ss_doc,
'ss_kv': parse_ss_doc,
'ss_pic': parse_ss_doc,
'ss_text': parse_ss_doc,
'ss_video': parse_ss_doc,
'baike': parse_ss_doc,
'structure_web_novel': parse_ss_doc,
'travel_dest_overview': parse_travel_dest_overview,
'travel_ranking_list': parse_travel_ranking_list,
}
pattern = r'<script\s+type="application/json"\s+id="s-data-[^"]+"\s+data-used-by="hydrate">(.*?)</script>'
matches = re.findall(pattern, text, re.DOTALL)
for match in matches:
data = json.loads(match)
initial_data = data.get('data', {}).get('initialData', {})
extra_data = data.get('extraData', {})
source_category = extra_data.get('sc')
parsers = source_category_parsers.get(source_category)
if parsers:
parsed_results = parsers(initial_data)
if isinstance(parsed_results, list):
# Extend if the result is a list
results.extend(parsed_results)
else:
# Append if it's a single result
results.append(parsed_results)
return results
def parse_addition(data):
return {
"title": html_to_text(data.get('title', {}).get('content')),
"url": data.get('source', {}).get('url'),
"content": html_to_text(data.get('summary', {}).get('content')),
}
def parse_ai_page(data):
results = []
for item in data.get('list', []):
content = (
" | ".join(map(str, item.get('content', [])))
if isinstance(item.get('content'), list)
else str(item.get('content'))
)
try:
published_date = datetime.fromtimestamp(int(item.get('source', {}).get('time')))
except (ValueError, TypeError):
published_date = None
results.append(
{
"title": html_to_text(item.get('title')),
"url": item.get('url'),
"content": html_to_text(content),
"publishedDate": published_date,
}
)
return results
def parse_baike_sc(data):
return {
"title": html_to_text(data.get('data', {}).get('title')),
"url": data.get('data', {}).get('url'),
"content": html_to_text(data.get('data', {}).get('abstract')),
"thumbnail": data.get('data', {}).get('img').replace("http://", "https://"),
}
def parse_finance_shuidi(data):
content = " | ".join(
(
info
for info in [
data.get('establish_time'),
data.get('company_status'),
data.get('controled_type'),
data.get('company_type'),
data.get('capital'),
data.get('address'),
data.get('business_scope'),
]
if info
)
)
return {
"title": html_to_text(data.get('company_name')),
"url": data.get('title_url'),
"content": html_to_text(content),
}
def parse_kk_yidian_all(data):
content_list = []
for section in data.get('list_container', []):
for item in section.get('list_container', []):
if 'dot_text' in item:
content_list.append(item['dot_text'])
return {
"title": html_to_text(data.get('title')),
"url": data.get('title_url'),
"content": html_to_text(' '.join(content_list)),
}
def parse_life_show_general_image(data):
results = []
for item in data.get('image', []):
try:
published_date = datetime.fromtimestamp(int(item.get("publish_time")))
except (ValueError, TypeError):
published_date = None
results.append(
{
"template": "images.html",
"url": item.get("imgUrl"),
"thumbnail_src": item.get("img"),
"img_src": item.get("bigPicUrl"),
"title": item.get("title"),
"source": item.get("site"),
"resolution": f"{item['width']} x {item['height']}",
"publishedDate": published_date,
}
)
return results
def parse_med_struct(data):
return {
"title": html_to_text(data.get('title')),
"url": data.get('message', {}).get('statistics', {}).get('nu'),
"content": html_to_text(data.get('message', {}).get('content_text')),
"thumbnail": data.get('message', {}).get('video_img').replace("http://", "https://"),
}
def parse_music_new_song(data):
results = []
for item in data.get('hit3', []):
results.append(
{
"title": f"{item['song_name']} | {item['song_singer']}",
"url": item.get("play_url"),
"content": html_to_text(item.get("lyrics")),
"thumbnail": item.get("image_url").replace("http://", "https://"),
}
)
return results
def parse_nature_result(data):
return {"title": html_to_text(data.get('title')), "url": data.get('url'), "content": html_to_text(data.get('desc'))}
def parse_news_uchq(data):
results = []
for item in data.get('feed', []):
try:
published_date = datetime.strptime(item.get('time'), "%Y-%m-%d")
except (ValueError, TypeError):
# Sometime Quark will return non-standard format like "1天前", set published_date as None
published_date = None
results.append(
{
"title": html_to_text(item.get('title')),
"url": item.get('url'),
"content": html_to_text(item.get('summary')),
"thumbnail": item.get('image').replace("http://", "https://"),
"publishedDate": published_date,
}
)
return results
def parse_ss_doc(data):
published_date = None
try:
timestamp = int(data.get('sourceProps', {}).get('time'))
# Sometime Quark will return 0, set published_date as None
if timestamp != 0:
published_date = datetime.fromtimestamp(timestamp)
except (ValueError, TypeError):
pass
try:
thumbnail = data.get('picListProps', [])[0].get('src').replace("http://", "https://")
except (ValueError, TypeError, IndexError):
thumbnail = None
return {
"title": html_to_text(
data.get('titleProps', {}).get('content')
# ss_kv variant 1 & 2
or data.get('title')
),
"url": data.get('sourceProps', {}).get('dest_url')
# ss_kv variant 1
or data.get('normal_url')
# ss_kv variant 2
or data.get('url'),
"content": html_to_text(
data.get('summaryProps', {}).get('content')
# ss_doc variant 1
or data.get('message', {}).get('replyContent')
# ss_kv variant 1
or data.get('show_body')
# ss_kv variant 2
or data.get('desc')
),
"publishedDate": published_date,
"thumbnail": thumbnail,
}
def parse_ss_note(data):
try:
published_date = datetime.fromtimestamp(int(data.get('source', {}).get('time')))
except (ValueError, TypeError):
published_date = None
return {
"title": html_to_text(data.get('title', {}).get('content')),
"url": data.get('source', {}).get('dest_url'),
"content": html_to_text(data.get('summary', {}).get('content')),
"publishedDate": published_date,
}
def parse_travel_dest_overview(data):
return {
"title": html_to_text(data.get('strong', {}).get('title')),
"url": data.get('strong', {}).get('baike_url'),
"content": html_to_text(data.get('strong', {}).get('baike_text')),
}
def parse_travel_ranking_list(data):
return {
"title": html_to_text(data.get('title', {}).get('text')),
"url": data.get('title', {}).get('url'),
"content": html_to_text(data.get('title', {}).get('title_tag')),
}

90
searx/engines/reuters.py Normal file
View File

@@ -0,0 +1,90 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Reuters_ (news) is an international news agency.
.. _Reuters: https://www.reuters.com
Configuration
=============
The engine has the following additional settings:
- :py:obj:`sort_order`
.. code:: yaml
- name: reuters
engine: reuters
shortcut: reu
sort_order: "relevance"
Implementations
===============
"""
from json import dumps
from urllib.parse import quote_plus
from datetime import datetime, timedelta
from searx.result_types import EngineResults
about = {
"website": "https://www.reuters.com",
"wikidata_id": "Q130879",
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": "JSON",
}
categories = ["news"]
time_range_support = True
paging = True
base_url = "https://www.reuters.com"
results_per_page = 20
sort_order = "relevance"
"""Sort order, one of ``relevance``, ``display_date:desc`` or ``display_data:asc``."""
time_range_duration_map = {
"day": 1,
"week": 7,
"month": 30,
"year": 365,
}
def request(query, params):
args = {
"keyword": query,
"offset": (params["pageno"] - 1) * results_per_page,
"orderby": sort_order,
"size": results_per_page,
"website": "reuters",
}
if params["time_range"]:
time_diff_days = time_range_duration_map[params["time_range"]]
start_date = datetime.now() - timedelta(days=time_diff_days)
args["start_date"] = start_date.isoformat()
params["url"] = f"{base_url}/pf/api/v3/content/fetch/articles-by-search-v2?query={quote_plus(dumps(args))}"
return params
def response(resp) -> EngineResults:
res = EngineResults()
for result in resp.json().get("result", {}).get("articles", []):
res.add(
res.types.MainResult(
url=base_url + result["canonical_url"],
title=result["web"],
content=result["description"],
thumbnail=result.get("thumbnail", {}).get("url", ""),
metadata=result.get("kicker", {}).get("name"),
publishedDate=datetime.strptime(result["display_time"], "%Y-%m-%dT%H:%M:%SZ"),
)
)
return res

View File

@@ -25,6 +25,8 @@ import re
import urllib.parse
import warnings
import typing
import time
import datetime
from collections.abc import Callable
@@ -212,6 +214,15 @@ def _filter_urls(result: Result | LegacyResult, filter_func: Callable[[Result |
result.normalize_result_fields()
def _normalize_date_fields(result: MainResult | LegacyResult):
if result.publishedDate: # do not try to get a date from an empty string or a None type
try: # test if publishedDate >= 1900 (datetime module bug)
result.pubdate = result.publishedDate.strftime('%Y-%m-%d %H:%M:%S%z')
except ValueError:
result.publishedDate = None
class Result(msgspec.Struct, kw_only=True):
"""Base class of all result types :ref:`result types`."""
@@ -347,6 +358,24 @@ class MainResult(Result): # pylint: disable=missing-class-docstring
thumbnail: str = ""
"""URL of a thumbnail that is displayed in the result item."""
publishedDate: datetime.datetime | None = None
"""The date on which the object was published."""
pubdate: str = ""
"""String representation of :py:obj:`MainResult.publishedDate`"""
length: time.struct_time | None = None
"""Playing duration in seconds."""
views: str = ""
"""View count in humanized number format."""
author: str = ""
"""Author of the title."""
metadata: str = ""
"""Miscellaneous metadata."""
priority: typing.Literal["", "high", "low"] = ""
"""The priority can be set via :ref:`hostnames plugin`, for example."""
@@ -379,8 +408,8 @@ class MainResult(Result): # pylint: disable=missing-class-docstring
def normalize_result_fields(self):
super().normalize_result_fields()
_normalize_text_fields(self)
_normalize_date_fields(self)
if self.engine:
self.engines.add(self.engine)
@@ -419,6 +448,8 @@ class LegacyResult(dict):
positions: list[int]
score: float
category: str
publishedDate: datetime.datetime | None = None
pubdate: str = ""
# infobox result
urls: list[dict[str, str]]
@@ -514,6 +545,7 @@ class LegacyResult(dict):
return f"LegacyResult: {super().__repr__()}"
def normalize_result_fields(self):
_normalize_date_fields(self)
_normalize_url_fields(self)
_normalize_text_fields(self)
if self.engine:

View File

@@ -560,6 +560,11 @@ engines:
engine: bing_videos
shortcut: biv
- name: bitchute
engine: bitchute
shortcut: bit
disabled: true
- name: bitbucket
engine: xpath
paging: true
@@ -1121,6 +1126,11 @@ engines:
require_api_key: false
results: JSON
- name: il post
engine: il_post
shortcut: pst
disabled: true
- name: imdb
engine: imdb
shortcut: imdb
@@ -1349,6 +1359,11 @@ engines:
shortcut: mwm
disabled: true
- name: niconico
engine: niconico
shortcut: nico
disabled: true
- name: npm
engine: npm
shortcut: npm
@@ -1390,6 +1405,11 @@ engines:
shortcut: od
disabled: true
- name: ollama
engine: ollama
shortcut: ollama
disabled: true
- name: openairedatasets
engine: json_engine
paging: true
@@ -1651,6 +1671,20 @@ engines:
shortcut: pypi
engine: pypi
- name: quark
quark_category: general
categories: [general]
engine: quark
shortcut: qk
disabled: true
- name: quark images
quark_category: images
categories: [images]
engine: quark
shortcut: qki
disabled: true
- name: qwant
qwant_categ: web
engine: qwant
@@ -1712,6 +1746,12 @@ engines:
page_size: 25
disabled: true
- name: reuters
engine: reuters
shortcut: reu
# https://docs.searxng.org/dev/engines/online/reuters.html
# sort_order = "relevance"
- name: right dao
engine: xpath
paging: true

View File

@@ -694,14 +694,6 @@ def search():
if 'title' in result and result['title']:
result['title'] = highlight_content(escape(result['title'] or ''), search_query.query)
if getattr(result, 'publishedDate', None): # do not try to get a date from an empty string or a None type
try: # test if publishedDate >= 1900 (datetime module bug)
result['pubdate'] = result['publishedDate'].strftime('%Y-%m-%d %H:%M:%S%z')
except ValueError:
result['publishedDate'] = None
else:
result['publishedDate'] = webutils.searxng_l10n_timespan(result['publishedDate'])
# set result['open_group'] = True when the template changes from the previous result
# set result['close_group'] = True when the template changes on the next result
if current_template != result.template: