Author SHA1 Message Date
searxng-bot 448ced4b18 [mod] data: update searx.data - update_wikidata.py 2026-10-29 00:17:16 +00:00
Bnyro c360868e5b [feat] xprivo: properly detect ratelimits instead of showing HTTP errors 2026-10-28 10:54:38 +01:00
dependabot[bot] b5ed73fcb6 [upd] pypi: Bump python-dateutil from 2.9.0.post0 to 3.0.0 (#6888)
Bumps [python-dateutil](https://github.com/dateutil/dateutil) from 2.9.0.post0 to 3.0.0.
- [Release notes](https://github.com/dateutil/dateutil/releases)
- [Changelog](https://github.com/dateutil/dateutil/blob/master/NEWS)
- [Commits](https://github.com/dateutil/dateutil/compare/2.9.0.post0...3.0.0)

---
updated-dependencies:
- dependency-name: python-dateutil
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-27 21:33:35 +01:00
dependabot[bot] 28423d332d [upd] pypi: Bump the minor group with 2 updates (#6887)
Bumps the minor group with 2 updates: [certifi](https://github.com/certifi/python-certifi) and [granian](https://github.com/emmett-framework/granian).


Updates `certifi` from 2026.10.19 to 2026.10.23
- [Commits](https://github.com/certifi/python-certifi/compare/2026.10.19...2026.10.23)

Updates `granian` from 2.8.4 to 2.8.5
- [Release notes](https://github.com/emmett-framework/granian/releases)
- [Commits](https://github.com/emmett-framework/granian/compare/v2.8.4...v2.8.5)

---
updated-dependencies:
- dependency-name: certifi
  dependency-version: 2026.10.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: granian
  dependency-version: 2.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-27 20:00:45 +01:00
Brock Vojkovic ef7bae9ddb [upd] pypi: Bump curl_cffi from 0.16.3 to 0.17.0 (#6890)
Fixes HTTP2Shell (CVE-2026-111497).

Signed-off-by: vojkovic <git@vojk.au>
2026-10-27 17:00:58 +08:00
Brock Vojkovic cb74e7f810 [fix] autocomplete: apply outgoing request timeout (#6886)
Fixes update_kwargs so it does not throw away the dict and instead uses the correct settings.

Signed-off-by: vojkovic <git@vojk.au>
2026-10-27 10:54:52 +08:00
Sandro 54ca1d9cfb [fix] tagesschau: remove from general category (#6882) 2026-10-25 19:34:17 +01:00
github-actions[bot]andsearxng-bot ef0ec03da0 [l10n] update translations from Weblate (#6850)
3dfe3d3f7 - 2026-10-19 - cyh21185 <cyh21185@noreply.codeberg.org>
896dc9faf - 2026-10-20 - marc-lopez <marc-lopez@noreply.codeberg.org>
f53baeeae - 2026-10-16 - marc-lopez <marc-lopez@noreply.codeberg.org>

Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-10-24 10:46:18 +02:00
dependabot[bot] 629368e40c [upd] web-client (simple): Bump the minor group (#6880)
Bumps the minor group in /client/simple with 2 updates: [browserslist](https://github.com/browserslist/browserslist) and [sort-package-json](https://github.com/keithamus/sort-package-json).


Updates `browserslist` from 4.29.3 to 4.29.4
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.29.3...4.29.4)

Updates `sort-package-json` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/keithamus/sort-package-json/releases)
- [Commits](https://github.com/keithamus/sort-package-json/compare/v4.0.0...v4.1.0)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.29.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: sort-package-json
  dependency-version: 4.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 20:33:46 +02:00
dependabot[bot] 68647d0806 [upd] pypi: Bump certifi from 2026.7.22 to 2026.10.19 in the minor group (#6881)
Bumps the minor group with 1 update: [certifi](https://github.com/certifi/python-certifi).


Updates `certifi` from 2026.7.22 to 2026.10.19
- [Commits](https://github.com/certifi/python-certifi/compare/2026.07.22...2026.10.19)

---
updated-dependencies:
- dependency-name: certifi
  dependency-version: 2026.10.19
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-23 20:33:12 +02:00
Bnyro 81371861f6 [del] engines: remove andisearch for now - #687
This reverts commit 2a6fc07be3.
2026-10-19 23:27:52 +02:00
WookieeOnCrack d47cb96bfb [feat] brave: parse JavaScript IIFE to fix engine
- Should resolve #6819 by reverting Accept-Encoding headers back to the curl_cffi default value
- Adds IIFE Parsing / Data Extraction that appears after headers update
2026-10-19 13:30:53 +02:00
jerry155756294 62366b05c0 [fix] engine - DDG: ignore zero-click answers without URLs (#6865)
If the provider uses DDG, and a HEX color code is searched for (for example, `A87771`), it directly reports `SearxEngineXPathException: index 0 not found` (the Web UI displays a parsing error), because DDG displays an icon for a color code.  This causes the parser to be unable to parse that icon. This fix directly ignores this special function.
2026-10-19 08:44:54 +02:00
Ivan Gabaldon 1c10abe2d3 [enh] container: nitpicks (#6862)
Reduce Valkey writes to database
Should error with 1, not 127
2026-10-17 20:30:22 +02:00
Ivan Gabaldon 3afd949268 [enh] container: merge dockerfiles (#6861)
* [enh] container: merge dockerfiles

Leftover from https://github.com/searxng/searxng/pull/6720

* [enh] apply suggestion

https://github.com/searxng/searxng/pull/6861#discussion_r4302473383
2026-10-17 20:30:09 +02:00
Bnyro 2a6fc07be3 [feat] engines: add andisearch (general) 2026-10-17 11:16:04 +02:00
dependabot[bot] decdfa6f7d [upd] pypi: Bump the minor group with 4 updates (#6852)
Bumps the minor group with 4 updates: [black](https://github.com/psf/black), [pylint](https://github.com/pylint-dev/pylint), [selenium](https://github.com/SeleniumHQ/Selenium) and [typer](https://github.com/fastapi/typer).


Updates `black` from 26.10.0 to 26.10.1
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/26.10.0...26.10.1)

Updates `pylint` from 4.1.2 to 4.1.3
- [Release notes](https://github.com/pylint-dev/pylint/releases)
- [Commits](https://github.com/pylint-dev/pylint/compare/v4.1.2...v4.1.3)

Updates `selenium` from 4.50.0 to 4.51.0
- [Release notes](https://github.com/SeleniumHQ/Selenium/releases)
- [Commits](https://github.com/SeleniumHQ/Selenium/compare/selenium-4.50.0...selenium-4.51.0)

Updates `typer` from 0.27.2 to 0.27.3
- [Release notes](https://github.com/fastapi/typer/releases)
- [Changelog](https://github.com/fastapi/typer/blob/master/docs/release-notes.md)
- [Commits](https://github.com/fastapi/typer/compare/0.27.2...0.27.3)

---
updated-dependencies:
- dependency-name: black
  dependency-version: 26.10.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: pylint
  dependency-version: 4.1.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: selenium
  dependency-version: 4.51.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: typer
  dependency-version: 0.27.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 10:15:00 +02:00
Bnyro 8252c0bf6d [chore] resulthunter: fix broken linter 2026-10-16 18:24:21 +02:00
Bnyro 47aa11475b [fix] resulthunter: blocked with access denied 2026-10-16 18:17:43 +02:00
dependabot[bot] 0ce72dd2cc [upd] github-actions: Bump actions/upload-artifact from 7.0.1 to 7.0.2 (#6851)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.1 to 7.0.2.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 14:56:49 +02:00
dependabot[bot] c6d7fe91d3 [upd] web-client (simple): Bump @types/node (#6849)
Bumps the minor group in /client/simple with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 26.6.4 to 26.6.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.6.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 14:56:19 +02:00
dependabot[bot] 7847767832 [upd] github-actions: Bump actions/download-artifact from 8.0.1 to 8.0.2 (#6848)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 8.0.1 to 8.0.2.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 14:55:46 +02:00
dependabot[bot] d2209bfbb0 [upd] github-actions: Bump actions/setup-node from 7.0.0 to 7.1.0 (#6847)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 7.0.0 to 7.1.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 14:55:17 +02:00
Aadniz ca8e1582be [build] /static 2026-10-16 13:27:46 +02:00
Aadniz 243d3a9df2 [fix] image descriptions disappearing on firefox 2026-10-16 13:27:46 +02:00
Bnyro ff8f5ddcc9 [fix] yep: send x-csrf-protection header to bypass botdetection 2026-10-16 00:18:44 +02:00
Brock Vojkovic b00212a836 [mod] image proxy: /image with the default network (#6816)
- Changes the path of the /image_proxy endpoint to /image. This ensures that if the user copies the image through the browser, it is named image.png rather than image_proxy.png as the latter can be attributed to searxng hence preserving the privacy of the user.
- Removed the dedicated network for the image proxy. Previously this was used to force http/2 off but since moving to curl from httpx this is not as much of a problem. Now instead uses the default network.

Signed-off-by: vojkovic <git@vojk.au>
2026-10-15 06:59:50 +08:00
Bnyro 22c64b8d39 [fix] xprivo: crashes when parsing response 2026-10-13 12:15:09 +02:00
ky0nshi ad46ebd7d8 [fix] typo in ayo engine - https://search.ayo.de (#6817)
typo in the address fixed -> https://search.ayo.de
2026-10-11 17:52:29 +02:00
dependabot[bot] f4822b3fc4 [upd] web-client (simple): Bump the minor group (#6826)
Bumps the minor group in /client/simple with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [ol](https://github.com/openlayers/openlayers) | `10.10.0` | `10.11.0` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.14` | `2.5.15` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.6.3` | `26.6.4` |
| [browserslist](https://github.com/browserslist/browserslist) | `4.29.2` | `4.29.3` |
| [stylelint](https://github.com/stylelint/stylelint) | `17.15.0` | `17.16.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.1` | `8.3.3` |

Updates `ol` from 10.10.0 to 10.11.0
- [Release notes](https://github.com/openlayers/openlayers/releases)
- [Commits](https://github.com/openlayers/openlayers/compare/v10.10.0...v10.11.0)

Updates `@biomejs/biome` from 2.5.14 to 2.5.15
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.15/packages/@biomejs/biome)

Updates `@types/node` from 26.6.3 to 26.6.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `browserslist` from 4.29.2 to 4.29.3
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.29.2...4.29.3)

Updates `stylelint` from 17.15.0 to 17.16.0
- [Release notes](https://github.com/stylelint/stylelint/releases)
- [Changelog](https://github.com/stylelint/stylelint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stylelint/stylelint/compare/17.15.0...17.16.0)

Updates `vite` from 8.3.1 to 8.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.3/packages/vite)

---
updated-dependencies:
- dependency-name: ol
  dependency-version: 10.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: "@types/node"
  dependency-version: 26.6.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: browserslist
  dependency-version: 4.29.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: stylelint
  dependency-version: 17.16.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: vite
  dependency-version: 8.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-09 20:59:16 +02:00
Markus Heiser 9f042d2f67 [build] /static 2026-10-09 16:16:48 +02:00
DangTAnh 815f58994a [feat] simple theme: translate hotkeys help dialog (#6806)
Move hardcoded English strings of the hotkeys help (keyboard.ts) to the
server side get_translations() so translators can pick them up. The client
looks them up via settings.translations with identical English fallbacks.
2026-10-09 16:16:48 +02:00
Markus Heiser 6a0ac6a391 [upd] update to gecko driver v0.37.1 (#6827)
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-10-09 15:19:50 +02:00
Markus Heiser bba4f64e8a [fix] issues reported by pylint v4.1.2 (#6827)
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-10-09 15:19:50 +02:00
dependabot[bot] 619696aa6c [upd] pypi: Bump the minor group with 5 updates (#6827)
Bumps the minor group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [granian](https://github.com/emmett-framework/granian) | `2.8.3` | `2.8.4` |
| [black](https://github.com/psf/black) | `26.5.1` | `26.10.0` |
| [pylint](https://github.com/pylint-dev/pylint) | `4.0.9` | `4.1.2` |
| [selenium](https://github.com/SeleniumHQ/Selenium) | `4.49.0` | `4.50.0` |
| [basedpyright](https://github.com/detachhead/basedpyright) | `1.40.1` | `1.40.2` |

Updates `granian` from 2.8.3 to 2.8.4
- [Release notes](https://github.com/emmett-framework/granian/releases)
- [Commits](https://github.com/emmett-framework/granian/compare/v2.8.3...v2.8.4)

Updates `black` from 26.5.1 to 26.10.0
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/26.5.1...26.10.0)

Updates `pylint` from 4.0.9 to 4.1.2
- [Release notes](https://github.com/pylint-dev/pylint/releases)
- [Commits](https://github.com/pylint-dev/pylint/compare/v4.0.9...v4.1.2)

Updates `selenium` from 4.49.0 to 4.50.0
- [Release notes](https://github.com/SeleniumHQ/Selenium/releases)
- [Commits](https://github.com/SeleniumHQ/Selenium/compare/selenium-4.49.0...selenium-4.50.0)

Updates `basedpyright` from 1.40.1 to 1.40.2
- [Release notes](https://github.com/detachhead/basedpyright/releases)
- [Commits](https://github.com/detachhead/basedpyright/compare/v1.40.1...v1.40.2)
2026-10-09 15:19:50 +02:00
github-actions[bot] 2dc2b14628 [l10n] update translations from Weblate (#6828)
cf8a68cc8 - 2026-10-05 - lugged9922 <lugged9922@noreply.codeberg.org>
046b92a92 - 2026-10-06 - kratos <makesocialfoss32@keemail.me>
475f33d38 - 2026-10-04 - SomeTr <sometr@noreply.codeberg.org>
2f508cdb2 - 2026-10-05 - Stephan-P <stephan-p@noreply.codeberg.org>
0de45dca2 - 2026-10-05 - Kito <q1t0@noreply.codeberg.org>
76a5f5248 - 2026-10-05 - ghose <ghose@noreply.codeberg.org>
d7bfd43bd - 2026-10-04 - Priit Jõerüüt <jrtcdbrg@noreply.codeberg.org>
bcc67b083 - 2026-10-04 - gallegonovato <gallegonovato@noreply.codeberg.org>
2026-10-09 14:08:51 +02:00
Guillaume P.andGuillaume PUTIER 6671d89bed [fix] engines: wikidata, no list as content when an entity has no description (#6824)
Wikidata kept showing up in `unresponsive_engines` as "unexpected crash", but only for some queries (`!wikidata loi anti gaspillage` for instance).

In `get_results`, `itemDescription` defaults to `[]`. When an entity has no description and `display_type` contains `"list"`, that empty list goes out as the `content` of a list result, and `ResultContainer.extend` fails with `TypeError: expected string or bytes-like object, got 'list'`. Every wikidata result of the query is dropped with it.

The default is now an empty string. `len(infobox_content) == 0` still works, and the infobox branch already expected a string.

`tests/unit/test_engine_wikidata.py` covers an entity with no description, once with `display_type` `["list"]` and once with `["infobox"]`. Without the fix both tests fail (`'content': []` instead of `''`), with it both pass.




itemDescription defaulted to [] in get_results. With "list" in display_type, that empty list was sent as the content of a list result and ResultContainer.extend raised a TypeError, so the whole wikidata answer of the query was lost. Default to an empty string instead.

Signed-off-by: Guillaume PUTIER <putier.guillaume@gmail.com>
Co-authored-by: Guillaume PUTIER <putier.guillaume@gmail.com>
2026-10-07 11:18:19 +02:00
Brock Vojkovic d48c4b5554 [feat] sort infobox so images are first (#6815)
Often there will be two or more results in the infobox.

We now sort this so infoboxes with an image show above those that do not.

Signed-off-by: vojkovic <git@vojk.au>
2026-10-04 08:16:51 +02:00
Brock Vojkovic 44b98e6102 [fix] engines: privacywall referer to bypass botdetection (#6813)
Privacywall needs a referer header now or else returns access denied.
2026-10-04 07:49:12 +02:00
Bnyro 7a3e41d420 [feat] s1search: parse suggestions (#6810)
- parse s1search suggestions
- thereby also fixes that some queries would crash the engine if using `https://search.nation.online` as base url because it tried to parse the suggestions as real results

### How to test this PR locally?

- set `base_url = "https://search.nation.online"` and `inactive: false`
- !s1 rust
2026-10-04 07:47:00 +02:00
Brock Vojkovic ce913b8934 [fix] engines: dogpile cors headers (#6814)
Added Sec-Fetch-Mode header to fix the engine. Dogpile now has better bot detection, similar to tusksearch, we now require CORS headers in the request.
2026-10-04 07:44:15 +02:00
dependabot[bot] 19ffbcd306 [upd] web-client (simple): Bump the minor group (#6807)
Bumps the minor group in /client/simple with 4 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [browserslist](https://github.com/browserslist/browserslist), [sharp](https://github.com/lovell/sharp) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).

Updates `@types/node` from 26.6.2 to 26.6.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `browserslist` from 4.29.0 to 4.29.2
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.29.0...4.29.2)

Updates `sharp` from 0.35.4 to 0.35.5
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.4...v0.35.5)

Updates `vite` from 8.3.0 to 8.3.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.1/packages/vite)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.6.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: browserslist
  dependency-version: 4.29.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: sharp
  dependency-version: 0.35.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: vite
  dependency-version: 8.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-02 16:37:24 +02:00
dependabot[bot] e652ca7d29 [upd] pypi: Bump msgspec from 0.21.1 to 0.22.0 in the minor group (#6811)
Bumps the minor group with 1 update: [msgspec](https://github.com/msgspec/msgspec).


Updates `msgspec` from 0.21.1 to 0.22.0
- [Release notes](https://github.com/msgspec/msgspec/releases)
- [Changelog](https://github.com/msgspec/msgspec/blob/main/docs/changelog.md)
- [Commits](https://github.com/msgspec/msgspec/compare/0.21.1...0.22.0)
2026-10-02 16:30:24 +02:00
Brock Vojkovic 26b90797e0 [enh] simplify and clean up preferences (#6126)
- The 'engine tokens' field is only shown now if there are any engines with tokens configured.

- DOI Resolver now has combined the previous two settings (one dropdown and one toggle) into just a single dropdown similar to the favicon resolver.
2026-10-02 18:36:07 +08:00
dependabot[bot] a5659a5364 [upd] pypi: Bump the minor group with 2 updates (#6808)
Bumps the minor group with 2 updates: [pylint](https://github.com/pylint-dev/pylint) and [wlc](https://github.com/WeblateOrg/wlc).


Updates `pylint` from 4.0.8 to 4.0.9
- [Release notes](https://github.com/pylint-dev/pylint/releases)
- [Commits](https://github.com/pylint-dev/pylint/compare/v4.0.8...v4.0.9)

Updates `wlc` from 2.2.0 to 2.2.1
- [Release notes](https://github.com/WeblateOrg/wlc/releases)
- [Changelog](https://github.com/WeblateOrg/wlc/blob/main/CHANGES.md)
- [Commits](https://github.com/WeblateOrg/wlc/compare/2.2.0...2.2.1)
2026-10-02 12:12:46 +02:00
kcs81 a9d9900334 [fix] map Brave API SafeSearch levels correctly (#6799)
API description:

- https://api-dashboard.search.brave.com/api-reference/web/search/get#parameter-safesearch
2026-09-30 07:39:34 +02:00
quacker 4e2c1ea7f4 [fix] duckduckgo_web: make preload link timeout use the global configuration - #6768 2026-09-29 14:33:54 +02:00
github-actions[bot] f5035873ad [mod] data: update searx.data - update_firefox_version.py (#6792) 2026-09-29 08:44:07 +02:00
github-actions[bot] c11cbda117 [mod] data: update searx.data - update_ahmia_blacklist.py (#6793) 2026-09-29 08:35:03 +02:00
github-actions[bot] bad451b3da [mod] data: update searx.data - update_currencies.py (#6794) 2026-09-29 08:08:20 +02:00
github-actions[bot] 896ec8d3af [mod] data: update searx.data - update_engine_descriptions.py (#6796) 2026-09-29 07:43:48 +02:00
Ivan Gabaldon 12f8b6515c [enh] ci: convert script to esm (#6725)
Embraces the ESM format. Allow spaces between the checkbox `([ x ])`
2026-09-25 18:50:35 +02:00
dependabot[bot] d8ae3abd59 [upd] web-client (simple): Bump the minor group (#6785)
Bumps the minor group in /client/simple with 2 updates: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@biomejs/biome` from 2.5.13 to 2.5.14
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.14/packages/@biomejs/biome)

Updates `@types/node` from 26.6.1 to 26.6.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: "@types/node"
  dependency-version: 26.6.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-25 16:30:37 +02:00
github-actions[bot] 487f519227 [l10n] update translations from Weblate (#6771)
4bf358751 - 2026-09-24 - bittin <bittin@noreply.codeberg.org>
ca4696cb4 - 2026-09-23 - ghose <ghose@noreply.codeberg.org>
2026-09-25 11:04:23 +02:00
Brock Vojkovic 3cd69d30e2 [fix] engines: tusksearch requires cors headers for botdetection (#6773)
Sec-Fetch-Mode and Sec-Fetch-Dest headers now mandatory
2026-09-23 23:18:45 +08:00
Bnyro 783a094625 [feat] engines: add xprivo - general, news, videos (#6641)
Add support for xprivo.com.  Xprivo has a rate-limit of smth like 20 requests
per day without an API key.  However, if you use a proxy like the IPv6 rotating
proxy [1] you basically get unlimited requests.

[1] https://github.com/vojkovic/http-proxy-ipv6-pool-docker
2026-09-23 13:00:51 +02:00
Bnyro dcf1d477ae [feat] engines: add iconify - icons (#6541) 2026-09-23 11:47:20 +02:00
Bnyro d63bbef7a0 [feat] engines: add findborg.com - general, news, images, videos (#6688)
Adds support for findborg.com which also uses the Brave results.
2026-09-23 09:31:34 +02:00
Bnyro 4d9c982cda [mod] brave-based engines: reuse result parsing methods (#6688)
Many engines use the JSON results from Brave (e.g. Brave, BraveAPI, Tusksearch
and more), so the result parsing logic is always the same. This PR deduplicates
this code.
2026-09-23 09:31:34 +02:00
Ivan Gabaldon 2ed96e6fcf [feat] ci: oneway sync to CB (#6770) 2026-09-22 18:16:59 +02:00
vojkovic 019460e07d [build] /static (#6766) 2026-09-22 09:14:25 +02:00
vojkovic 8a8b4eaec0 [fix] video thumbnails on left side again (#6766)
- Imports video.less now to apply the stylesheet.
- Ammends thumbnail_bg to dim the background and blur it more to make the thumbnail easier to see.
2026-09-22 09:14:25 +02:00
Crizomb b3c3895294 [docs] indicate modification of settings.yml is necessary to add new … (#6685)
* [docs] indicate modification of settings.yml is necessary to add new plugin + small exemple code correction
* [docs] plugins: add more details about adding new plugins
2026-09-22 09:13:02 +02:00
Austin-Olacsi df67eb6c26 [fix] rewrite vimeo engine 2026-09-21 17:56:49 +02:00
Markus Heiser f372096fb1 [build] /static (#6742) 2026-09-21 14:49:04 +02:00
Bnyro 52284fd320 [feat] categories: add icons for remaining category tabs (#6759)
Icons are provided for the main categories known to us.

The distinction between CATEGORY_GROUPS and CATEGORY_NAMES is abandoned in favor
of the latter.

HINT: we need single-color icons; transparent background and the color must be
`currentColor` so that the icons can be used in light/dark themes.

The following SVGs from the ionicons had to be revised (use ``currentColor``).

- client/simple/src/svg/ionicons/logo-rss.svg
- client/simple/src/svg/ionicons/radio-outline.svg
- client/simple/src/svg/ionicons/color-palette-outline.svg

For the following categories, I did not find any usable icons in Ionicons; new
SVGs were created for these categories.

- onions: client/simple/src/svg/ionicons/tor-browser.svg
- lyrics: client/simple/src/svg/ionicons/song-lyric-music.svg [1]
- packages: client/simple/src/svg/ionicons/package_2.svg [1]

[1] Based on Google's Material icons: https://fonts.google.com/icons

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-09-21 14:49:04 +02:00
Bnyro 49064747a2 [fix] c3tv: crashes on missing description (#6752) 2026-09-21 10:07:38 +02:00
Bnyro 591ad564a9 [mod] c3tv: modernize engine (#6752) 2026-09-21 10:07:38 +02:00
Markus Heiser 2e624bed40 [fix] utils.parse_duration_string - the last three of [00, HH, MM, SS] (#6743)
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-09-20 16:57:45 +02:00
Markus Heiser 2e41cf7407 [mod] migration of some engine results to the video class (#6743)
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-09-20 16:57:45 +02:00
Markus Heiser ce48993997 [build] /static 2026-09-20 16:57:45 +02:00
Markus Heiser e40de2d992 [mod] typification of SearXNG: add new result type Video (#6743)
- Python class:   searx/result_types/video.py
- Jinja template: searx/templates/simple/result_templates/videos.html
- CSS (less)      client/simple/src/less/result_types/video.less

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-09-20 16:57:45 +02:00
vojkovic fdd8525b1a [build] /static 2026-09-20 18:58:14 +08:00
vojkovic bcd2b709fe [mod] theme: improve thumbnails display on results page
Deletes thumbnails instead of showing an error image and also moves them to the right side. Also gives them a background and makes them into rounded 4:3 rectangles for better ui.
2026-09-20 18:58:14 +08:00
Brock Vojkovic e75fd22d09 [mod] defaults: disable image engines with poor results (#6690)
The image search defaults are a bit of a mix of results atm with random stock photos, some broken thumbnails (artic) and svg icons.
2026-09-20 11:10:16 +08:00
Bnyro e831fc2a1c [fix] categories: rename stock_images category to stock images (#6760)
Category ``stock_images`` is referenced to as ``stock images`` everywhere else,
so SXNG didn't find any translation for ``stock_images``.
2026-09-19 15:01:12 +02:00
WookieeOnCrack c0014f015e [mod] brave - modernize response handling (#6742) 2026-09-19 13:04:37 +02:00
Bnyro fe5bdd1bf2 [feat] magnific: solve captcha to bypass botblocking
Magnific now employs a very simple to solve CAPTCHA before
all API requests (essentially summing up some numbers).

Python script for testing:
```python
import re
from lxml import html
import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:156.0) Gecko/20100101 Firefox/156.0',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'en-US,en;q=0.9',
    'Sec-GPC': '1',
    'Connection': 'keep-alive',
    'Upgrade-Insecure-Requests': '1',
    'Sec-Fetch-Dest': 'document',
    'Sec-Fetch-Mode': 'navigate',
    'Sec-Fetch-Site': 'same-origin',
    'Priority': 'u=0, i',
    'Pragma': 'no-cache',
    'Cache-Control': 'no-cache',
}

resp = requests.get(
    "https://www.magnific.com/api/regular/search?term=tree&filters%5Bai-generated%5D%5Bexcluded%5D=1&page=1&locale=en&filters%5Blicense%5D=free",
    headers=headers,
)
cookies = resp.cookies
print(resp.text)

_NUMBER_RE = re.compile("\d+")
_BM_VERIFY = re.compile(r"\"bm-verify\":\s*\"(.*?)\"")

doc = html.fromstring(resp.text)
script = doc.xpath("//script")[0].text
numbers = [int(m) for m in _NUMBER_RE.findall(script)]
bm_verify = _BM_VERIFY.search(resp.text)
if not bm_verify:
    exit(1)
bm_verify = bm_verify.group(1)
print("bm-verify: " + bm_verify)

solution = sum(numbers)
print(f"sum({numbers}) = {solution}")

resp = requests.post(
    "https://www.magnific.com/_sec/verify?provider=interstitial",
    json={"bm-verify": bm_verify, "pow": solution},
    headers=headers,
    cookies=cookies,
)

resp = requests.get(
    "https://www.magnific.com/api/regular/search?term=bird&filters%5Bai-generated%5D%5Bexcluded%5D=1&page=1&locale=en&filters%5Blicense%5D=free",
    headers=headers,
    cookies=resp.cookies,
)
print(resp.text)
```
2026-09-19 11:31:00 +02:00
263 changed files with 22072 additions and 15868 deletions
+41
View File
@@ -0,0 +1,41 @@
// @ts-check
// Closes issues and prs whose authors/agents don't accept the ai policy
// https://github.com/searxng/searxng/blob/master/AI_POLICY.rst
const boxCocRegex = /\[\s*[Xx]\s*\].*AI Policy/;
const refCocRegex = /\[AI Policy\](?::\s*|\()https:\/\/github\.com\/searxng\/searxng\/.*AI_POLICY/;
export default async ({ github, context }) => {
const item = context.payload.issue ?? context.payload.pull_request;
if (!item?.body) {
return;
}
if (boxCocRegex.test(item.body) && refCocRegex.test(item.body)) {
return;
}
const type = context.payload.issue ? "issue" : "pull request";
await github.rest.issues.createComment({
issue_number: item.number,
body: `Hello! Thank you for your contribution.
Unfortunately your ${type} was closed as the AI Policy has not been accepted.
Please open a new ${type} after confirming your contribution aligns with our AI Policy.`,
...context.repo
});
await github.rest.issues.addLabels({
issue_number: item.number,
labels: ["invalid:slop"],
...context.repo
});
await github.rest.issues.update({
issue_number: item.number,
state: "closed",
state_reason: "not_planned",
...context.repo
});
};
-39
View File
@@ -1,39 +0,0 @@
// Closes issues and prs whose authors/agents don't accept the ai policy
// https://github.com/searxng/searxng/blob/master/AI_POLICY.rst
module.exports = async ({ github, context }) => {
const item = context.payload.pull_request || context.payload.issue;
const body = item.body || '';
const kind = context.payload.pull_request ? 'pull request' : 'issue';
// https://github.com/searxng/searxng/pull/6476#discussion_r3683782481
const hasBox = /\[[Xx]\].*AI Policy/.test(body);
const hasRef = /\[AI Policy\](?::\s*|\()https:\/\/github\.com\/searxng\/searxng\/.*AI_POLICY/.test(body);
if (hasBox && hasRef) {
return;
}
const { owner, repo } = context.repo;
await github.rest.issues.createComment({
owner,
repo,
issue_number: item.number,
body:
'Hello! Thank you for your contribution.\n\n' +
`Unfortunately your ${kind} was closed as the AI Policy has not been accepted.\n\n` +
`Please open a new ${kind} after confirming your contribution aligns with our AI Policy.`,
});
await github.rest.issues.addLabels({
owner,
repo,
issue_number: item.number,
labels: ['invalid:slop'],
});
await github.rest.issues.update({
owner,
repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
};
+3 -3
View File
@@ -18,7 +18,7 @@ permissions:
jobs:
check:
name: Check AI Policy
name: Check
# for issues with an author who has not contributed before
if: >-
github.event.sender.type != 'Bot' &&
@@ -34,5 +34,5 @@ jobs:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const script = require('./.github/scripts/ai_policy.cjs');
await script({ github, context });
const script = await import("${{ github.workspace }}/.github/scripts/aiPolicy.mjs")
await script.default({ github, context });
+3 -3
View File
@@ -83,7 +83,7 @@ jobs:
run: make container.build
- name: Upload container image
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: container-image-${{ matrix.arch }}
path: "/var/tmp/searxng-podman/*.tar"
@@ -116,7 +116,7 @@ jobs:
persist-credentials: "false"
- name: Download container image
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: container-image-${{ matrix.arch }}
path: "/var/tmp/searxng-podman"
@@ -160,7 +160,7 @@ jobs:
persist-credentials: "false"
- name: Download container images
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: container-image-*
path: "/var/tmp/searxng-podman"
+1 -1
View File
@@ -67,7 +67,7 @@ jobs:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "26"
check-latest: "true"
+38
View File
@@ -0,0 +1,38 @@
---
# yamllint disable rule:line-length
name: Sync
# yamllint disable-line rule:truthy
on:
workflow_dispatch:
push:
branches:
- master
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
codeberg:
if: github.repository_owner == 'searxng'
name: Codeberg
runs-on: ubuntu-26.04-arm
steps:
- name: Sync
env:
ORIGIN: "github.com/searxng/searxng"
DESTINATION: "codeberg.org/searxng/searxng"
DESTINATION_USER: "${{ secrets.CODEBERG_USER }}"
DESTINATION_TOKEN: "${{ secrets.CODEBERG_TOKEN }}"
run: |
set -eu
git init --quiet --bare .sync
cd .sync
git fetch --no-tags "https://${ORIGIN}.git" \
'+refs/heads/master:refs/heads/master'
git push --force "https://${DESTINATION_USER}:${DESTINATION_TOKEN}@${DESTINATION}.git" \
'refs/heads/master:refs/heads/master'
+341 -341
View File
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -25,25 +25,25 @@
"dependencies": {
"ionicons": "^8.1.0",
"normalize.css": "8.0.1",
"ol": "^10.10.0",
"ol": "^10.11.0",
"swiped-events": "1.2.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.13",
"@types/node": "^26.5.1",
"browserslist": "^4.28.8",
"@biomejs/biome": "2.5.15",
"@types/node": "^26.6.5",
"browserslist": "^4.29.4",
"browserslist-to-esbuild": "^2.1.1",
"edge.js": "^6.5.1",
"less": "^4.9.0",
"mathjs": "^15.2.0",
"sharp": "~0.35.4",
"sort-package-json": "^4.0.0",
"stylelint": "^17.14.1",
"sharp": "~0.35.5",
"sort-package-json": "^4.1.0",
"stylelint": "^17.16.0",
"stylelint-config-standard-less": "^4.1.0",
"stylelint-prettier": "^5.0.3",
"svgo": "^4.1.0",
"typescript": "~7.0.2",
"vite": "^8.3.0",
"vite": "^8.3.3",
"vite-bundle-analyzer": "^1.3.9"
}
}
+47 -44
View File
@@ -8,67 +8,68 @@ export type KeyBindingLayout = "default" | "vim";
type KeyBinding = {
key: string;
fun: (event: KeyboardEvent) => void;
des: string;
cat: string;
des?: string;
cat?: string;
};
type HighlightResultElement = "down" | "up" | "visible" | "bottom" | "top";
/* common base for layouts */
const baseKeyBinding: Record<string, KeyBinding> = {
Escape: {
key: "ESC",
fun: (event: KeyboardEvent) => removeFocus(event),
des: "remove focus from the focused input",
cat: "Control"
des: settings.translations?.hotkeys_remove_focus,
cat: settings.translations?.hotkeys_category_control
},
c: {
key: "c",
fun: () => copyURLToClipboard(),
des: "copy url of the selected result to the clipboard",
cat: "Results"
des: settings.translations?.hotkeys_copy_url,
cat: settings.translations?.hotkeys_category_results
},
h: {
key: "h",
fun: () => toggleHelp(keyBindings),
des: "toggle help window",
cat: "Other"
des: settings.translations?.hotkeys_toggle_help,
cat: settings.translations?.hotkeys_category_other
},
i: {
key: "i",
fun: () => searchInputFocus(),
des: "focus on the search input",
cat: "Control"
des: settings.translations?.hotkeys_focus_search,
cat: settings.translations?.hotkeys_category_control
},
n: {
key: "n",
fun: () => GoToNextPage(),
des: "go to next page",
cat: "Results"
des: settings.translations?.hotkeys_next_page,
cat: settings.translations?.hotkeys_category_results
},
o: {
key: "o",
fun: () => openResult(false),
des: "open search result",
cat: "Results"
des: settings.translations?.hotkeys_open_result,
cat: settings.translations?.hotkeys_category_results
},
p: {
key: "p",
fun: () => GoToPreviousPage(),
des: "go to previous page",
cat: "Results"
des: settings.translations?.hotkeys_previous_page,
cat: settings.translations?.hotkeys_category_results
},
r: {
key: "r",
fun: () => reloadPage(),
des: "reload page from the server",
cat: "Control"
des: settings.translations?.hotkeys_reload_page,
cat: settings.translations?.hotkeys_category_control
},
t: {
key: "t",
fun: () => openResult(true),
des: "open the result in a new tab",
cat: "Results"
des: settings.translations?.hotkeys_open_new_tab,
cat: settings.translations?.hotkeys_category_results
}
};
@@ -78,14 +79,14 @@ const keyBindingLayouts: Record<KeyBindingLayout, Record<string, KeyBinding>> =
ArrowLeft: {
key: "←",
fun: () => highlightResult("up")(),
des: "select previous search result",
cat: "Results"
des: settings.translations?.hotkeys_select_previous,
cat: settings.translations?.hotkeys_category_results
},
ArrowRight: {
key: "→",
fun: () => highlightResult("down")(),
des: "select next search result",
cat: "Results"
des: settings.translations?.hotkeys_select_next,
cat: settings.translations?.hotkeys_category_results
},
...baseKeyBinding
},
@@ -95,56 +96,56 @@ const keyBindingLayouts: Record<KeyBindingLayout, Record<string, KeyBinding>> =
b: {
key: "b",
fun: () => scrollPage(-window.innerHeight),
des: "scroll one page up",
cat: "Navigation"
des: settings.translations?.hotkeys_page_up,
cat: settings.translations?.hotkeys_category_navigation
},
d: {
key: "d",
fun: () => scrollPage(window.innerHeight / 2),
des: "scroll half a page down",
cat: "Navigation"
des: settings.translations?.hotkeys_half_page_down,
cat: settings.translations?.hotkeys_category_navigation
},
f: {
key: "f",
fun: () => scrollPage(window.innerHeight),
des: "scroll one page down",
cat: "Navigation"
des: settings.translations?.hotkeys_page_down,
cat: settings.translations?.hotkeys_category_navigation
},
g: {
key: "g",
fun: () => scrollPageTo(-document.body.scrollHeight, "top"),
des: "scroll to the top of the page",
cat: "Navigation"
des: settings.translations?.hotkeys_scroll_top,
cat: settings.translations?.hotkeys_category_navigation
},
j: {
key: "j",
fun: () => highlightResult("down")(),
des: "select next search result",
cat: "Results"
des: settings.translations?.hotkeys_select_next,
cat: settings.translations?.hotkeys_category_results
},
k: {
key: "k",
fun: () => highlightResult("up")(),
des: "select previous search result",
cat: "Results"
des: settings.translations?.hotkeys_select_previous,
cat: settings.translations?.hotkeys_category_results
},
u: {
key: "u",
fun: () => scrollPage(-window.innerHeight / 2),
des: "scroll half a page up",
cat: "Navigation"
des: settings.translations?.hotkeys_half_page_up,
cat: settings.translations?.hotkeys_category_navigation
},
v: {
key: "v",
fun: () => scrollPageTo(document.body.scrollHeight, "bottom"),
des: "scroll to the bottom of the page",
cat: "Navigation"
des: settings.translations?.hotkeys_scroll_bottom,
cat: settings.translations?.hotkeys_category_navigation
},
y: {
key: "y",
fun: () => copyURLToClipboard(),
des: "copy url of the selected result to the clipboard",
cat: "Results"
des: settings.translations?.hotkeys_copy_url,
cat: settings.translations?.hotkeys_category_results
},
...baseKeyBinding
}
@@ -344,6 +345,7 @@ const initHelpContent = (divElement: HTMLElement, keyBindings: typeof baseKeyBin
for (const binding of Object.values(keyBindings)) {
const { cat } = binding;
if (!cat) continue;
categories[cat] ??= [];
categories[cat].push(binding);
}
@@ -352,8 +354,8 @@ const initHelpContent = (divElement: HTMLElement, keyBindings: typeof baseKeyBin
(a, b) => (categories[b]?.length ?? 0) - (categories[a]?.length ?? 0)
);
let html = '<a href="#" class="close" aria-label="close" title="close">×</a>';
html += "<h3>How to navigate SearXNG with hotkeys</h3>";
let html = `<a href="#" class="close" aria-label="${settings.translations?.close}" title="${settings.translations?.close}">×</a>`;
html += `<h3>${settings.translations?.hotkeys_navigation_title}</h3>`;
html += "<table>";
for (const [i, categoryKey] of sortedCategoryKeys.entries()) {
@@ -372,6 +374,7 @@ const initHelpContent = (divElement: HTMLElement, keyBindings: typeof baseKeyBin
html += '<ul class="list-unstyled">';
for (const binding of bindings) {
if (!binding.des) continue;
html += `<li><kbd>${binding.key}</kbd> ${binding.des}</li>`;
}
+21 -10
View File
@@ -5,6 +5,7 @@ import { listen, mutable, settings } from "../toolkit.ts";
import { assertElement } from "../util/assertElement.ts";
let imgTimeoutID: number;
const loadErrorSrc = `${settings.theme_static_path}/img/img_load_error.svg`;
const imageLoader = (resultElement: HTMLElement): void => {
if (imgTimeoutID) clearTimeout(imgTimeoutID);
@@ -15,7 +16,7 @@ const imageLoader = (resultElement: HTMLElement): void => {
// use thumbnail until full image loads
const thumbnail = resultElement.querySelector<HTMLImageElement>(".image_thumbnail");
if (thumbnail) {
if (thumbnail.src === `${settings.theme_static_path}/img/img_load_error.svg`) return;
if (thumbnail.src === loadErrorSrc) return;
imgElement.onerror = (): void => {
imgElement.src = thumbnail.src;
@@ -35,17 +36,27 @@ const imageLoader = (resultElement: HTMLElement): void => {
}, 1000) as unknown as number;
};
const imageThumbnails: NodeListOf<HTMLImageElement> = document.querySelectorAll<HTMLImageElement>(
"#urls img.image_thumbnail, img.thumbnail"
);
for (const thumbnail of imageThumbnails) {
if (thumbnail.complete && thumbnail.naturalWidth === 0) {
thumbnail.src = `${settings.theme_static_path}/img/img_load_error.svg`;
const hideBrokenThumbnail = (img: HTMLImageElement): void => {
if (img.classList.contains("thumbnail")) {
img.closest("a.thumbnail_link")?.remove();
return;
}
if (img.classList.contains("image_thumbnail") && img.src !== loadErrorSrc) {
img.src = loadErrorSrc;
}
};
thumbnail.onerror = (): void => {
thumbnail.src = `${settings.theme_static_path}/img/img_load_error.svg`;
};
document.addEventListener(
"error",
(event: Event) => {
const img = event.target;
if (img instanceof HTMLImageElement) hideBrokenThumbnail(img);
},
true
);
// some images may have already failed
for (const img of document.querySelectorAll<HTMLImageElement>("img.thumbnail, #urls img.image_thumbnail")) {
if (img.complete && img.naturalWidth === 0) hideBrokenThumbnail(img);
}
const copyUrlButton: HTMLButtonElement | null =
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/*
Layout of the Video result class
*/
.result-videos {
a.thumbnail_link {
.ltr-float-left();
.ltr-margin-right(1rem);
.ltr-margin-left(0);
width: 10rem !important;
aspect-ratio: 16 / 9 !important;
}
.content {
overflow: hidden;
}
.embedded-video iframe {
width: 100%;
aspect-ratio: 16 / 9;
padding: 10px 0 0 0;
}
}
+39 -40
View File
@@ -255,6 +255,8 @@ article[data-vim-selected].category-social {
font-size: 1rem;
color: var(--color-result-url-font);
flex-flow: row nowrap;
flex: 1;
min-width: 0;
overflow: hidden;
margin: 0;
padding: 0;
@@ -305,22 +307,44 @@ article[data-vim-selected].category-social {
a.thumbnail_link {
position: relative;
margin-top: 0.6rem;
.ltr-margin-right(1rem);
.ltr-float-left();
display: block;
.ltr-float-right();
.ltr-margin-left(0.5rem);
width: 8rem;
aspect-ratio: 4 / 3;
border-radius: 0.5rem;
overflow: hidden;
background: var(--color-result-border);
img.thumbnail_bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(2rem) brightness(0.5);
pointer-events: none;
}
img.thumbnail {
width: 7rem;
height: unset; // remove height value that was needed for lazy loading
position: relative;
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.thumbnail_length {
.image-label-bottom-right();
right: 6px;
z-index: 1;
}
}
.result_inner {
overflow: hidden;
}
.break {
clear: both;
}
@@ -407,32 +431,10 @@ article[data-vim-selected].category-social {
padding: 10px 0 0 0;
}
.result-videos {
a.thumbnail_link img.thumbnail {
width: 20rem;
}
.content {
overflow: hidden;
}
.embedded-video iframe {
width: 100%;
aspect-ratio: 16 / 9;
padding: 10px 0 0 0;
}
}
@supports not (aspect-ratio: 1 / 1) {
// support older browsers which do not have aspect-ratio
// https://caniuse.com/?search=aspect-ratio
.result-videos .embedded-video iframe {
height: calc(@results-width * 9 / 16);
}
}
.engines {
.ltr-float-right();
clear: both;
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
@@ -471,6 +473,8 @@ article[data-vim-selected].category-social {
& > a {
position: relative;
outline: none;
display: inline-block;
height: 100%;
}
img {
@@ -930,10 +934,6 @@ summary.title {
}
.result {
.thumbnail {
max-width: 98%;
}
.url {
span.url {
display: block;
@@ -1144,13 +1144,11 @@ summary.title {
*/
@media screen and (max-width: @small-phone) {
.result-videos {
img.thumbnail {
float: none !important;
}
.content {
overflow: inherit;
.result {
a.thumbnail_link {
aspect-ratio: 1 / 1;
width: 7rem;
border-radius: 1.25rem;
}
}
}
@@ -1165,3 +1163,4 @@ pre code {
@import "result_types/paper.less";
@import "result_types/file.less";
@import "result_types/image.less";
@import "result_types/video.less";
@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="ionicon">
<path
d="M430.11 347.9c-6.6-6.1-16.3-7.6-24.6-9-11.5-1.9-15.9-4-22.6-10-14.3-12.7-14.3-31.1 0-43.8l30.3-26.9c46.4-41 46.4-108.2 0-149.2-34.2-30.1-80.1-45-127.8-45-55.7 0-113.9 20.3-158.8 60.1-83.5 73.8-83.5 194.7 0 268.5 41.5 36.7 97.5 55 152.9 55.4h1.7c55.4 0 110-17.9 148.8-52.4 14.4-12.7 11.99-36.6.1-47.7Z"
fill="none"
stroke="currentColor"
stroke-miterlimit="10"
stroke-width="32px"
/>
<circle
fill="currentColor"
cx="144"
cy="208"
r="32"
/>
<circle
fill="currentColor"
cx="152"
cy="311"
r="32"
/>
<circle
fill="currentColor"
cx="224"
cy="144"
r="32"
/>
<circle
fill="currentColor"
cx="256"
cy="367"
r="48"
/>
<circle
fill="currentColor"
cx="328"
cy="144"
r="32"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,6 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M248 64C146.39 64 64 146.39 64 248s82.39 184 184 184 184-82.39 184-184S349.61 64 248 64z" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M220 220h32v116"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32" d="M208 340h88"/>
<path d="M248 130a26 26 0 1026 26 26 26 0 00-26-26z" fill="currentColor" stroke="currentColor" stroke-miterlimit="10" stroke-width="1"/>
<path
fill="none"
stroke="currentColor"
stroke-miterlimit="10"
stroke-width="32"
d="M248 64C146.39 64 64 146.39 64 248s82.39 184 184 184 184-82.39 184-184S349.61 64 248 64z"
/>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="32"
d="M220 220h32v116"
/>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-miterlimit="10"
stroke-width="32" d="M208 340h88"
/>
<path
d="M248 130a26 26 0 1026 26 26 26 0 00-26-26z"
fill="currentColor"
stroke="currentColor"
stroke-miterlimit="10"
stroke-width="1"
/>
</svg>

Before

Width:  |  Height:  |  Size: 648 B

After

Width:  |  Height:  |  Size: 852 B

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="ionicon">
<path
fill="currentColor"
stroke="currentColor"
d="M108.56 342.78a60.34 60.34 0 1 0 60.56 60.44 60.63 60.63 0 0 0-60.56-60.44"
/>
<path
fill="currentColor"
stroke="currentColor"
d="M48 186.67v86.55c52 0 101.94 15.39 138.67 52.11s52 86.56 52 138.67h86.66c0-151.56-125.66-277.33-277.33-277.33"
/>
<path
fill="currentColor"
stroke="currentColor"
d="M48 48v86.56c185.25 0 329.22 144.08 329.22 329.44H464C464 234.66 277.67 48 48 48"
/>
</svg>

After

Width:  |  Height:  |  Size: 625 B

@@ -1,6 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M368 415.86V72a24.07 24.07 0 00-24-24H72a24.07 24.07 0 00-24 24v352a40.12 40.12 0 0040 40h328" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"/>
<path d="M416 464h0a48 48 0 01-48-48V128h72a24 24 0 0124 24v264a48 48 0 01-48 48z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"/>
<path d="M240 128h64M240 192h64M112 256h192M112 320h192M112 384h192" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32"/>
<path d="M176 208h-64a16 16 0 01-16-16v-64a16 16 0 0116-16h64a16 16 0 0116 16v64a16 16 0 01-16 16z" fill="currentColor" stroke="currentColor" stroke-linejoin="round" stroke-width="1" />
<path
fill="none"
stroke="currentColor"
stroke-linejoin="round"
stroke-width="32"
d="M368 415.86V72a24.07 24.07 0 00-24-24H72a24.07 24.07 0 00-24 24v352a40.12 40.12 0 0040 40h328"
/>
<path
fill="none"
stroke="currentColor"
stroke-linejoin="round"
stroke-width="32"
d="M416 464h0a48 48 0 01-48-48V128h72a24 24 0 0124 24v264a48 48 0 01-48 48z"
/>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="32"
d="M240 128h64M240 192h64M112 256h192M112 320h192M112 384h192"
/>
<path
fill="currentColor"
stroke="currentColor"
stroke-linejoin="round"
stroke-width="1"
d="M176 208h-64a16 16 0 01-16-16v-64a16 16 0 0116-16h64a16 16 0 0116 16v64a16 16 0 01-16 16z"
/>
</svg>

Before

Width:  |  Height:  |  Size: 787 B

After

Width:  |  Height:  |  Size: 988 B

@@ -0,0 +1,7 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
>
<path d="M450-154v-309L180-619v309l270 156Zm60 0 270-156v-310L510-463v309Zm-60 69L150-258q-14-8-22-22t-8-30v-340q0-16 8-30t22-22l300-173q14-8 30-8t30 8l300 173q14 8 22 22t8 30v340q0 16-8 30t-22 22L510-85q-14 8-30 8t-30-8Zm194-525 102-59-266-154-102 59 266 154Zm-164 96 104-61-267-154-104 60 267 155Z"/>
</svg>

After

Width:  |  Height:  |  Size: 415 B

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="ionicon">
<circle
fill="currentColor"
cx="256"
cy="256.02"
r="32"
/>
<path
d="M184.25 192.25a96 96 0 0 0 0 127.52M327.77 319.77a96 96 0 0 0 0-127.52M133.28 141.28a168 168 0 0 0 0 229.44M378.72 370.72a168 168 0 0 0 0-229.44" fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="32px"
/>
<path
d="M435 416a240.34 240.34 0 0 0 0-320M77 96a240.34 240.34 0 0 0 0 320"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="32px"
/>
</svg>

After

Width:  |  Height:  |  Size: 741 B

@@ -0,0 +1,11 @@
<svg
height="512"
viewBox="0 -960 20480 20480"
width="512"
version="1.1"
>
<path
d="M 19480,18100 V 2980 q 0,-693 -493.5,-1186.5 Q 18493,1300 17800,1300 H 8560 q -693,0 -1186.5,493.5 Q 6880,2287 6880,2980 v 357 q 504,231 924,567 420,336 756,756 V 2980 h 9240 v 11067 l -987,-987 H 8560 V 9700 q -336,420 -756,756 -420,336 -924,567 v 2037 q 0,693 493.5,1186.5 Q 7867,14740 8560,14740 h 7560 z M 16120,11380 H 12760 V 9700 h 3360 z M 5200,9700 q 1050,0 1785,-735 735,-735 735,-1785 0,-1050 -735,-1785 -735,-735 -1785,-735 -231,0 -441,42 -210,42 -399,105 V 460 H 1000 v 1680 h 1680 v 5040 q 0,1050 735,1785 735,735 1785,735 z M 16120,8860 H 10240 V 7180 h 5880 z m 0,-2520 H 10240 V 4660 h 5880 z m 1680,6720 V 2980 Z"
stroke="currentColor"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 808 B

@@ -0,0 +1,26 @@
<svg
viewBox="0 0 512 512"
fill="none"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="34.6274"
d="M 256,469.53574 C 373.93232,469.53574 469.53574,373.93232 469.53574,256 469.53574,138.06479 373.93232,42.464261 256,42.464261 138.06479,42.464261 42.464263,138.06479 42.464263,256 42.464263,373.93232 138.06479,469.53574 256,469.53574 Z"
/>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-width="34.6274"
d="m 256,111.7191 a 144.28091,144.28091 0 1 1 0,288.56181"
/>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-width="34.6274"
d="m 256,180.97393 a 75.034727,75.034727 0 0 1 69.31543,46.31417 75.060698,75.060698 0 0 1 0,57.4238 75.092439,75.092439 0 0 1 -16.26334,24.34019 A 75.069355,75.069355 0 0 1 256,331.02607"
/>
</svg>

After

Width:  |  Height:  |  Size: 979 B

+13 -3
View File
@@ -39,14 +39,17 @@ const simple_icons: IconSet[] = [
alert: "alert-outline.svg",
appstore: "apps-outline.svg",
book: "book-outline.svg",
camera: "camera-outline.svg",
close: "close-outline.svg",
download: "download-outline.svg",
"ellipsis-vertical": "ellipsis-vertical-outline.svg",
"file-tray-full": "file-tray-full-outline.svg",
"file-tray-stacked": "file-tray-stacked-outline.svg",
film: "film-outline.svg",
globe: "globe-outline.svg",
heart: "heart-outline.svg",
image: "image-outline.svg",
language: "language-outline.svg",
layers: "layers-outline.svg",
leecher: "arrow-down.svg",
location: "location-outline.svg",
@@ -58,13 +61,14 @@ const simple_icons: IconSet[] = [
"navigate-up": "chevron-up-outline.svg",
people: "people-outline.svg",
play: "play-outline.svg",
radio: "radio-outline.svg",
rainy: "rainy-outline.svg",
save: "save-outline.svg",
school: "school-outline.svg",
search: "search-outline.svg",
seeder: "swap-vertical.svg",
settings: "settings-outline.svg",
tv: "tv-outline.svg"
tv: "tv-outline.svg",
videocam: "videocam-outline.svg"
},
svgo_opts: sxng_icon_opts
},
@@ -75,7 +79,13 @@ const simple_icons: IconSet[] = [
base: resolve(HERE, "src/svg/ionicons"),
set: {
"information-circle": "information-circle-outline.svg",
newspaper: "newspaper-outline.svg"
lyrics: "song-lyric-music.svg",
newspaper: "newspaper-outline.svg",
palette: "color-palette-outline.svg",
package: "package_2.svg",
radio: "radio-outline.svg",
rss: "logo-rss.svg",
tor: "tor-browser.svg"
},
svgo_opts: sxng_icon_opts
}
@@ -7,9 +7,18 @@ for the backend. Example output of this EDGE template:
--}}
{#
Catalog of SVG symbols that can be inserted into the HTML output of a Jinja
template. This file from:
template. This file is generated by:
make static.build.commit
from template:
client/simple/tools/jinja_svg_catalog.html.edge
To modify the icon set, edit the script:
client/simple/theme_icons.ts
client/simple/tools/icon_catalog.edge.html
#}
{%-
+1 -1
View File
@@ -1,7 +1,7 @@
# Read the documentation before using the `docker-compose.yml` file:
# https://docs.searxng.org/admin/installation-docker.html
#
# Additional ENVs:
# Additional variables:
# https://docs.searxng.org/admin/settings/settings_general.html#settings-general
# https://docs.searxng.org/admin/settings/settings_server.html#settings-server
@@ -1,4 +1,29 @@
FROM localhost/searxng/searxng:builder AS builder
FROM docker.io/searxng/base:searxng-builder AS builder
COPY ./requirements.txt ./requirements-server.txt ./
ENV UV_NO_MANAGED_PYTHON="true"
RUN --mount=type=cache,id=uv,target=/root/.cache/uv set -eux -o pipefail; \
uv venv; \
uv pip install --requirements ./requirements.txt --requirements ./requirements-server.txt; \
uv cache prune --ci; \
find ./.venv/lib/ -type f -exec strip --strip-unneeded {} + || true; \
find ./.venv/lib/ -type d -name "__pycache__" -exec rm -rf {} +; \
find ./.venv/lib/ -type f -name "*.pyc" -delete; \
python -m compileall -q -f -j 0 --invalidation-mode=unchecked-hash ./.venv/lib/
COPY --exclude=./searx/version_frozen.py ./searx/ ./searx/
RUN set -eux -o pipefail; \
python -m compileall -q -f -j 0 --invalidation-mode=unchecked-hash ./searx/; \
find ./searx/static/ -type f \
\( -name "*.html" -o -name "*.css" -o -name "*.js" -o -name "*.svg" \) \
-exec gzip -9 -k {} + \
-exec brotli -9 -k {} + \
-exec gzip --test {}.gz + \
-exec brotli --test {}.br +
FROM docker.io/searxng/base:searxng AS dist
COPY --chown=977:977 --from=builder /usr/local/searxng/.venv/ ./.venv/
@@ -21,6 +46,8 @@ LABEL org.opencontainers.image.created="$CREATED" \
org.opencontainers.image.url="https://searxng.org" \
org.opencontainers.image.version="$VERSION"
# Extended from:
# https://codeberg.org/searxng/base/src/branch/main/Dockerfile
ENV __SEARXNG_VERSION="$VERSION" \
__SEARXNG_SETTINGS_PATH="$__SEARXNG_CONFIG_PATH/settings.yml" \
GRANIAN_PROCESS_NAME="searxng" \
@@ -32,7 +59,6 @@ ENV __SEARXNG_VERSION="$VERSION" \
GRANIAN_WORKERS_KILL_TIMEOUT="30s" \
GRANIAN_BLOCKING_THREADS_IDLE_TIMEOUT="5m"
# "*_PATH" ENVs are defined in base images
VOLUME $__SEARXNG_CONFIG_PATH
VOLUME $__SEARXNG_DATA_PATH
-25
View File
@@ -1,25 +0,0 @@
FROM docker.io/searxng/base:searxng-builder AS builder
COPY ./requirements.txt ./requirements-server.txt ./
ENV UV_NO_MANAGED_PYTHON="true"
RUN --mount=type=cache,id=uv,target=/root/.cache/uv set -eux -o pipefail; \
uv venv; \
uv pip install --requirements ./requirements.txt --requirements ./requirements-server.txt; \
uv cache prune --ci; \
find ./.venv/lib/ -type f -exec strip --strip-unneeded {} + || true; \
find ./.venv/lib/ -type d -name "__pycache__" -exec rm -rf {} +; \
find ./.venv/lib/ -type f -name "*.pyc" -delete; \
python -m compileall -q -f -j 0 --invalidation-mode=unchecked-hash ./.venv/lib/
COPY --exclude=./searx/version_frozen.py ./searx/ ./searx/
RUN set -eux -o pipefail; \
python -m compileall -q -f -j 0 --invalidation-mode=unchecked-hash ./searx/; \
find ./searx/static/ -type f \
\( -name "*.html" -o -name "*.css" -o -name "*.js" -o -name "*.svg" \) \
-exec gzip -9 -k {} + \
-exec brotli -9 -k {} + \
-exec gzip --test {}.gz + \
-exec brotli --test {}.br +
+1 -1
View File
@@ -18,7 +18,7 @@ services:
valkey:
container_name: searxng-valkey
image: docker.io/valkey/valkey:9-alpine
command: valkey-server --save 30 1 --loglevel warning
command: valkey-server --save 300 1 --save 60 1000 --loglevel warning
restart: always
volumes:
- valkey-data:/data/
+2 -2
View File
@@ -13,7 +13,7 @@ check_file() {
!!! "$target" is not a valid file, exiting...
!!!
EOF
exit 127
exit 1
fi
}
@@ -28,7 +28,7 @@ check_directory() {
!!! "$target" is not a valid directory, exiting...
!!!
EOF
exit 127
exit 1
fi
}
+6 -6
View File
@@ -283,12 +283,12 @@ container images are not officially supported):
$ make container
$ docker images
REPOSITORY TAG IMAGE ID SIZE
localhost/searxng/searxng 2026.6.19-93f66bfb4 ... 265 MB
localhost/searxng/searxng latest ... 265 MB
localhost/searxng/searxng builder ... 687 MB
docker.io/searxng/base searxng-builder ... 565 MB
docker.io/searxng/base searxng ... 143 MB
REPOSITORY TAG IMAGE ID SIZE
localhost/searxng/searxng latest ... 271 MB
localhost/searxng/searxng 2026.10.17-* ... 271 MB
<none> <none> ... 677 MB
docker.io/searxng/base searxng ... 144 MB
docker.io/searxng/base searxng-builder ... 550 MB
Migrate from ``searxng-docker``
===============================
+7
View File
@@ -0,0 +1,7 @@
.. _result_types.video:
=============
Video Results
=============
.. automodule:: searx.result_types.video
+1 -1
View File
@@ -19,13 +19,13 @@ following types have been implemented so far ..
main/paper
main/file
main/image
main/video
The :ref:`LegacyResult <LegacyResult>` is used internally for the results that
have not yet been typed. The templates can be used as orientation until the
final typing is complete.
- :ref:`template default` / :py:obj:`Result`
- :ref:`template videos`
- :ref:`template torrent`
- :ref:`template map`
- :ref:`template packages`
-23
View File
@@ -129,29 +129,6 @@ audio_src : uri,
URL of an embedded ``<audio controls>``.
.. _template videos:
``videos.html``
---------------
Displays result fields from:
- :ref:`macro result_header` and
- :ref:`macro result_sub_header`
Additional fields used in the :origin:`videos.html
<searx/templates/simple/result_templates/videos.html>`:
iframe_src : :py:class:`str`
URL of an embedded ``<iframe>`` / the frame is collapsible.
The videos are displayed as small thumbnails in the main results list, there
is an additional button to collaps/open the embeded video.
content : :py:class:`str`
Description of the code fragment.
.. _template torrent:
``torrent.html``
+1 -1
View File
@@ -48,7 +48,7 @@ PATH="${PY_ENV}/bin:${REPO_ROOT}/node_modules/.bin:${GOROOT}/bin:${GOPATH}/bin:$
PYOBJECTS="searx"
PY_SETUP_EXTRAS='[test]'
GECKODRIVER_VERSION="v0.37.0"
GECKODRIVER_VERSION="v0.37.1"
# SPHINXOPTS=
BLACK_OPTIONS=("--target-version" "py311" "--line-length" "120" "--skip-string-normalization")
BLACK_TARGETS=("--exclude" "(searx/static|searx/languages.py)" "--include" 'searxng.msg|\.pyi?$' "searx" "searxng_extra" "tests")
+6 -6
View File
@@ -1,10 +1,10 @@
mock==5.2.0
nose2[coverage_plugin]==0.16.0
cov-core==1.15.0
black==26.5.1
pylint==4.0.8
black==26.10.1
pylint==4.1.3
splinter==0.21.0
selenium==4.49.0
selenium==4.51.0
Sphinx==8.2.3;python_version <= "3.11"
Sphinx==9.1.0; python_version > "3.11"
sphinx-issues==6.0.0
@@ -18,11 +18,11 @@ myst-parser==5.0.0
linuxdoc==20260504
aiounittest==1.5.0
yamllint==1.38.0
wlc==2.2.0
wlc==2.2.1
coloredlogs==15.0.1
docutils>=0.21.2;python_version <= "3.11"
docutils>=0.22.4; python_version > "3.11"
parameterized==0.9.0
granian[reload]==2.8.3
basedpyright==1.40.1
granian[reload]==2.8.5
basedpyright==1.40.2
types-lxml==2026.2.16
+2 -2
View File
@@ -1,2 +1,2 @@
granian==2.8.3
granian[pname]==2.8.3
granian==2.8.5
granian[pname]==2.8.5
+5 -5
View File
@@ -1,17 +1,17 @@
certifi==2026.7.22
certifi==2026.10.23
babel==2.18.0
flask-babel==4.0.0
flask==3.1.3
jinja2==3.1.6
lxml==6.1.3
pygments==2.21.0
python-dateutil==2.9.0.post0
python-dateutil==3.0.0
pyyaml==6.0.3
curl_cffi==0.16.3
curl_cffi==0.17.0
valkey==6.1.1
markdown-it-py==4.2.0
msgspec==0.21.1
typer==0.27.2
msgspec==0.22.0
typer==0.27.3
isodate==0.7.2
whitenoise==6.12.0
typing-extensions==4.16.0
+3 -3
View File
@@ -28,19 +28,19 @@ if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
def update_kwargs(**kwargs) -> None: # type: ignore
def update_kwargs(kwargs) -> None: # type: ignore
if 'timeout' not in kwargs:
kwargs['timeout'] = settings['outgoing']['request_timeout']
kwargs['raise_for_httperror'] = True
def get(*args, **kwargs) -> "SXNG_Response": # type: ignore
update_kwargs(**kwargs) # pyright: ignore[reportUnknownArgumentType]
update_kwargs(kwargs)
return http_get(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType]
def post(*args, **kwargs) -> "SXNG_Response": # type: ignore
update_kwargs(**kwargs) # pyright: ignore[reportUnknownArgumentType]
update_kwargs(kwargs)
return http_post(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -93,7 +93,7 @@ class TrackerPatternsDB:
continue
if resp.status_code != 200:
log.warning(f"TRACKER_PATTERNS: ClearURL ignore HTTP {resp.status_code} {url}")
log.warning("TRACKER_PATTERNS: ClearURL ignore HTTP %s %s", resp.status_code, url)
continue
break
+2 -2
View File
@@ -5,7 +5,7 @@
],
"ua": "Mozilla/5.0 ({os}; rv:{version}) Gecko/20100101 Firefox/{version}",
"versions": [
"154.0",
"153.0"
"157.0",
"156.0"
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -52,7 +52,7 @@ def response(resp) -> EngineResults:
published_date = None
res.add(
res.types.LegacyResult(
res.types.Video(
url=entry["play_url"],
title=html_to_text(entry["title"]),
content=html_to_text(entry["description"]),
+1 -1
View File
@@ -33,7 +33,7 @@ about = {
base_url = "https://500px.com"
api_url = "https://api-neo.500px.com"
categories = ["stock_images"]
categories = ["stock images"]
paging = True
results_per_page = 30
+14 -12
View File
@@ -115,26 +115,26 @@ def load_engine(engine_data: dict[str, t.Any]) -> "Engine | types.ModuleType | N
logger.error('An engine does not have a "name" field')
return None
if '_' in engine_name:
logger.error('Engine name contains underscore: "{}"'.format(engine_name))
logger.error('Engine name contains underscore: "%s"', engine_name)
return None
if engine_name.lower() != engine_name:
logger.warning('Engine name is not lowercase: "{}", converting to lowercase'.format(engine_name))
logger.warning('Engine name is not lowercase: "%s", converting to lowercase', engine_name)
engine_name = engine_name.lower()
engine_data['name'] = engine_name
# load_module
module_name = engine_data.get('engine')
if module_name is None:
logger.error('The "engine" field is missing for the engine named "{}"'.format(engine_name))
logger.error('The "engine" field is missing for the engine named "%s"', engine_name)
return None
try:
engine = load_module(module_name + '.py', ENGINE_DIR)
except (SyntaxError, KeyboardInterrupt, SystemExit, SystemError, ImportError, RuntimeError):
logger.exception('Fatal exception in engine "{}"'.format(module_name))
logger.exception('Fatal exception in engine "%s"', module_name)
sys.exit(1)
except BaseException:
logger.exception('Cannot load engine "{}"'.format(module_name))
logger.exception('Cannot load engine "%s"', module_name)
return None
check_engine_module(engine)
@@ -247,7 +247,7 @@ def is_missing_required_attributes(engine: "Engine | types.ModuleType"):
missing = False
for engine_attr in dir(engine):
if not engine_attr.startswith('_') and getattr(engine, engine_attr) is None:
logger.error('Missing engine config attribute: "{0}.{1}"'.format(engine.name, engine_attr))
logger.error('Missing engine config attribute: "%s.%s"', engine.name, engine_attr)
missing = True
return missing
@@ -277,12 +277,12 @@ def call_engine_setup(engine: "Engine | types.ModuleType", engine_data: dict[str
if setup_func is None:
setup_ok = True
elif not callable(setup_func):
logger.error(f"engine's setup method isn't a callable (is of type: {type(setup_func)})")
logger.error("engine's setup method isn't a callable (is of type: %s)", type(setup_func))
else:
try:
setup_ok = engine.setup(engine_data)
except Exception as e: # pylint: disable=broad-except
logger.exception(f"(PID {os.getpid()}) {engine.name}: engine SETUP failed, exception: {e}")
logger.exception("(PID %s) %s: engine SETUP failed, exception: %s", os.getpid(), engine.name, e)
setup_ok = False
# The evaluation of the return value is analogous to Engine.init
@@ -290,18 +290,18 @@ def call_engine_setup(engine: "Engine | types.ModuleType", engine_data: dict[str
setup_ok = True
if not setup_ok:
logger.error(f"(PID {os.getpid()}) {engine.name}: engine setup was not successful")
logger.error("(PID %s) %s: engine setup was not successful", os.getpid(), engine.name)
return setup_ok
def register_engine(engine: "Engine | types.ModuleType"):
if engine.name in engines:
logger.error('Engine config error: ambiguous name: {0}'.format(engine.name))
logger.error('Engine config error: ambiguous name: %s', engine.name)
sys.exit(1)
engines[engine.name] = engine
if engine.shortcut in engine_shortcuts:
logger.error('Engine config error: ambiguous shortcut: {0}'.format(engine.shortcut))
logger.error('Engine config error: ambiguous shortcut: %s', engine.shortcut)
sys.exit(1)
engine_shortcuts[engine.shortcut] = engine.name
@@ -327,7 +327,9 @@ def load_engines(engine_list: list[dict[str, t.Any]]):
# if an engine can't be loaded (if for example the engine is missing
# tor or some other requirements) its set to inactive!
logger.error(
f"(PID {os.getpid()}) {engine_data.get('name', '???')}: can't register engine (loading engine failed)"
"(PID %s) %s: can't register engine (loading engine failed)",
os.getpid(),
engine_data.get("name", "???"),
)
engine_data["inactive"] = True
return engines
+1 -2
View File
@@ -37,8 +37,7 @@ def obtain_token():
)
token['value'] = loads(actual_token.text)['authInfo']['access_token']
token['last_updated'] = update_time
# pylint: disable=bare-except
except:
except: # pylint: disable=bare-except
pass
return token
+241 -180
View File
@@ -118,27 +118,30 @@ Implementations
"""
import json
import re
import typing as t
from urllib.parse import (
urlencode,
urlparse,
)
from collections.abc import Callable
from urllib.parse import urlencode
from dateutil import parser
from searx import locales
from searx import locales, logger
from searx.enginelib.traits import EngineTraits
from searx.extended_types import SXNG_Response
from searx.result_types import EngineResults
from searx.exceptions import SearxEngineResponseException
from searx.result_types import EngineResults, MainResult, Video
from searx.result_types.image import Image
from searx.utils import (
eval_xpath_getindex,
eval_xpath_list,
extract_text,
get_embedded_stream_url,
html_to_text,
js_find_matching_bracket,
js_obj_str_to_json_str,
js_obj_str_to_python,
js_split_args,
js_find_variable_positions,
)
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
about = {
"website": "https://search.brave.com/",
"wikidata_id": "Q22906900",
@@ -193,6 +196,16 @@ time_range_map: dict[str, str] = {
"year": "py",
}
IIFE_RE = re.compile(
r"""
\(function\s*\( # (function(
(?P<params>[^)]*) # formal parameters
\)\s*\{\s*return\s* # ){ return
(?P<body>\{) # the object literal starts
""",
re.VERBOSE | re.DOTALL,
)
def request(query: str, params: dict[str, t.Any]) -> None:
@@ -212,12 +225,10 @@ def request(query: str, params: dict[str, t.Any]) -> None:
if brave_category == "goggles":
args["goggles_id"] = Goggles
params["headers"]["Accept-Encoding"] = "gzip, deflate"
params["url"] = f"{base_url}{brave_category}?{urlencode(args)}"
logger.debug("url %s", params["url"])
# set properties in the cookies
params["cookies"]["safesearch"] = safesearch_map.get(params["safesearch"], "off")
# the useLocation is IP based, we use cookie "country" for the region
params["cookies"]["useLocation"] = "0"
@@ -240,176 +251,228 @@ def _extract_published_date(published_date_raw: str | None):
return None
def extract_iife_object(data_src: str, match: re.Match[str]) -> tuple[str, int]:
"""
Extract and substitute the object literal from a JavaScript IIFE.
Returns the transformed object literal and the position immediately after the IIFE call.
"""
body_start = match.start("body")
body_end = js_find_matching_bracket(data_src, body_start, "{", "}")
obj_str = data_src[body_start : body_end + 1]
# 1. Locate the argument call execution signature block: ...)(arg1, arg2)
call_start = data_src.find("(", body_end)
if call_start == -1:
raise ValueError("IIFE missing call arguments")
call_end = js_find_matching_bracket(data_src, call_start, "(", ")")
# 2. map arguments
args = js_split_args(data_src[call_start + 1 : call_end])
params = [p.strip() for p in match["params"].split(",") if p.strip()]
mapping = dict(zip(params, args))
# 3. substitute idents
variables = js_find_variable_positions(obj_str)
for variable_name, pos in reversed(variables):
if variable_name in mapping:
value = mapping[variable_name]
obj_str = obj_str[:pos] + value + obj_str[pos + len(variable_name) :]
return obj_str, call_end + 1
def extract_json_data(text: str) -> dict[str, t.Any]:
"""
Extract the SvelteKit data payload that Brave embeds in the page.
Handles:
- plain-object form
- pure IIFE form
- mixed form (IIFE + plain object) that appears on some news pages
"""
marker = "data: ["
start = text.find(marker)
if start == -1:
raise ValueError("Brave data payload not found (no 'data: [')")
array_start = start + len(marker) - 1 # Points directly at '['
array_end = js_find_matching_bracket(text, array_start, "[", "]")
data_src = text[array_start + 1 : array_end]
objects: list[str] = []
pos = 0
n = len(data_src)
while pos < n:
# Move past whitespace and commas safely
while pos < n and data_src[pos] in " \t\n\r,":
pos += 1
if pos >= n:
break
# Case 1: IIFE Object Signature Matches
if m := IIFE_RE.match(data_src, pos):
obj_str, next_pos = extract_iife_object(data_src, m)
objects.append(obj_str)
pos = next_pos
# Brave sometimes emits an extra ')' after the IIFE call
while pos < n and data_src[pos] in " \t\n\r)":
pos += 1
continue
# Case 2: Standard raw plain JSON mapping block {type:"data", ...}
if data_src[pos] == "{":
obj_end = js_find_matching_bracket(data_src, pos, "{", "}")
objects.append(data_src[pos : obj_end + 1])
pos = obj_end + 1
continue
raise ValueError(f"unexpected token in data array at offset {pos}: {data_src[pos : pos + 40]!r}")
if not objects:
raise ValueError("no data objects found inside Brave payload")
combined = '{"data":[' + ",".join(objects) + "]}"
json_str = js_obj_str_to_json_str(combined)
return json.loads(json_str)
def response(resp: "SXNG_Response") -> EngineResults:
# delegate the response to the appropriate parser based on search type
match brave_category:
case "search" | "goggles":
return _parse_results(parse_search_result, resp)
case "news":
return _parse_results(parse_news_result, resp)
case "images":
return _parse_results(parse_image_result, resp)
case "videos":
return _parse_results(parse_video_result, resp)
case _:
raise ValueError(f"Unsupported brave category: {brave_category}") # pyright: ignore[reportUnreachable]
def parse_search_result(result: dict[str, t.Any]) -> MainResult:
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
return MainResult(
template="default.html",
title=result.get("title", ""),
content=html_to_text(result.get("description", "")),
url=result.get("url", ""),
publishedDate=_extract_published_date(result.get("page_age")),
pubdate=result.get("age", ""),
thumbnail=thumbnail.get("src", "") if thumbnail and not thumbnail.get("logo") else "",
)
def _parse_secondary_items(json_data: dict[str, t.Any], results: EngineResults):
# video results utilize same schema as video search -> re-use _parse_video_result
body_resp: dict[str, t.Any] = _get_response_data(json_data)
videos_resp: dict[str, t.Any] = body_resp.get("videos", {})
if videos_resp and "results" in videos_resp:
for result in videos_resp.get("results", []):
results.add(parse_video_result(result))
# related queries -> suggestion
query: dict[str, t.Any] = body_resp.get("query", {})
if query and "related_queries" in query:
for x in query.get("related_queries", []):
suggestion = " ".join(val[1] for val in x)
results.add(results.types.LegacyResult(suggestion=suggestion))
def parse_news_result(result: dict[str, t.Any]) -> MainResult:
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
return MainResult(
title=result.get("title", ""),
content=result.get("description", ""),
url=result.get("url"),
publishedDate=_extract_published_date(result.get("age")),
pubdate=result.get("age", ""),
thumbnail=thumbnail.get("src", "") if thumbnail else "",
)
def parse_image_result(result: dict[str, t.Any]) -> Image:
properties: dict[str, t.Any] = result.get("properties", {})
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
width, height = properties.get("width"), properties.get("height")
return Image(
title=result.get("title", ""),
url=result.get("url"),
img_src=properties.get("url", ""),
thumbnail_src=thumbnail.get("src", "") if thumbnail else "",
source=result.get("source", ""),
resolution=f"{width}x{height}" if width and height else "",
)
def parse_video_result(result: dict[str, t.Any]) -> Video:
video: dict[str, t.Any] = result.get("video", {})
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
return Video(
title=result.get("title", ""),
url=result.get("url"),
content=result.get("description", ""),
length=video.get("duration"),
publishedDate=_extract_published_date(result.get("age")),
pubdate=result.get("age", ""),
views=video.get("views", ""),
thumbnail=thumbnail.get("src", "") if thumbnail else "",
)
def _get_response_data(json_data: dict[str, t.Any], category: str | None = None) -> dict[str, t.Any]:
"""Navigate the Brave embedded JSON to the category-specific response object."""
# Brave’s structure is mostly consistent but has a couple of quirks:
# - most categories live under data[1].data.body.response.<category>
# - news omits the intermediate "body" key
try:
data: dict[str, t.Any] = json_data["data"][1]["data"]
if data.get("noResults"): # Boolean Value
return {}
if category == "news":
return data["response"]["news"]
body_resp = data["body"]["response"]
if category in ("search", "goggles"):
return body_resp["web"]
# images / videos / secondary items
return body_resp
except (KeyError, IndexError, TypeError) as e:
raise SearxEngineResponseException(f"Unexpected Brave JSON structure for category {category!r}") from e
def _parse_results(parse_func: Callable[..., MainResult | Image], resp: "SXNG_Response") -> EngineResults:
"""Extract json data and loop through result list
The suppled :py.obj:`parse_func` parses individual result items
General search / goggle search relies on :py.obj:`_parse_secondary_items` for mixed result-types"""
# Example script source containing the data:
#
# kit.start(app, element, {
# node_ids: [0, 19],
# data: [{type:"data",data: .... ["q","goggles_id"],route:1,url:1}}]
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# form: null,
# error: null
# });
start = text.index("data: [{")
newline = text.index("\n", start)
end = text.rindex("}}]", start, newline)
js_obj_str = "{" + text[start:end] + "}}]}"
# js_obj_str = js_obj_str.replace("\xa0", "") # remove ASCII for &nbsp;
# js_obj_str = js_obj_str.replace(r"\u003C", "<").replace(r"\u003c", "<") # fix broken HTML tags in strings
json_str = js_obj_str_to_json_str(js_obj_str)
data: dict[str, t.Any] = json.loads(json_str)
return data
def response(resp: SXNG_Response) -> EngineResults:
if brave_category in ("search", "goggles"):
return _parse_search(resp)
if brave_category in ("news"):
return _parse_news(resp)
# Example script source containing the data:
#
# kit.start(app, element, {
# node_ids: [0, 19],
# data: [{type:"data",data: .... ["q","goggles_id"],route:1,url:1}}]
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
results = EngineResults()
json_data: dict[str, t.Any] = extract_json_data(resp.text)
json_resp: dict[str, t.Any] = json_data["data"][1]["data"]["body"]["response"]
json_resp: dict[str, t.Any] = _get_response_data(json_data, brave_category)
if not json_resp:
# if _get_response_data returns {} - indicates it was parsed successfully but had "noResults" = True
return results
if brave_category == "images":
return _parse_images(json_resp)
if brave_category == "videos":
return _parse_videos(json_resp)
json_results: list[dict[str, t.Any]] = json_resp["results"]
for result in json_results:
results.add(parse_func(result))
raise ValueError(f"Unsupported brave category: {brave_category}")
# general search / goggle might have secondary items
if brave_category in ("search", "goggles"):
_parse_secondary_items(json_data, results)
def _parse_search(resp: SXNG_Response) -> EngineResults:
res = EngineResults()
dom = resp.html()
for result in eval_xpath_list(dom, "//div[contains(@class, 'snippet ')]"):
url: str | None = eval_xpath_getindex(result, ".//a/@href", 0, default=None)
title_tag = eval_xpath_getindex(result, ".//div[contains(@class, 'title')]", 0, default=None)
if url is None or title_tag is None or not urlparse(url).netloc: # partial url likely means it's an ad
continue
content: str = ""
pub_date = None
# there are other classes like 'site-name-content' we don't want to match,
# however only using contains(@class, 'content') would e.g. also match `site-name-content`
# thus, we explicitly also require the spaces as class separator
_content = eval_xpath_getindex(
result,
".//div[contains(concat(' ', @class, ' '), ' content ')]",
0,
default="",
)
if len(_content):
content = extract_text(_content) # type: ignore
_pub_date = extract_text(
eval_xpath_getindex(_content, ".//span[contains(@class, 't-secondary')]", 0, default="")
)
if _pub_date:
pub_date = _extract_published_date(_pub_date)
content = content.lstrip(_pub_date).strip("- \n\t")
thumbnail: str = eval_xpath_getindex(result, ".//a[contains(@class, 'thumbnail')]//img/@src", 0, default="")
item = res.types.LegacyResult(
template="default.html",
url=url,
title=extract_text(title_tag),
content=content,
publishedDate=pub_date,
thumbnail=thumbnail,
)
res.add(item)
video_tag = eval_xpath_getindex(
result,
".//div[contains(@class, 'video-snippet') and @data-macro='video']",
0,
default=[],
)
if len(video_tag):
# In my tests a video tag in the WEB search was most often not a
# video, except the ones from youtube ..
iframe_src = get_embedded_stream_url(url)
if iframe_src:
item["iframe_src"] = iframe_src
item["template"] = "videos.html"
for suggestion in eval_xpath_list(dom, "//a[contains(@class, 'related-query')]"):
res.append(res.types.LegacyResult({"suggestion": extract_text(suggestion)}))
return res
def _parse_news(resp: SXNG_Response) -> EngineResults:
res = EngineResults()
dom = resp.html()
for result in eval_xpath_list(dom, "//div[@data-type='news']"):
url = eval_xpath_getindex(result, ".//a/@href", 0, default=None)
if url is None:
continue
title = eval_xpath_list(result, ".//div[contains(@class, 'title')]")
content = eval_xpath_list(result, ".//div[contains(@class, 'description')]")
thumbnail = eval_xpath_getindex(result, ".//a[contains(@class, 'thumbnail')]//img/@src", 0, default="")
item = res.types.LegacyResult(
template="default.html",
url=url,
title=extract_text(title),
thumbnail=thumbnail,
content=extract_text(content),
)
res.add(item)
return res
def _parse_images(json_resp: dict[str, t.Any]) -> EngineResults:
res = EngineResults()
for result in json_resp["results"]:
item = res.types.LegacyResult(
template="images.html",
url=result["url"],
title=result["title"],
source=result["source"],
img_src=result["properties"]["url"],
thumbnail_src=result["thumbnail"]["src"],
)
res.add(item)
return res
def _parse_videos(json_resp: dict[str, t.Any]) -> EngineResults:
res = EngineResults()
for result in json_resp["results"]:
item = res.types.LegacyResult(
template="videos.html",
url=result["url"],
title=result["title"],
content=result["description"],
length=result["video"]["duration"],
duration=result["video"]["duration"],
publishedDate=_extract_published_date(result["age"]),
)
if result["thumbnail"] is not None:
item["thumbnail"] = result["thumbnail"]["src"]
res.add(item)
return res
return results
def fetch_traits(engine_traits: EngineTraits):
@@ -447,10 +510,9 @@ def fetch_traits(engine_traits: EngineTraits):
# silently ignore unknown languages
continue
conflict = engine_traits.custom["ui_lang"].get(sxng_tag) # type: ignore
if conflict:
if conflict := engine_traits.custom["ui_lang"].get(sxng_tag):
if conflict != ui_lang:
print("CONFLICT: babel %s --> %s, %s" % (sxng_tag, conflict, ui_lang))
print(f"CONFLICT: babel {sxng_tag} --> {conflict}, {ui_lang}")
continue
engine_traits.custom["ui_lang"][sxng_tag] = ui_lang
@@ -477,15 +539,14 @@ def fetch_traits(engine_traits: EngineTraits):
for lang_tag in babel.languages.get_official_languages(country_tag, de_facto=True):
lang_tag = lang_map.get(lang_tag, lang_tag)
try:
sxng_tag = region_tag(babel.Locale.parse("%s_%s" % (lang_tag, country_tag.upper())))
sxng_tag = region_tag(babel.Locale.parse(f"{lang_tag}_{country_tag.upper()}"))
except babel.UnknownLocaleError:
# silently ignore unknown languages
continue
# print("%-20s: %s <-- %s" % (v["label"], country_tag, sxng_tag))
conflict = engine_traits.regions.get(sxng_tag)
if conflict:
if conflict != country_tag:
print("CONFLICT: babel %s --> %s, %s" % (sxng_tag, conflict, country_tag))
continue
if conflict and conflict != country_tag:
print(f"CONFLICT: babel {sxng_tag} --> {conflict}, {country_tag}")
continue
engine_traits.regions[sxng_tag] = country_tag
+8 -39
View File
@@ -27,11 +27,10 @@ The API supports paging and time filters.
import typing as t
from urllib.parse import urlencode
from dateutil import parser
from searx.engines.brave import parse_video_result
from searx.exceptions import SearxEngineAPIException
from searx.result_types import EngineResults
from searx.utils import html_to_text
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
@@ -52,6 +51,7 @@ api_key: str = ""
categories = ["general", "web"]
paging = True
safesearch = True
safesearch_map = {2: "strict", 1: "moderate", 0: "off"}
time_range_support = True
results_per_page: int = 20
@@ -85,52 +85,21 @@ def request(query: str, params: "OnlineParams") -> None:
if params["time_range"]:
search_args["time_range"] = time_range_map.get(params["time_range"])
# Apply SafeSearch if enabled
if params["safesearch"]:
search_args["safesearch"] = "strict"
# Apply SafeSearch
search_args["safesearch"] = safesearch_map.get(params["safesearch"], "off")
params["url"] = f"{base_url}?{urlencode(search_args)}"
params["headers"]["X-Subscription-Token"] = api_key
params["headers"]["Accept"] = "application/json"
def _extract_published_date(published_date_raw: str):
"""Extract and parse the published date from the API response.
Args:
published_date_raw: Raw date string from the API
Returns:
Parsed datetime object or None if parsing fails
"""
if not published_date_raw:
return None
try:
return parser.parse(published_date_raw)
except parser.ParserError:
return None
def response(resp: "SXNG_Response") -> EngineResults:
"""Process the API response and return results."""
res = EngineResults()
data = resp.json()
for result in (data.get("web") or {}).get("results", []):
thumbnail_obj = result.get("thumbnail")
thumbnail = ""
if thumbnail_obj and not thumbnail_obj.get("logo", False):
thumbnail = thumbnail_obj.get("src") or ""
res.add(
res.types.MainResult(
url=result["url"],
title=html_to_text(result["title"]),
content=html_to_text(result.get("description", "")),
publishedDate=_extract_published_date(result.get("age")),
thumbnail=thumbnail,
),
)
res = EngineResults()
results_json = (data.get("web") or {}).get("results", [])
for result in results_json:
res.add(parse_video_result(result))
return res
+38 -34
View File
@@ -1,59 +1,63 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""media.ccc.de"""
import typing as t
import datetime
from urllib.parse import urlencode
from dateutil import parser
import dateutil.parser
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
about = {
'website': 'https://media.ccc.de',
'official_api_documentation': 'https://github.com/voc/voctoweb',
'use_official_api': True,
'require_api_key': False,
'results': 'JSON',
"website": "https://media.ccc.de",
"official_api_documentation": "https://github.com/voc/voctoweb",
"use_official_api": True,
"require_api_key": False,
"results": "JSON",
}
categories = ['videos']
categories = ["videos"]
paging = True
api_url = "https://api.media.ccc.de"
def request(query, params):
args = {'q': query, 'page': params['pageno']}
params['url'] = f"{api_url}/public/events/search?{urlencode(args)}"
return params
def request(query: str, params: "OnlineParams"):
args = {"q": query, "page": params["pageno"]}
params["url"] = f"{api_url}/public/events/search?{urlencode(args)}"
def response(resp):
results = []
def response(resp: "SXNG_Response") -> EngineResults:
res = EngineResults()
for item in resp.json()['events']:
for item in resp.json()["events"]:
publishedDate = None
if item.get('date'):
publishedDate = parser.parse(item['date'])
if item.get("date"):
publishedDate = dateutil.parser.parse(item["date"])
iframe_src = None
for rec in item['recordings']:
if rec['mime_type'].startswith('video'):
for rec in item["recordings"]:
if rec["mime_type"].startswith("video"):
if not iframe_src:
iframe_src = rec['recording_url']
elif rec['mime_type'] == 'video/mp4':
iframe_src = rec["recording_url"]
elif rec["mime_type"] == "video/mp4":
# prefer mp4 (minimal data rates)
iframe_src = rec['recording_url']
iframe_src = rec["recording_url"]
results.append(
{
'template': 'videos.html',
'url': item['frontend_link'],
'title': item['title'],
'content': item['description'],
'thumbnail': item['thumb_url'],
'publishedDate': publishedDate,
'length': datetime.timedelta(seconds=item['length']),
'iframe_src': iframe_src,
}
res.add(
res.types.Video(
url=item["frontend_link"],
title=item["title"],
content=item["description"] or "",
thumbnail=item["thumb_url"],
publishedDate=publishedDate,
length=datetime.timedelta(seconds=item["length"]),
iframe_src=iframe_src or "",
)
)
return results
return res
+1 -1
View File
@@ -169,7 +169,7 @@ def _get_results_from_process(res: EngineResults, cmd, pageno):
for raw_result in raw_results:
result = __parse_single_result(raw_result)
if result is None:
_command_logger.debug('skipped result:', raw_result)
_command_logger.debug('skipped result: %s', raw_result)
continue
if start <= count and count <= end: # pylint: disable=chained-comparison
+10 -6
View File
@@ -5,13 +5,13 @@
"""
import typing as t
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
import html
from searx.enginelib import EngineCache
from searx.exceptions import SearxEngineAPIException
from searx.network import post
from searx.utils import format_duration, html_to_text, humanize_number
from searx.utils import html_to_text, humanize_number
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
@@ -54,7 +54,11 @@ def _obtain_token() -> str:
token = CACHE.get("token")
if token:
return token
resp = post(f"{base_url}/api/token/refresh", headers={"Origin": base_url}, cookies={"dp_api_token": "1"})
resp = post(
f"{base_url}/api/token/refresh",
headers={"Origin": base_url, "Sec-Fetch-Mode": "cors"},
cookies={"dp_api_token": "1"},
)
if not resp.ok:
raise SearxEngineAPIException("failed to obtain dogpile token")
token = resp.json()["token"]
@@ -65,6 +69,7 @@ def _obtain_token() -> str:
def request(query: str, params: "OnlineParams"):
params["url"] = f"{base_url}/api/{dogpile_categ}"
params["headers"]["Origin"] = base_url
params["headers"]["Sec-Fetch-Mode"] = "cors"
params["cookies"]["dp_api_token"] = "1"
params["headers"]["x-dogpile-token"] = _obtain_token()
@@ -98,14 +103,13 @@ def response(resp: "SXNG_Response"):
)
elif dogpile_categ == "videos":
res.add(
res.types.LegacyResult(
template="videos.html",
res.types.Video(
url=result["clickUrl"],
title=html_to_text(result["title"]),
content=html_to_text(result["description"]),
thumbnail=result["thumbnailUrl"],
publishedDate=datetime.fromisoformat(result["publishDate"]),
length=format_duration(result["duration"]),
length=timedelta(seconds=result["duration"]),
views=humanize_number(result["viewCount"]),
)
)
+6 -5
View File
@@ -510,12 +510,13 @@ def response(resp: "SXNG_Response") -> EngineResults:
and "Your user agent:" not in zero_click
and "URL Decoded:" not in zero_click
):
res.add(
res.types.Answer(
answer=zero_click,
url=eval_xpath_getindex(doc, '//div[@id="zero_click_abstract"]/a/@href', 0),
if zero_click_url := eval_xpath_getindex(doc, '//div[@id="zero_click_abstract"]/a/@href', 0, None):
res.add(
res.types.Answer(
answer=zero_click,
url=zero_click_url,
)
)
)
return res
+2 -4
View File
@@ -10,7 +10,7 @@ from datetime import datetime
from urllib.parse import urlencode
from urllib.parse import quote_plus
from searx.result_types import EngineResults, MainResult, LegacyResult, Image
from searx.result_types import EngineResults, MainResult, Image, Video
from searx.utils import html_to_text, gen_useragent, extr
from searx.network import get # see https://github.com/searxng/searxng/issues/762
@@ -162,13 +162,11 @@ def _image_result(result):
def _video_result(result):
return LegacyResult(
template='videos.html',
return Video(
url=result['content'],
title=result['title'],
content=result['description'],
thumbnail=result['images'].get('small') or result['images'].get('medium'),
source=result['provider'],
length=result['duration'],
metadata=result.get('uploader'),
)
-1
View File
@@ -74,7 +74,6 @@ def _fetch_first_page_link(
headers=headers,
impersonate="firefox",
default_headers=False,
timeout=2,
)
if resp.status_code != 200:
+64
View File
@@ -0,0 +1,64 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Findborg_ is a small search engine that gets its results from Brave.
.. _Findborg: https://www.findborg.com/about/
"""
import typing as t
from urllib.parse import urlencode
from searx.engines.brave import parse_image_result, parse_news_result, parse_search_result, parse_video_result
from searx.extended_types import SXNG_Response
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from searx.search.processors import OnlineParams
about = {
"website": "https://www.findborg.com",
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": "JSON",
}
categories: list[str] = None # type: ignore[reportAssignmentType]
time_range_support = True
FindborgCategType = t.Literal["search", "images", "videos", "news"]
findborg_categ: FindborgCategType = None # type: ignore[reportAssignmentType]
base_url = "https://www.findborg.com"
def setup(_: dict[str, t.Any]):
if findborg_categ not in t.get_args(FindborgCategType):
raise ValueError("invalid category: %s" % findborg_categ)
def request(query: str, params: "OnlineParams"):
args = {"q": query, "type": findborg_categ}
if params["time_range"]:
args["time"] = params["time_range"]
params["url"] = f"{base_url}/apis/proxy.php?{urlencode(args)}"
def response(resp: "SXNG_Response") -> EngineResults:
data: dict[str, t.Any] = resp.json()["data"] # type: ignore[reportAny]
res = EngineResults()
match findborg_categ:
case "search":
for result in data["web"]["results"]:
res.add(parse_search_result(result))
case "news":
for result in data["results"]:
res.add(parse_news_result(result))
case "videos":
for result in data["results"]:
res.add(parse_video_result(result))
case "images":
for result in data["results"]:
res.add(parse_image_result(result))
return res
+1 -2
View File
@@ -92,8 +92,7 @@ def response(resp: "SXNG_Response") -> EngineResults:
):
video_src = extract_text(eval_xpath(result, ".//video/@src")) or ""
res.add(
res.types.LegacyResult(
template="videos.html",
res.types.Video(
url=video_src,
title=extract_text(eval_xpath(result, ".//div[contains(@class, 'caption')]/span")) or "",
iframe_src=video_src or "",
+7 -10
View File
@@ -153,16 +153,13 @@ def response(resp: "SXNG_Response") -> EngineResults:
length = result["video"].get("duration")
res.add(
res.types.LegacyResult(
{
"template": "videos.html",
"url": result["url"],
"title": html_to_text(result["title"]),
"content": html_to_text(result["description"]),
"thumbnail": result.get("thumbnail", {}).get("original"),
"length": length,
"publishedDate": published_date,
}
res.types.Video(
url=result["url"],
title=html_to_text(result["title"]),
content=html_to_text(result["description"]),
thumbnail=result.get("thumbnail", {}).get("original"),
length=length,
publishedDate=published_date,
)
)
+1 -1
View File
@@ -18,7 +18,7 @@ about = {
"results": 'JSON',
}
categories = ["stock_images"]
categories = ["stock images"]
page_size = 15
paging = True
+1 -2
View File
@@ -73,12 +73,11 @@ def response(resp: "SXNG_Response") -> EngineResults:
break
results.add(
results.types.MainResult(
results.types.Video(
url=url,
title=title,
thumbnail=thumbnail,
length=length,
template="videos.html",
)
)
+50
View File
@@ -0,0 +1,50 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Iconify aggregates icons from different open source icon sets."""
import typing as t
from urllib.parse import urlencode
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from extended_types import SXNG_Response
from search.processors.online import OnlineParams
about = {
"website": "https://iconify.design",
"wikidata_id": None,
"official_api_documentation": "https://iconify.design/docs/api/queries.html",
"use_official_api": True,
"results": "JSON",
}
categories = ["images", "icons"]
paging = True
base_url = "https://api.iconify.design"
page_size = 20
def request(query: str, params: "OnlineParams"):
# actual number of results isn't exaxctly the same as the page size, only approximately
args = {"query": query, "start": (params["pageno"] - 1) * page_size, "limit": page_size}
params["url"] = f"{base_url}/search?{urlencode(args)}"
def response(resp: "SXNG_Response") -> EngineResults:
res = EngineResults()
for icon in resp.json()["icons"]:
icon: str
icon_source, icon_name = icon.split(":", 1)
icon_url = f"{base_url}/{icon_source}/{icon_name}.svg"
res.add(
res.types.Image(
url=icon_url,
title=icon_name,
img_src=icon_url,
)
)
return res
+8 -11
View File
@@ -178,17 +178,14 @@ def response(resp: "SXNG_Response") -> EngineResults:
length = parse_duration_string(result["props"]["duration"])
res.add(
res.types.LegacyResult(
{
"template": "videos.html",
"url": result["url"],
"title": html_to_text(result.get("title", "no title available")),
"content": html_to_text(result.get("snippet", "")),
"thumbnail": result.get("image", {}).get("url"),
"publishedDate": published_date,
"author": result.get("props", {}).get("creator_name"),
"length": length,
}
res.types.Video(
url=result["url"],
title=html_to_text(result.get("title", "no title available")),
content=html_to_text(result.get("snippet", "")),
thumbnail=result.get("image", {}).get("url"),
publishedDate=published_date,
author=result.get("props", {}).get("creator_name"),
length=length,
)
)
+1 -2
View File
@@ -157,8 +157,7 @@ def _news_results(doc: ElementType, res: EngineResults):
def _video_results(doc: ElementType, res: EngineResults):
for result in eval_xpath_list(doc, "//div[@id='mainResults']/div[contains(@class, 'mediaResult')]"):
res.add(
res.types.MainResult(
template="videos.html",
res.types.Video(
url=extract_text(eval_xpath(result, "./@data-url")) or "",
title=extract_text(eval_xpath(result, ".//div[contains(@class, 'mediaResultTitleVideo')]/a")) or "",
content=extract_text(eval_xpath(result, ".//div[contains(@class, 'mediaResultDescription')]")) or "",
+55 -1
View File
@@ -4,11 +4,16 @@
.. _Magnific: https://www.magnific.com
"""
import re
from urllib.parse import urlencode
import typing as t
from searx.enginelib import EngineCache
from searx.exceptions import SearxEngineAPIException
from searx.network import get, post
from searx.result_types import EngineResults
from searx.utils import eval_xpath_getindex, extract_text
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
@@ -26,7 +31,7 @@ about = {
base_url = "https://www.magnific.com"
categories = ["images"]
categories = ["stock images"]
paging = True
free_images_only = True
@@ -34,6 +39,47 @@ free_images_only = True
Whether to only load images that may be used for free, without a Magnific account.
"""
# regular expressions for solving the captcha
_NUMBER_RE = re.compile(r"\d+")
_BM_VERIFY_RE = re.compile(r"\"bm-verify\":\s*\"(.*?)\"")
_CAPTCHA_COOKIE_NAME = "ak_bmsc"
CACHE: EngineCache
"""Cache for storing the cookie to bypass botblocking, obtained by solving a CAPTCHA."""
def setup(engine_settings: dict[str, t.Any]):
global CACHE # pylint: disable=global-statement
CACHE = EngineCache(engine_settings["name"])
def _solve_captcha(original_resp: "SXNG_Response") -> "SXNG_Response":
"""Solves the Magnific CAPTCHA by summing all numbers in the response.
E.g., the relevant part of the script that contains the numbers could look like
``var i = 1789729970; var j = i + Number("1947" + "43160");``."""
doc = original_resp.html()
script = extract_text(eval_xpath_getindex(doc, "//script", 0)) or ""
bm_verify = _BM_VERIFY_RE.search(original_resp.text)
if not bm_verify:
raise SearxEngineAPIException("failed to extract bm verify token")
bm_verify = bm_verify.group(1)
# challenge is just adding a bunch of number together (some are strings, some are ints)
numbers = [int(m) for m in _NUMBER_RE.findall(script)]
solution = sum(numbers)
challenge_resp = post(
f"{base_url}/_sec/verify?provider=interstitial",
json={"bm-verify": bm_verify, "pow": solution},
cookies=original_resp.cookies,
)
resp = get(original_resp.url, headers=original_resp.search_params["headers"], cookies=challenge_resp.cookies)
# cookie is valid for 2 hours
CACHE.set(_CAPTCHA_COOKIE_NAME, challenge_resp.cookies[_CAPTCHA_COOKIE_NAME], expire=7200)
return resp
def request(query: str, params: "OnlineParams") -> None:
args = {"term": query, "filters[ai-generated][excluded]": 1, "page": params["pageno"], "locale": "en"}
@@ -41,10 +87,18 @@ def request(query: str, params: "OnlineParams") -> None:
args["filters[license]"] = "free"
params["headers"]["Referer"] = f"{base_url}/search"
# cookie that is obtained after solving the CAPTCHA
if cookie := CACHE.get(_CAPTCHA_COOKIE_NAME):
params["cookies"][_CAPTCHA_COOKIE_NAME] = cookie
params["url"] = f"{base_url}/api/regular/search?{urlencode(args)}"
def response(resp: "SXNG_Response"):
if resp.headers["content-type"] == "text/html":
resp = _solve_captcha(resp)
res = EngineResults()
result: dict[str, t.Any] # TBH: dict[str, t.Any]
+3 -3
View File
@@ -5,6 +5,7 @@ import typing as t
import datetime
from searx.result_types import EngineResults
from searx.utils import parse_duration_string
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
@@ -67,13 +68,12 @@ def response(resp: "SXNG_Response") -> EngineResults:
continue
res.add(
res.types.LegacyResult(
res.types.Video(
url=video_url,
title="%(channel)s: %(title)s (%(hms)s)" % item,
length=item["hms"],
length=parse_duration_string(item["hms"]),
content="%(description)s" % item,
iframe_src=video_url,
template="videos.html",
)
)
+3 -4
View File
@@ -201,7 +201,7 @@ def parse_videos(data):
for item in eval_xpath_list(dom, "//li[contains(@class, 'video_item')]"):
url = eval_xpath_getindex(item, ".//a[contains(@class, 'info_title')]/@href", 0)
thumbnail = None
thumbnail = ""
try:
thumbnail = eval_xpath_getindex(item, ".//img[contains(@class, 'thumb')]/@src", 0)
except (ValueError, TypeError, SearxEngineXPathException):
@@ -214,9 +214,8 @@ def parse_videos(data):
pass
res.add(
res.types.LegacyResult(
template="videos.html",
title=extract_text(eval_xpath(item, ".//a[contains(@class, 'info_title')]")),
res.types.Video(
title=extract_text(eval_xpath(item, ".//a[contains(@class, 'info_title')]")) or "",
url=url,
thumbnail=thumbnail,
length=length,
+22 -24
View File
@@ -4,14 +4,19 @@
.. _Odysee: https://github.com/OdyseeTeam/odysee-frontend
"""
from datetime import datetime
import typing as t
from datetime import datetime, timedelta
from urllib.parse import urlencode
import babel
from searx.enginelib.traits import EngineTraits
from searx.locales import language_tag
from searx.utils import format_duration
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
# Engine metadata
about = {
@@ -34,7 +39,7 @@ categories = ["videos"]
base_url = "https://lighthouse.odysee.tv/search"
def request(query, params):
def request(query: str, params: "OnlineParams"):
time_range_dict = {
"day": "today",
"week": "thisweek",
@@ -59,22 +64,17 @@ def request(query, params):
query_params["time_filter"] = time_range_dict[params["time_range"]]
params["url"] = f"{base_url}?{urlencode(query_params)}"
return params
def response(resp):
def response(resp: "SXNG_Response") -> EngineResults:
res = EngineResults()
data = resp.json()
results = []
for item in data:
name = item["name"]
claim_id = item["claimId"]
title = item["title"]
thumbnail_url = item["thumbnail_url"]
description = item["description"] or ""
channel = item["channel"]
release_time = item["release_time"]
duration = item["duration"]
release_date = datetime.fromisoformat(release_time.split("T")[0])
formatted_date = datetime.fromtimestamp(release_date.timestamp())
@@ -82,23 +82,21 @@ def response(resp):
url = f"https://odysee.com/{name}:{claim_id}"
iframe_url = f"https://odysee.com/$/embed/{name}:{claim_id}"
odysee_thumbnail = f"https://thumbnails.odycdn.com/optimize/s:390:0/quality:85/plain/{thumbnail_url}"
formatted_duration = format_duration(duration)
results.append(
{
"title": title,
"url": url,
"content": description,
"author": channel,
"publishedDate": formatted_date,
"length": formatted_duration,
"thumbnail": odysee_thumbnail,
"iframe_src": iframe_url,
"template": "videos.html",
}
res.add(
res.types.Video(
title=item["title"],
url=url,
content=item["description"] or "",
author=item["channel"],
publishedDate=formatted_date,
length=timedelta(seconds=item["duration"]),
thumbnail=odysee_thumbnail,
iframe_src=iframe_url,
)
)
return results
return res
def fetch_traits(engine_traits: EngineTraits):
+1 -1
View File
@@ -26,7 +26,7 @@ about = {
}
base_url = "https://www.pexels.com"
categories = ["stock_images"]
categories = ["stock images"]
api_key = "H2jk9uKnhRmL6WPwh89zBezWvr"
"""
+1 -1
View File
@@ -26,7 +26,7 @@ about = {
base_url = "https://picjumbo.com"
categories = ["stock_images"]
categories = ["stock images"]
paging = True
+2 -2
View File
@@ -88,6 +88,7 @@ def request(query: str, params: "OnlineParams") -> None:
params["url"] = f"{base_url}/search/secure/?{urlencode(args)}"
else:
params["url"] = f"{base_url}/{privacywall_category}/?{urlencode(args)}"
params["headers"]["Referer"] = f"{base_url}/"
def _general_results(doc: "ElementBase") -> EngineResults:
@@ -146,8 +147,7 @@ def _video_results(doc: "ElementBase") -> EngineResults:
thumbnail = _extract_thumbnail_url(extr(thumbnail_style, ":url(", ")"))
res.add(
res.types.LegacyResult(
template="videos.html",
res.types.Video(
url=url,
title=extract_text(eval_xpath(result, ".//h2[contains(@class, 'video-card-title')]")) or "",
content=extract_text(eval_xpath(result, ".//p")) or "",
+1 -1
View File
@@ -292,7 +292,7 @@ def response(resp: "SXNG_Response") -> EngineResults:
thumbnail = thumbnail.replace("https://s2.qwant.com", "https://s1.qwant.com", 1)
res.add(
res.types.LegacyResult(
res.types.Video(
title=title,
url=res_url,
content=content,
+34 -3
View File
@@ -4,11 +4,14 @@
.. _Resulthunter : https://resulthunter.com
"""
import re
import typing as t
from urllib.parse import urlencode
from searx.enginelib import EngineCache
from searx import locales
from searx.exceptions import SearxEngineResponseException
from searx.exceptions import SearxEngineAPIException, SearxEngineResponseException
from searx.network import get
from searx.result_types import EngineResults
from searx.utils import eval_xpath_list, eval_xpath, extract_text
@@ -40,18 +43,46 @@ base_url = "https://resulthunter.com"
resulthunter_categ = "web"
"""Supported categories are ``web`` and ``images``."""
CACHE: EngineCache
"""Cache for storing search token and cookies."""
_SEARCH_TOKEN_RE = re.compile(r"window.__rhSearchToken\s*=\s*'(.*)'")
def setup(_: dict[str, str]) -> bool | None:
global CACHE # pylint: disable=global-statement
CACHE = EngineCache("resulthunter")
def setup(_: dict[str, t.Any]) -> bool | None:
if resulthunter_categ not in ("web", "images"):
raise ValueError("invalid category: %s" % resulthunter_categ)
def _get_search_token() -> tuple[str, dict[str, str]]:
if cached := CACHE.get("token"):
return cached
resp = get(base_url)
token = _SEARCH_TOKEN_RE.search(resp.text)
if not token:
raise SearxEngineAPIException("failed to find search token")
token = token.group(1)
cookies = dict(resp.cookies)
CACHE.set("token", (token, cookies))
return token, cookies
def request(query: str, params: "OnlineParams") -> None:
search_token, cookies = _get_search_token()
params["cookies"] = cookies
args = {
"q": query,
"search_type": resulthunter_categ,
"offset": params["pageno"] - 1,
"search_source": "other",
"search_source": "header",
"st": search_token,
}
# uses Brave's engine traits
+15 -10
View File
@@ -48,16 +48,21 @@ def response(resp: "SXNG_Response") -> EngineResults:
mainline = [s for s in data["search"]["regions"] if s["name"] == "mainline"][0]
for group in mainline["groups"]:
for result in group["results"]:
if not ("url" in result or "clickUrl" in result):
continue
match group["result_type"]:
case "web":
for result in group["results"]:
res.add(
res.types.MainResult(
url=result.get("url") or result.get("clickUrl"),
title=html_to_text(result["title"]),
content=html_to_text(result["description"]),
)
)
case "aylf":
for result in group["results"]:
res.add(res.types.LegacyResult(suggestion=result["term"]))
res.add(
res.types.MainResult(
url=result.get("url") or result.get("clickUrl"),
title=html_to_text(result["title"]),
content=html_to_text(result["description"]),
)
)
case _:
pass
return res
+1 -1
View File
@@ -25,7 +25,7 @@ about = {
base_url = "https://www.shopify.com"
categories = ["stock_images"]
categories = ["stock images"]
paging = True
+4 -6
View File
@@ -7,10 +7,9 @@ results from Google.
import typing as t
from urllib.parse import urlencode
import datetime
from dateutil import parser
from searx.utils import format_duration
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
@@ -49,7 +48,7 @@ def request(query: str, params: "OnlineParams") -> None:
params["url"] = f"{api_url}/api/v2/search/{startpagina_categ}/?{urlencode(args)}"
def response(resp: "SXNG_Response"):
def response(resp: "SXNG_Response") -> EngineResults:
res = EngineResults()
json_resp = resp.json()
@@ -81,13 +80,12 @@ def response(resp: "SXNG_Response"):
)
elif startpagina_categ == "videos":
res.add(
res.types.LegacyResult(
template="videos.html",
res.types.Video(
url=result["original_url"],
title=result["title"],
content=result["description"],
thumbnail=result["video"]["thumbnail_url"],
length=format_duration(result["video"]["duration"]),
length=datetime.timedelta(seconds=result["video"]["duration"]),
)
)
elif startpagina_categ == "images":
+1 -1
View File
@@ -25,7 +25,7 @@ about = {
base_url = "https://stocksnap.io"
cdn_url = "https://cdn.stocksnap.io"
categories = ["stock_images"]
categories = ["stock images"]
paging = True
+17 -24
View File
@@ -16,7 +16,7 @@ from urllib.parse import urlencode
from babel.core import get_global
from searx.result_types import EngineResults, LegacyResult # pyright: ignore[reportPrivateLocalImportUsage]
from searx.result_types import EngineResults, Video
from searx.utils import humanize_number, html_to_text
if t.TYPE_CHECKING:
@@ -215,28 +215,24 @@ def request(query: str, params: "OnlineParams") -> None:
params["url"] = f"{base_url}{base_path}?{urlencode(args)}"
def _video_result(result: dict[str, str]) -> LegacyResult:
def _video_result(result: dict[str, str]) -> Video:
published_date = None
if result.get("datePublished"):
published_date = datetime.fromisoformat(result["datePublished"])
view_count = None
view_count = ""
if result.get("viewCount"):
view_count = humanize_number(result["viewCount"]) # pyright: ignore[reportArgumentType]
return LegacyResult(
{
"template": "videos.html",
"url": result["url"],
"title": html_to_text(result.get("title") or result["name"]),
"content": result["description"],
"thumbnail": result.get("thumbnailUrl")
or result.get("thumbnail", {}).get("url"), # pyright: ignore[reportAttributeAccessIssue]
"length": result.get("duration"),
"iframe_src": result.get("embedUrl"),
"publishedDate": published_date,
"views": view_count,
}
return Video(
url=result["url"],
title=html_to_text(result.get("title") or result["name"]),
content=result["description"],
thumbnail=result.get("thumbnailUrl") or result.get("thumbnail", {}).get("url"),
length=result.get("duration"),
iframe_src=result.get("embedUrl"),
publishedDate=published_date,
views=view_count,
)
@@ -272,14 +268,11 @@ def response(resp: "SXNG_Response") -> EngineResults:
res.add(_video_result(video))
elif result["type"] == "ImageObject":
res.add(
res.types.LegacyResult(
{
"template": "images.html",
"url": result["url"],
"thumbnail_src": result["thumbnail"]["url"],
"img_src": result["contentUrl"],
"title": result["name"],
}
res.types.Image(
url=result["url"],
thumbnail_src=result["thumbnail"]["url"],
img_src=result["contentUrl"],
title=result["name"],
)
)
elif result["type"] == "video":
+12 -11
View File
@@ -20,6 +20,8 @@ from datetime import datetime
from urllib.parse import urlencode
import re
from searx.result_types import Video
about = {
'website': "https://tagesschau.de",
'wikidata_id': "Q703907",
@@ -30,7 +32,7 @@ about = {
}
language = "de"
categories = ['general', 'news']
categories = ['news']
paging = True
results_per_page = 10
@@ -87,7 +89,7 @@ def _story(item):
}
def _video(item):
def _video(item) -> Video:
streams = item['streams']
video_url = streams.get('h264s') or streams.get('h264m') or streams.get('h264l') or streams.get('h264xl')
title = item['title']
@@ -99,12 +101,11 @@ def _video(item):
# sometimes, only adaptive m3u8 streams are available, so video_url is None
url = video_url or f"{base_url}/multimedia/video/{item['sophoraId']}.html"
return {
'template': 'videos.html',
'title': title,
'thumbnail': item.get('teaserImage', {}).get('imageVariants', {}).get('16x9-256'),
'publishedDate': datetime.fromisoformat(item['date'][:19]),
'content': item.get('firstSentence', ''),
'iframe_src': video_url,
'url': url,
}
return Video(
title=title,
thumbnail=item.get('teaserImage', {}).get('imageVariants', {}).get('16x9-256'),
publishedDate=datetime.fromisoformat(item['date'][:19]),
content=item.get('firstSentence', ''),
iframe_src=video_url,
url=url,
)
+1 -2
View File
@@ -119,8 +119,7 @@ def _video_results(doc: ElementType, res: EngineResults):
continue
title_parts: list[ElementType] = eval_xpath(result, ".//a[starts-with(@class, 'tMMReshl')]")
res.add(
res.types.LegacyResult(
template="videos.html",
res.types.Video(
url=url,
title=" - ".join(extract_text(part) or "" for part in title_parts),
thumbnail=extract_text(eval_xpath(result, ".//img/@src") or "") or "",
+24 -63
View File
@@ -9,12 +9,11 @@ from json import loads
import random
import typing as t
from urllib.parse import urlencode
from dateutil import parser
from searx.result_types import EngineResults
from searx.engines.brave import parse_image_result, parse_news_result, parse_search_result, parse_video_result
from searx.exceptions import SearxEngineAPIException
from searx.network import get
from searx.utils import html_to_text
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
@@ -32,7 +31,8 @@ about = {
paging = True
categories = ["general"]
tusk_categ = "web"
TuskCategType = t.Literal["web", "images", "videos", "news"]
tusk_categ: TuskCategType = "web"
"""Category to search in. Can be either "web", "images", "videos" or "news"."""
@@ -40,7 +40,7 @@ api_url = "https://api.tusksearch.com"
def setup(_: dict[str, t.Any]) -> bool | None:
if tusk_categ not in ("web", "images", "videos", "news"):
if tusk_categ not in t.get_args(TuskCategType):
raise ValueError("invalid search type: %s" % tusk_categ)
@@ -98,70 +98,31 @@ def request(query: str, params: "OnlineParams") -> None:
"Referer": "https://tusksearch.com/",
"Origin": "https://tusksearch.com",
"Sec-Fetch-Site": "same-site",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
}
)
def response(resp: "SXNG_Response"):
res = EngineResults()
json_resp = resp.json()["results"]
if tusk_categ == "web":
for result in (json_resp.get("web") or {}).get("results", []):
res.add(
res.types.MainResult(
url=result["url"],
title=html_to_text(result["title"]),
content=html_to_text(result["description"]),
thumbnail=(result["thumbnail"] or {}).get("src") or "",
)
)
elif tusk_categ == "news":
for result in (json_resp.get("news") or {}).get("results", []):
publishedDate = None
try:
publishedDate = parser.parse(result["age"])
except parser.ParserError:
pass
res.add(
res.types.MainResult(
url=result["url"],
title=html_to_text(result["title"]),
content=html_to_text(result["description"]),
thumbnail=result["thumbnail"]["src"],
publishedDate=publishedDate,
)
)
elif tusk_categ == "videos":
for result in (json_resp.get("videos") or {}).get("results", []):
publishedDate = None
try:
publishedDate = parser.parse(result["age"])
except parser.ParserError:
pass
res.add(
res.types.LegacyResult(
template="videos.html",
url=result["url"],
title=html_to_text(result["title"]),
content=html_to_text(result["description"]),
thumbnail=result["thumbnail"]["src"],
publishedDate=publishedDate,
length=result["video"].get("duration"),
)
)
elif tusk_categ == "images":
for result in json_resp:
res.add(
res.types.Image(
url=result["url"],
title=html_to_text(result["title"]),
img_src=result["properties"]["url"],
thumbnail_src=result["thumbnail"]["src"],
)
)
res = EngineResults()
match tusk_categ:
case "web":
results = (json_resp.get("web") or {}).get("results", [])
for result in results:
res.add(parse_search_result(result))
case "news":
results = (json_resp.get("news") or {}).get("results", [])
for result in results:
res.add(parse_news_result(result))
case "videos":
results = (json_resp.get("videos") or {}).get("results", [])
for result in results:
res.add(parse_video_result(result))
case "images":
for result in json_resp:
res.add(parse_image_result(result))
return res
+1 -1
View File
@@ -18,7 +18,7 @@ about = {
base_url = 'https://unsplash.com/'
search_url = base_url + 'napi/search/photos?'
categories = ["stock_images"]
categories = ["stock images"]
page_size = 20
paging = True
+75 -44
View File
@@ -1,67 +1,98 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Wikipedia (Web
"""
"""Vimeo (videos)"""
from urllib.parse import urlencode
from json import loads
from dateutil import parser
import typing as t
from datetime import datetime, timedelta
from urllib.parse import urlencode, urlparse
from searx.result_types import EngineResults
from searx.network import get
from searx.enginelib import EngineCache
from searx.utils import extr
# about
# Engine metadata
about = {
"website": 'https://vimeo.com/',
"wikidata_id": 'Q156376',
"official_api_documentation": 'http://developer.vimeo.com/api',
"use_official_api": False,
"require_api_key": False,
"results": 'HTML',
"results": 'JSON',
}
# engine dependent config
categories = ['videos']
# Engine configuration
paging = True
categories = ['videos']
results_per_page = 20
# search-url
base_url = 'https://vimeo.com/'
search_url = base_url + 'search/page:{pageno}?{query}'
# Search URL
base_url = "https://api.vimeo.com"
# Cache keys & expiration
JWT_CACHE_KEY = "jwt"
JWT_CACHE_EXPIRATION_SECONDS = 300
CACHE: EngineCache
# do search-request
def request(query, params):
params['url'] = search_url.format(pageno=params['pageno'], query=urlencode({'q': query}))
return params
def setup(engine_settings: dict[str, t.Any]) -> bool:
global CACHE # pylint: disable=global-statement
CACHE = EngineCache(engine_settings["name"])
# get response from search-request
def response(resp):
results = []
def fetch_json_web_token():
data = loads(extr(resp.text, 'var data = ', ';\n'))
if json_web_token := CACHE.get(JWT_CACHE_KEY):
return json_web_token
# parse results
for result in data['filtered']['data']:
result = result[result['type']]
videoid = result['uri'].split('/')[-1]
url = base_url + videoid
title = result['name']
thumbnail = result['pictures']['sizes'][-1]['link']
publishedDate = parser.parse(result['created_time'])
jwt_url = "https://vimeo.com/_next/jwt"
# append result
results.append(
{
'url': url,
'title': title,
'content': '',
'template': 'videos.html',
'publishedDate': publishedDate,
'iframe_src': "https://player.vimeo.com/video/" + videoid,
'thumbnail': thumbnail,
}
jwt_headers = {
'x-requested-with': "XMLHttpRequest",
}
resp = get(url=jwt_url, headers=jwt_headers, timeout=5)
json_web_token = resp.json()["token"]
CACHE.set(key=JWT_CACHE_KEY, value=json_web_token, expire=JWT_CACHE_EXPIRATION_SECONDS)
return json_web_token
def request(query: str, params: "OnlineParams"):
json_web_token = fetch_json_web_token()
query_params = {
"filter_type": "clip",
"query": query,
"page": params["pageno"],
"per_page": results_per_page,
}
params["url"] = f"{base_url}/search?{urlencode(query_params)}"
params["headers"]["content-type"] = "application/json"
params["headers"]["authorization"] = "jwt " + json_web_token
params["headers"]["Accept"] = "application/vnd.vimeo.*+json;version=3.3"
def response(resp: "SXNG_Response") -> EngineResults:
results = EngineResults()
search_res = resp.json()
for item in search_res["data"]:
video_id = urlparse(item["clip"]["link"]).path.strip('/')
results.add(
results.types.LegacyResult(
template="videos.html",
title=item["clip"]["name"],
url=item["clip"]["link"],
author=item["clip"]["user"]["name"],
thumbnail=item["clip"]["pictures"]["base_link"],
length=timedelta(seconds=item["clip"]["duration"]),
publishedDate=datetime.fromisoformat(item["clip"]["created_time"]),
iframe_src="https://player.vimeo.com/video/" + video_id,
)
)
# return results
return results
+1 -2
View File
@@ -85,8 +85,7 @@ def _video_results(doc: "ElementBase") -> EngineResults:
for result in eval_xpath_list(doc, "//div[contains(@class, 'item video')]"):
(
res.add(
res.types.MainResult(
template="videos.html",
res.types.Video(
url=extract_text(eval_xpath(result, "./a/@href")) or "",
title=extract_text(eval_xpath(result, "./a/@title")) or "",
content=extract_text(eval_xpath(result, ".//div[contains(@class, 'abaslik')]")) or "",
+1 -2
View File
@@ -181,8 +181,7 @@ def response(resp: "SXNG_Response") -> EngineResults:
if wc_search_type == "video":
res.add(
res.types.LegacyResult(
template="videos.html",
res.types.Video(
title=title,
url=url,
content=content,
+1 -1
View File
@@ -167,7 +167,7 @@ def get_results(
infobox_id_lang: str | None = None
infobox_urls: list[dict[str, str]] = []
infobox_attributes: list[dict[str, str]] = []
infobox_content = attribute_result.get("itemDescription", [])
infobox_content: str = attribute_result.get("itemDescription", "")
img_src: str | None = None
img_src_priority = 0
+1 -1
View File
@@ -17,7 +17,7 @@ about = {
}
# engine dependent config
categories = ["stock_images"]
categories = ["stock images"]
paging = False
# search-url
+101
View File
@@ -0,0 +1,101 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""XPrivo"""
import json
import typing as t
from searx.exceptions import SearxEngineAPIException, SearxEngineTooManyRequestsException
from searx.network import raise_for_httperror
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
about = {
"website": 'https://www.xprivo.com',
"wikidata_id": None,
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": 'JSON',
}
categories = ["general"]
paging = True
time_range_support = True
base_url = "https://www.xprivo.com"
_page_size = 10 # not configurable
api_key = "API_KEY_XPRIVO"
XPrivoSearchType = t.Literal["all", "news", "videos"]
xprivo_search_type: XPrivoSearchType = "all"
def setup(_):
if xprivo_search_type not in t.get_args(XPrivoSearchType):
raise ValueError(f"invalid search type: {xprivo_search_type}")
def request(query: str, params: "OnlineParams"):
params["url"] = f"{base_url}/v1/searchengine/completions"
params["headers"].update({"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"})
params["method"] = "POST"
params["json"] = {
"query": query,
"mode": "classic",
"search_type": xprivo_search_type,
"time_filter": params["time_range"],
"region": "",
"offset": (params["pageno"] - 1) * _page_size,
"source": "",
}
params["raise_for_httperror"] = False
def response(resp: "SXNG_Response") -> EngineResults:
if resp.status_code in (400, 429):
raise SearxEngineTooManyRequestsException()
raise_for_httperror(resp)
res = EngineResults()
# response is a Server-Side Events (SSE) stream
# we search for a JSON object with the "results" key
data_parts = [line for line in resp.text.split("\n") if line]
for data_raw in data_parts:
data_raw = data_raw.removeprefix("data: ")
data: dict[str, t.Any] = json.loads(data_raw)
if "results" in data:
break
else:
raise SearxEngineAPIException("no results in response")
result: dict[str, str]
for result in data["results"]:
if xprivo_search_type == "videos":
res.add(
res.types.Video(
url=result["url"],
title=result["title"],
content=result["snippet"],
thumbnail=result.get("og_image") or "",
)
)
else:
res.add(
res.types.MainResult(
url=result["url"],
title=result["title"],
content=result["snippet"],
thumbnail=result.get("og_image") or "",
)
)
return res
+1
View File
@@ -48,6 +48,7 @@ def request(query: str, params: "OnlineParams") -> None:
{
"Referer": f"{web_base_url}/",
"Origin": web_base_url,
"x-csrf-protection": "1",
}
)
+2 -2
View File
@@ -32,10 +32,10 @@ def init():
cfg_file = (settings_loader.get_user_cfg_folder() or pathlib.Path("/etc/searxng")) / "favicons.toml"
if not cfg_file.exists():
if is_active():
logger.error(f"missing favicon config: {cfg_file}")
logger.error("missing favicon config: %s", cfg_file)
cfg_file = config.DEFAULT_CFG_TOML_PATH
logger.debug(f"load favicon config: {cfg_file}")
logger.debug("load favicon config: %s", cfg_file)
cfg = config.FaviconConfig.from_toml_file(cfg_file, use_cache=True)
cache.init(cfg.cache)
proxy.init(cfg.proxy)
+1 -1
View File
@@ -352,7 +352,7 @@ CREATE TABLE IF NOT EXISTS blob_map (
bytes_c = len(data or b"")
if bytes_c > self.cfg.BLOB_MAX_BYTES:
logger.info(
"favicon of resolver: %s / authority: %s to big to cache (bytes: %s) " % (resolver, authority, bytes_c)
"favicon of resolver: %s / authority: %s to big to cache (bytes: %s) ", resolver, authority, bytes_c
)
return False
+1 -1
View File
@@ -137,7 +137,7 @@ class Request(t.NamedTuple):
method: str
url: str
kwargs: dict[str, str] = {}
kwargs: dict[str, str] = {} # pylint: disable=dangerous-default-value
@staticmethod
def get(url: str, **kwargs: t.Any):
-8
View File
@@ -409,14 +409,6 @@ def initialize(
if isinstance(network, str):
NETWORKS[engine_name] = NETWORKS[network]
# the /image_proxy endpoint has a dedicated network.
# same parameters than the default network, but HTTP/2 is disabled.
# It decreases the CPU load average, and the total time is more or less the same
if 'image_proxy' not in NETWORKS:
image_proxy_params = default_params.copy()
image_proxy_params['enable_http2'] = False
NETWORKS['image_proxy'] = new_network(image_proxy_params, logger_name='image_proxy')
@atexit.register
def done():
+20 -1
View File
@@ -27,10 +27,12 @@ Add Answer example
Here is an example of a very simple plugin that adds a "Hello World" into the
answer area:
In ``searx/plugins`` create a file named ``hello_world.py`` with the following content:
.. code:: python
from flask_babel import gettext as _
from searx.plugins import Plugin
from searx.plugins import Plugin, PluginInfo
from searx.result_types import Answer
class MyPlugin(Plugin):
@@ -44,6 +46,23 @@ answer area:
def post_search(self, request, search):
return [ Answer(answer="Hello World") ]
You will then need to add your new plugin in the :ref:`settings.yml` file like this:
.. code:: yaml
plugins:
...
searx.plugins.hello_world.MyPlugin:
active: true
``searx.plugins.hello_world.MyPlugin`` is the Python import path of the new plugin we created.
``searx.plugins.hello_world`` represents the file path, while ``MyPlugin`` represents the name of the plugin class.
If you don't want the plugin to be part of SearXNG's core (i.e. maintain it independently of SearXNG),
you may also place the file at any other path as long as you update the reference to it in :ref:`settings.yml`.
For more examples, see :ref:`settings external_plugins`.
.. _filter urls example:
Filter URLs example
+9 -8
View File
@@ -19,9 +19,6 @@ if typing.TYPE_CHECKING:
from searx.plugins import PluginCfg
ahmia_blacklist: list = []
def filter_url_field(result: "Result|LegacyResult", field_name: str, url_src: str) -> bool | str:
"""Returns bool ``True`` to use URL unchanged (``False`` to ignore URL).
If URL should be modified, the returned string is the new URL to use."""
@@ -29,11 +26,15 @@ def filter_url_field(result: "Result|LegacyResult", field_name: str, url_src: st
if field_name != "url":
return True # use it unchanged
resolver_url = get_doi_resolver()
if not resolver_url:
return True # use it unchanged
doi = extract_doi(result.parsed_url)
if doi and len(doi) < 50:
for suffix in ("/", ".pdf", ".xml", "/full", "/meta", "/abstract"):
doi = doi.removesuffix(suffix)
new_url = get_doi_resolver() + doi
new_url = resolver_url + doi
if "doi" not in result:
result["doi"] = doi
log.debug("oa_doi_rewrite: [URL field: %s] %s -> %s", field_name, url_src, new_url)
@@ -53,7 +54,7 @@ class SXNGPlugin(Plugin):
id=self.id,
name=gettext("Open Access DOI rewrite"),
description=gettext("Avoid paywalls by redirecting to open-access versions of publications when available"),
preference_section="general",
preference_section=None,
)
def on_result(
@@ -83,7 +84,7 @@ def extract_doi(url):
def get_doi_resolver() -> str:
doi_resolvers = get_setting("doi_resolvers")
selected_resolver = sxng_request.preferences.get_value('doi_resolver')[0]
if selected_resolver not in doi_resolvers:
selected_resolver = get_setting("default_doi_resolver")
selected_resolver = sxng_request.preferences.get_value("doi_resolver")
if not selected_resolver or selected_resolver not in doi_resolvers:
return ""
return doi_resolvers[selected_resolver]
+7 -3
View File
@@ -457,10 +457,10 @@ class Preferences:
get_setting("ui.results_on_new_tab"),
locked="results_on_new_tab" in self.cfg.lock,
),
'doi_resolver': MultipleChoiceSetting(
[get_setting("default_doi_resolver")],
"doi_resolver": EnumStringSetting(
get_setting("default_doi_resolver"),
locked="doi_resolver" in self.cfg.lock,
choices=DOI_RESOLVERS,
choices=["", *DOI_RESOLVERS],
),
'simple_style': EnumStringSetting(
get_setting("ui.theme_args.simple_style"),
@@ -536,6 +536,8 @@ class Preferences:
self.plugins.parse_cookie(input_data.get('disabled_plugins', ''), input_data.get('enabled_plugins', ''))
elif user_setting_name == 'tokens':
self.tokens.parse(user_setting)
if "oa_doi_rewrite" in self.plugins.choices:
self.plugins.choices["oa_doi_rewrite"] = bool(self.get_value("doi_resolver"))
def parse_form(self, input_data: dict[str, str]):
"""Parse formular (``<input>``) data from a ``flask.request.form``"""
@@ -564,6 +566,8 @@ class Preferences:
self.key_value_settings['categories'].parse_form(enabled_categories) # type: ignore
self.engines.parse_form(disabled_engines)
self.plugins.parse_form(disabled_plugins)
if "oa_doi_rewrite" in self.plugins.choices:
self.plugins.choices["oa_doi_rewrite"] = bool(self.get_value("doi_resolver"))
# cannot be used in case of engines or plugins
def get_value(self, user_setting_name: str) -> t.Any:
+3
View File
@@ -28,6 +28,7 @@ __all__ = [
"File",
"Image",
"ImageRef",
"Video",
]
import typing as t
@@ -40,6 +41,7 @@ from .code import Code
from .paper import Paper
from .file import File
from .image import Image, ImageRef
from .video import Video
class ResultList(list[Result | LegacyResult], abc.ABC):
@@ -56,6 +58,7 @@ class ResultList(list[Result | LegacyResult], abc.ABC):
Paper = Paper
File = File
Image = Image
Video = Video
MainResult = MainResult
Result = Result
Translations = Translations
+5 -6
View File
@@ -31,7 +31,6 @@ from collections.abc import Callable
import msgspec
from searx import logger
from searx.utils import get_embedded_stream_url
log = logger.getChild("result_types")
@@ -386,7 +385,11 @@ class MainResult(Result): # pylint: disable=missing-class-docstring
"""URL of a image that is displayed in the result item."""
iframe_src: str = ""
"""URL of an embedded ``<iframe>`` / the frame is collapsible."""
"""URL of an embedded ``<iframe>`` / the frame is collapsible.
To convert a standard video URL from a widely know video services into its
embed format, have a look at :obj:`searx.utils.get_embedded_stream_url`.
"""
audio_src: str = ""
"""URL of an embedded ``<audio controls>``."""
@@ -542,10 +545,6 @@ class LegacyResult(dict[str, t.Any]):
DeprecationWarning,
)
# TODO: move into typed video results class once it is implemented # pylint: disable=fixme
if self.template == "videos.html" and self.url and not self.iframe_src:
self.iframe_src = get_embedded_stream_url(self.url)
def __getattr__(self, name: str, default: t.Any = UNSET) -> t.Any:
if default == UNSET and name not in self:
raise AttributeError(f"LegacyResult object has no field named: {name}")
+30
View File
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Typification of the *video* results. Results of this type are rendered in
the :origin:`videos.html <searx/templates/simple/result_templates/videos.html>`
template.
.. autoclass:: Video
:members:
:show-inheritance:
"""
# pylint: disable=too-few-public-methods
__all__ = ["Video"]
import typing as t
from searx.utils import get_embedded_stream_url
from ._base import MainResult
@t.final
class Video(MainResult, kw_only=True):
"""Result type suitable for displaying videos."""
template: str = "videos.html"
def __post_init__(self):
super().__post_init__()
if self.url and not self.iframe_src:
self.iframe_src = get_embedded_stream_url(self.url)
+2
View File
@@ -182,6 +182,8 @@ class ResultContainer:
def close(self):
self._closed = True
# infobox with images first
self.infoboxes.sort(key=lambda infobox: not infobox.img_src)
for result in self.main_results_map.values():
result.score = calculate_score(result, result.priority)

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