#15 Feature: thumbnails

Open
CYBERDEViL wants to merge 23 commits from CYBERDEViL/images into CYBERDEViL/master

+ 15 - 3
data/schema/searxng_query.json

@@ -77,7 +77,10 @@
 					"is_onion": {"type": "boolean"},
 					"is_onion": {"type": "boolean"},
 					"publishedDate": {"type": "string", "format": "date-time"},
 					"publishedDate": {"type": "string", "format": "date-time"},
 					"pubdate": {"type": "string"},
 					"pubdate": {"type": "string"},
-					"thumbnail_src": {"type": "string"},
+					"thumbnail_src": {
+						"description": "URL to thumbnail, used by images.",
+						"type": "string"
+					},
 					"template": {"type": "string"},
 					"template": {"type": "string"},
 					"author": {
 					"author": {
 						"oneOf": [
 						"oneOf": [
@@ -86,20 +89,26 @@
 						]
 						]
 					},
 					},
 					"source": {"type": "string"},
 					"source": {"type": "string"},
-					"img_format": {"type": "string"},
+					"img_format": {
+						"description": "example: '2586 x 1260 · png'",
+						"type": "string"
+					},
 					"img_src": {
 					"img_src": {
+						"description": "URL to image",
 						"oneOf": [
 						"oneOf": [
 							{"type": "string"},
 							{"type": "string"},
 							{"type": "null"}
 							{"type": "null"}
 						]
 						]
 					},
 					},
 					"seed": {
 					"seed": {
+						"description": "How many (torrent) seeders there are.",
 						"oneOf": [
 						"oneOf": [
 							{"type": "string"},
 							{"type": "string"},
 							{"type": "number"}
 							{"type": "number"}
 						]
 						]
 					},
 					},
 					"leech": {
 					"leech": {
+						"description": "How many (torrent) leechers there are.",
 						"oneOf": [
 						"oneOf": [
 							{"type": "string"},
 							{"type": "string"},
 							{"type": "number"}
 							{"type": "number"}
@@ -122,7 +131,10 @@
 						]
 						]
 					},
 					},
 					"link": {"type": "string"},
 					"link": {"type": "string"},
-					"thumbnail": {"type": "string"},
+					"thumbnail": {
+						"description": "Thumbnail URL used by videos",
+						"type": "string"
+					},
 					"embedded": {"type": "string"},
 					"embedded": {"type": "string"},
 					"length": {"type": "string"},
 					"length": {"type": "string"},
 					"code_language": {"type": "string"},
 					"code_language": {"type": "string"},

+ 8 - 0
searxqt/core/dummy.py

@@ -0,0 +1,8 @@
+def BytesIO(dummy):
+    return None
+
+def Image(dummy, mode=None):
+    return None
+
+class UnidentifiedImageError(Exception):
+    pass

+ 51 - 7
searxqt/core/htmlGen.py

@@ -23,6 +23,7 @@
 import html
 import html
 from urllib.parse import quote
 from urllib.parse import quote
 
 
+from searxqt.core.images import ImagesSettings
 from searxqt.utils.string import formatFileSize, formatFileCount
 from searxqt.utils.string import formatFileSize, formatFileCount
 from searxqt.translations import _
 from searxqt.translations import _
 
 
@@ -85,7 +86,7 @@ class FailedResponsesHtml:
 
 
 
 
 class ResultsHtml:
 class ResultsHtml:
-    """ Create HTML from a valid search response.
+    """! Create HTML from a valid search response.
     """
     """
 
 
     def create(jsonResult, css):
     def create(jsonResult, css):
@@ -134,11 +135,19 @@ class ResultsHtml:
             elemStr += ResultsHtml.createStrListElement(_('Answers'), data)
             elemStr += ResultsHtml.createStrListElement(_('Answers'), data)
             elemStr += "<hr>"
             elemStr += "<hr>"
 
 
-        # Results
+        # First 'normal' results
         data = jsonResult.get('results', [])
         data = jsonResult.get('results', [])
