search.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  1. ########################################################################
  2. # Searx-qt - Lightweight desktop application for SearX.
  3. # Copyright (C) 2020 CYBERDEViL
  4. #
  5. # This file is part of Searx-qt.
  6. #
  7. # Searx-qt is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # Searx-qt is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  19. #
  20. ########################################################################
  21. from PyQt5.QtWidgets import (
  22. QWidget,
  23. QVBoxLayout,
  24. QHBoxLayout,
  25. QLineEdit,
  26. QTextBrowser,
  27. QCheckBox,
  28. QLabel,
  29. QComboBox,
  30. QFrame,
  31. QMenu,
  32. QWidgetAction,
  33. QSpacerItem,
  34. QSizePolicy
  35. )
  36. from PyQt5.QtCore import pyqtSignal, QThread, Qt, QVariant
  37. from searxqt.models.search import SearchStatus
  38. from searxqt.widgets.buttons import Button, CheckboxOptionsButton
  39. class Thread(QThread):
  40. finished = pyqtSignal()
  41. def __init__(self, func, args=[], kwargs={}, parent=None):
  42. QThread.__init__(self, parent=parent)
  43. self._func = func
  44. self._args = args
  45. self._kwargs = kwargs
  46. self._result = None
  47. def result(self): return self._result
  48. def run(self):
  49. self._result = self._func(*self._args, **self._kwargs)
  50. self.finished.emit()
  51. class SearchNavigation(QWidget):
  52. requestPage = pyqtSignal(int) # pageno
  53. def __init__(self, parent=None):
  54. QWidget.__init__(self, parent=parent)
  55. layout = QHBoxLayout(self)
  56. self.prevPageButton = Button("<<", self)
  57. self.pageNoLabel = QLabel("1", self)
  58. self.nextPageButton = Button(">>", self)
  59. layout.addWidget(self.prevPageButton, 0, Qt.AlignLeft)
  60. layout.addWidget(self.pageNoLabel, 0, Qt.AlignCenter)
  61. layout.addWidget(self.nextPageButton, 0, Qt.AlignRight)
  62. self.prevPageButton.setEnabled(False)
  63. self.nextPageButton.clicked.connect(self._nextPage)
  64. self.prevPageButton.clicked.connect(self._prevPage)
  65. self.reset()
  66. def _updateLabel(self):
  67. self.pageNoLabel.setText(str(self._pageno))
  68. def _nextPage(self):
  69. self._pageno += 1
  70. if self._pageno > 1 and not self.prevPageButton.isEnabled():
  71. self.prevPageButton.setEnabled(True)
  72. self._updateLabel()
  73. self.requestPage.emit(self._pageno)
  74. def _prevPage(self):
  75. self._pageno -= 1
  76. if self._pageno == 1:
  77. self.prevPageButton.setEnabled(False)
  78. self.setNextEnabled(True)
  79. self._updateLabel()
  80. self.requestPage.emit(self._pageno)
  81. def reset(self):
  82. self._pageno = 1
  83. self.prevPageButton.setEnabled(False)
  84. self._updateLabel()
  85. def setNextEnabled(self, state):
  86. self.nextPageButton.setEnabled(state)
  87. class SearchEngines(CheckboxOptionsButton):
  88. def __init__(self, searchModel, instancesModel, parent=None):
  89. """
  90. @param searchModel: needed for getting and setting current
  91. enabled/disabled engines.
  92. @type searchModel: SearchModel
  93. @param instancesModel: needed for listing current available
  94. engines and update it's current filter
  95. to filter out instances without atleast
  96. one of the required engine(s).
  97. @type instancesModel: InstanceModelFilter
  98. """
  99. self._instancesModel = instancesModel
  100. self._searchModel = searchModel
  101. CheckboxOptionsButton.__init__(
  102. self,
  103. labelName="Engines",
  104. parent=parent
  105. )
  106. """ Below are re-implementations.
  107. """
  108. def getCheckedOptionNames(self):
  109. """ Should return a list with checked option names.
  110. @return: should return a list with strings.
  111. @rtype: list
  112. """
  113. return self._searchModel.engines
  114. def getOptions(self):
  115. """ Should return a list with options tuple(key, name, state)
  116. This will be used to generate the options.
  117. """
  118. list_ = []
  119. tmp = []
  120. for url, instance in self._instancesModel.items():
  121. for engine in instance.engines:
  122. if engine.enabled and engine.name not in tmp:
  123. state = engine.name in self.getCheckedOptionNames()
  124. list_.append((engine.name, engine.name, state))
  125. tmp.append(engine.name)
  126. return sorted(list_)
  127. def optionToggled(self, key, state):
  128. if state:
  129. self._searchModel.engines.append(key)
  130. else:
  131. self._searchModel.engines.remove(key)
  132. self._instancesModel.updateKwargs(
  133. {'engines': self.getCheckedOptionNames()}
  134. )
  135. class SearchCategories(CheckboxOptionsButton):
  136. def __init__(self, searchModel, parent=None):
  137. self._searchModel = searchModel
  138. CheckboxOptionsButton.__init__(
  139. self,
  140. labelName="Categories",
  141. parent=parent
  142. )
  143. """ Below are re-implementations.
  144. """
  145. def getCheckedOptionNames(self):
  146. """ Should return a list with checked option names.
  147. @return: should return a list with strings.
  148. @rtype: list
  149. """
  150. return(
  151. [value[0] for value in
  152. self._searchModel.categories.types.values()]
  153. )
  154. def getOptions(self):
  155. """ Should return a list with options tuple(key, name, state)
  156. This will be used to generate the options.
  157. """
  158. list_ = []
  159. for key, t in self._searchModel.categories.types.items():
  160. state = self._searchModel.categories.get(key)
  161. list_.append((key, t[0], state))
  162. return list_
  163. def optionToggled(self, key, state):
  164. self._searchModel.categories.set(key, state)
  165. class SearchPeriod(QComboBox):
  166. def __init__(self, model, parent=None):
  167. QComboBox.__init__(self, parent=parent)
  168. self._model = model
  169. self.setMinimumContentsLength(2)
  170. for period in model.Periods:
  171. self.addItem(model.Periods[period], QVariant(period))
  172. self.currentIndexChanged.connect(self.__indexChanged)
  173. def __indexChanged(self, index):
  174. self._model.timeRange = self.currentData()
  175. class SearchLanguage(QComboBox):
  176. def __init__(self, model, parent=None):
  177. QComboBox.__init__(self, parent=parent)
  178. self._model = model
  179. self.setMinimumContentsLength(2)
  180. for lang in model.Languages:
  181. self.addItem(model.Languages[lang], QVariant(lang))
  182. self.currentIndexChanged.connect(self.__indexChanged)
  183. def __indexChanged(self, index):
  184. self._model.lang = self.currentData()
  185. class SearchOptionsContainer(QFrame):
  186. """ Custom QFrame to be able to show or hide certain widgets.
  187. """
  188. def __init__(self, searchModel, instancesModel, parent=None):
  189. QFrame.__init__(self, parent=parent)
  190. layout = QHBoxLayout(self)
  191. self._widgets = {
  192. 'categories': SearchCategories(searchModel, self),
  193. 'engines': SearchEngines(searchModel, instancesModel, self),
  194. 'period': SearchPeriod(searchModel, self),
  195. 'lang': SearchLanguage(searchModel, self)
  196. }
  197. for widget in self._widgets.values():
  198. layout.addWidget(widget, 0, Qt.AlignTop)
  199. # Keep widgets left aligned.
  200. spacer = QSpacerItem(
  201. 40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum
  202. )
  203. layout.addItem(spacer)
  204. def saveSettings(self):
  205. data = {}
  206. for key, widget in self._widgets.items():
  207. data.update(
  208. {
  209. '{0}Visible'.format(key): not widget.isHidden()
  210. }
  211. )
  212. return data
  213. def loadSettings(self, data):
  214. for key, widget in self._widgets.items():
  215. if data.get('{0}Visible'.format(key), True):
  216. widget.show()
  217. else:
  218. widget.hide()
  219. def __checkBoxStateChanged(self, key, state):
  220. if state:
  221. self._widgets[key].show()
  222. else:
  223. self._widgets[key].hide()
  224. """ QFrame re-implementations
  225. """
  226. def contextMenuEvent(self, event):
  227. menu = QMenu(self)
  228. menu.addSection("Show / Hide")
  229. for key, widget in self._widgets.items():
  230. action = QWidgetAction(menu)
  231. checkbox = QCheckBox(key, menu)
  232. checkbox.setTristate(False)
  233. checkbox.setChecked(not widget.isHidden())
  234. action.setDefaultWidget(checkbox)
  235. checkbox.stateChanged.connect(
  236. lambda state, key=key:
  237. self.__checkBoxStateChanged(key, state)
  238. )
  239. menu.addAction(action)
  240. menu.exec_(self.mapToGlobal(event.pos()))
  241. class SearchContainer(QWidget):
  242. def __init__(self, searchModel, instancesModel,
  243. instanceSelecter, parent=None):
  244. """
  245. @type searchModel: models.search.SearchModel
  246. @type instancesModel: models.instances.InstancesModelFilter
  247. @type instanceSelecter: models.instances.instanceSelecterModel
  248. """
  249. QWidget.__init__(self, parent=parent)
  250. layout = QVBoxLayout(self)
  251. self._model = searchModel
  252. self._instancesModel = instancesModel
  253. self._instanceSelecter = instanceSelecter
  254. self._searchThread = None
  255. # Current fail count.
  256. self._searchFailCount = 0
  257. # Maximum other instances to try on fail.
  258. self._maxSearchFailCount = 10
  259. # Set `_useFallback` to True to try another instance when the
  260. # search failed somehow or set to False to try same instance or
  261. # for pagination which also should use the same instance.
  262. self._useFallback = True
  263. # `_fallbackActive` should be False when a fresh list of fallback
  264. # instances should be picked on failed search and should be True
  265. # when search(es) fail and `_fallbackInstancesQueue` is beeing
  266. # used.
  267. self._fallbackActive = False
  268. # Every first request that has `_useFallback` set to True will
  269. # use this list as a resource for next instance(s) to try until
  270. # it is out of instance url's. Also on the first request this
  271. # list will be cleared and filled again with `_maxSearchFailCount`
  272. # of random instances.
  273. self._fallbackInstancesQueue = []
  274. # Set to True to break out of the fallback loop.
  275. # This is used for the Stop action.
  276. self._breakFallback = False
  277. searchLayout = QHBoxLayout()
  278. layout.addLayout(searchLayout)
  279. # -- Start search bar
  280. self.queryEdit = QLineEdit(self)
  281. self.queryEdit.setPlaceholderText("Search for ..")
  282. searchLayout.addWidget(self.queryEdit)
  283. self.searchButton = Button("Searx", self)
  284. self.searchButton.setToolTip("Preform search.")
  285. searchLayout.addWidget(self.searchButton)
  286. self.reloadButton = Button("♻", self)
  287. self.reloadButton.setToolTip("Reload")
  288. searchLayout.addWidget(self.reloadButton)
  289. self.randomButton = Button("⤳", self)
  290. self.randomButton.setToolTip("Search with random instance.\n"
  291. "(Obsolete when 'Random Every is "
  292. "checked')")
  293. searchLayout.addWidget(self.randomButton)
  294. rightLayout = QVBoxLayout()
  295. rightLayout.setSpacing(0)
  296. searchLayout.addLayout(rightLayout)
  297. self._fallbackCheck = QCheckBox("Fallback", self)
  298. self._fallbackCheck.setToolTip("Try random other instance on fail.")
  299. rightLayout.addWidget(self._fallbackCheck)
  300. self._randomCheckEvery = QCheckBox("Random every", self)
  301. self._randomCheckEvery.setToolTip("Pick a random instance for "
  302. "every request.")
  303. rightLayout.addWidget(self._randomCheckEvery)
  304. # -- End search bar
  305. # -- Start search options toolbar
  306. self._optionsContainer = SearchOptionsContainer(
  307. searchModel, instancesModel, self
  308. )
  309. layout.addWidget(self._optionsContainer)
  310. # -- End search options toolbar
  311. self.resultsContainer = QTextBrowser(self)
  312. self.resultsContainer.setOpenExternalLinks(True)
  313. self.resultsContainer.setLineWrapMode(1)
  314. layout.addWidget(self.resultsContainer)
  315. self.navBar = SearchNavigation(self)
  316. self.navBar.setEnabled(False)
  317. layout.addWidget(self.navBar)
  318. self.queryEdit.textChanged.connect(self.__queryChanged)
  319. self._model.statusChanged.connect(self.__searchStatusChanged)
  320. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  321. self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
  322. self._randomCheckEvery.stateChanged.connect(
  323. self.__randomEveryRequestChanged)
  324. self._instanceSelecter.instanceChanged.connect(
  325. self.__instanceChanged)
  326. self.queryEdit.returnPressed.connect(self.__searchButtonClicked)
  327. self.searchButton.clicked.connect(self.__searchButtonClicked)
  328. self.reloadButton.clicked.connect(self.__reloadButtonClicked)
  329. self.randomButton.clicked.connect(self.__randomSearchButtonClicked)
  330. self.navBar.requestPage.connect(self.__navBarRequest)
  331. self.__queryChanged("")
  332. def __searchButtonClicked(self, checked=0):
  333. # Set to use fallback
  334. self._useFallback = self._model.useFallback
  335. if (self._model.randomEvery or
  336. (self._useFallback and
  337. not self._instanceSelecter.currentUrl)):
  338. self._instanceSelecter.randomInstance()
  339. self._resetPagination()
  340. self._newSearch(self._instanceSelecter.currentUrl)
  341. def __stopButtonClicked(self):
  342. self._breakFallback = True
  343. self.searchButton.setEnabled(False)
  344. def __reloadButtonClicked(self):
  345. self._useFallback = False
  346. self._newSearch(self._instanceSelecter.currentUrl)
  347. def __randomSearchButtonClicked(self):
  348. self._useFallback = self._model.useFallback
  349. self._instanceSelecter.randomInstance()
  350. self._newSearch(self._instanceSelecter.currentUrl)
  351. def __navBarRequest(self, pageNo):
  352. self._useFallback = False
  353. self._model.pageno = pageNo
  354. self._newSearch(self._instanceSelecter.currentUrl)
  355. def _resetPagination(self):
  356. self.navBar.reset()
  357. self._model.pageno = 1
  358. def __instanceChanged(self):
  359. self._resetPagination()
  360. def __searchOptionsChanged(self):
  361. """ From the model (on load settings)
  362. """
  363. self._randomCheckEvery.stateChanged.disconnect(
  364. self.__randomEveryRequestChanged)
  365. self._fallbackCheck.stateChanged.disconnect(
  366. self.__useFallbackChanged)
  367. self._randomCheckEvery.setChecked(self._model.randomEvery)
  368. self._fallbackCheck.setChecked(self._model.useFallback)
  369. self.__handleRandomButtonState()
  370. self._randomCheckEvery.stateChanged.connect(
  371. self.__randomEveryRequestChanged)
  372. self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
  373. def __handleRandomButtonState(self):
  374. """ Hides or shows the 'Random search button'.
  375. We don't need the button when the model it's randomEvery is True.
  376. """
  377. if self._model.randomEvery:
  378. self.randomButton.hide()
  379. else:
  380. self.randomButton.show()
  381. def __randomEveryRequestChanged(self, state):
  382. """ From the checkbox
  383. """
  384. self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
  385. self._model.randomEvery = bool(state)
  386. self.__handleRandomButtonState()
  387. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  388. def __useFallbackChanged(self, state):
  389. """ From the checkbox
  390. """
  391. self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
  392. self._model.useFallback = bool(state)
  393. self._model.optionsChanged.connect(self.__searchOptionsChanged)
  394. def __queryChanged(self, q):
  395. if self._model.status() == SearchStatus.Busy:
  396. return
  397. if q:
  398. self.searchButton.setEnabled(True)
  399. self.reloadButton.setEnabled(True)
  400. self.randomButton.setEnabled(True)
  401. else:
  402. self.searchButton.setEnabled(False)
  403. self.reloadButton.setEnabled(False)
  404. self.randomButton.setEnabled(False)
  405. def _setOptionsState(self, state=True):
  406. if self._useFallback:
  407. if state:
  408. # Search stopped
  409. self.searchButton.setText("Search")
  410. self.searchButton.clicked.disconnect(
  411. self.__stopButtonClicked
  412. )
  413. self.searchButton.clicked.connect(self.__searchButtonClicked)
  414. self.searchButton.setEnabled(True)
  415. else:
  416. # Searching
  417. self.searchButton.setText("Stop")
  418. self.searchButton.clicked.disconnect(
  419. self.__searchButtonClicked
  420. )
  421. self.searchButton.clicked.connect(self.__stopButtonClicked)
  422. else:
  423. self.searchButton.setEnabled(state)
  424. self.reloadButton.setEnabled(state)
  425. self.randomButton.setEnabled(state)
  426. self._randomCheckEvery.setEnabled(state)
  427. self._fallbackCheck.setEnabled(state)
  428. self.queryEdit.setEnabled(state)
  429. self._optionsContainer.setEnabled(state)
  430. def __searchStatusChanged(self, status):
  431. if status == SearchStatus.Busy:
  432. self._setOptionsState(False)
  433. elif status == SearchStatus.Done:
  434. self._setOptionsState()
  435. @property
  436. def query(self): return self.queryEdit.text()
  437. def _newSearch(self, url, query=''):
  438. self.resultsContainer.clear()
  439. self._search(url, query)
  440. def _search(self, url, query=''):
  441. if self._searchThread:
  442. return
  443. if not query:
  444. query = self.query
  445. if not query:
  446. self.resultsContainer.setHtml("Please enter a search query.")
  447. return
  448. if not url:
  449. self.resultsContainer.setHtml("Please select a instance first.")
  450. return
  451. self.navBar.setEnabled(False)
  452. self._model.url = url
  453. self._model.query = query
  454. kwargs = {
  455. 'requestKwargs': {
  456. 'data': {
  457. 'q': query,
  458. 'format': 'json'
  459. },
  460. }
  461. }
  462. kwargs['requestKwargs'].update(self._model.requestSettings.data)
  463. self._searchThread = Thread(self._model.search, kwargs=kwargs,
  464. parent=self)
  465. self._searchThread.finished.connect(self._searchFinished)
  466. self._searchThread.start()
  467. def _searchFailed(self, result):
  468. if self._useFallback: # Re-try another instance
  469. if self._breakFallback: # Stop button pressed
  470. self._breakFallback = False
  471. return
  472. if not self._fallbackActive:
  473. # Get new list with instances to try same request.
  474. self._fallbackActive = True
  475. self._fallbackInstancesQueue.clear()
  476. self._fallbackInstancesQueue = (
  477. self._instanceSelecter.getRandomInstances(
  478. amount=self._maxSearchFailCount))
  479. if not self._fallbackInstancesQueue:
  480. self.resultsContainer.setHtml(
  481. "Max fail count reached! ({0})".format(
  482. self._maxSearchFailCount))
  483. self._fallbackActive = False
  484. return
  485. # Set next instance url to try.
  486. self._instanceSelecter.currentUrl = (
  487. self._fallbackInstancesQueue.pop(0))
  488. self._search(self._instanceSelecter.currentUrl)
  489. return
  490. if self._model.pageno > 1:
  491. self.navBar.setEnabled(True)
  492. self.navBar.setNextEnabled(False)
  493. self.resultsContainer.setHtml("Search failed: {} {}"
  494. .format(result.errorType(),
  495. result.error()))
  496. def _searchFinished(self):
  497. result = self._searchThread.result()
  498. self._clearSearchThread()
  499. if not bool(result): # Failed
  500. self._searchFailed(result)
  501. return
  502. self._fallbackActive = False
  503. # Create HTML from results
  504. elemStr = ""
  505. for item in result.json().get('results', {}):
  506. elemStr += self.createResultElement(item)
  507. self.resultsContainer.setHtml(self.compileHtml(elemStr))
  508. self.navBar.setEnabled(True)
  509. self.navBar.setNextEnabled(True)
  510. def _clearSearchThread(self):
  511. self._searchThread.finished.disconnect(self._searchFinished)
  512. self._searchThread.deleteLater()
  513. self._searchThread = None
  514. def createResultElement(self, data):
  515. """result['title']
  516. result['content']
  517. result['url']
  518. result['engine']
  519. """
  520. elem = """<div class="result" id="result-{id}">
  521. <h4 class="result-title"><i>{engine}: </i><a href="{url}">{title}</a></h4>
  522. <p class="result-description">{content}</p>
  523. <p class="result-url">{url}</p>
  524. </div>""".format(id="TODO-id", title=data.get('title', ''),
  525. url=data.get('url', ''), content=data.get('content', ''),
  526. engine=data.get('engine', '?'))
  527. return elem
  528. def compileHtml(self, elemStr=""):
  529. return """<html>
  530. <head>
  531. <title>Yeah</title>
  532. </head>
  533. <body>
  534. {0}
  535. </body>
  536. </html>""".format(elemStr)
  537. def saveSettings(self):
  538. return {
  539. 'searchOptions': self._optionsContainer.saveSettings()
  540. }
  541. def loadSettings(self, data):
  542. self._optionsContainer.loadSettings(
  543. data.get('searchOptions', {})
  544. )