-        if data:
-            for resultData in data:
-                elemStr += ResultsHtml.createResultElement(resultData)
+        for resultData in data:
+            if resultData.get('category') == 'images':
+                continue
+            elemStr += ResultsHtml.createResultElement(resultData)
+        elemStr += "<hr>"
+
+        # Images results
+        for resultData in data:
+            if resultData.get('category') != 'images':
+                continue
+            elemStr += ResultsHtml.createImageResultElement(resultData)
 
 
         return HtmlGen.wrapHtml(elemStr, css)
         return HtmlGen.wrapHtml(elemStr, css)
 
 
@@ -183,6 +192,13 @@ class ResultsHtml:
             elemStr += "<center><div class=\"infobox_id\">" \
             elemStr += "<center><div class=\"infobox_id\">" \
                        f"<a href=\"{data}\">{data}</a></div></center>"
                        f"<a href=\"{data}\">{data}</a></div></center>"
 
 
+        # Image
+        if ImagesSettings.enabled:
+            data = infoboxData.get("img_src", None)
+            if data:
+                data = html.escape(data)
+                elemStr += f"<center><img class=\"infobox_img\" src=\"{data}\"/></center>"
+
         # Content
         # Content
         data = infoboxData.get("content", None)
         data = infoboxData.get("content", None)
         if data:
         if data:
@@ -233,6 +249,30 @@ class ResultsHtml:
 
 
         return elemStr
         return elemStr
 
 
+    def createImageResultElement(data):
+        thumbnail_src=html.escape(data.get('thumbnail_src', ''))
+
+        imgElem = ""
+        if ImagesSettings.enabled:
+            imgElem = f"""<a href="thumb://{thumbnail_src}"><img class="result-thumbnail" src="{thumbnail_src}"/></a>"""
+        else:
+            imgElem = f"""<a href="thumb://{thumbnail_src}">Thumb</a>"""
+
+        # The given width and height are used for minimum width and height,
+        # it will stretch when the image is larger. The image should not be
+        # larger then this so we get nice even rows and columns.
+        width = ImagesSettings.maxWidth  + 26  # FIXME 26 are margings from the
+                                               # table/tr/td.
+        height = ImagesSettings.maxWidth + 26
+
+        # Wrap inside a single row/column table as a HACK so we can have
+        # floating elements and control their style a little better (padding,
+        # background etc.).
+        elemStr = f"""<table class="thumbnail" width={width} height={height}>
+<tr><td><center>{imgElem}</center></td></tr></table>"""
+
+        return elemStr;
+
     def createResultElement(data):
     def createResultElement(data):
         # Create general elements
         # Create general elements
         title=html.escape(data.get('title', ''))
         title=html.escape(data.get('title', ''))
@@ -244,7 +284,11 @@ class ResultsHtml:
             engine += f"{e} "
             engine += f"{e} "
         engine = engine.rstrip()
         engine = engine.rstrip()
 
 
-        elemStr = "<div class=\"results\">" \
+        # Wrap inside a single row/column table as a HACK so we can have
+        # floating elements and control their style a little better (padding,
+        # background etc.).
+        elemStr = """<table class="result"><tr><td>"""
+        elemStr += "<div class=\"results\">" \
                   f"<h4 class=\"result-title\"><i>{engine}: </i>" \
                   f"<h4 class=\"result-title\"><i>{engine}: </i>" \
                   f"<a href=\"{url}\">{title}</a></h4>" \
                   f"<a href=\"{url}\">{title}</a></h4>" \
                   "<div style=\"margin-left: 10px;\">" \
                   "<div style=\"margin-left: 10px;\">" \
@@ -254,7 +298,7 @@ class ResultsHtml:
         # Add file data elements
         # Add file data elements
         elemStr += ResultsHtml.createFileSection(data)
         elemStr += ResultsHtml.createFileSection(data)
 
 
-        elemStr += "</div></div>"
+        elemStr += "</div></div></tr></td></table>"
         return elemStr
         return elemStr
 
 
     def createFileSection(data):
     def createFileSection(data):

+ 69 - 0
searxqt/core/images.py

@@ -0,0 +1,69 @@
+from searxqt.core import log
+
+
+## Whether PIL is found on the system or not.
+HAVE_PIL = False
+try:
+    import PIL
+    HAVE_PIL = True
+    del PIL
+except ImportError:
+    log.debug("PIL not installed! No image support.")
+
+
+class _ImagesSettings:
+    """! Settings for thumbnails.
+    """
+    def __init__(self):
+        self.__enabled = False
+        self.__customProxy = False
+
+        ## Use `Qt.FastTransformation` when set to `True`, it will use
+        ## `Qt.SmoothTransformation` when set to `False`.
+        ## @see https://doc.qt.io/qt-5/qt.html#TransformationMode-enum
+        self.fastTransform = True
+
+        ## Maximum amount of thumbnail download threads.
+        self.maxThreads = 3
+
+        ## Maximum thumbnail display width in pixels.
+        self.maxWidth = 300
+
+        ## Maximum thumbnail display height in pixels.
+        self.maxHeight = 300
+
+    @property
+    def supported(self):
+        """! Returns `True` when `PIL` is found on the system and thus images
+             are supported. `False` otherwise.
+        """
+        return HAVE_PIL
+
+    @property
+    def enabled(self):
+        """! Whether downloading/displaying thumbnails is enabled or not."""
+        return False if not HAVE_PIL else self.__enabled
+
+    @enabled.setter
+    def enabled(self, state):
+        """! Enable/Disable downloading/displaying thumbnails."""
+        self.__enabled = state
+
+    def serialize(self):
+        return {
+            'enabled': self.enabled,
+            'fastTransform': self.fastTransform,
+            'maxThreads': self.maxThreads,
+            'maxWidth': self.maxWidth,
+            'maxHeight': self.maxHeight
+        }
+
+    def deserialize(self, data):
+        self.enabled = data.get('enabled', False)
+        self.fastTransform = data.get('fastTransform', True)
+        self.maxThreads = data.get('maxThreads', 3)
+        self.maxWidth = data.get('maxWidth', 300)
+        self.maxHeight = data.get('maxHeight', 300)
+
+## Global image settings object.
+ImagesSettings = _ImagesSettings()

+ 142 - 30
searxqt/core/requests.py

@@ -37,6 +37,7 @@ from jsonschema.exceptions import ValidationError, SchemaError
 import random
 import random
 
 
 from searxqt.core import log
 from searxqt.core import log
+from searxqt.core.images import ImagesSettings
 
 
 HAVE_SOCKS = False
 HAVE_SOCKS = False
 try:
 try:
@@ -59,7 +60,8 @@ class ErrorType:
     SSLError = 8
     SSLError = 8
     InvalidSchema = 9
     InvalidSchema = 9
     ContentSizeExceeded = 10
     ContentSizeExceeded = 10
-    Other = 11
+    CorruptImage = 11
+    Other = 12
 
 
 
 
 ErrorTypeStr = {
 ErrorTypeStr = {
@@ -74,6 +76,7 @@ ErrorTypeStr = {
     ErrorType.SSLError: "SSLError",
     ErrorType.SSLError: "SSLError",
     ErrorType.InvalidSchema: "InvalidSchema",
     ErrorType.InvalidSchema: "InvalidSchema",
     ErrorType.ContentSizeExceeded: "ContentSizeExceeded",
     ErrorType.ContentSizeExceeded: "ContentSizeExceeded",
+    ErrorType.CorruptImage: "CorruptImage",
     ErrorType.Other: "Other"
     ErrorType.Other: "Other"
 }
 }
 
 
@@ -200,6 +203,26 @@ class JsonResult(Result):
         return json.loads(self._response.content)
         return json.loads(self._response.content)
 
 
 
 
+if ImagesSettings.supported:
+    from io import BytesIO
+    from PIL import Image, UnidentifiedImageError
+else:
+    from searxqt.core.dummy import BytesIO, Image, UnidentifiedImageError
+
+
+class ImageResult(Result):
+    def verifyFurther(self):
+        content = BytesIO(self.content())
+        try:
+            Image.open(content, mode='r')
+        except UnidentifiedImageError as err:
+            self._errType = ErrorType.CorruptImage
+            self._err = f"CorruptImage: `{err}` for: {self.url()}"
+        except OSError as err:
+            self._errType = ErrorType.CorruptImage
+            self._err = f"CorruptImage: `{err}` for: {self.url()}"
+
+
 class ProxyProtocol:
 class ProxyProtocol:
     HTTP    = 1
     HTTP    = 1
     SOCKS4  = 2
     SOCKS4  = 2
@@ -248,32 +271,32 @@ class RequestSettings:
 
 
     def getData(self):
     def getData(self):
         return {
         return {
-            "useragents": self.useragents,
-            "randomUserAgent": self.randomUserAgent,
-            "verifySSL": self.verifySSL,
-            "timeout": self.timeout,
-            "maxSize": self.maxSize,
-            "chunkSize": self.chunkSize,
-            "proxyEnabled": self.proxyEnabled,
-            "proxyDNS": self.proxyDNS,
-            "proxyHost": self.proxyHost,
-            "proxyProtocol": self.proxyProtocol,
+            "useragents": self._useragents,
+            "randomUserAgent": self._randomUserAgent,
+            "verifySSL": self._verifySSL,
+            "timeout": self._timeout,
+            "maxSize": self._maxSize,
+            "chunkSize": self._chunkSize,
+            "proxyEnabled": self._proxyEnabled,
+            "proxyDNS": self._proxyDNS,
+            "proxyHost": self._proxyHost,
+            "proxyProtocol": self._proxyProtocol,
             "extraHeaders": self._extraHeaders
             "extraHeaders": self._extraHeaders
         }
         }
 
 
     def setData(self, data):
     def setData(self, data):
-        self.useragents.clear()
+        self._useragents.clear()
         for useragent in data.get("useragents", []):
         for useragent in data.get("useragents", []):
-            self.useragents.append(useragent)
-        self.randomUserAgent = data.get("randomUserAgent", False)
-        self.verifySSL = data.get("verifySSL", True)
-        self.timeout = data.get("timeout", 10)
-        self.maxSize = data.get("maxSize", 10 * 1024 * 1024)
-        self.chunkSize = data.get("chunkSize", 500 * 1024)
-        self.proxyEnabled = data.get("proxyEnabled", False)
-        self.proxyDNS = data.get("proxyDNS", True)
-        self.proxyHost = data.get("proxyHost", "")
-        self.proxyProtocol = data.get("proxyProtocol", 0)
+            self._useragents.append(useragent)
+        self._randomUserAgent = data.get("randomUserAgent", False)
+        self._verifySSL = data.get("verifySSL", True)
+        self._timeout = data.get("timeout", 10)
+        self._maxSize = data.get("maxSize", 10 * 1024 * 1024)
+        self._chunkSize = data.get("chunkSize", 500 * 1024)
+        self._proxyEnabled = data.get("proxyEnabled", False)
+        self._proxyDNS = data.get("proxyDNS", True)
+        self._proxyHost = data.get("proxyHost", "")
+        self._proxyProtocol = data.get("proxyProtocol", 0)
         self._extraHeaders = data.get("extraHeaders", {})
         self._extraHeaders = data.get("extraHeaders", {})
 
 
         self.updateRequestKwargs()
         self.updateRequestKwargs()
@@ -281,6 +304,10 @@ class RequestSettings:
     """ Settings """
     """ Settings """
 
 
     @property
     @property
+    def headers(self):
+        return self._headers
+
+    @property
     def extraHeaders(self):
     def extraHeaders(self):
         return self._extraHeaders
         return self._extraHeaders
 
 
@@ -398,33 +425,118 @@ class RequestSettings:
         kwargs = {
         kwargs = {
             "verify": self.verifySSL,
             "verify": self.verifySSL,
             "timeout": self.timeout,
             "timeout": self.timeout,
-            "headers": self._headers
+            "headers": self.headers
         }
         }
 
 
         self._headers.clear()
         self._headers.clear()
         self._headers.update(self.extraHeaders)
         self._headers.update(self.extraHeaders)
 
 
-        if self._proxyEnabled:
+        if self.proxyEnabled:
             kwargs.update({"proxies": self._compileProxies()})
             kwargs.update({"proxies": self._compileProxies()})
 
 
         self._kwargs.clear()
         self._kwargs.clear()
         self._kwargs.update(kwargs)
         self._kwargs.update(kwargs)
 
 
     def _getUseragent(self):
     def _getUseragent(self):
-        if not self._useragents:
+        if not self.useragents:
             return ""
             return ""
 
 
         # Return first useragent string
         # Return first useragent string
-        if len(self._useragents) == 1 or not self._randomUserAgent:
-            return self._useragents[0]
+        if len(self.useragents) == 1 or not self.randomUserAgent:
+            return self.useragents[0]
 
 
         # Return random useragent
         # Return random useragent
-        return random.choice(self._useragents)
+        return random.choice(self.useragents)
+
+
+class RequestSettingsWithParent(RequestSettings):
+    # This is read-only when in parent mode
+    def __init__(self, parentSettings):
+        self._parentSettings = parentSettings
+        self._useParent = False
+        self._current = self
+        RequestSettings.__init__(self)
+
+    @property
+    def useParent(self):
+        """! When it returns `True` the settings this object holds will be
+             ignored and values from the parent will be returned instead, in
+             this case this object should be treated as read-only. When it
+             returns `False` this object will behave like a normal
+             `RequestSettings` instance.
+        """
+        return self._useParent
+
+    @useParent.setter
+    def useParent(self, state):
+        self._useParent = state
+        self._current = self._parentSettings if state else self
+
+    def getData(self):
+        data = RequestSettings.getData(self)
+        data.update({"useParent": self.useParent})
+        return data
+
+    def setData(self, data):
+        self.useParent = data.get("useParent", False)
+        RequestSettings.setData(self, data)
+
+    """ Override Settings """
+
+    @RequestSettings.headers.getter
+    def headers(self):
+        return self._current._headers
+
+    @RequestSettings.extraHeaders.getter
+    def extraHeaders(self):
+        return self._current._extraHeaders
+
+    @RequestSettings.verifySSL.getter
+    def verifySSL(self):
+        return self._current._verifySSL
+
+    @RequestSettings.timeout.getter
+    def timeout(self):
+        return self._current._timeout
+
+    @RequestSettings.proxyEnabled.getter
+    def proxyEnabled(self):
+        return self._current._proxyEnabled
+
+    @RequestSettings.proxyHost.getter
+    def proxyHost(self):
+        return self._current._proxyHost
+
+    @RequestSettings.proxyProtocol.getter
+    def proxyProtocol(self):
+        return self._current._proxyProtocol
+
+    @RequestSettings.proxyDNS.getter
+    def proxyDNS(self):
+        return self._current._proxyDNS
+
+    @RequestSettings.useragents.getter
+    def useragents(self):
+        return self._current._useragents
+
+    @RequestSettings.randomUserAgent.getter
+    def randomUserAgent(self):
+        return self._current._randomUserAgent
+
 
 
 
 
 class RequestsHandler:
 class RequestsHandler:
-    def __init__(self):
-        self._settings = RequestSettings()
+    def __init__(self, settings=None):
+        """! Handles remote requests.
+
+        @param settings `RequestSettings` object or `None`. When `None` is
+                        given it will create a new `RequestSettings` object,
+                        else the given `settings` object will be used.
+        """
+        if settings is None:
+            self._settings = RequestSettings()
+        else:
+            self._settings = settings
 
 
     @property
     @property
     def settings(self):
     def settings(self):

+ 174 - 3
searxqt/core/searx.py

@@ -68,11 +68,20 @@ class SearchResult(JsonResult):
                 self._err = f"NoResults: got: `{self.json()}`"
                 self._err = f"NoResults: got: `{self.json()}`"
 
 
 
 
+def fixUrlScheme(url):
+    """! Adds 'https://' when the scheme is missing."""
+    parsedUrl = urllib.parse.urlparse(url)
+    if not parsedUrl.scheme:
+        return f"https://{url}"
+    return url
+
+
 ## HTML result that will be parsed into JSON
 ## HTML result that will be parsed into JSON
 class SearchResult2(SearchResult):
 class SearchResult2(SearchResult):
     Schema = Schemas['searxng_query']
     Schema = Schemas['searxng_query']
 
 
     def __init__(self, url, response, err="", errType=ErrorType.Success):
     def __init__(self, url, response, err="", errType=ErrorType.Success):
+        self.__json = {}
         ## @see https://github.com/searxng/searxng/blob/master/searx/botdetection/link_token.py
         ## @see https://github.com/searxng/searxng/blob/master/searx/botdetection/link_token.py
         self._linktoken = None
         self._linktoken = None
         SearchResult.__init__(self, url, response, err=err, errType=errType)
         SearchResult.__init__(self, url, response, err=err, errType=errType)
@@ -92,7 +101,153 @@ class SearchResult2(SearchResult):
             url = f"{instanceUrl.scheme}://{url}"
             url = f"{instanceUrl.scheme}://{url}"
         return url
         return url
 
 
+    def verifyFurther(self):
+        self.__json = self.parse()
+        SearchResult.verifyFurther(self)
+
     def json(self):
     def json(self):
+        return self.__json
+
+    def makeUrlAbsolute(self, url):
+        """! Returns a absolute URL. It will add the SearXNG instance its
+             schema and location in front when they are missing."""
+        parsedUrl = urllib.parse.urlparse(url)
+        instanceUrl = urllib.parse.urlparse(self.url())
+        if not parsedUrl.netloc:
+            url = f"{instanceUrl.netloc}{url}"
+        if not parsedUrl.scheme:
+            url = f"{instanceUrl.scheme}://{url}"
+        return url
+
+    def parseImagesResult(self, result):
+        """! Parse image results from HTML."""
+        """Example HTML:
+
+        <article class="result result-images category-images">
+            <a href="https://wallup.net/wp-content/uploads/2019/09/441567-landscapes-nature-wallpaper.jpg" rel="noreferrer">
+                <img alt="landscapes, Nature, Wallpaper Wallpapers HD / Desktop and Mobile ..." class="image_thumbnail" height="200" loading="lazy" rel="noreferrer" src="/image_proxy?url=https%3A%2F%2Fs2.qwant.com%2Fthumbr%2F474x315%2Ff%2F1%2F5fe20d297b0af77d40641a1c2d1a0a430b235e0f98e1584d580cf7931b28f9%2Fth.jpg%3Fu%3Dhttps%253A%252F%252Ftse.mm.bing.net%252Fth%253Fid%253DOIP.bLDwvUIZXCd5HCilSOxKCAHaE7%2526pid%253DApi%26q%3D0%26b%3D1%26p%3D0%26a%3D0&amp;h=HASH ..." width="200"/>
+
+                <span class="title">
+                    landscapes, Nature, Wallpaper Wallpapers HD / Desktop and Mobile ...
+                </span>
+                <span class="source">wallup.net</span>
+            </a>
+            <div class="detail">
+                <a class="result-detail-close" href="#">
+                    <svg SVG_STUFF ...></svg>
+                </a>
+                <a class="result-detail-previous" href="#">
+                    <svg SVG_STUFF ...></svg>
+                </a>
+                <a class="result-images-source" href="https://wallup.net/wp-content/uploads/2019/09/441567-landscapes-nature-wallpaper.jpg" rel="noreferrer">
+                    <img alt="landscapes, Nature, Wallpaper Wallpapers HD / Desktop and Mobile ..." data-src="/image_proxy?url=https%3A%2F%2Fwallup.net%2Fwp-content%2Fuploads%2F2019%2F09%2F441567-landscapes-nature-wallpaper.jpg&amp;h=HASH ..." src=""/>
+                </a>
+                <div class="result-images-labels">
+                    <h4>landscapes, Nature, Wallpaper Wallpapers HD / Desktop and Mobile ...</h4>
+                    <p class="result-content"> </p>
+                    <hr/>
+                    <p class="result-author"> </p>
+                    <p class="result-format"> </p>
+                    <p class="result-source"> </p>
+                    <p class="result-engine">
+                        <span>Engine:</span>qwant images
+                    </p>
+                    <p class="result-url">
+                        <span>View source:</span>
+                        <a href="https://wallup.net/landscapes-nature-wallpaper-69/" rel="noreferrer">https://wallup.net/landscapes-nature-wallpaper-69/</a>
+                    </p>
+                </div>
+            </div>
+        </article>
+        """
+        title = ''         # image title
+        url = ''           # url to the website of the image
+        content = ''       # probably same as the title
+        engines = []       # see img_src
+        #publishedDate = ''
+        img_format = ''    # size/format of the image in string format
+        img_src = ''       # source if the image (engine)
+        thumbnail_src = '' # url to thumbnail
+        source = ''        # where does the image come from?
+        category = 'images'
+
+        # !! GET Title
+        try:
+            title = result.a.img.get('alt')
+        except AttributeError:
+            log.debug("Failed to get img title", self)
+
+        # !! GET thumbnail_src
+        try:
+            thumbnail_src = result.a.img.get('src')
+        except AttributeError:
+            log.debug("Failed to get img thumbnail url", self)
+        # Make sure the thumbnail url is absolute
+        thumbnail_src = self.makeUrlAbsolute(thumbnail_src)
+
+        # !! GET url
+        felem = result.find("p", {"class": "result-url"})
+        if felem:
+            try:
+                url = felem.a.get('href')
+            except AttributeError:
+                log.debug("Failed to get img url (1)", self)
+        else:
+            log.debug("Failed to get img url (2)", self)
+
+        # !! GET img_src
+        felem = result.find("a", {"class": "result-images-source"})
+        if felem:
+            img_src = felem.get('href')
+            img_src = fixUrlScheme(img_src)  # Make sure it has a scheme
+        else:
+            log.debug("Failed to get img_src", self)
+
+        ## !! GET content
+        # p class=result-content
+        felem = result.find("p", {"class": "result-content"})
+        if felem:
+            content = felem.get_text()
+        else:
+            log.debug("Failed to get img content", self)
+
+        # !! GET img_format
+        # p class=result-format
+        felem = result.find("p", {"class": "result-format"})
+        if felem:
+            img_format = felem.get_text()
+        else:
+            log.debug("Failed to get img format", self)
+
+        # !! GET source
+        felem = result.find("span", {"class": "source"})
+        if felem:
+            source = felem.get_text()
+        else:
+            log.debug("Failed to get img source", self)
+
+        # !! GET engines
+        #<p class="result-engine">
+        felem = result.find("p", {"class": "result-engine"})
+        if felem:
+            for engine in felem.find_all("span"):
+                engines.append(engine.nextSibling.get_text().replace(' ', '-'))
+        else:
+            log.debug("Failed to get img source", self)
+
+        return {
+            'title': title,
+            'url': url,
+            'content': content,
+            'engines': [engine for engine in engines],
+            'img_format': img_format,
+            'img_src': img_src,
+            'thumbnail_src': thumbnail_src,
+            'source': source,
+            'category': category
+        }
+
+    def parse(self):
         if self.errorType() != ErrorType.Success:
         if self.errorType() != ErrorType.Success:
             return {}
             return {}
 
 
@@ -117,8 +272,24 @@ class SearchResult2(SearchResult):
 
 
         #######################################################################
         #######################################################################
         ## 'results' key
         ## 'results' key
-        ##########################################################################
-        for result in soup.find_all("article", {"class": "result"}):
+        #######################################################################
+        def _getResults():
+            # Because the element may be a 'article' or 'div', also depending
+            # on the category of the result.
+            for result in soup.find_all("article", {"class": "result"}):
+                yield result
+            for result in soup.find_all("div", {"class": "result"}):
+                yield result
+
+        for result in _getResults():
+            # Image results
+            if "result-images" in result.get("class"):
+                jsonResult['results'].append(
+                    self.parseImagesResult(result)
+                )
+                continue
+
+            # Normal search results
             """
             """
             <article class="result result-default category-general qwant duckduckgo google">
             <article class="result result-default category-general qwant duckduckgo google">
               <a href="https://linuxize.com/post/curl-post-request/" class="url_wrapper" rel="noreferrer">
               <a href="https://linuxize.com/post/curl-post-request/" class="url_wrapper" rel="noreferrer">
@@ -419,7 +590,7 @@ class SearchResult2(SearchResult):
             # Image
             # Image
             felem = infobox.find("img")
             felem = infobox.find("img")
             if felem:
             if felem:
-                img_src = felem.get("src")
+                img_src = self.makeUrlAbsolute(felem.get("src"))
 
 
             # URLs
             # URLs
             for felem in infobox.find_all("li", {"class": "url"}):
             for felem in infobox.find_all("li", {"class": "url"}):

+ 15 - 2
searxqt/main.py

@@ -72,7 +72,8 @@ from searxqt.views import about
 from searxqt.views.profiles import ProfileChooserDialog
 from searxqt.views.profiles import ProfileChooserDialog
 
 
 from searxqt.core.customAnchorCmd import AnchorCMD
 from searxqt.core.customAnchorCmd import AnchorCMD
-from searxqt.core.requests import RequestsHandler
+from searxqt.core.images import ImagesSettings
+from searxqt.core.requests import RequestsHandler, RequestSettingsWithParent
 from searxqt.core.guard import Guard
 from searxqt.core.guard import Guard
 
 
 from searxqt.translations import _
 from searxqt.translations import _
@@ -93,8 +94,12 @@ class MainWindow(QMainWindow):
 
 
         # Request handler
         # Request handler
         self._requestHandler = RequestsHandler()
         self._requestHandler = RequestsHandler()
+        imgRequestSettings = RequestSettingsWithParent(self._requestHandler.settings)
+        self._imgRequestHandler = RequestsHandler(imgRequestSettings)
 
 
-        self._settingsModel = SettingsModel(self._requestHandler.settings, self)
+        self._settingsModel = SettingsModel(self._requestHandler.settings,
+                                            self._imgRequestHandler.settings,
+                                            self)
 
 
         # Persistent models
         # Persistent models
         self._persistantInstancesModel = PersistentInstancesModel()
         self._persistantInstancesModel = PersistentInstancesModel()
@@ -176,6 +181,7 @@ class MainWindow(QMainWindow):
 
 
         self.searchContainer = SearchContainer(
         self.searchContainer = SearchContainer(
             self._searchModel,
             self._searchModel,
+            self._imgRequestHandler,
             self.instanceFilter,
             self.instanceFilter,
             self.instanceSelecter,
             self.instanceSelecter,
             self._persistantEnginesModel,
             self._persistantEnginesModel,
@@ -404,6 +410,10 @@ class MainWindow(QMainWindow):
             profileSettings.value('searchModel', dict(), dict)
             profileSettings.value('searchModel', dict(), dict)
         )
         )
 
 
+        ImagesSettings.deserialize(
+            profileSettings.value('imgSettings', dict(), dict)
+        )
+
         # Guard
         # Guard
         self._guard.deserialize(profileSettings.value('Guard', dict(), dict))
         self._guard.deserialize(profileSettings.value('Guard', dict(), dict))
 
 
@@ -474,6 +484,9 @@ class MainWindow(QMainWindow):
         profileSettings.setValue(
         profileSettings.setValue(
             'searchModel', self._searchModel.saveSettings()
             'searchModel', self._searchModel.saveSettings()
         )
         )
+        profileSettings.setValue(
+            'imgSettings', ImagesSettings.serialize()
+        )
 
 
         # Guard
         # Guard
         profileSettings.setValue('Guard', self._guard.serialize())
         profileSettings.setValue('Guard', self._guard.serialize())

+ 1 - 1
searxqt/models/instances.py

@@ -147,7 +147,7 @@ class Stats2Model(Stats2, ThreadManagerProto):
 
 
         return True
         return True
 
 
-    def __updateInstancesThreadFinished(self):
+    def __updateInstancesThreadFinished(self, thread):
         result = self._thread.result()
         result = self._thread.result()
         if result:
         if result:
             log.info('Instances updated!', self)
             log.info('Instances updated!', self)

+ 1 - 1
searxqt/models/search.py

@@ -371,7 +371,7 @@ class UserInstancesHandler(SearxConfigHandler, ThreadManagerProto):
         self._thread.deleteLater()
         self._thread.deleteLater()
         self._thread = None
         self._thread = None
 
 
-    def __updateInstanceThreadFinished(self):
+    def __updateInstanceThreadFinished(self, thread):
         result = self._thread.result()
         result = self._thread.result()
 
 
         self.__clearUpdateThread()
         self.__clearUpdateThread()

+ 0 - 0
searxqt/models/settings.py


Some files were not shown because too many files changed in this diff