132 Commits

Author SHA1 Message Date
github-actions[bot]
d226b78bc4 [mod] data: update searx.data - update_engine_traits.py (#6594)
Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-08-29 10:14:39 +02:00
github-actions[bot]
bdbf9774f5 [mod] data: update searx.data - update_firefox_version.py (#6592)
Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-08-29 10:14:25 +02:00
github-actions[bot]
451c46aa32 [mod] data: update searx.data - update_ahmia_blacklist.py (#6591)
Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-08-29 10:13:59 +02:00
dependabot[bot]
a30b2d4749 [upd] pypi: Bump the minor group with 2 updates (#6589)
Bumps the minor group with 2 updates: [granian](https://github.com/emmett-framework/granian) and [lxml](https://github.com/lxml/lxml).


Updates `granian` from 2.8.1 to 2.8.2
- [Release notes](https://github.com/emmett-framework/granian/releases)
- [Commits](https://github.com/emmett-framework/granian/compare/v2.8.1...v2.8.2)

Updates `lxml` from 6.1.1 to 6.1.2
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-6.1.1...lxml-6.1.2)

---
updated-dependencies:
- dependency-name: granian
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: lxml
  dependency-version: 6.1.2
  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-08-28 10:14:10 +02:00
github-actions[bot]
9fea41204f [l10n] update translations from Weblate (#6565)
930f4d95f - 2026-08-21 - return42 <return42@noreply.codeberg.org>
45e05f3a5 - 2026-08-21 - return42 <return42@noreply.codeberg.org>
f97f47265 - 2026-08-21 - return42 <return42@noreply.codeberg.org>
c5de4638c - 2026-08-21 - return42 <return42@noreply.codeberg.org>
02aac306b - 2026-08-21 - return42 <return42@noreply.codeberg.org>
f88e28cd0 - 2026-08-14 - Mooo <mooo@noreply.codeberg.org>
2026-08-22 07:32:09 +02:00
Markus Heiser
777ba8fa48 [mod] data: update searx.data - update_engine_traits.py (#6546)
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-08-22 11:00:06 +08:00
vojkovic
a4cb7df053 [fix] google: use Nokia UA (#6546) 2026-08-22 11:00:06 +08:00
dependabot[bot]
bbb3c7d829 [upd] web-client (simple): Bump the minor group (#6558)
Bumps the minor group in /client/simple with 2 updates: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [less](https://github.com/less/less.js).


Updates `@biomejs/biome` from 2.5.7 to 2.5.9
- [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.9/packages/@biomejs/biome)

Updates `less` from 4.8.1 to 4.9.0
- [Release notes](https://github.com/less/less.js/releases)
- [Changelog](https://github.com/less/less.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/less/less.js/compare/v4.8.1...v4.9.0)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: less
  dependency-version: 4.9.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-08-21 22:33:59 +02:00
dependabot[bot]
f31ff05db3 [upd] pypi: Bump the minor group with 2 updates (#6559)
Bumps the minor group with 2 updates: [basedpyright](https://github.com/detachhead/basedpyright) and [pygments](https://github.com/pygments/pygments).

Updates `basedpyright` from 1.39.9 to 1.39.10
- [Release notes](https://github.com/detachhead/basedpyright/releases)
- [Commits](https://github.com/detachhead/basedpyright/compare/v1.39.9...v1.39.10)

Updates `pygments` from 2.20.0 to 2.21.0
- [Release notes](https://github.com/pygments/pygments/releases)
- [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES)
- [Commits](https://github.com/pygments/pygments/compare/2.20.0...2.21.0)

---
updated-dependencies:
- dependency-name: basedpyright
  dependency-version: 1.39.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: pygments
  dependency-version: 2.21.0
  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-08-21 22:32:36 +02:00
vojkovic
5f4005996e [fix] preferences: cap zlib decompress 2026-08-21 20:55:33 +08:00
Ivan Gabaldon
487d7a96e0 [fix] py: (un)trusted proxies (#6556)
A missing condition allowed an attacker to mask their IP address from the
server. This exploit can only be carried under specific client & server
environments.

Reported-by: Raffaele Forte <raffaele@backbox.org>
Signed-off-by: Ivan Gabaldon <igabaldon@inetol.net>
2026-08-20 18:06:24 +02:00
SVHawk13
8d3dd0cd45 [feat] marginalia: add support for custom filters (#6543) 2026-08-20 14:22:22 +02:00
Brock Vojkovic
5ffd32ca2f [fix] calculator: XSS from innerHTML (#6551)
* [fix] calculator: XSS from innerHTML

* [build] /static
2026-08-19 14:09:02 +02:00
vojkovic
374939b888 [fix] engines: deviantart access denied 2026-08-17 20:06:57 +08:00
dependabot[bot]
b2da6b90f2 [upd] pypi: Bump the minor group across 1 directory with 6 updates (#6540)
* [upd] pypi: Bump the minor group across 1 directory with 6 updates

Bumps the minor group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [granian](https://github.com/emmett-framework/granian) | `2.7.9` | `2.8.1` |
| [pylint](https://github.com/pylint-dev/pylint) | `4.0.6` | `4.0.7` |
| [selenium](https://github.com/SeleniumHQ/Selenium) | `4.46.0` | `4.47.0` |
| [wlc](https://github.com/WeblateOrg/wlc) | `2.1.0` | `2.1.1` |
| [httpx-socks](https://github.com/romis2012/httpx-socks) | `0.10.0` | `0.13.0` |
| [typer](https://github.com/fastapi/typer) | `0.27.0` | `0.27.1` |



Updates `granian` from 2.7.9 to 2.8.1
- [Release notes](https://github.com/emmett-framework/granian/releases)
- [Commits](https://github.com/emmett-framework/granian/compare/v2.7.9...v2.8.1)

Updates `pylint` from 4.0.6 to 4.0.7
- [Release notes](https://github.com/pylint-dev/pylint/releases)
- [Commits](https://github.com/pylint-dev/pylint/compare/v4.0.6...v4.0.7)

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

Updates `wlc` from 2.1.0 to 2.1.1
- [Release notes](https://github.com/WeblateOrg/wlc/releases)
- [Changelog](https://github.com/WeblateOrg/wlc/blob/main/CHANGES.rst)
- [Commits](https://github.com/WeblateOrg/wlc/compare/2.1.0...2.1.1)

Updates `httpx-socks` from 0.10.0 to 0.13.0
- [Release notes](https://github.com/romis2012/httpx-socks/releases)
- [Commits](https://github.com/romis2012/httpx-socks/compare/v0.10.0...v0.13.0)

Updates `typer` from 0.27.0 to 0.27.1
- [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.0...0.27.1)

---
updated-dependencies:
- dependency-name: granian
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: httpx-socks
  dependency-version: 0.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: pylint
  dependency-version: 4.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: selenium
  dependency-version: 4.47.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: typer
  dependency-version: 0.27.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: wlc
  dependency-version: 2.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* [fix] network: bump httpx-socks to 0.13.1 for python-socks 3

httpx-socks 0.10.0 calls the default AnyIO Proxy with proxy_ssl.
python-socks 3.0.0 dropped that kwarg, so Tor/SOCKS startup checks
crash. 0.13.1 uses the v2 API (proxy_ssl still accepted) and allows
python-socks<4.

Drop the unused loop= argument; 0.13.1 forwards extra kwargs to
httpcore.AsyncConnectionPool, which rejects loop.

Closes: searxng/searxng#6514
Signed-off-by: Richard Freeman <rich@rich0.org>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Richard Freeman <rich@rich0.org>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Richard Freeman <rich@rich0.org>
2026-08-16 11:10:39 +02:00
dependabot[bot]
36698aff6b [upd] web-client (simple): Bump the minor group across 1 directory with 7 updates (#6522)
Bumps the minor group with 7 updates in the /client/simple directory:

| Package | From | To |
| --- | --- | --- |
| [ionicons](https://github.com/ionic-team/ionicons) | `8.0.13` | `8.1.0` |
| [ol](https://github.com/openlayers/openlayers) | `10.9.0` | `10.10.0` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.5` | `2.5.7` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.1` | `26.2.0` |
| [browserslist](https://github.com/browserslist/browserslist) | `4.28.7` | `4.28.8` |
| [less](https://github.com/less/less.js) | `4.8.0` | `4.8.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.5` | `8.2.1` |

Updates `ionicons` from 8.0.13 to 8.1.0
- [Release notes](https://github.com/ionic-team/ionicons/releases)
- [Commits](https://github.com/ionic-team/ionicons/compare/v8.0.13...v8.1.0)

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

Updates `@biomejs/biome` from 2.5.5 to 2.5.7
- [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.7/packages/@biomejs/biome)

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

Updates `browserslist` from 4.28.7 to 4.28.8
- [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.28.7...4.28.8)

Updates `less` from 4.8.0 to 4.8.1
- [Release notes](https://github.com/less/less.js/releases)
- [Changelog](https://github.com/less/less.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/less/less.js/compare/v4.8.0...v4.8.1)

Updates `vite` from 8.1.5 to 8.2.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.2.1/packages/vite)

---
updated-dependencies:
- dependency-name: ionicons
  dependency-version: 8.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: ol
  dependency-version: 10.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: browserslist
  dependency-version: 4.28.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: less
  dependency-version: 4.8.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: vite
  dependency-version: 8.2.1
  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-08-16 10:34:47 +02:00
dependabot[bot]
094c33d406 [upd] github-actions: Bump JamesIves/github-pages-deploy-action (#6521)
Bumps [JamesIves/github-pages-deploy-action](https://github.com/jamesives/github-pages-deploy-action) from 4.8.0 to 4.9.0.
- [Release notes](https://github.com/jamesives/github-pages-deploy-action/releases)
- [Commits](d92aa235d0...fa24774553)

---
updated-dependencies:
- dependency-name: JamesIves/github-pages-deploy-action
  dependency-version: 4.9.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-08-14 10:18:45 +02:00
ArsenBilov
ef9a188cc8 [feat] engines: add paid yandex search api (#6349) 2026-08-13 18:16:56 +02:00
Bnyro
cdfdaa5a88 [mod] dogpile: set to inactive by default 2026-08-12 16:14:58 +02:00
Bnyro
5638231358 [fix] dogpile: access denied due to missing origin header 2026-08-12 16:13:15 +02:00
vojkovic
1c3bb1e88f [mod] defaults: GET method and ddg autocomplete 2026-08-12 19:05:02 +08:00
Ivan Gabaldon
54613defc7 Revert "[upd] pypi: Bump the minor group across 1 directory with 3 updates (#…" (#6515)
This reverts commit e8e710e42a.
2026-08-12 01:34:08 +02:00
dependabot[bot]
e8e710e42a [upd] pypi: Bump the minor group across 1 directory with 3 updates (#6495)
Bumps the minor group with 3 updates in the / directory: [typer](https://github.com/fastapi/typer), [wlc](https://github.com/WeblateOrg/wlc) and [granian](https://github.com/emmett-framework/granian).


Updates `typer` from 0.27.0 to 0.27.1
- [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.0...0.27.1)

Updates `wlc` from 2.1.0 to 2.1.1
- [Release notes](https://github.com/WeblateOrg/wlc/releases)
- [Changelog](https://github.com/WeblateOrg/wlc/blob/main/CHANGES.rst)
- [Commits](https://github.com/WeblateOrg/wlc/compare/2.1.0...2.1.1)

Updates `granian` from 2.7.9 to 2.8.0
- [Release notes](https://github.com/emmett-framework/granian/releases)
- [Commits](https://github.com/emmett-framework/granian/compare/v2.7.9...v2.8.0)

---
updated-dependencies:
- dependency-name: typer
  dependency-version: 0.27.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: wlc
  dependency-version: 2.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: granian
  dependency-version: 2.8.0
  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-08-11 17:45:08 +02:00
dependabot[bot]
7b0c7f0bf8 [upd] github-actions: Bump docker/login-action from 4.5.2 to 4.6.0 (#6493)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](371161bbe7...dbcb813823)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.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-08-11 17:38:34 +02:00
github-actions[bot]
e033be7c7c [l10n] update translations from Weblate (#6496)
8f9804206 - 2026-08-03 - Aindriú Mac Giolla Eoin <aindriu80@noreply.codeberg.org>
6850566e7 - 2026-08-04 - mvtbh <mvtbh@noreply.codeberg.org>
f97257801 - 2026-08-01 - ShinyDust <shinydust@noreply.codeberg.org>

Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-08-11 17:37:46 +02:00
Bnyro
0a118066d8 [feat] engines: add jina (general) 2026-08-10 14:14:33 +02:00
Bnyro
b023a28bab [fix] engines: only print search traceback in debug mode 2026-08-06 17:43:17 +02:00
Ivan Gabaldon
c63835bd2a [mod] data: update searx.data - update_wikidata_units.py (#6474) 2026-08-04 19:38:59 +02:00
vojkovic
1689cb1b53 [feat] autocomplete: add kagi autocompleter 2026-08-05 00:00:54 +08:00
Bnyro
aa059419ff [fix] autocompleter: suggestions contain HTML escape codes
The results were previously HTML-escaped for some reason.
That doesn't really make much sense because they never got
unescaped anywhere.

For example, `A&B` gets escaped to `A&amp;B` and never unescaped,
so the autocompletion frontend shows `A&amp;B`, which isn't user-friendly
at all.

Also, it doesn't make sense to escape the full JSON instead
of only escaping the suggestion texts individually because that
makes it even more unclear.
2026-08-03 19:17:57 +02:00
vojkovic
0734ee6c71 [build] /static 2026-08-03 19:51:02 +08:00
vojkovic
d81810d2a7 [fix] autocomplete: drop POST method 2026-08-03 19:51:02 +08:00
vojkovic
8892414dc3 [feat] add kagi favicon resolver 2026-08-01 11:09:50 +08:00
dependabot[bot]
6bfd82705a [upd] github-actions: Bump docker/login-action from 4.5.0 to 4.5.2 (#6478)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.0 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](06fb636fac...371161bbe7)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.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-07-31 17:03:08 +02:00
vojkovic
057a77168d [ci] add ai policy checkbox detection 2026-07-31 06:28:06 +08:00
Ilya Bogin
0be6f87801 [feat] engines: add keenable (general) 2026-07-30 16:11:42 +02:00
Ivan Gabaldon
ef3a6ea9fd [mod] data: update searx.data - update_currencies.py (#6475) 2026-07-30 15:16:02 +02:00
Ivan Gabaldon
f25b75e613 [mod] py: cleanup link_token.py 2026-07-30 14:54:39 +02:00
Bnyro
98e10f9ab4 [mod] swisscows: use alphabet from string module instead of hardcoding alphabet 2026-07-30 14:54:39 +02:00
Bnyro
a449518ed4 [mod] engines: replace [random.choice(...) for ... in range(..)] with random.choices 2026-07-30 14:54:39 +02:00
Bnyro
702f702f9b [del] reddit: remove engine, requires authentication now 2026-07-30 14:53:46 +02:00
github-actions[bot]
afdfd81613 [mod] data: update searx.data - update_firefox_version.py (#6466)
Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-07-30 14:26:18 +02:00
github-actions[bot]
c81ed99c69 [mod] data: update searx.data - update_engine_traits.py (#6468)
Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-07-30 14:26:02 +02:00
github-actions[bot]
ecf8497b65 [mod] data: update searx.data - update_ahmia_blacklist.py (#6467)
Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-07-30 14:25:37 +02:00
Ivan Gabaldon
e28131f8d4 [mod] docs: sidebar remove verbose features (#6465)
Remove all references to `dev.searxng.org` and get rid of some highlights
2026-07-30 14:24:39 +02:00
Léon Tiekötter
81b0ed7b38 chore: remove presearch engine
Removing the presearch engine and configuration because it got shutdown.

ref.: https://news.presearch.io/a-message-from-the-presearch-team-aaa448052b2b
https://x.com/presearchnews/status/2080744472791441807
2026-07-30 14:23:05 +02:00
prodzeus
c01178d031 Updated pronoun in favicons documentation (#6464)
Updated pronoun 'his' to 'their', to be more inclusive.
2026-07-28 21:40:42 +02:00
github-actions[bot]
c73861ab46 [l10n] update translations from Weblate (#6447)
c62975ec7 - 2026-07-22 - alexgabi <alexgabi@noreply.codeberg.org>
a0f3db680 - 2026-07-23 - gallegonovato <gallegonovato@noreply.codeberg.org>
c1271523f - 2026-07-17 - codebergnew2 <codebergnew2@noreply.codeberg.org>

Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-07-28 21:03:43 +02:00
dependabot[bot]
891bc69550 [upd] web-client (simple): Bump the minor group across 1 directory with 6 updates (#6445)
Bumps the minor group with 6 updates in the /client/simple directory:

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.3` | `2.5.5` |
| [browserslist](https://github.com/browserslist/browserslist) | `4.28.6` | `4.28.7` |
| [less](https://github.com/less/less.js) | `4.6.7` | `4.8.0` |
| [stylelint](https://github.com/stylelint/stylelint) | `17.14.0` | `17.14.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.4` | `8.1.5` |
| [vite-bundle-analyzer](https://github.com/nonzzz/vite-bundle-analyzer) | `1.3.8` | `1.3.9` |

Updates `@biomejs/biome` from 2.5.3 to 2.5.5
- [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.5/packages/@biomejs/biome)

Updates `browserslist` from 4.28.6 to 4.28.7
- [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.28.6...4.28.7)

Updates `less` from 4.6.7 to 4.8.0
- [Release notes](https://github.com/less/less.js/releases)
- [Changelog](https://github.com/less/less.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/less/less.js/compare/v4.6.7...v4.8.0)

Updates `stylelint` from 17.14.0 to 17.14.1
- [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.14.0...17.14.1)

Updates `vite` from 8.1.4 to 8.1.5
- [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.1.5/packages/vite)

Updates `vite-bundle-analyzer` from 1.3.8 to 1.3.9
- [Release notes](https://github.com/nonzzz/vite-bundle-analyzer/releases)
- [Changelog](https://github.com/nonzzz/vite-bundle-analyzer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nonzzz/vite-bundle-analyzer/compare/v1.3.8...v1.3.9)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: browserslist
  dependency-version: 4.28.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: less
  dependency-version: 4.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: stylelint
  dependency-version: 17.14.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: vite
  dependency-version: 8.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: vite-bundle-analyzer
  dependency-version: 1.3.9
  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-07-28 21:02:52 +02:00
dependabot[bot]
d661b6114d [upd] pypi: Bump certifi from 2026.6.17 to 2026.7.22 in the minor group (#6446)
Bumps the minor group with 1 update: [certifi](https://github.com/certifi/python-certifi).


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

---
updated-dependencies:
- dependency-name: certifi
  dependency-version: 2026.7.22
  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-07-28 20:52:53 +02:00
Bnyro
8372f5d855 [fix] exaapi: missing shortcut causes engine to crash 2026-07-28 18:33:27 +02:00
kesku
8f8b5d2b8d [feat] engines: add Exa Search API engine 2026-07-28 18:02:42 +02:00
vojkovic
b060c780d0 Revert "[ci] add ai policy checkbox detection"
This reverts commit 6d8b550280.
2026-07-26 16:53:10 +00:00
vojkovic
6d8b550280 [ci] add ai policy checkbox detection 2026-07-26 16:51:14 +00:00
dependabot[bot]
0909dbc9ef [upd] github-actions: Bump actions/setup-python from 6.3.0 to 7.0.0 (#6444)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](ece7cb06ca...5fda3b95a4)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 7.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-07-25 08:44:50 +02:00
dependabot[bot]
b4e94417b7 [upd] github-actions: Bump actions/checkout from 7.0.0 to 7.0.1 (#6443)
Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](9c091bb21b...3d3c42e5aa)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  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-07-25 08:44:30 +02:00
dependabot[bot]
4f64d95013 [upd] github-actions: Bump docker/login-action from 4.4.0 to 4.5.0 (#6442)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.4.0 to 4.5.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](af1e73f918...06fb636fac)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.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-07-24 16:15:49 +02:00
Burak Emir Sezen
ef8f6470e0 [fix] container: ignore invalid SEARXNG_PORT values (#6439)
* [fix] container: ignore invalid SEARXNG_PORT values

* add note

---------

Co-authored-by: Ivan Gabaldon <igabaldon@inetol.net>
2026-07-22 22:23:30 +02:00
Kes
6da6eee265 [upd] update to gecko driver v37 (#6433) 2026-07-19 17:04:57 +02:00
github-actions[bot]
277d8469cd [l10n] update translations from Weblate (#6426)
b498ffaf1 - 2026-07-17 - return42 <return42@noreply.codeberg.org>
341dacc88 - 2026-07-17 - impar <impar@noreply.codeberg.org>
2650a73a9 - 2026-07-17 - return42 <return42@noreply.codeberg.org>
37d2751a8 - 2026-07-17 - return42 <return42@noreply.codeberg.org>
2026-07-18 11:44:39 +02:00
dependabot[bot]
81c9c23862 [upd] github-actions: Bump actions/setup-node from 6.4.0 to 7.0.0 (#6423)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.4.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](48b55a011b...8207627860)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.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-07-17 14:27:38 +02:00
dependabot[bot]
6913fba208 [upd] pypi: Bump the minor group with 2 updates (#6425)
Bumps the minor group with 2 updates: [typer](https://github.com/fastapi/typer) and [selenium](https://github.com/SeleniumHQ/Selenium).


Updates `typer` from 0.26.8 to 0.27.0
- [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.26.8...0.27.0)

Updates `selenium` from 4.45.0 to 4.46.0
- [Release notes](https://github.com/SeleniumHQ/Selenium/releases)
- [Commits](https://github.com/SeleniumHQ/Selenium/compare/selenium-4.45.0...selenium-4.46.0)
2026-07-17 14:24:17 +02:00
Bnyro
2daa4d4815 [fix] qwant: can't fetch engine traits because engine.about is no longer a dict 2026-07-17 12:27:46 +02:00
Bnyro
de8f73f434 [fix] cache: maintenance period comment duration 2h instead of 1h 2026-07-17 12:24:47 +02:00
Bnyro
9f9c00819e [fix] tiger: remember auth cookie for 2 months, not 1 day 2026-07-16 23:31:59 +02:00
Bnyro
b72a87676f [fix] public domain image archive: crashes upon response
The code used here has always been "bad" because `about` shouldn't be used
as data source, but the engine probably broke when type checks / dataclasses
for the about parameter in engines has been added with
<https://github.com/searxng/searxng/pull/6258>.

Error log:
```
WARNING searx.engines.public domain im: ErrorContext('searx/engines/public_domain_image_archive.py', 143, '\'url\': _clean_url(f"{about[\'website\']}/images/{result[\'objectID\']}"),', 'TypeError', None, ("'EngineAbout' object is not subscriptable",)) False
ERROR   searx.engines.public domain im: exception : 'EngineAbout' object is not subscriptable
Traceback (most recent call last):
  File "/home/bnyro/Projects/searxng/searx/search/processors/online.py", line 253, in search
    search_results = self._search_basic(query, params)
  File "/home/bnyro/Projects/searxng/searx/search/processors/online.py", line 239, in _search_basic
    return self.engine.response(response)
           ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^
  File "/home/bnyro/Projects/searxng/searx/engines/public_domain_image_archive.py", line 143, in response
    'url': _clean_url(f"{about['website']}/images/{result['objectID']}"),
                         ~~~~~^^^^^^^^^^^
TypeError: 'EngineAbout' object is not subscriptable
```
2026-07-16 15:50:58 +02:00
Bnyro
f2432e33d6 [fix] engine cache: return default value when cache entry expired
It's possible that it's expired but has not yet been automatically deleted
by the periodic maintenance because that, by default, runs only every 2 hours.

Related:
- see https://github.com/searxng/searxng/pull/6409#discussion_r3584712601
2026-07-16 12:37:26 +02:00
vojkovic
7b2199ecdf [fix] autocomplete suggestrelevance scores for chromium 2026-07-15 23:45:25 +08:00
Bnyro
4a9c19d7bf [mod] images.html: display all engines that found the result, not only the first one 2026-07-15 17:44:11 +02:00
Bnyro
5cb4cb2bc5 [fix] qwant: engine blocked with captcha
Qwant now requires a `datadome` cookie that it returns
in the first search response as a `Set-Cookie`.

This cookie has to be sent for all requests, otherwise they
will be blocked.

This means that now, the first search request is blocked (results in CAPTCHA),
and only the subsequent searches work (same happens on the Qwant website for me).

However, I don't think it's worth repeating the same
search request multiple times very quickly because
that also makes us more suspicious.
2026-07-15 17:39:10 +02:00
Bnyro
5a448596ab [feat] engines: add avalw.org (general)
The index is very small, so it often doesn't find anything.

It also has news support, but the results are unusable.
2026-07-15 17:38:46 +02:00
Bnyro
9c49b7e0d7 [fix] results: display internationalized domain names human-friendly
Some search engines encode domains that contain special characters
in the IDN format, i.e. by using Punycode (https://en.wikipedia.org/wiki/Punycode).

This means that domains formatted like that are not human readable
and thus very unintuive.

Also, as only some engines use Punycode, this often causes a result
to appear multiple times, once with a Punycode domain (e.g. xn--allestrungen-9ib.de),
and once with a normal domain (e.g. allestörungen.de).

Always formatting the domains nicely has the caveat that the official sites
are harder to distinguish from malicious clones that just swapped out some
characters by using Punycode, e.g. replaced `a` with `á`. I don't think
that will be much of an issue though - I don't think Punycode results
previously stopped users from clicking the link, and I also think that
most search engines filter out such bad results or don't even have them
indexed.
2026-07-15 17:38:04 +02:00
Bnyro
58e02a01ae [fix] tusksearch: fix engine blocked by bot protection
Changes:
- the `embed.js` request now requires a user agent header
- we include a user agent in the "actual" request (I dropped it by accident)
- we only send the first 4 decimal places of the location
  instead of 7+ (not required, but harder to detect)
2026-07-14 20:32:31 +02:00
Bnyro
7fa9f16225 [fix] heexy: blocked by cloudflare 2026-07-14 20:32:18 +02:00
Onev
9e25585aec [refactor] engines: use datetime.fromisoformat instead of datetime.strptime where possible (#6394)
Refactor engines that parse ISO 8601 dates with strptime to use
fromisoformat instead. In most cases this is a direct replacement of
strptime(text, "format") with fromisoformat(text).

For engines where the source has a trailing "Z" that strptime consumed
as a literal (e.g. "%Y-%m-%dT%H:%M:%S.%fZ" in huggingface.py), add
rstrip("Z") to keep the output naive and preserve the existing behavior.

In sogou.py the date is extracted with a regular expression, which can
yield strings like "2026-7-11". strptime accepts this via its format
string, but fromisoformat does not. To preserve the existing behavior
and satisfy the format fromisoformat expects, add zero-padding for the
month and day.

Closes: #6098
---------
Signed-off-by: OneVth <onebrotravel@gmail.com>
2026-07-13 17:46:56 +02:00
Ivan Gabaldon
c19d86faa3 [mod] ci: remove security (#6400)
Manage alerts directly from Docker Hub
2026-07-12 13:09:19 +02:00
Ivan Gabaldon
21fa7b0be1 [mod] ci: rework workflows (#6401)
* [mod] ci: rework workflows

mod: ubuntu 26.04 runners
mod: remove podman-static
mod: longer container cache
mod: remove useless pr information
mod: sort steps
fix: use commit ref on workflow_run children
fix: missing container workflow_run check
fix: nodejs cache

* [fix] ci: apply suggestions

https://github.com/searxng/searxng/pull/6401#discussion_r3566147961
https://github.com/searxng/searxng/pull/6401#discussion_r3566147970
2026-07-12 13:04:11 +02:00
dependabot[bot]
74b4e7c8d1 [upd] web-client (simple): Bump the minor group across 1 directory with 5 updates (#6399)
Bumps the minor group with 5 updates in the /client/simple directory:

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.2` | `2.5.3` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.0` | `26.1.1` |
| [browserslist](https://github.com/browserslist/browserslist) | `4.28.4` | `4.28.6` |
| [svgo](https://github.com/svg/svgo) | `4.0.1` | `4.0.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.3` | `8.1.4` |

Updates `@biomejs/biome` from 2.5.2 to 2.5.3
- [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.3/packages/@biomejs/biome)

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

Updates `browserslist` from 4.28.4 to 4.28.6
- [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.28.4...4.28.6)

Updates `svgo` from 4.0.1 to 4.0.2
- [Release notes](https://github.com/svg/svgo/releases)
- [Commits](https://github.com/svg/svgo/compare/v4.0.1...v4.0.2)

Updates `vite` from 8.1.3 to 8.1.4
- [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.1.4/packages/vite)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: "@types/node"
  dependency-version: 26.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: browserslist
  dependency-version: 4.28.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: svgo
  dependency-version: 4.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: vite
  dependency-version: 8.1.4
  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-07-12 08:56:35 +02:00
dependabot[bot]
39f4dd24a5 [upd] web-client (simple): Bump typescript in /client/simple (#6391)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 6.0.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/commits)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  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-07-12 08:44:34 +02:00
dependabot[bot]
62a1ab7edd [upd] pypi: Bump granian from 2.7.8 to 2.7.9 in the minor group (#6392)
Bumps the minor group with 1 update: [granian](https://github.com/emmett-framework/granian).


Updates `granian` from 2.7.8 to 2.7.9
- [Release notes](https://github.com/emmett-framework/granian/releases)
- [Commits](https://github.com/emmett-framework/granian/compare/v2.7.8...v2.7.9)

---
updated-dependencies:
- dependency-name: granian
  dependency-version: 2.7.9
  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-07-11 09:13:34 +02:00
github-actions[bot]
4abac08de5 [l10n] update translations from Weblate (#6393)
493c1d2a7 - 2026-07-07 - lukisko <lukisko@noreply.codeberg.org>
a67b467e5 - 2026-07-06 - marc-lopez <marc-lopez@noreply.codeberg.org>

Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-07-10 23:19:38 +02:00
dependabot[bot]
6a4d5148d6 [upd] github-actions: Bump github/codeql-action/upload-sarif (#6389)
Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.36.3 to 4.37.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](54f647b7e1...99df26d4f1)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.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-07-10 14:17:57 +02:00
dependabot[bot]
799086874d [upd] github-actions: Bump docker/login-action from 4.3.0 to 4.4.0 (#6387)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.3.0 to 4.4.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](c99871dec2...af1e73f918)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.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-07-10 14:15:21 +02:00
dependabot[bot]
83139c26b3 [upd] github-actions: Bump docker/scout-action from 1.23.0 to 1.23.1 (#6388)
Bumps [docker/scout-action](https://github.com/docker/scout-action) from 1.23.0 to 1.23.1.
- [Release notes](https://github.com/docker/scout-action/releases)
- [Commits](ce97ec1bb8...2688993af7)

---
updated-dependencies:
- dependency-name: docker/scout-action
  dependency-version: 1.23.1
  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-07-10 14:14:09 +02:00
Markus Heiser
8456831a04 [mod] show img_load_error.svg if thumbnail fails to load (#6368)
* [mod] show img_load_error.svg if thumbnail fails to load (#6368)

Brave and a few other engines occasionally provide dead URLs for the thumbnails,
which looks pretty ugly in the UI.  As already with the image search, in case of
an error img_load_error.svg should be displayed here.

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>

* [build] /static (#6368)

---------

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-07-09 18:37:41 +02:00
Bnyro
b512eaa272 [feat] engines: add kovunka.net (general) 2026-07-09 12:11:41 +02:00
Jayant Sharma
1412926f5c [fix] braveapi: strip HTML tags and filter favicon thumbnails (#6381) 2026-07-08 21:19:11 +02:00
Bnyro
3b573e0f89 [fix] heexy: use cookies for cacheft token
Heexy now passes the cacheft token via cookies and no
longer via HTTP headers.

Hence, the engine is broken without that change.
2026-07-08 21:01:11 +02:00
Bnyro
da6a230413 [fix] autocomplete: google autocompleter crashes
`engines` only contains active engines. Since `google` was set to
inactive, it's no longer part of the , making the engine crash.

With this workaround, we directly load the engine traits from the data
dictionary instead of going the intermediate step with the Google engine.
2026-07-08 20:48:15 +02:00
Bnyro
f69b22c45c [fix] kagi: crashes when there are no results 2026-07-07 10:49:54 +02:00
Patrick Evans
f930443726 [fix] kagi: safely access unpromised result properties
I was testing the new Kagi engine and found for some queries I was getting a
`KeyError` exception from the result parsing.

This PR ensures we only assumes the `url` key exists, and we use `.get()` to
retrieve values for keys that may not be present.

Their API documentation [1] clarifies that only the `url` and `title` properties
are required/guaranteed in the search result object, but this is not correct:

    File "/home/patrick/code/searxng/searx/engines/kagi.py", line 161, in response
        title=html.unescape(result["title"]),
                            ~~~~~~^^^^^^^^^
    KeyError: 'title

Heard back from Kagi support:

> The image results "title' should be marked as optional, as many images likely
> don't have titles - as you've noticed.  The only required field there should be
> the "url" field.

[1] https://kagi.redocly.app/api/docs/openapi/search/search#search/search/t=response&c=200&path=data/search
[2] https://github.com/searxng/searxng/issues/2247#issuecomment-4692976877
2026-07-07 10:49:54 +02:00
Bnyro
d58ced8f71 [fix] kagi: results contain HTML tags 2026-07-07 10:35:06 +02:00
Markus Heiser
556d08c395 [fix] google CSE - leftover from PR #6364 (#6369)
The `ZZ` issue has been fixed in the google.py and the default lang_code is
already `en`:

- https://github.com/searxng/searxng/pull/6364#discussion_r3524461287

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-07-06 09:23:28 +02:00
Bnyro
1017631800 [chore] make data.traits (#6369) 2026-07-06 09:23:28 +02:00
Bnyro
b64e6ee44a [feat] google CSE engine: add support for images (#6369)
Add support for Google CSE images, the normal google images engine doesn't work
anymore, so this is a good workaround for now.
2026-07-06 09:23:28 +02:00
Austin-Olacsi
a6438586a5 [feat] engine: add neocities.org 2026-07-05 13:15:29 +02:00
Markus Heiser
fd5eb84a37 [chore] Google (HTML) engine: remove obsolete GSA User-Agents (#6366)
The GSA headers that were introduced in PR #5644 unfortunately no longer
work (#6359).

The Google engines

- google.py
- google_videos.py

do not work anymore either, but we'll leave it in the code for now:

- In google.py, central functions like get_google_info(..) are provided, which
  are also used by other modules.
- We will probably need a Google HTML (and video) engine again very soon.

Related:

- https://github.com/searxng/searxng/issues/6359
- https://github.com/searxng/searxng/pull/6364

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-07-05 12:07:23 +02:00
vojkovic
888364c1ce [chore] update traits for google cse (#6364) 2026-07-05 11:50:23 +02:00
vojkovic
1cdf01a719 [feat] engines: add google cse engine (#6364)
Adds a new google cse engine as an alternative to the currently broken html
google engine.

It works by generating a token which is cached for 1 hour and it only uses the
public CSE id from blackle.com.  We do have two others but I think just
hardcoding one will be enough.

Will be broken in 2027 due to google deprecating it but I it will be good while
it lasts.

I found I can get up to 20 results per page. Setting safesearch and language
works and I just copied the traits from the google engine.

Also set old google engine as inactive as it is completely broken for now.
2026-07-05 11:50:23 +02:00
Ivan Gabaldon
747cec4c23 [mod] container: docker.io base source (#6344) 2026-07-03 16:10:04 +02:00
dependabot[bot]
4ef70e9451 [upd] web-client (simple): Bump the minor group across 1 directory with 7 updates (#6318)
Bumps the minor group with 7 updates in the /client/simple directory:

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.0` | `2.5.2` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.0` | `26.1.0` |
| [browserslist](https://github.com/browserslist/browserslist) | `4.28.2` | `4.28.4` |
| [less](https://github.com/less/less.js) | `4.6.6` | `4.6.7` |
| [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.3` |
| [stylelint](https://github.com/stylelint/stylelint) | `17.13.0` | `17.14.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.16` | `8.1.3` |

Updates `@biomejs/biome` from 2.5.0 to 2.5.2
- [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.2/packages/@biomejs/biome)

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

Updates `browserslist` from 4.28.2 to 4.28.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.28.2...4.28.4)

Updates `less` from 4.6.6 to 4.6.7
- [Release notes](https://github.com/less/less.js/releases)
- [Changelog](https://github.com/less/less.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/less/less.js/compare/v4.6.6...v4.6.7)

Updates `sharp` from 0.35.1 to 0.35.3
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.3)

Updates `stylelint` from 17.13.0 to 17.14.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.13.0...17.14.0)

Updates `vite` from 8.0.16 to 8.1.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.1.3/packages/vite)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: "@types/node"
  dependency-version: 26.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: browserslist
  dependency-version: 4.28.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: less
  dependency-version: 4.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: sharp
  dependency-version: 0.35.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor
- dependency-name: stylelint
  dependency-version: 17.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor
- dependency-name: vite
  dependency-version: 8.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-07-03 16:09:38 +02:00
github-actions[bot]
80c9806de1 [l10n] update translations from Weblate (#6357)
f29d35e7c - 2026-06-27 - Anonymous104 <anonymous104@noreply.codeberg.org>
e574b7601 - 2026-06-27 - nebras <nebras@noreply.codeberg.org>

Co-authored-by: searxng-bot <searxng-bot@users.noreply.github.com>
2026-07-03 15:50:52 +02:00
dependabot[bot]
9d7ca4febc [upd] github-actions: Bump docker/setup-qemu-action from 4.1.0 to 4.2.0 (#6355)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](06116385d9...96fe6ef7f3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.2.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-07-03 15:50:27 +02:00
dependabot[bot]
c5cd510d82 [upd] github-actions: Bump actions/cache from 6.0.0 to 6.1.0 (#6353)
Bumps [actions/cache](https://github.com/actions/cache) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](2c8a9bd745...55cc834586)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 6.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-07-03 15:35:29 +02:00
dependabot[bot]
73a0219ab8 [upd] github-actions: Bump docker/scout-action from 1.22.0 to 1.23.0 (#6352)
Bumps [docker/scout-action](https://github.com/docker/scout-action) from 1.22.0 to 1.23.0.
- [Release notes](https://github.com/docker/scout-action/releases)
- [Commits](7520205ff6...ce97ec1bb8)

---
updated-dependencies:
- dependency-name: docker/scout-action
  dependency-version: 1.23.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-07-03 15:35:07 +02:00
dependabot[bot]
7ed7adfb05 [upd] github-actions: Bump github/codeql-action/upload-sarif (#6354)
Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.36.2 to 4.36.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](8aad20d150...54f647b7e1)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.36.3
  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-07-03 15:33:41 +02:00
dependabot[bot]
d7367e0897 [upd] github-actions: Bump docker/login-action from 4.2.0 to 4.3.0 (#6351)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](650006c6eb...c99871dec2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.3.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-07-03 15:30:16 +02:00
dependabot[bot]
21773bbb2d [upd] pypi: Bump the minor group with 5 updates (#6356)
Bumps the minor group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [granian](https://github.com/emmett-framework/granian) | `2.7.7` | `2.7.8` |
| [typer](https://github.com/fastapi/typer) | `0.26.7` | `0.26.8` |
| [typing-extensions](https://github.com/python/typing_extensions) | `4.15.0` | `4.16.0` |
| [wlc](https://github.com/WeblateOrg/wlc) | `2.0.0` | `2.1.0` |
| [basedpyright](https://github.com/detachhead/basedpyright) | `1.39.8` | `1.39.9` |


Updates `granian` from 2.7.7 to 2.7.8
- [Release notes](https://github.com/emmett-framework/granian/releases)
- [Commits](https://github.com/emmett-framework/granian/compare/v2.7.7...v2.7.8)

Updates `typer` from 0.26.7 to 0.26.8
- [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.26.7...0.26.8)

Updates `typing-extensions` from 4.15.0 to 4.16.0
- [Release notes](https://github.com/python/typing_extensions/releases)
- [Changelog](https://github.com/python/typing_extensions/blob/main/CHANGELOG.md)
- [Commits](https://github.com/python/typing_extensions/compare/4.15.0...4.16.0)

Updates `wlc` from 2.0.0 to 2.1.0
- [Release notes](https://github.com/WeblateOrg/wlc/releases)
- [Changelog](https://github.com/WeblateOrg/wlc/blob/main/CHANGES.rst)
- [Commits](https://github.com/WeblateOrg/wlc/compare/2.0.0...2.1.0)

Updates `basedpyright` from 1.39.8 to 1.39.9
- [Release notes](https://github.com/detachhead/basedpyright/releases)
- [Commits](https://github.com/detachhead/basedpyright/compare/v1.39.8...v1.39.9)
2026-07-03 10:09:55 +02:00
Markus Heiser
67973783de [fix] typo in utils/lib.sh
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-07-02 14:48:03 +02:00
Markus Heiser
0f9e30e3f9 [fix] docs.live (sphinx-autobuild) - watch .rst, .py, .yml and .conf (#6348)
So far, any change to any file triggered a new build, and changes to the
doc-strings (in ./searx) were not taken into account. With this patch, a build
is only triggered when files with the specified file extensions change.

The watch has been expanded to ./searx so that changes to doc strings also
trigger a new build.

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-07-02 13:22:12 +02:00
Markus Heiser
640f88c9bd [fix] API docs: remove doc of not existing parameters (#6346)
TBH: Some of the parameters such as engines or plugins still theoretically exist
in SearXNG, but we will no longer support them.  They are usually accessed via
cookies and should not pollute the API / it is these supposed features that
hinder SearXNG in further development.

Closes: https://github.com/searxng/searxng/issues/6003

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-07-02 11:48:53 +02:00
Bnyro
c5d8d05f03 [fix] searchzee: circumvent botblocking 2026-07-01 15:35:46 +02:00
Markus Heiser
d115c61a70 [fix] clarify the mess of Engine.setup and Engine.init (#6343)
Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2026-06-30 19:22:23 +02:00
Zhijie He
c5b1d066e5 [feat] engines: add sina engine
Apply suggestions from code review

Co-authored-by: Bnyro <bnyro@tutanota.com>
2026-06-30 16:59:55 +02:00
Bnyro
774616ada6 [fix] heexy: circumvent botblocking 2026-06-30 15:46:58 +02:00
Bnyro
6b2ec018e2 [fix] qwant: access denied due to mismatching query order
We're now shuffling the order of query arguments, as
it seems that Qwant actively blocks the query order of
SearXNG.

tgp seems to be short for "test group" (look at the XHR requests the
browser sends to "https://www.qwant.com/action/ui") - its actual value
doesn't matter, as long as it's sent and at the correct position in the
query params and doesn't change too frequently
2026-06-30 15:23:09 +02:00
dependabot[bot]
bfeaad6c37 [upd] pypi: Bump granian from 2.7.6 to 2.7.7 in the minor group (#6319)
Bumps the minor group with 1 update: [granian](https://github.com/emmett-framework/granian).


Updates `granian` from 2.7.6 to 2.7.7
- [Release notes](https://github.com/emmett-framework/granian/releases)
- [Commits](https://github.com/emmett-framework/granian/compare/v2.7.6...v2.7.7)

---
updated-dependencies:
- dependency-name: granian
  dependency-version: 2.7.7
  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-06-30 08:18:49 +02:00
Sisyphus
28d3885764 [fix] naver: update HTML parsing for redesigned Naver search results page
Naver redesigned their search results page, replacing the old CSS classes
(lst_total, link_tit, total_dsc_wrap, api_txt_lines) with a new layout
using fds-web-doc-root containers and sds-comps design system.

Updated parse_general():
- Result container: fds-web-doc-root + fds-web-normal-doc-root
- Title: sds-comps-text-type-headline1 span
- URL: first external http link (with try/except guard)
- Content: sds-comps-text-type-body1 span
- Thumbnail: profile-thumbnail img src
- Added guard: skip results without title or URL
2026-06-29 20:25:23 +02:00
Hu Butui
b084194c09 [fix] engine baidu: fix image search blocked by antiFlag
Baidu image search returns antiFlag=1 when the acjson API is called
without session cookies from image.baidu.com.

Warm up cookies with a GET to the image homepage, cache them for one
hour via EngineCache, and pass them to image search requests. Raise
SearxEngineAccessDeniedException when antiFlag is still returned.

Signed-off-by: Hu Butui <hot123tea123@gmail.com>
2026-06-29 19:54:10 +02:00
Hu Butui
8c4df4cf3f [feat] bilibili engine: add time range support
Signed-off-by: Hu Butui <hot123tea123@gmail.com>
2026-06-29 19:53:20 +02:00
Bnyro
a0e594e16e [fix] neosearch: crashes when result has no url 2026-06-29 18:54:01 +02:00
Bnyro
cb4bfbe129 [chore] data.traits: update traits for neosearch 2026-06-29 13:05:01 +02:00
Bnyro
a5660bcc4f [feat] engines: add support for neosearch.org (general) 2026-06-29 13:05:01 +02:00
Bnyro
1396265774 [feat] engines: add magnific images engine 2026-06-29 10:19:19 +02:00
Bnyro
0fd40d5f29 [feat] engines: add shopify stock images engine 2026-06-29 10:02:16 +02:00
Bnyro
13a5ace8b5 [feat] engines: add stocksnap images engine 2026-06-29 10:02:04 +02:00
Bnyro
a6831797ba [feat] engines: add support for picjumbo.com 2026-06-29 10:01:43 +02:00
github-actions[bot]
0e990f78a3 [data] update searx.data - update_gsa_useragents.py (#6329) 2026-06-29 08:03:21 +02:00
github-actions[bot]
990c63b709 [data] update searx.data - update_currencies.py (#6333) 2026-06-29 07:09:12 +02:00
github-actions[bot]
e535e4c61a [data] update searx.data - update_firefox_version.py (#6332) 2026-06-29 07:08:32 +02:00
github-actions[bot]
79506441c5 [data] update searx.data - update_wikidata_units.py (#6330) 2026-06-29 07:08:02 +02:00
github-actions[bot]
6cb1c077e9 [data] update searx.data - update_ahmia_blacklist.py (#6331) 2026-06-29 07:07:28 +02:00
277 changed files with 7685 additions and 9154 deletions

39
.github/scripts/ai_policy.cjs vendored Normal file
View File

@@ -0,0 +1,39 @@
// 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',
});
};

38
.github/workflows/ai-policy.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
---
# yamllint disable rule:line-length
name: AI Policy
# Closes any new issues and PRs from people (or agents) who don't accept the AI Policy
# yamllint disable-line rule:truthy
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check:
name: Check AI Policy
# for issues with an author who has not contributed before
if: >-
github.event.sender.type != 'Bot' &&
contains(fromJSON('["NONE","FIRST_TIMER","FIRST_TIME_CONTRIBUTOR"]'),
github.event.issue.author_association ||
github.event.pull_request.author_association)
runs-on: ubuntu-26.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: "false"
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const script = require('./.github/scripts/ai_policy.cjs');
await script({ github, context });

View File

@@ -25,25 +25,21 @@ env:
jobs:
build:
if: github.repository_owner == 'searxng' || github.event_name == 'workflow_dispatch'
if: |
github.event_name == 'workflow_dispatch'
|| (github.repository_owner == 'searxng' && github.event.workflow_run.conclusion == 'success')
name: Build (${{ matrix.arch }})
runs-on: ${{ matrix.os }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
march: amd64
os: ubuntu-24.04
emulation: false
- arch: arm64
march: arm64
os: ubuntu-24.04-arm
emulation: false
- arch: armv7
march: arm64
os: ubuntu-24.04-arm
emulation: true
- runner: ubuntu-26.04
arch: amd64
- runner: ubuntu-26.04-arm
arch: arm64
- runner: ubuntu-26.04-arm
arch: armv7
permissions:
packages: write
@@ -53,109 +49,82 @@ jobs:
git_url: ${{ steps.build.outputs.git_url }}
steps:
# yamllint disable rule:line-length
- name: Setup podman
env:
PODMAN_VERSION: "v5.7.1"
run: |
sudo apt-get purge -y podman runc crun conmon
curl -fsSLO "https://github.com/mgoltzsche/podman-static/releases/download/${{ env.PODMAN_VERSION }}/podman-linux-${{ matrix.march }}.tar.gz"
curl -fsSLO "https://github.com/mgoltzsche/podman-static/releases/download/${{ env.PODMAN_VERSION }}/podman-linux-${{ matrix.march }}.tar.gz.asc"
gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 0CCF102C4F95D89E583FF1D4F8B5AF50344BB503
gpg --batch --verify "podman-linux-${{ matrix.march }}.tar.gz.asc" "podman-linux-${{ matrix.march }}.tar.gz"
tar -xzf "podman-linux-${{ matrix.march }}.tar.gz"
sudo cp -rfv ./podman-linux-${{ matrix.march }}/etc/. /etc/
sudo cp -rfv ./podman-linux-${{ matrix.march }}/usr/. /usr/
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
# yamllint enable rule:line-length
- name: Login to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: "ghcr.io"
username: "${{ github.repository_owner }}"
password: "${{ secrets.GITHUB_TOKEN }}"
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Setup QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: "${{ github.event.workflow_run.head_sha || github.sha }}"
persist-credentials: "false"
fetch-depth: "0"
- name: Setup cache Python
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: "python-${{ env.PYTHON_VERSION }}-${{ runner.arch }}-${{ hashFiles('./requirements*.txt') }}"
restore-keys: |
python-${{ env.PYTHON_VERSION }}-${{ runner.arch }}-
path: "./local/"
- name: Get date
id: date
run: echo "date=$(date +'%Y%m%d')" >>$GITHUB_OUTPUT
- name: Setup cache container
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: "container-${{ matrix.arch }}-${{ steps.date.outputs.date }}-${{ hashFiles('./requirements*.txt') }}"
key: "container-${{ matrix.arch }}-${{ hashFiles('./requirements*.txt') }}"
restore-keys: |
container-${{ matrix.arch }}-${{ steps.date.outputs.date }}-
container-${{ matrix.arch }}-
path: "/var/tmp/buildah-cache-*/*"
- if: ${{ matrix.emulation }}
name: Setup QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Login to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: "ghcr.io"
username: "${{ github.repository_owner }}"
password: "${{ secrets.GITHUB_TOKEN }}"
- name: Build
id: build
env:
OVERRIDE_ARCH: "${{ matrix.arch }}"
run: make podman.build
run: make container.build
test:
name: Test (${{ matrix.arch }})
runs-on: ${{ matrix.os }}
runs-on: ${{ matrix.runner }}
needs: build
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
os: ubuntu-24.04
emulation: false
- arch: arm64
os: ubuntu-24.04-arm
emulation: false
- arch: armv7
os: ubuntu-24.04-arm
emulation: true
- runner: ubuntu-26.04
arch: amd64
- runner: ubuntu-26.04-arm
arch: arm64
- runner: ubuntu-26.04-arm
arch: armv7
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: "false"
- if: ${{ matrix.emulation }}
name: Setup QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Login to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: "ghcr.io"
username: "${{ github.repository_owner }}"
password: "${{ secrets.GITHUB_TOKEN }}"
- name: Setup QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: "${{ github.event.workflow_run.head_sha || github.sha }}"
persist-credentials: "false"
- name: Test
env:
OVERRIDE_ARCH: "${{ matrix.arch }}"
@@ -165,7 +134,7 @@ jobs:
release:
if: github.repository_owner == 'searxng' && github.ref_name == 'master'
name: Release
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
needs:
- build
- test
@@ -174,24 +143,25 @@ jobs:
packages: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Login to Docker Hub
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
persist-credentials: "false"
registry: "docker.io"
username: "${{ secrets.DOCKER_USER }}"
password: "${{ secrets.DOCKER_TOKEN }}"
- name: Login to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: "ghcr.io"
username: "${{ github.repository_owner }}"
password: "${{ secrets.GITHUB_TOKEN }}"
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
registry: "docker.io"
username: "${{ secrets.DOCKER_USER }}"
password: "${{ secrets.DOCKER_TOKEN }}"
ref: "${{ github.event.workflow_run.head_sha || github.sha }}"
persist-credentials: "false"
- name: Release
env:

View File

@@ -21,7 +21,7 @@ jobs:
data:
if: github.repository_owner == 'searxng'
name: ${{ matrix.fetch }}
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
strategy:
fail-fast: false
matrix:
@@ -33,7 +33,6 @@ jobs:
- update_engine_traits.py
- update_wikidata_units.py
- update_engine_descriptions.py
- update_gsa_useragents.py
permissions:
contents: write
@@ -41,17 +40,17 @@ jobs:
steps:
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: "false"
- name: Setup cache Python
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: "python-${{ env.PYTHON_VERSION }}-${{ runner.arch }}-${{ hashFiles('./requirements*.txt') }}"
restore-keys: |
@@ -65,23 +64,17 @@ jobs:
run: V=1 ./manage pyenv.cmd python "./searxng_extra/update/${{ matrix.fetch }}"
- name: Create PR
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
author: "searxng-bot <searxng-bot@users.noreply.github.com>"
committer: "searxng-bot <searxng-bot@users.noreply.github.com>"
title: "[data] update searx.data - ${{ matrix.fetch }}"
commit-message: "[data] update searx.data - ${{ matrix.fetch }}"
branch: "update_data_${{ matrix.fetch }}"
title: "[mod] data: update searx.data - ${{ matrix.fetch }}"
commit-message: "[mod] data: update searx.data - ${{ matrix.fetch }}"
branch: "ci-data-${{ matrix.fetch }}"
delete-branch: "true"
draft: "false"
signoff: "false"
body: |
[data] update searx.data - ${{ matrix.fetch }}
Update searx.data - ${{ matrix.fetch }}
labels: |
data
- name: Display information
run: |
echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}"
echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}"

View File

@@ -25,25 +25,25 @@ jobs:
release:
if: github.repository_owner == 'searxng' || github.event_name == 'workflow_dispatch'
name: Release
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
permissions:
# for JamesIves/github-pages-deploy-action to push
contents: write
steps:
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: "false"
fetch-depth: "0"
- name: Setup cache Python
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: "python-${{ env.PYTHON_VERSION }}-${{ runner.arch }}-${{ hashFiles('./requirements*.txt') }}"
restore-keys: |
@@ -61,11 +61,11 @@ jobs:
- if: github.ref_name == 'master'
name: Release
uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0
uses: JamesIves/github-pages-deploy-action@fa24774553152dd7873cd16ebd8d959b010c5445 # v4.9.0
with:
folder: "dist/docs"
branch: "gh-pages"
commit-message: "[doc] build from commit ${{ github.sha }}"
commit-message: "[mod] docs: build from commit ${{ github.sha }}"
# Automatically remove deleted files from the deploy branch
clean: "true"
single-commit: "true"

View File

@@ -23,7 +23,7 @@ env:
jobs:
test:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
strategy:
matrix:
python-version:
@@ -34,17 +34,17 @@ jobs:
steps:
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "${{ matrix.python-version }}"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: "false"
- name: Setup cache Python
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: "python-${{ matrix.python-version }}-${{ runner.arch }}-${{ hashFiles('./requirements*.txt') }}"
restore-keys: |
@@ -59,37 +59,40 @@ jobs:
theme:
name: Theme
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
steps:
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "26"
check-latest: "true"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: "false"
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: "./.nvmrc"
- name: Setup cache Node.js
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
key: "nodejs-${{ runner.arch }}-${{ hashFiles('./.nvmrc', './package.json') }}"
path: "./client/simple/node_modules/"
- name: Setup cache Python
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: "python-${{ env.PYTHON_VERSION }}-${{ runner.arch }}-${{ hashFiles('./requirements*.txt') }}"
restore-keys: |
python-${{ env.PYTHON_VERSION }}-${{ runner.arch }}-
path: "./local/"
- name: Setup cache Node.js
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: "nodejs-${{ runner.arch }}-${{ hashFiles('**/package-lock.json') }}"
restore-keys: |
nodejs-${{ runner.arch }}-
path: "./client/simple/node_modules/"
- name: Setup venv
run: make V=1 install

View File

@@ -26,27 +26,27 @@ env:
jobs:
update:
if: github.repository_owner == 'searxng' && github.event.workflow_run.conclusion == 'success'
if: github.event.workflow_run.conclusion == 'success' && github.repository_owner == 'searxng'
name: Update
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
permissions:
# For "make V=1 weblate.push.translations"
contents: write
steps:
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
token: "${{ secrets.WEBLATE_GITHUB_TOKEN }}"
fetch-depth: "0"
- name: Setup cache Python
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: "python-${{ env.PYTHON_VERSION }}-${{ runner.arch }}-${{ hashFiles('./requirements*.txt') }}"
restore-keys: |
@@ -74,7 +74,7 @@ jobs:
github.repository_owner == 'searxng'
&& (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule')
name: Pull Request
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
permissions:
# For "make V=1 weblate.translations.commit"
contents: write
@@ -83,18 +83,18 @@ jobs:
steps:
- name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "${{ env.PYTHON_VERSION }}"
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
token: "${{ secrets.WEBLATE_GITHUB_TOKEN }}"
fetch-depth: "0"
- name: Setup cache Python
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
key: "python-${{ env.PYTHON_VERSION }}-${{ runner.arch }}-${{ hashFiles('./requirements*.txt') }}"
restore-keys: |
@@ -118,23 +118,17 @@ jobs:
run: make V=1 weblate.translations.commit
- name: Create PR
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
author: "searxng-bot <searxng-bot@users.noreply.github.com>"
committer: "searxng-bot <searxng-bot@users.noreply.github.com>"
title: "[l10n] update translations from Weblate"
commit-message: "[l10n] update translations from Weblate"
title: "[mod] i18n: update translations from Weblate"
commit-message: "[mod] i18n: update translations from Weblate"
branch: "translations_update"
delete-branch: "true"
draft: "false"
signoff: "false"
body: |
[l10n] update translations from Weblate
Update translations from Weblate
labels: |
area:i18n
- name: Display information
run: |
echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}"
echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}"

View File

@@ -1,46 +0,0 @@
---
name: Security
# yamllint disable-line rule:truthy
on:
workflow_dispatch:
schedule:
- cron: "42 05 * * *"
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
permissions:
contents: read
jobs:
container:
if: github.repository_owner == 'searxng'
name: Container
runs-on: ubuntu-24.04-arm
permissions:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: "false"
- name: Sync GHCS from Docker Scout
uses: docker/scout-action@7520205ff60037fdc436b40b6a1d1e55a839ec2d # v1.22.0
with:
organization: "searxng"
dockerhub-user: "${{ secrets.DOCKER_USER }}"
dockerhub-password: "${{ secrets.DOCKER_TOKEN }}"
image: "registry://ghcr.io/searxng/searxng:latest"
command: "cves"
sarif-file: "./scout.sarif"
exit-code: "false"
write-comment: "false"
- name: Upload SARIFs
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: "./scout.sarif"

View File

@@ -63,7 +63,7 @@ format: format.python format.shell
# wrap ./manage script
MANAGE += weblate.translations.commit weblate.push.translations
MANAGE += data.all data.traits data.useragents data.gsa_useragents data.locales data.currencies
MANAGE += data.all data.traits data.useragents data.locales data.currencies
MANAGE += docs.html docs.live docs.gh-pages docs.prebuild docs.clean
MANAGE += podman.build
MANAGE += docker.build docker.buildx

View File

@@ -2,7 +2,7 @@
/*
this file is generated automatically by searxng_extra/update/update_pygments.py
using pygments version 2.20.0:
using pygments version 2.21.0:
./manage templates.simple.pygments
*/
@@ -114,14 +114,14 @@
.gd { color: #FF4689 } /* Generic.Deleted */
.ge { color: #F8F8F2; font-style: italic } /* Generic.Emph */
.ges { color: #F8F8F2; font-weight: bold; font-style: italic } /* Generic.EmphStrong */
.gr { color: #F8F8F2 } /* Generic.Error */
.gr { color: #FF4689 } /* Generic.Error */
.gh { color: #F8F8F2 } /* Generic.Heading */
.gi { color: #A6E22E } /* Generic.Inserted */
.go { color: #66D9EF } /* Generic.Output */
.gp { color: #FF4689; font-weight: bold } /* Generic.Prompt */
.gs { color: #F8F8F2; font-weight: bold } /* Generic.Strong */
.gu { color: #959077 } /* Generic.Subheading */
.gt { color: #F8F8F2 } /* Generic.Traceback */
.gt { color: #66D9EF } /* Generic.Traceback */
.kc { color: #66D9EF } /* Keyword.Constant */
.kd { color: #66D9EF } /* Keyword.Declaration */
.kn { color: #FF4689 } /* Keyword.Namespace */
@@ -132,7 +132,7 @@
.m { color: #AE81FF } /* Literal.Number */
.s { color: #E6DB74 } /* Literal.String */
.na { color: #A6E22E } /* Name.Attribute */
.nb { color: #F8F8F2 } /* Name.Builtin */
.nb { color: #A6E22E } /* Name.Builtin */
.nc { color: #A6E22E } /* Name.Class */
.no { color: #66D9EF } /* Name.Constant */
.nd { color: #A6E22E } /* Name.Decorator */
@@ -166,7 +166,7 @@
.sr { color: #E6DB74 } /* Literal.String.Regex */
.s1 { color: #E6DB74 } /* Literal.String.Single */
.ss { color: #E6DB74 } /* Literal.String.Symbol */
.bp { color: #F8F8F2 } /* Name.Builtin.Pseudo */
.bp { color: #A6E22E } /* Name.Builtin.Pseudo */
.fm { color: #A6E22E } /* Name.Function.Magic */
.vc { color: #F8F8F2 } /* Name.Variable.Class */
.vg { color: #F8F8F2 } /* Name.Variable.Global */

File diff suppressed because it is too large Load Diff

View File

@@ -23,27 +23,27 @@
"not dead"
],
"dependencies": {
"ionicons": "^8.0.13",
"ionicons": "^8.1.0",
"normalize.css": "8.0.1",
"ol": "^10.9.0",
"ol": "^10.10.0",
"swiped-events": "1.2.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.0",
"@types/node": "^26.0.0",
"browserslist": "^4.28.2",
"@biomejs/biome": "2.5.9",
"@types/node": "^26.2.0",
"browserslist": "^4.28.8",
"browserslist-to-esbuild": "^2.1.1",
"edge.js": "^6.5.1",
"less": "^4.6.6",
"less": "^4.9.0",
"mathjs": "^15.2.0",
"sharp": "~0.35.1",
"sharp": "~0.35.3",
"sort-package-json": "^4.0.0",
"stylelint": "^17.13.0",
"stylelint": "^17.14.1",
"stylelint-config-standard-less": "^4.1.0",
"stylelint-prettier": "^5.0.3",
"svgo": "^4.0.1",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vite-bundle-analyzer": "^1.3.8"
"svgo": "^4.0.2",
"typescript": "~7.0.2",
"vite": "^8.2.1",
"vite-bundle-analyzer": "^1.3.9"
}
}

View File

@@ -5,13 +5,7 @@ import { assertElement } from "../util/assertElement.ts";
const fetchResults = async (qInput: HTMLInputElement, query: string): Promise<void> => {
try {
let res: Response;
if (settings.method === "GET") {
res = await http("GET", `./autocompleter?q=${query}`);
} else {
res = await http("POST", "./autocompleter", { body: new URLSearchParams({ q: query }) });
}
const res = await http("GET", `./autocompleter?q=${query}`);
const results = await res.json();

View File

@@ -35,8 +35,9 @@ const imageLoader = (resultElement: HTMLElement): void => {
}, 1000) as unknown as number;
};
const imageThumbnails: NodeListOf<HTMLImageElement> =
document.querySelectorAll<HTMLImageElement>("#urls img.image_thumbnail");
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`;

View File

@@ -80,7 +80,12 @@ export default class Calculator extends Plugin {
try {
const node = Calculator.math.parse(searchInput.value);
return `${node.toString()} = ${node.evaluate()}`;
const value = node.evaluate();
if (typeof value !== "number") {
return;
}
return `${node.toString()} = ${value}`;
} catch {
// not a compatible math expression
return;

View File

@@ -23,7 +23,7 @@ export const appendAnswerElement = (element: HTMLElement | string | number): voi
if (!(element instanceof HTMLElement)) {
const span = document.createElement("span");
span.innerHTML = element.toString();
span.textContent = element.toString();
// biome-ignore lint/style/noParameterAssign: TODO
element = span;
}

View File

@@ -1,4 +1,4 @@
FROM ghcr.io/searxng/base:searxng-builder AS builder
FROM docker.io/searxng/base:searxng-builder AS builder
COPY ./requirements.txt ./requirements-server.txt ./

View File

@@ -2,7 +2,7 @@ ARG CONTAINER_IMAGE_ORGANIZATION="searxng"
ARG CONTAINER_IMAGE_NAME="searxng"
FROM localhost/$CONTAINER_IMAGE_ORGANIZATION/$CONTAINER_IMAGE_NAME:builder AS builder
FROM ghcr.io/searxng/base:searxng AS dist
FROM docker.io/searxng/base:searxng AS dist
COPY --chown=977:977 --from=builder /usr/local/searxng/.venv/ ./.venv/
COPY --chown=977:977 --from=builder /usr/local/searxng/searx/ ./searx/

View File

@@ -112,6 +112,15 @@ if [ "$(id -u)" -eq 0 ]; then
fi
# ENVs aliases
export GRANIAN_PORT="${SEARXNG_PORT:-$GRANIAN_PORT}"
# https://github.com/searxng/searxng/issues/5934
case "${SEARXNG_PORT:-}" in
'') ;;
*[!0-9]*)
unset SEARXNG_PORT
;;
*)
export GRANIAN_PORT="$SEARXNG_PORT"
;;
esac
exec /usr/local/searxng/.venv/bin/granian searx.webapp:app

View File

@@ -283,12 +283,12 @@ container images are not officially supported):
$ make container
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/searxng/searxng 2025.8.1-3d96414 ... About a minute ago 183 MB
localhost/searxng/searxng latest ... About a minute ago 183 MB
localhost/searxng/searxng builder ... About a minute ago 524 MB
ghcr.io/searxng/base searxng-builder ... 2 days ago 378 MB
ghcr.io/searxng/base searxng ... 2 days ago 42.2 MB
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
Migrate from ``searxng-docker``
===============================

View File

@@ -29,10 +29,11 @@ By default and without any extensions, SearXNG serves these resolvers:
- ``duckduckgo``
- ``allesedv``
- ``google``
- ``kagi``
- ``yandex``
With the above setting favicons are displayed, the user has the option to
deactivate this feature in his settings. If the user is to have the option of
deactivate this feature in their settings. If the user is to have the option of
selecting from several *resolvers*, a further setting is required / but this
setting will be discussed :ref:`later <register resolvers>` in this article,
first we have to setup the favicons cache.
@@ -208,6 +209,7 @@ choose from, the following configuration could be used:
"duckduckgo" = "searx.favicons.resolvers.duckduckgo"
"allesedv" = "searx.favicons.resolvers.allesedv"
# "google" = "searx.favicons.resolvers.google"
# "kagi" = "searx.favicons.resolvers.kagi"
# "yandex" = "searx.favicons.resolvers.yandex"
.. note::
@@ -226,6 +228,7 @@ into the *proxy*:
- :py:obj:`searx.favicons.resolvers.duckduckgo`
- :py:obj:`searx.favicons.resolvers.allesedv`
- :py:obj:`searx.favicons.resolvers.google`
- :py:obj:`searx.favicons.resolvers.kagi`
- :py:obj:`searx.favicons.resolvers.yandex`

View File

@@ -8,7 +8,7 @@
search:
safe_search: 0
autocomplete: ""
autocomplete: "duckduckgo"
favicon_resolver: ""
default_lang: ""
ban_time_on_fail: 5
@@ -32,7 +32,7 @@
- ``2``: Strict
``autocomplete``:
Existing autocomplete backends, leave blank to turn it off.
Existing autocomplete backends, set blank to turn it off.
- ``360search``
- ``baidu``
@@ -41,6 +41,7 @@
- ``dbpedia``
- ``duckduckgo``
- ``google``
- ``kagi``
- ``mwmbl``
- ``naver``
- ``privacywall``

View File

@@ -14,7 +14,7 @@
limiter: false
public_instance: false
image_proxy: false
method: "POST"
method: "GET"
default_http_headers:
X-Content-Type-Options : nosniff
X-Download-Options : noopen
@@ -58,8 +58,8 @@
``method`` : ``GET`` | ``POST``
HTTP method. By defaults ``POST`` is used / The ``POST`` method has the
advantage with some WEB browsers that the history is not easy to read, but
HTTP method. By default, ``GET`` is used / The ``POST`` method has the
advantage with some browsers that the history is not saved, but
there are also various disadvantages that sometimes **severely restrict the
ease of use for the end user** (e.g. back button to jump back to the previous
search page and drag & drop of search term to new tabs do not work as

View File

@@ -0,0 +1,8 @@
.. _exaapi engine:
==============
Exa API Engine
==============
.. automodule:: searx.engines.exaapi
:members:

View File

@@ -0,0 +1,8 @@
.. _jina engine:
===========
Jina Engine
===========
.. automodule:: searx.engines.jina
:members:

View File

@@ -1,8 +0,0 @@
.. _engine presearch:
================
Presearch Engine
================
.. automodule:: searx.engines.presearch
:members:

View File

@@ -0,0 +1,8 @@
.. _yandex api engine:
=================
Yandex Search API
=================
.. automodule:: searx.engines.yandex_api
:members:

View File

@@ -4,31 +4,32 @@
Search API
==========
SearXNG supports querying via a simple HTTP API.
Two endpoints, ``/`` and ``/search``, are supported for both GET and POST methods.
The GET method expects parameters as URL query parameters, while the POST method expects parameters as form data.
SearXNG supports querying via a simple HTTP API. Two endpoints, ``/`` and
``/search``, are supported for both GET and POST methods. The ``GET`` method
expects parameters as URL query parameters, while the POST method expects
parameters as form data (``application/x-www-form-urlencoded``).
If you want to consume the results as JSON, CSV, or RSS, you need to set the
``format`` parameter accordingly. Supported formats are defined in ``settings.yml``, under the ``search`` section.
Requesting an unset format will return a 403 Forbidden error. Be aware that many public instances have these formats disabled.
``format`` parameter accordingly. Supported formats are defined in
``settings.yml``, under the :ref:`settings search` section. Requesting an
unset format will return a 403 Forbidden error. Be aware that many public
instances have these formats disabled.
Endpoints:
``GET /``
``GET /search``
.. code::
``POST /``
``POST /search``
GET /
GET /search
POST /
POST /search
example cURL calls:
.. code-block:: bash
.. code:: bash
curl 'https://searx.example.org/search?q=searxng&format=json'
curl -X POST 'https://searx.example.org/search' -d 'q=searxng&format=csv'
curl -L -X POST -d 'q=searxng&format=json' 'https://searx.example.org/'
Parameters
@@ -53,90 +54,27 @@ Parameters
Comma separated list, specifies the active search categories (see
:ref:`configured engines`)
``engines`` : optional
Comma separated list, specifies the active search engines (see
:ref:`configured engines`).
``language`` : default from :ref:`settings search`
Code of the language.
``pageno`` : default ``1``
Search page number.
``time_range`` : optional
[ ``day``, ``month``, ``year`` ]
``time_range`` : optional : [ ``day``, ``month``, ``year`` ]
Time range of search for engines which support it. See if an engine supports
time range search in the preferences page of an instance.
``format`` : optional
[ ``json``, ``csv``, ``rss`` ]
``format`` : optional : [ ``json``, ``csv``, ``rss`` ]
Output format of results. Format needs to be activated in :ref:`settings
search`.
``results_on_new_tab`` : default ``0``
[ ``0``, ``1`` ]
Open search results on new tab.
``image_proxy`` : default from :ref:`settings server`
[ ``True``, ``False`` ]
Proxy image results through SearXNG.
``autocomplete`` : default from :ref:`settings search`
[ ``google``, ``dbpedia``, ``duckduckgo``, ``mwmbl``, ``startpage``,
``privacywall``, ``wikipedia``, ``swisscows``, ``qwant`` ]
Service which completes words as you type.
``safesearch`` : default from :ref:`settings search`
[ ``0``, ``1``, ``2`` ]
``safesearch`` : default from :ref:`settings search` : [ ``0``, ``1``, ``2`` ]
Filter search results of engines which support safe search. See if an engine
supports safe search in the preferences page of an instance.
``theme`` : default ``simple``
[ ``simple`` ]
``theme`` : default ``simple`` : [ ``simple`` ]
Theme of instance.
Please note, available themes depend on an instance. It is possible that an
instance administrator deleted, created or renamed themes on their instance.
See the available options in the preferences page of the instance.
``enabled_plugins`` : optional
List of enabled plugins.
:default:
``Hash_plugin``, ``Self_Information``,
``Tracker_URL_remover``, ``Ahmia_blacklist``
:values:
.. enabled by default
``Hash_plugin``, ``Self_Information``,
``Tracker_URL_remover``, ``Ahmia_blacklist``,
.. disabled by default
``Hostnames_plugin``, ``Open_Access_DOI_rewrite``,
``Vim-like_hotkeys``, ``Tor_check_plugin``
``disabled_plugins``: optional
List of disabled plugins.
:default:
``Hostnames_plugin``, ``Open_Access_DOI_rewrite``,
``Vim-like_hotkeys``, ``Tor_check_plugin``
:values:
see values from ``enabled_plugins``
``enabled_engines`` : optional : *all* :origin:`engines <searx/engines>`
List of enabled engines.
``disabled_engines`` : optional : *all* :origin:`engines <searx/engines>`
List of disabled engines.

View File

@@ -20,15 +20,11 @@ If you don't trust anyone, you can set up your own, see :ref:`installation`.
- :ref:`self hosted <installation>`
- :ref:`no user tracking / no profiling <SearXNG protect privacy>`
- script & cookies are optional
- secure, encrypted connections
- javascript & cookies are optional
- :ref:`{{engines | length}} search engines <configured engines>`
- `58 translations <https://translate.codeberg.org/projects/searxng/searxng/>`_
- about 70 `well maintained <https://uptime.searxng.org/>`__ instances on searx.space_
- :ref:`easy integration of search engines <demo online engine>`
- professional development: `CI <https://github.com/searxng/searxng/actions>`_,
`quality assurance <https://dev.searxng.org/>`_ &
`automated tested UI <https://dev.searxng.org/screenshots.html>`_
.. sidebar:: be a part

2
manage
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.36.0"
GECKODRIVER_VERSION="v0.37.0"
# 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")

View File

@@ -2,9 +2,9 @@ mock==5.2.0
nose2[coverage_plugin]==0.16.0
cov-core==1.15.0
black==25.9.0
pylint==4.0.6
pylint==4.0.7
splinter==0.21.0
selenium==4.45.0
selenium==4.47.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.0.0
wlc==2.1.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.7.6
basedpyright==1.39.8
granian[reload]==2.8.2
basedpyright==1.39.10
types-lxml==2026.2.16

View File

@@ -1,2 +1,2 @@
granian==2.7.6
granian[pname]==2.7.6
granian==2.8.2
granian[pname]==2.8.2

View File

@@ -1,19 +1,19 @@
certifi==2026.6.17
certifi==2026.7.22
babel==2.18.0
flask-babel==4.0.0
flask==3.1.3
jinja2==3.1.6
lxml==6.1.1
pygments==2.20.0
lxml==6.1.2
pygments==2.21.0
python-dateutil==2.9.0.post0
pyyaml==6.0.3
httpx[http2]==0.28.1
httpx-socks[asyncio]==0.10.0
httpx-socks[asyncio]==0.13.1
sniffio==1.3.1
valkey==6.1.1
markdown-it-py==4.2.0
msgspec==0.21.1
typer==0.26.7
typer==0.27.1
isodate==0.7.2
whitenoise==6.12.0
typing-extensions==4.15.0
typing-extensions==4.16.0

View File

@@ -16,7 +16,7 @@ from . import Answerer, AnswererInfo
def random_characters():
random_string_letters = string.ascii_lowercase + string.digits + string.ascii_uppercase
return [random.choice(random_string_letters) for _ in range(random.randint(8, 32))]
return random.choices(random_string_letters, k=random.randint(8, 32))
def random_string():

View File

@@ -21,6 +21,8 @@ from searx.engines import (
from searx.network import get as http_get, post as http_post
from searx.exceptions import SearxEngineResponseException
from searx.utils import extr, gen_useragent
from searx.data import ENGINE_TRAITS
from searx.enginelib.traits import EngineTraits
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
@@ -60,7 +62,7 @@ def bing(query: str, _sxng_locale: str) -> list[str]:
# bing search autocompleter
base_url = "https://www.bing.com/AS/Suggestions?"
# cvid has to be a 32 character long string consisting of numbers and uppsercase characters
cvid = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(32))
cvid = ''.join(random.choices(string.ascii_uppercase + string.digits, k=32))
response = get(base_url + urlencode({'qry': query, 'csr': 1, 'cvid': cvid}))
results: list[str] = []
@@ -125,16 +127,17 @@ def duckduckgo(query: str, sxng_locale: str) -> list[str]:
def google_complete(query: str, sxng_locale: str) -> list[str]:
"""Autocomplete from Google. Supports Google's languages and subdomains
"""Autocomplete from Google. Supports Google's languages
(:py:obj:`searx.engines.google.get_google_info`) by using the async REST
API::
https://{subdomain}/complete/search?{args}
https://www.google.com/complete/search?{args}
"""
google_info: dict[str, t.Any] = google.get_google_info({'searxng_locale': sxng_locale}, engines['google'].traits)
url = 'https://{subdomain}/complete/search?{args}'
data = ENGINE_TRAITS.get("google") or {}
traits = EngineTraits(**data)
google_info: dict[str, t.Any] = google.get_google_info({'searxng_locale': sxng_locale}, traits)
args = urlencode(
{
'q': query,
@@ -144,7 +147,7 @@ def google_complete(query: str, sxng_locale: str) -> list[str]:
)
results: list[str] = []
resp = get(url.format(subdomain=google_info['subdomain'], args=args))
resp = get('https://www.google.com/complete/search?' + args)
if resp and resp.ok:
json_txt = resp.text[resp.text.find('[') : resp.text.find(']', -3) + 1]
data = json.loads(json_txt)
@@ -153,6 +156,24 @@ def google_complete(query: str, sxng_locale: str) -> list[str]:
return results
def kagi(query: str, sxng_locale: str) -> list[str]:
"""Autocomplete from Kagi."""
args: dict[str, str] = {'q': query}
if '-' in sxng_locale:
args['r'] = sxng_locale.split('-')[1].lower()
resp = get("https://kagisuggest.com/api/autosuggest?" + urlencode(args))
results: list[str] = []
if resp.ok:
data = resp.json()
if len(data) > 1:
results = data[1]
return results
def mwmbl(query: str, _sxng_locale: str) -> list[str]:
"""Autocomplete from Mwmbl_."""
@@ -376,6 +397,7 @@ backends: dict[str, t.Callable[[str, str], list[str]]] = {
'dbpedia': dbpedia,
'duckduckgo': duckduckgo,
'google': google_complete,
'kagi': kagi,
'mwmbl': mwmbl,
'naver': naver,
'privacywall': privacywall,

View File

@@ -151,6 +151,6 @@ def get_token() -> str:
if token:
token = token.decode('UTF-8') # type: ignore
else:
token = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(16))
token = ''.join(random.choices(string.ascii_lowercase + string.digits, k=16))
valkey_client.set(TOKEN_KEY, token, ex=TOKEN_LIVE_TIME)
return token

View File

@@ -63,6 +63,20 @@ class ProxyFix:
proxy_list: list[str] = cfg.get("botdetection.trusted_proxies", default=[])
return [ip_network(net, strict=False) for net in proxy_list]
def is_trusted_proxy(
self,
addr: IPv4Address | IPv6Address | None,
trusted_proxies: list[IPv4Network | IPv6Network],
) -> bool:
if addr is None:
return False
for net in trusted_proxies:
if addr.version == net.version and addr in net:
return True
return False
def trusted_remote_addr(
self,
x_forwarded_for: list[IPv4Address | IPv6Address],
@@ -70,16 +84,8 @@ class ProxyFix:
) -> str:
# always rtl
for addr in reversed(x_forwarded_for):
trust: bool = False
for net in trusted_proxies:
if addr.version == net.version and addr in net:
logger.debug("trust proxy %s (member of %s)", addr, net)
trust = True
break
# client address
if not trust:
if not self.is_trusted_proxy(addr, trusted_proxies):
logger.debug("client address from X-Forwarded-For: %s", addr)
return addr.compressed
# fallback to first address
@@ -95,19 +101,21 @@ class ProxyFix:
# in this function!
orig_remote_addr: str | None = environ.pop("REMOTE_ADDR")
orig_remote_ip: IPv4Address | IPv6Address | None = None
# Validate the IPs involved in this game and delete all invalid ones
# from the WSGI environment.
if orig_remote_addr:
try:
addr = ip_address(orig_remote_addr)
if addr.version == 6 and addr.ipv4_mapped:
addr = addr.ipv4_mapped
orig_remote_addr = addr.compressed
orig_remote_ip = ip_address(orig_remote_addr)
if orig_remote_ip.version == 6 and orig_remote_ip.ipv4_mapped:
orig_remote_ip = orig_remote_ip.ipv4_mapped
orig_remote_addr = orig_remote_ip.compressed
except ValueError as exc:
logger.error("REMOTE_ADDR: %s / discard REMOTE_ADDR from WSGI environment", exc)
orig_remote_addr = None
orig_remote_ip = None
x_real_ip: str | None = environ.get("HTTP_X_REAL_IP")
if x_real_ip:
@@ -141,11 +149,13 @@ class ProxyFix:
if not x_forwarded_for and not x_real_ip:
log_error_only_once("X-Forwarded-For nor X-Real-IP header is set!")
if x_forwarded_for and not trusted_proxies:
if x_forwarded_for or x_real_ip:
if not trusted_proxies:
log_error_only_once("missing botdetection.trusted_proxies config")
# without trusted_proxies, this variable is useless for determining
# the real IP
if not self.is_trusted_proxy(orig_remote_ip, trusted_proxies):
x_forwarded_for = []
x_real_ip = None
# securing the WSGI environment variables that are adjusted

View File

@@ -48,7 +48,7 @@ class ExpireCacheCfg(msgspec.Struct): # pylint: disable=too-few-public-methods
MAXHOLD_TIME: int = 60 * 60 * 24 * 7 # 7 days
"""Hold time (default in sec.), after which a value is removed from the cache."""
MAINTENANCE_PERIOD: int = 60 * 60 # 2h
MAINTENANCE_PERIOD: int = 60 * 60 # 1h
"""Maintenance period in seconds / when :py:obj:`MAINTENANCE_MODE` is set to
``auto``."""
@@ -458,12 +458,22 @@ class ExpireCacheSQLite(sqlitedb.SQLiteAppl, ExpireCache):
# Before values are taken from the table, a maintenance interval may
# need to be carried out.
self.maintenance()
sql = f"SELECT value FROM {table} WHERE key = ?"
sql = f"SELECT value, expire FROM {table} WHERE key = ?"
row = self.DB.execute(sql, (key,)).fetchone()
if row is None:
return default
return self.deserialize(row[0])
# Check if value is expired. It's possible that it's expired but has not
# yet been automatically deleted by the periodic maintenance
(value, expire) = row
now = time.time()
if expire < now:
# The record is deleted during the maintenance interval. Deleting
# the record at this point offers no advantage, as a SELECT
# statement must be executed for every cache.get request anyways.
return default
return self.deserialize(value)
def pairs(self, ctx: str) -> Iterator[tuple[str, typing.Any]]:
"""Iterate over key/value pairs from table given by argument ``ctx``.

View File

@@ -6,7 +6,7 @@ make data.all
"""
# pylint: disable=invalid-name
__all__ = ["ahmia_blacklist_loader", "gsa_useragents_loader", "data_dir", "get_cache"]
__all__ = ["ahmia_blacklist_loader", "data_dir", "get_cache"]
import json
import typing as t
@@ -63,7 +63,6 @@ lazy_globals = {
"ENGINE_TRAITS": None,
"LOCALES": None,
"TRACKER_PATTERNS": TrackerPatternsDB(),
"GSA_USER_AGENTS": None,
}
data_json_files = {
@@ -106,24 +105,3 @@ def ahmia_blacklist_loader() -> list[str]:
"""
with open(data_dir / 'ahmia_blacklist.txt', encoding='utf-8') as f:
return f.read().split()
def gsa_useragents_loader() -> list[str]:
"""Load data from `gsa_useragents.txt` and return a list of user agents
suitable for Google. The user agents are fetched by::
searxng_extra/update/update_gsa_useragents.py
This function is used by :py:mod:`searx.engines.google`.
"""
data = lazy_globals["GSA_USER_AGENTS"]
if data is not None:
return data
log.debug("init searx.data.%s", "GSA_USER_AGENTS")
with open(data_dir / 'gsa_useragents.txt', encoding='utf-8') as f:
lazy_globals["GSA_USER_AGENTS"] = f.read().splitlines()
return lazy_globals["GSA_USER_AGENTS"]

File diff suppressed because it is too large Load Diff

View File

@@ -288,7 +288,7 @@
"oc": "Kwanza",
"pa": "ਅੰਗੋਲਨ ਕਵਾਂਜ਼ਾ",
"pl": "Kwanza",
"pt": "Kwanza",
"pt": "kwanza",
"ru": "ангольская кванза",
"si": "ක්වන්සා",
"sr": "анголска кванза",
@@ -334,6 +334,7 @@
"ro": "Peso argentinian",
"ru": "аргентинское песо",
"sk": "Argentinské peso",
"sl": "argentinski peso",
"sr": "аргентински пезос",
"sv": "Argentinsk peso",
"ta": "ஆர்ஜென்டின பீசோ",
@@ -773,7 +774,7 @@
"nl": "Boliviaanse boliviano",
"oc": "Boliviano",
"pa": "ਬੋਲੀਵੀਆਨੋ",
"pl": "boliviano",
"pl": "boliwiano",
"pt": "Boliviano",
"ro": "boliviano",
"ru": "боливиано",
@@ -1107,7 +1108,7 @@
"fi": "Kongon frangi",
"fr": "franc congolais",
"ga": "franc an Chongó",
"gl": "Franco congolés",
"gl": "franco congolés",
"he": "פרנק קונגולזי",
"hr": "Kongoanski franak",
"hu": "kongói frank",
@@ -1636,7 +1637,7 @@
"fi": "Algerian dinaari",
"fr": "dinar algérien",
"ga": "dinar na hAilgéire",
"gl": "Dinar alxeriano",
"gl": "dinar alxeriano",
"he": "דינר אלג'ירי",
"hr": "Alžirski dinar",
"hu": "algériai dinár",
@@ -1651,7 +1652,7 @@
"pap": "dinar argelino",
"pl": "dinar algierski",
"pt": "dinar argelino",
"ro": "Dinar algerian",
"ro": "dinar algerian",
"ru": "алжирский динар",
"sk": "Alžírský dinár",
"sl": "alžirski dinar",
@@ -1786,7 +1787,7 @@
"cy": "Ewro",
"da": "Euro",
"de": "Euro",
"en": "Euro",
"en": "euro",
"eo": "eŭro",
"es": "Euro",
"et": "Euro",
@@ -1803,7 +1804,7 @@
"it": "Euro",
"ja": "ユーロ",
"ko": "유로",
"lt": "Euras",
"lt": "euras",
"lv": "eiro",
"ml": "യൂറോ",
"ms": "Euro",
@@ -2002,7 +2003,7 @@
"eo": "ganaa cedio",
"es": "cedi",
"fi": "Cedi",
"fr": "Cedi",
"fr": "cedi",
"ga": "cedi",
"gl": "Cedi",
"he": "סדי גאני",
@@ -2759,7 +2760,7 @@
"pa": "ਜਪਾਨੀ ਯੈੱਨ",
"pl": "jen",
"pt": "iene",
"ro": "yeni",
"ro": "yen",
"ru": "японская иена",
"sk": "jen",
"sl": "japonski jen",
@@ -2952,7 +2953,7 @@
"pap": "won nortkoreano",
"pl": "won północnokoreański",
"pt": "won norte-coreano",
"ro": "Won nord-coreean",
"ro": "won nord-coreean",
"ru": "вона КНДР",
"sk": "severokorejsky won",
"sl": "severnokorejski von",
@@ -3094,6 +3095,7 @@
"ca": "tenge",
"cs": "Tenge",
"cy": "tenge Casachstan",
"da": "Tenge",
"de": "Tenge",
"en": "Kazakhstani tenge",
"eo": "kazaĥa tengo",
@@ -3337,6 +3339,7 @@
"fi": "Libyan dinaari",
"fr": "dinar libyen",
"ga": "dinar na Libia",
"gl": "dinar libio",
"he": "דינר לובי ",
"hr": "Libijski dinar",
"hu": "líbiai dinár",
@@ -3539,6 +3542,7 @@
"ja": "チャット",
"ko": "미얀마 짯",
"lt": "Kijatas",
"lv": "Kjats",
"ml": "ബർമ്മീസ് ക്യാറ്റ്",
"nl": "Myanmarese kyat",
"oc": "Kyat",
@@ -4310,7 +4314,7 @@
"ar": "بيسو فلبيني",
"bg": "Филипинско песо",
"ca": "peso filipí",
"cs": "Filipínské peso",
"cs": "filipínské peso",
"de": "philippinischer Peso",
"en": "Philippine peso",
"eo": "filipina peso",
@@ -4614,7 +4618,7 @@
"fi": "Serbian dinaari",
"fr": "dinar serbe",
"ga": "Dinar na Seirbia",
"gl": "Dinar serbio",
"gl": "dinar serbio",
"he": "דינר סרבי",
"hr": "srpski dinar",
"hu": "szerb dinár",
@@ -4832,6 +4836,7 @@
"nl": "Seychelse roepie",
"pl": "Rupia seszelska",
"pt": "rupia das Seicheles",
"ro": "rupie seychelloză",
"ru": "сейшельская рупия",
"sk": "Seychelská rupia",
"sl": "sejšelska rupija",
@@ -5062,6 +5067,7 @@
"nl": "Somalische shilling",
"pl": "Szyling somalijski",
"pt": "xelim somaliano",
"ro": "șiling somalez",
"ru": "сомалийский шиллинг",
"sk": "Somálsky šiling",
"sl": "somalski šiling",
@@ -5331,7 +5337,7 @@
"pa": "ਤਾਜਿਕਿਸਤਾਨੀ ਸੋਮੋਨੀ",
"pl": "Somoni",
"pt": "Somoni",
"ro": "Somoni tadjic",
"ro": "somoni tadjic",
"ru": "таджикский сомони",
"sk": "tadžický som",
"sl": "tadžikistanski somoni",
@@ -5394,6 +5400,7 @@
"fi": "Tunisian dinaari",
"fr": "dinar tunisien",
"ga": "dinar na Túinéise",
"gl": "dinar tunisiano",
"he": "דינר תוניסאי",
"hr": "tuniski dinar",
"hu": "tunéziai dinár",
@@ -5766,7 +5773,7 @@
"fi": "Uruguayn peso",
"fr": "peso uruguayen",
"ga": "peso Uragua",
"gl": "Peso uruguaio",
"gl": "peso uruguaio",
"he": "פסו של אורוגוואי",
"hr": "Urugvajski pezo",
"hu": "uruguayi peso",
@@ -5880,6 +5887,7 @@
"ja": "ドン",
"ko": "베트남 동",
"lt": "Vietnamo dongas",
"ms": "Dồng Vietnam",
"nl": "Vietnamese dong",
"oc": "Dong",
"pa": "ਵੀਅਤਨਾਮੀ ਦੋਙ",
@@ -6119,7 +6127,8 @@
"ro": "Gulden caraibian",
"ru": "Карибский гульден",
"sk": "Karibský gulden",
"sl": "karibski goldinar"
"sl": "karibski goldinar",
"sv": "Karibisk gulden"
},
"XDR": {
"ar": "حقوق السحب الخاصة",
@@ -6147,7 +6156,7 @@
"oc": "Drechs de tiratge Especials",
"pl": "specjalne prawa ciągnienia",
"pt": "direitos especiais de saque",
"ro": "Drepturi speciale de tragere",
"ro": "drepturi speciale de tragere",
"ru": "специальные права заимствования",
"sk": "Zvláštne práva čerpania",
"sl": "posebne pravice črpanja",
@@ -6225,6 +6234,7 @@
"ja": "CFPフラン",
"ko": "CFP 프랑",
"lt": "CFP frankas",
"lv": "Klusā okeāna franks",
"ms": "Franc CFP",
"nl": "CFP-frank",
"oc": "Franc CFP",
@@ -6720,6 +6730,8 @@
"antilliaanse gulden": "ANG",
"antilski gulden": "ANG",
"aoa": "AOA",
"apvienotās karalistes ekonomika": "GBP",
"apvienotās karalistes saimniecība": "GBP",
"apvienotās karalistes sterliņu mārciņa": "GBP",
"ar": "MGA",
"arab accounting dinar": "XAD",
@@ -6832,6 +6844,7 @@
"avustralya doları": "AUD",
"awg": "AWG",
"az arany mint befektetés": "XAU",
"az egyesült királyság gazdasága": "GBP",
"azerbaidžanin manat": "AZN",
"azerbaidžano manatas": "AZN",
"azerbaidžānas manats": "AZN",
@@ -7015,6 +7028,7 @@
"bir etíope": "ETB",
"biras": "ETB",
"birleşik arap emirlikleri dirhemi": "AED",
"birleşik krallık ekonomisi": "GBP",
"birma kjato": "MMK",
"birr": "ETB",
"birr da etiópia": "ETB",
@@ -7055,6 +7069,7 @@
"bolivjano": "BOB",
"bolivya bolivianosu": "BOB",
"bolivya bolivyanosu": "BOB",
"boliwiano": "BOB",
"bolívar digital": "VED",
"bolívar soberano": "VES",
"bolívar sobirà": "VES",
@@ -7101,15 +7116,19 @@
"brit font": "GBP",
"brita pundo": "GBP",
"britaj pundoj": "GBP",
"britannian talous": "GBP",
"britanska funta": "GBP",
"britanski funt": "GBP",
"britische wirtschaft": "GBP",
"britisches pfund": "GBP",
"british economy": "GBP",
"british pound": "GBP",
"britisk pund": "GBP",
"britiske pund": "GBP",
"brits pond": "GBP",
"britse pond": "GBP",
"britská libra": "GBP",
"brittisk ekonomi": "GBP",
"brittiska pund": "GBP",
"brittiskt pund": "GBP",
"brunei doları": "BND",
@@ -7193,6 +7212,7 @@
"cedi du ghana": "GHS",
"cedi ghana": "GHS",
"cedi ghanese": "GHS",
"cedi ghanéen": "GHS",
"centr afrika franko": "XAF",
"central african cfa franc": "XAF",
"centralafrikansk cfa franc": "XAF",
@@ -7295,7 +7315,6 @@
"colón costa ricense": "CRC",
"colón costa riquenho": "CRC",
"colón costa riquense": "CRC",
"colón costa riqueny": "CRC",
"colón costaricain": "CRC",
"colón costaricano": "CRC",
"colón costaricien": "CRC",
@@ -8408,6 +8427,7 @@
"dólares canadenses": "CAD",
"dólares estadounidenses": "USD",
"dólares neozelandeses": "NZD",
"dồng vietnam": "VND",
"dram": "AMD",
"dram armean": "AMD",
"dram armenia": "AMD",
@@ -8425,11 +8445,13 @@
"drame arménio": "AMD",
"dramm": "AMD",
"drechs de tiratge especials": "XDR",
"drept special de tragere": "XDR",
"drepturi speciale de tragere": "XDR",
"drets especials de gir": "XDR",
"droits de tirage speciaux": "XDR",
"droits de tirage spéciaux": "XDR",
"dschibuti franc": "DJF",
"dvn": "VND",
"dzd": "DZD",
"dzsibuti frank": "DJF",
"džibučio frankas": "DJF",
@@ -8443,6 +8465,21 @@
"eastern caribbean currency union": "XCD",
"eastern caribbean dollar": "XCD",
"ec$": "XCD",
"economi'r deyrnas unedig": "GBP",
"economia": "GBP",
"economia del regne unit": "GBP",
"economia del regno unito": "GBP",
"economia del reialme unit": "GBP",
"economia del reino unido": "GBP",
"economia do reino unido": "GBP",
"economia regatului unit": "GBP",
"economie du royaume uni": "GBP",
"economie van het verenigd koninkrijk": "GBP",
"economía del reino unido": "GBP",
"economía do reino unido": "GBP",
"economy": "GBP",
"economy of the uk": "GBP",
"economy of the united kingdom": "GBP",
"egipatska funta": "EGP",
"egipta pundo": "EGP",
"egipto svaras": "EGP",
@@ -8462,6 +8499,12 @@
"einr": "INR",
"eiro": "EUR",
"ekialdeko karibeko dolar": "XCD",
"ekonomi britania raya": "GBP",
"ekonomi united kingdom": "GBP",
"ekonomie van die verenigde koninkryk": "GBP",
"ekonomika spojeného království": "GBP",
"ekonomika v spojenom kráľovstve": "GBP",
"ekonomio de britujo": "GBP",
"el peso": "GTQ",
"emalangeni": "SZL",
"emas sebagai pelaburan": "XAU",
@@ -8493,6 +8536,7 @@
"ermenistan dramı": "AMD",
"ern": "ERN",
"erreal brasildar": "BRL",
"erresuma batuko ekonomia": "GBP",
"errublo": "RUB",
"errublo errusiar": "RUB",
"errupia indiar": "INR",
@@ -8563,6 +8607,8 @@
"eyrir": "ISK",
"e£": "EGP",
"èuro": "EUR",
"économie britannique": "GBP",
"économie du royaume uni": "GBP",
"észak ír font": "GBP",
"észak koreai von": "KPW",
"e₹": "INR",
@@ -8696,6 +8742,9 @@
"forintti": "HUF",
"forinți": "HUF",
"fòrint": "HUF",
"förenade konungariket storbritannien och irlands ekonomi": "GBP",
"förenade konungariket storbritannien och nordirlands ekonomi": "GBP",
"förenade kungarikets ekonomi": "GBP",
"franak cfp": "XPF",
"franc": [
"XPF",
@@ -8948,6 +8997,9 @@
"gold als kapitalanlage": "XAU",
"gold as an investment": "XAU",
"gold as currency": "XAU",
"gospodarka wielkiej brytanii": "GBP",
"gospodarstvo ujedinjenog kraljevstva": "GBP",
"gospodarstvo združenega kraljestva": "GBP",
"gourde": "HTG",
"gourde haiti": "HTG",
"gourde haitiano": "HTG",
@@ -9367,6 +9419,7 @@
"juaņs": "CNY",
"juhokoréjsky won": "KRW",
"juhosudánska libra": "SSP",
"jungtinės karalystės ekonomika": "GBP",
"jungtinių arabų emyratų dirhamas": "AED",
"jungtinių valstijų doleris": "USD",
"južnoafrički rand": "ZAR",
@@ -9431,6 +9484,7 @@
"karibi forint": "XCG",
"karibia guldeno": "XCG",
"karibischer gulden": "XCG",
"karibisk gulden": "XCG",
"karibski goldinar": "XCG",
"karibský gulden": "XCG",
"karipski gulden": "XCG",
@@ -9491,6 +9545,9 @@
"kina papua nugini": "PGK",
"kina papuana": "PGK",
"kina papuásia": "PGK",
"kinh tế anh": "GBP",
"kinh tế vương quốc anh": "GBP",
"kinh tế vương quốc liên hiệp anh và bắc ireland": "GBP",
"kip": "LAK",
"kip laos": "LAK",
"kip laosiano": "LAK",
@@ -9518,6 +9575,8 @@
"kíp lào": "LAK",
"kīp": "LAK",
"kjat": "MMK",
"kjats": "MMK",
"klusā okeāna franks": "XPF",
"km": "BAM",
"kmf": "KMF",
"koeweitse dinar": "KWD",
@@ -10023,6 +10082,7 @@
"lire sterline": "GBP",
"lire turque": "TRY",
"lisente": "LSL",
"list of syrian coins": "SYP",
"liura de gibartar": "GIP",
"liura egipciana": "EGP",
"liura esterlina": "GBP",
@@ -11132,6 +11192,7 @@
"põhja korea won": "KPW",
"põhja makedoonia denaar": "MKD",
"prata como investimento": "XAG",
"produits agricole de l'angleterre": "GBP",
"pula": "BWP",
"pula botswana": "BWP",
"pula botswanais": "BWP",
@@ -11182,6 +11243,7 @@
"qatarisk rial": "QAR",
"qäpik": "AZN",
"qindarka": "ALL",
"quanza": "AOA",
"quetzal": "GTQ",
"quetzal guatemala": "GTQ",
"quetzal guatemalteco": "GTQ",
@@ -11313,7 +11375,6 @@
"riel camboxano": "KHR",
"riel camboyano": "KHR",
"riel campuchia": "KHR",
"riel kambodżański": "KHR",
"riel kamboja": "KHR",
"riel na cambóide": "KHR",
"rietumāfrikas franks": "XOF",
@@ -11508,6 +11569,7 @@
"rupia del pakistan": "PKR",
"rupia dell'india": "INR",
"rupia delle seychelles": "SCR",
"rupia din seychelles": "SCR",
"rupia do nepal": "NPR",
"rupia do paquistão": "PKR",
"rupia do seri lanca": "LKR",
@@ -11563,6 +11625,7 @@
],
"rupie indiană": "INR",
"rupie indiane": "INR",
"rupie seychelloză": "SCR",
"rupies índies": "INR",
"rupija": [
"NPR",
@@ -11992,6 +12055,10 @@
"sterliņu mārciņa": "GBP",
"stērliņu mārciņa": "GBP",
"stn": "STN",
"storbritannien och irlands ekonomi": "GBP",
"storbritannien och nordirlands ekonomi": "GBP",
"storbritanniens ekonomi": "GBP",
"storbritanniens økonomi": "GBP",
"stredoafrický frank": "XAF",
"středoafrický frank": "XAF",
"sucre": "XSU",
@@ -12041,6 +12108,7 @@
"suriye lirası": "SYP",
"suudi arabistan riyali": "SAR",
"suudi riyali": "SAR",
"suurbritannia majandus": "GBP",
"suurbritannia nael": "GBP",
"suurbritannia naelsterling": "GBP",
"suvereni bolivar": "VES",
@@ -12147,6 +12215,7 @@
"švicarski frank": "CHF",
"švýcarský frank": "CHF",
"șekel nou": "ILS",
"șiling somalez": "SOS",
"şekel": "ILS",
"şili pesosu": "CLP",
"s₣": "CHF",
@@ -12257,6 +12326,7 @@
"thaise baht": "THB",
"thajský baht": "THB",
"thb": "THB",
"the australian dollar": "AUD",
"thebe": "BWP",
"third belarusian ruble": "BYN",
"tical": "THB",
@@ -12488,6 +12558,8 @@
"uguiya": "MRU",
"ugx": "UGX",
"ui": "UYI",
"uk economy": "GBP",
"uk's economy": "GBP",
"ukl": "GBP",
"ukraina grivna": "UAH",
"ukraina hrivno": "UAH",
@@ -12528,6 +12600,8 @@
"unidades de inversion": "MXV",
"unidades de inversión": "MXV",
"united arab emirates dirham": "AED",
"united kingdom economy": "GBP",
"united kingdom's economy": "GBP",
"united states dollar": [
"USN",
"USD"
@@ -12629,6 +12703,7 @@
"venemaa rubla": "RUB",
"venezuelai bolívar": "VES",
"venezuelan digital bolívar": "VED",
"verenigd koninkrijk economie": "GBP",
"verenigde arabiese emirate dirham": "AED",
"verenigde arabische emiraten dirham": "AED",
"ves": "VES",
@@ -12660,6 +12735,12 @@
"wir euro": "CHE",
"wir franc": "CHW",
"wir franken": "CHW",
"wirtschaft": "GBP",
"wirtschaft des vereinigten königreichs": "GBP",
"wirtschaft im vereinigten königreich": "GBP",
"wirtschaft in dem vereinigten königreich": "GBP",
"wirtschaft vom vereinigten königreich": "GBP",
"wirtschaft von dem vereinigten königreich": "GBP",
"wit russische roebel": "BYN",
"won": "KRW",
"won bắc triều tiên": "KPW",
@@ -12740,6 +12821,7 @@
"yen": "JPY",
"yen giapponese": "JPY",
"yen japones": "JPY",
"yen japonez": "JPY",
"yen japonés": "JPY",
"yeni": "JPY",
"yeni i̇srail şekeli": "ILS",
@@ -12752,6 +12834,7 @@
"yeşil burun adaları eskudosu": "CVE",
"yên nhật": "JPY",
"yhdistyneen kuningaskunnan punta": "GBP",
"yhdistyneen kuningaskunnan talous": "GBP",
"yhdistyneiden arabiemiraattien dirhami": "AED",
"yhdysvaltain dollari": "USD",
"ytl": "TRY",
@@ -12806,7 +12889,6 @@
"zimbabwe zig": "ZWG",
"zimbabwean dollar": "ZWL",
"zimbabwean gold": "ZWG",
"zimbabwean zig": "ZWG",
"zimbabwen kulta": "ZWG",
"zimbabwiansky zlatý": "ZWG",
"zimbabwský dolar": "ZWL",
@@ -12965,6 +13047,7 @@
"FKP",
"EGP"
],
"£S": "SYP",
"£e": "EGP",
"£s": "SYP",
"¥": [
@@ -13501,6 +13584,8 @@
"египетский фунт": "EGP",
"единая система региональных взаиморасчётов": "XSU",
"единая система региональных взаиморасчетов": "XSU",
"економіка великобританії": "GBP",
"економіка великої британії": "GBP",
"енглеска фунта": "GBP",
"еритрейська накфа": "ERN",
"еритрејска накфа": "ERN",
@@ -13557,6 +13642,8 @@
"израелски шекел": "ILS",
"израильский новый шекель": "ILS",
"източнокарибски долар": "XCD",
"икономика на великобритания": "GBP",
"икономика на обединеното кралство": "GBP",
"индийска рупия": "INR",
"индийская рупия": "INR",
"индијска рупија": "INR",
@@ -13970,6 +14057,7 @@
"PLZ",
"PLN"
],
"привреда уједињеног краљевства": "GBP",
"пула": "BWP",
"південно африканський ранд": "ZAR",
"південнокорейська вона": "KRW",
@@ -14057,6 +14145,7 @@
"севернокорејски вон": "KPW",
"северо корейская вона": "KPW",
"северокорейская вона": "KPW",
"седі": "GHS",
"сейшел рупиясе": "SCR",
"сейшелска рупия": "SCR",
"сейшельская рупия": "SCR",
@@ -14109,6 +14198,8 @@
"старый румынский лей": "RON",
"стерлинг фунты": "GBP",
"стерлиң фунты": "GBP",
"стопанство на великобритания": "GBP",
"стопанство на обединеното кралство": "GBP",
"суверен боливар": "VES",
"суверенний болівар": "VES",
"суверенный боливар": "VES",
@@ -14359,6 +14450,7 @@
"шриланкийска рупия": "LKR",
"шриланчанска рупија": "LKR",
"щатски долар": "USD",
"экономика великобритании": "GBP",
"эритрейская накфа": "ERN",
"эритрея накфасы": "ERN",
"эсватини лилангение": "SZL",
@@ -14508,6 +14600,8 @@
"יואן סיני": "CNY",
"ין יפני": "JPY",
"כארתולי לארי": "GEL",
"כלכלת בריטניה": "GBP",
"כלכלת הממלכה המאוחדת": "GBP",
"כתר דני": "DKK",
"כתר נורבגי": "NOK",
"כתר נורווגי": "NOK",
@@ -14655,6 +14749,7 @@
"استثمار البلاتين": "XPT",
"استثمار الذهب": "XAU",
"استثمار الفضة": "XAG",
"اقتصاد المملكة المتحدة": "GBP",
"الاستثمار في الذهب": "XAU",
"الأوقية الموريتانية": "MRU",
"البات": "THB",
@@ -14708,6 +14803,7 @@
"أوقية": "MRU",
"أوقية موريتانية": "MRU",
"أوقيه موريتانيه": "MRU",
"إقتصاد بريطانى": "GBP",
"إيسكودو جزر الرأس الأخضر": "CVE",
"بات": "THB",
"بات تايلاندي": "THB",
@@ -14959,6 +15055,8 @@
"فورنت مجري": "HUF",
"فورينت مجري": "HUF",
"فِرَنْكٌ رُوَنْدِيٌّ": "RWF",
"قائمة النقود المعدنية السورية": "SYP",
"قائمة عملات سوريا المعدنية": "SYP",
"ك": "KWD",
"كتزال غواتيمالي": "GTQ",
"كرونة آيسلندية": "ISK",
@@ -15088,6 +15186,7 @@
"মালদ্বীপীয় রুফিয়াহ": "MVR",
"মিয়ানমার ক্যত": "MMK",
"মিশরীয় পাউন্ড": "EGP",
"যুক্তরাজ্যের অর্থনীতি": "GBP",
"রুশ রুবল": "RUB",
"রেনমিনবি": "CNY",
"রেন্মিন্বি": "CNY",
@@ -15719,6 +15818,7 @@
"엔": "JPY",
"엔화": "JPY",
"영국 파운드": "GBP",
"영국의 경제": "GBP",
"예멘 리알": "YER",
"예멘 리얄": "YER",
"예멘리얄": "YER",
@@ -15926,9 +16026,11 @@
"イエメン・リアル": "YER",
"イエメン・リヤル": "YER",
"イエメン・リヤール": "YER",
"イギリスの経済": "GBP",
"イギリスの通貨": "GBP",
"イギリスポンド": "GBP",
"イギリス・ポンド": "GBP",
"イギリス経済": "GBP",
"イラクの通貨": "IQD",
"イラク・ディナール": "IQD",
"イランの通貨": "IRR",
@@ -16230,6 +16332,7 @@
"英ポンド": "GBP",
"西アフリカcfaフラン": "XOF",
"豪ドル": "AUD",
"財政・経済政策": "GBP",
"越南銅": "VND",
"金投資": "XAU",
"韓国ウォン": "KRW",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
],
"ua": "Mozilla/5.0 ({os}; rv:{version}) Gecko/20100101 Firefox/{version}",
"versions": [
"151.0",
"150.0"
"154.0",
"153.0"
]
}

View File

@@ -3272,7 +3272,7 @@
"Q128822": {
"si_name": "Q182429",
"symbol": "kn",
"to_si_factor": 0.5144444444444445
"to_si_factor": 0.514
},
"Q12912288": {
"si_name": null,
@@ -3521,7 +3521,7 @@
},
"Q1377741": {
"si_name": "Q25250",
"symbol": "V_P",
"symbol": "V<sub>P</sub>",
"to_si_factor": 1.0429e+27
},
"Q1386162": {
@@ -3694,6 +3694,11 @@
"symbol": "apc",
"to_si_factor": 0.0308568
},
"Q16068": {
"si_name": null,
"symbol": "DM",
"to_si_factor": null
},
"Q160857": {
"si_name": "Q25236",
"symbol": "hp",
@@ -3872,11 +3877,11 @@
"Q180892": {
"si_name": "Q11570",
"symbol": "M☉",
"to_si_factor": 1.9884e+30
"to_si_factor": 1.988416e+30
},
"Q1811": {
"si_name": "Q11573",
"symbol": "AU",
"symbol": "au",
"to_si_factor": 149597870700.0
},
"Q1815100": {
@@ -4454,6 +4459,11 @@
"symbol": "ng",
"to_si_factor": 1e-12
},
"Q2285395": {
"si_name": null,
"symbol": "dBW",
"to_si_factor": null
},
"Q22934083": {
"si_name": "Q25406",
"symbol": "nC",
@@ -6316,7 +6326,7 @@
},
"Q536785": {
"si_name": "Q844211",
"symbol": "ρ_P",
"symbol": "ρ<sub>P</sub>",
"to_si_factor": 5.155e+96
},
"Q53679433": {
@@ -6971,7 +6981,7 @@
},
"Q685662": {
"si_name": "Q44395",
"symbol": "p_P",
"symbol": "p<sub>P</sub>",
"to_si_factor": 4.633e+113
},
"Q686163": {

View File

@@ -47,7 +47,7 @@ ENGINES_CACHE: ExpireCacheSQLite = ExpireCacheSQLite.build_cache(
ExpireCacheCfg(
name="ENGINES_CACHE",
MAXHOLD_TIME=60 * 60 * 24 * 7, # 7 days
MAINTENANCE_PERIOD=60 * 60, # 2h
MAINTENANCE_PERIOD=60 * 60, # 1h
MAX_VALUE_LEN=1024 * 1024 * 1024, # 1MB
)
)
@@ -361,37 +361,43 @@ class Engine(abc.ABC): # pylint: disable=too-few-public-methods
https: socks5://proxy:port
"""
def setup(self, engine_settings: dict[str, t.Any]) -> bool: # pylint: disable=unused-argument
def setup(self, engine_settings: dict[str, t.Any]) -> bool | None: # pylint: disable=unused-argument
"""Dynamic setup of the engine settings.
With this method, the engine's setup is carried out. For example, to
check or dynamically adapt the values handed over in the parameter
``engine_settings``. The return value (True/False) indicates whether
the setup was successful and the engine can be built or rejected.
``engine_settings``.
The method is optional and is called synchronously as part of the
Whether the initialization was successful can be indicated by the return
value ``True`` or even ``False``.
- If no return value (``None`` ) is given from this method , this is
equivalent to ``True``.
- If an exception is thrown as part of the initialization, this is
equivalent to ``False``.
The method is optional and is called **synchronously** as part of the
initialization of the service and is therefore only suitable for simple
(local) exams/changes at the engine setting. The :py:obj:`Engine.init`
method must be used for longer tasks in which values of a remote must be
determined, for example.
(local) exams/changes at the engine setting.
The :py:obj:`Engine.init` method must be used for longer tasks in which
values of a remote must be determined, for example.
"""
return True
def init(self, engine_settings: dict[str, t.Any]) -> bool | None: # pylint: disable=unused-argument
"""Initialization of the engine.
The method is optional and asynchronous (in a thread). It is suitable,
for example, for setting up a cache (for the engine) or for querying
values (required by the engine) from a remote.
The method is optional and called **asynchronous** (in a thread). The
method is comparable to :py:obj:`Engine.setup`, it is suitable, for
caching data that first needs to be requested from a remote.
Whether the initialization was successful can be indicated by the return
value ``True`` or even ``False``.
The method is optional and runs **asynchronously** (in a thread), it is
comparable to :py:obj:`Engine.setup`. For instance, it is suitable for
caching data that first needs to be requested from a remote source.
- If no return value is given from this init method (``None``), this is
equivalent to ``True``.
- If an exception is thrown as part of the initialization, this is
equivalent to ``False``.
The evaluation of the return value is analogous to :py:obj:`Engine.setup`.
"""
return True

View File

@@ -82,7 +82,7 @@ fragment SXNG_query on Query {
def setup(_) -> bool:
global SXNG_query # pylint: disable=global-statement
rand_str: str = "".join(random.choice(string.ascii_letters) for _ in range(5))
rand_str: str = "".join(random.choices(string.ascii_letters, k=5))
SXNG_query = SXNG_query.replace("SXNG_query", "PhotoSearchPaginationContainer_query_1" + rand_str)
return True

View File

@@ -269,21 +269,27 @@ def is_engine_active(engine: "Engine | types.ModuleType"):
def call_engine_setup(engine: "Engine | types.ModuleType", engine_data: dict[str, t.Any]) -> bool:
setup_ok = False
setup_ok: bool | None = False
setup_func = getattr(engine, "setup", None)
if setup_func is None:
setup_ok = True
elif not callable(setup_func):
logger.error("engine's setup method isn't a callable (is of type: %s)", type(setup_func))
logger.error(f"engine's setup method isn't a callable (is of type: {type(setup_func)})")
else:
try:
setup_ok = engine.setup(engine_data)
except Exception as e: # pylint: disable=broad-except
logger.exception('exception : {0}'.format(e))
logger.exception(f"(PID {os.getpid()}) {engine.name}: engine SETUP failed, exception: {e}")
setup_ok = False
# The evaluation of the return value is analogous to Engine.init
if setup_ok is None:
setup_ok = True
if not setup_ok:
logger.error("%s: Engine setup was not successful, engine is set to inactive.", engine.name)
logger.error(f"(PID {os.getpid()}) {engine.name}: engine setup was not successful")
return setup_ok
@@ -311,14 +317,16 @@ def load_engines(engine_list: list[dict[str, t.Any]]):
for engine_data in engine_list:
if engine_data.get("inactive") is True:
continue
engine = load_engine(engine_data)
if engine:
register_engine(engine)
else:
# 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()}) loading engine %s failed: set engine to inactive!", engine_data.get("name", "???")
f"(PID {os.getpid()}) {engine_data.get('name', '???')}: can't register engine (loading engine failed)"
)
engine_data["inactive"] = True
return engines

View File

@@ -83,7 +83,7 @@ def extract_video_data(video_block):
published_date = None
if create_time:
try:
published_date = datetime.strptime(create_time.strip(), "%Y-%m-%d")
published_date = datetime.fromisoformat(create_time.strip())
except (ValueError, TypeError):
pass

View File

@@ -109,7 +109,7 @@ def response(resp: "SXNG_Response") -> EngineResults:
comments_elements = eval_xpath_getindex(entry, xpath_comment, 0, default=None)
comments: str = "" if comments_elements is None else comments_elements.text
publishedDate = datetime.strptime(eval_xpath_getindex(entry, xpath_published, 0).text, "%Y-%m-%dT%H:%M:%SZ")
publishedDate = datetime.fromisoformat(eval_xpath_getindex(entry, xpath_published, 0).text.rstrip("Z"))
res.add(
res.types.Paper(

View File

@@ -7,13 +7,17 @@
# There exits a https://github.com/ohblue/baidu-serp-api/
# but we don't use it here (may we can learn from).
import typing as t
from urllib.parse import urlencode
from datetime import datetime
from html import unescape
import time
import json
from searx.exceptions import SearxEngineAPIException, SearxEngineCaptchaException
from searx.exceptions import SearxEngineAPIException, SearxEngineCaptchaException, SearxEngineAccessDeniedException
from searx.enginelib import EngineCache
from searx.network import get as http_get
from searx.utils import html_to_text
about = {
@@ -35,6 +39,31 @@ baidu_category = 'general'
time_range_support = True
time_range_dict = {"day": 86400, "week": 604800, "month": 2592000, "year": 31536000}
image_base_url = "https://image.baidu.com/"
COOKIE_CACHE_KEY = "cookie"
COOKIE_CACHE_EXPIRATION_SECONDS = 3600
CACHE: EngineCache
"""Stores cookies from Baidu image search warmup."""
def setup(engine_settings: dict[str, t.Any]) -> bool:
global CACHE # pylint: disable=global-statement
CACHE = EngineCache(engine_settings["name"])
return True
def get_image_cookies(headers: dict[str, str]) -> dict[str, str]:
cookies: dict[str, str] | None = CACHE.get(COOKIE_CACHE_KEY)
if cookies:
return cookies
warmup = http_get(image_base_url, headers=headers, timeout=10)
cookies = dict(warmup.cookies.items())
CACHE.set(key=COOKIE_CACHE_KEY, value=cookies, expire=COOKIE_CACHE_EXPIRATION_SECONDS)
return cookies
def init(_):
if baidu_category not in ('general', 'images', 'it'):
@@ -88,6 +117,9 @@ def request(query, params):
if baidu_category == 'it':
query_params["paramList"] += f",timestamp_range={past}-{now}"
if baidu_category == 'images':
params["cookies"] = get_image_cookies(params["headers"])
params["url"] = f"{query_url}?{urlencode(query_params)}"
params["allow_redirects"] = False
return params
@@ -103,6 +135,8 @@ def response(resp):
# baidu's JSON encoder wrongly quotes / and ' characters by \\ and \'
text = text.replace(r"\/", "/").replace(r"\'", "'")
data = json.loads(text, strict=False)
if data.get("antiFlag") == 1:
raise SearxEngineAccessDeniedException(data.get("message", "Forbid spider access"))
parsers = {'general': parse_general, 'images': parse_images, 'it': parse_it}
return parsers[baidu_category](data)
@@ -152,7 +186,7 @@ def parse_images(data):
img_date = item.get("bdImgnewsDate")
publishedDate = None
if img_date:
publishedDate = datetime.strptime(img_date, "%Y-%m-%d %H:%M")
publishedDate = datetime.fromisoformat(img_date)
results.append(
{
"template": "images.html",

View File

@@ -8,6 +8,7 @@ import random
import string
from urllib.parse import urlencode
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from searx import utils
@@ -31,7 +32,7 @@ base_url = "https://api.bilibili.com/x/web-interface/search/type"
cookie = {
"innersign": "0",
"buvid3": "".join(random.choice(string.hexdigits) for _ in range(16)) + "infoc",
"buvid3": "".join(random.choices(string.hexdigits, k=16)) + "infoc",
"i-wanna-go-back": "-1",
"b_ut": "7",
"FEED_LIVE_VERSION": "V8",
@@ -39,6 +40,32 @@ cookie = {
"home_feed_column": "4",
}
_CN_TZ = ZoneInfo("Asia/Shanghai")
# Calendar-day time filter (Asia/Shanghai); dict values are days to subtract from today.
time_range_support = True
time_range_dict = {"day": 0, "week": 6, "month": 29, "year": 364}
def _pubtime_range(time_range: str) -> tuple[int, int]:
"""Return ``(pubtime_begin_s, pubtime_end_s)`` for Bilibili's search API.
Time ranges follow Bilibili's website semantics: they are counted in
**calendar days** in China Standard Time (``Asia/Shanghai``), not as
sliding 24-hour windows. For example, ``day`` means from 00:00:00 to
23:59:59 of the current local day; ``week`` spans from 00:00:00 on the
calendar day six days ago through the end of today, and so on.
The returned Unix timestamps (seconds) map to Bilibili's
``pubtime_begin_s`` and ``pubtime_end_s`` query parameters.
"""
now = datetime.now(_CN_TZ)
pubtime_end_s = int(now.replace(hour=23, minute=59, second=59, microsecond=0).timestamp())
begin_day = now - timedelta(days=time_range_dict[time_range])
pubtime_begin_s = int(begin_day.replace(hour=0, minute=0, second=0, microsecond=0).timestamp())
return pubtime_begin_s, pubtime_end_s
def request(query, params):
query_params = {
@@ -50,6 +77,11 @@ def request(query, params):
"search_type": "video",
}
if params.get("time_range") in time_range_dict:
pubtime_begin_s, pubtime_end_s = _pubtime_range(params["time_range"])
query_params["pubtime_begin_s"] = pubtime_begin_s
query_params["pubtime_end_s"] = pubtime_end_s
params["url"] = f"{base_url}?{urlencode(query_params)}"
params["headers"]["Referer"] = "https://www.bilibili.com/"
params["headers"]["Accept"] = "application/json, text/javascript, */*; q=0.01"

View File

@@ -44,7 +44,7 @@ def response(resp):
"url": 'https://www.bitchute.com/video/' + item['video_id'],
"content": html_to_text(item['description']),
"author": item['channel']['channel_name'],
"publishedDate": datetime.strptime(item["date_published"], "%Y-%m-%dT%H:%M:%S.%fZ"),
"publishedDate": datetime.fromisoformat(item["date_published"].rstrip("Z")),
"length": item['duration'],
"views": item['view_count'],
"thumbnail": item['thumbnail_url'],

View File

@@ -104,7 +104,7 @@ def response(resp: "SXNG_Response") -> EngineResults:
title=_remove_keyword_marker(result["Subject"]),
content=_remove_keyword_marker(result["Text"]),
url=result["Url"],
publishedDate=datetime.strptime(result["Published"], "%Y-%m-%d %H:%M:%S"),
publishedDate=datetime.fromisoformat(result["Published"]),
metadata=gettext.gettext("Posted by {author}").format(author=result["Author"]),
)
)

View File

@@ -31,6 +31,7 @@ from dateutil import parser
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
@@ -75,6 +76,7 @@ def request(query: str, params: "OnlineParams") -> None:
"q": query,
"count": results_per_page,
"offset": (params["pageno"] - 1) * results_per_page,
"text_decorations": False,
}
# Apply time filter if specified
@@ -112,14 +114,19 @@ def response(resp: "SXNG_Response") -> EngineResults:
res = EngineResults()
data = resp.json()
for result in data.get("web", {}).get("results", []):
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=result["title"],
content=result.get("description", ""),
title=html_to_text(result["title"]),
content=html_to_text(result.get("description", "")),
publishedDate=_extract_published_date(result.get("age")),
thumbnail=result.get("thumbnail", {}).get("src"),
thumbnail=thumbnail,
),
)

View File

@@ -43,7 +43,7 @@ def response(resp):
publishedDate = None
if recipe['submissionDate']:
publishedDate = datetime.strptime(result['recipe']['submissionDate'][:19], "%Y-%m-%dT%H:%M:%S")
publishedDate = datetime.fromisoformat(result['recipe']['submissionDate'][:19])
content = [
f"Schwierigkeitsstufe (1-3): {recipe['difficulty']}",

View File

@@ -1,11 +1,18 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Deviantart (Images)"""
import typing as t
import urllib.parse
from lxml import html
from searx.result_types import EngineResults
from searx.utils import extract_text, eval_xpath, eval_xpath_list
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
# about
about = {
"website": 'https://www.deviantart.com/',
@@ -23,63 +30,62 @@ paging = True
# search-url
base_url = 'https://www.deviantart.com'
results_xpath = '//div[@class="V_S0t_"]/div/div/a'
url_xpath = './@href'
thumbnail_src_xpath = './div/img/@src'
img_src_xpath = './div/img/@srcset'
title_xpath = './@aria-label'
premium_xpath = '../div/div/div/text()'
premium_keytext = 'Watch the artist to view this deviation'
cursor_xpath = '(//a[@class="vQ2brP"]/@href)[last()]'
results_xpath = '//div[@data-testid="content_row"]//a[.//*[@data-testid="thumb"]]'
img_src_xpath = './/img/@srcset'
thumbnail_src_xpath = './/img/@src'
author_xpath = './/*[@property="schema:name"]/@content'
cursor_xpath = '//a[contains(@href, "cursor=") and contains(., "Next")]/@href'
def request(query, params):
def request(query: str, params: "OnlineParams"):
# https://www.deviantart.com/search?q=foo
nextpage_url = params['engine_data'].get('nextpage')
# don't use nextpage when user selected to jump back to page 1
if params['pageno'] > 1 and nextpage_url is not None:
params['url'] = nextpage_url
else:
params['url'] = f"{base_url}/search?{urllib.parse.urlencode({'q': query})}"
args = {'q': query}
if params['pageno'] > 1:
cursor = params['engine_data'].get('cursor')
if cursor:
args['cursor'] = cursor
return params
params['url'] = f"{base_url}/search?{urllib.parse.urlencode(args)}"
def response(resp):
def response(resp: "SXNG_Response") -> EngineResults:
results = []
res = EngineResults()
dom = html.fromstring(resp.text)
for result in eval_xpath_list(dom, results_xpath):
# skip images that are blurred
_text = extract_text(eval_xpath(result, premium_xpath))
if _text and premium_keytext in _text:
continue
thumbnail_src = extract_text(eval_xpath(result, thumbnail_src_xpath))
img_src = extract_text(eval_xpath(result, img_src_xpath))
# mature locked thumbs have blur transform (blur_15, blur_30 etc..)
if ',blur_' in f'{thumbnail_src}{img_src}':
continue
if img_src:
img_src = img_src.split(' ')[0]
parsed_url = urllib.parse.urlparse(img_src)
img_src = parsed_url._replace(path=parsed_url.path.split('/v1')[0]).geturl()
results.append(
{
'template': 'images.html',
'url': extract_text(eval_xpath(result, url_xpath)),
'img_src': img_src,
'thumbnail_src': extract_text(eval_xpath(result, thumbnail_src_xpath)),
'title': extract_text(eval_xpath(result, title_xpath)),
}
author = extract_text(eval_xpath(result, author_xpath))
res.add(
res.types.Image(
template='images.html',
url=result.get('href'),
img_src=img_src or "",
thumbnail_src=thumbnail_src or "",
title=result.get('aria-label'),
author=author or "",
)
)
nextpage_url = extract_text(eval_xpath(dom, cursor_xpath))
if nextpage_url:
results.append(
{
'engine_data': nextpage_url.replace("http://", "https://"),
'key': 'nextpage',
}
cursor = urllib.parse.parse_qs(urllib.parse.urlparse(nextpage_url or '').query).get('cursor', [None])[0]
if cursor:
res.add(
res.types.LegacyResult(
engine_data=cursor,
key='cursor',
)
)
return results
return res

View File

@@ -43,9 +43,10 @@ def init(_):
def request(query: str, params: "OnlineParams"):
params["url"] = f"{base_url}/api/{dogpile_categ}"
params["headers"]["Origin"] = base_url
params["method"] = "POST"
params["json"] = {"q": query, "qadf": safe_search_map[params["safesearch"]], "page": params["pageno"]}
return params
def response(resp: "SXNG_Response"):

168
searx/engines/exaapi.py Normal file
View File

@@ -0,0 +1,168 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Engine to search using the official `Exa Search API`_. Exa is a search engine for AI agents.
.. _Exa Search API: https://exa.ai/docs/reference/search
Configuration
=============
The engine has the following mandatory setting:
- :py:obj:`api_key`
You can obtain an API key from the `API Key section <https://dashboard.exa.ai/api-keys>`_ in the Exa dashboard.
Optional settings are:
- :py:obj:`results_per_page`
- :py:obj:`search_type`
- :py:obj:`content_mode`
- :py:obj:`content_max_characters`
.. code:: yaml
- name: exaapi
engine: exaapi
shortcut: exa
api_key: "..."
results_per_page: 10
search_type: auto
content_mode: highlights
inactive: false
The API supports SafeSearch and region-aware results.
"""
import typing as t
from dateutil import parser
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
from searx.search.processors import OnlineParams
SearchType = t.Literal["fast", "auto", "instant", "deep", "deep-lite", "deep-reasoning"]
ContentMode = t.Literal["highlights", "text"]
about = {
"website": "https://exa.ai",
"wikidata_id": None,
"official_api_documentation": "https://exa.ai/docs/reference/search",
"use_official_api": True,
"require_api_key": True,
"results": "JSON",
}
api_key: str = ""
"""API key for Exa Search API (required)."""
categories = ["general", "web"]
safesearch = True
base_url = "https://api.exa.ai/search"
results_per_page: int = 10
"""Maximum number of results per request. Value must be between 1 and 100, default is 10."""
search_type: SearchType = "auto"
"""Search type. Default is auto, see documentation for more information."""
content_mode: ContentMode = "highlights"
"""Content to request from the API: ``highlights`` (excerpts) or ``text`` (page text)."""
content_max_characters: int = 500
"""Maximum characters for the requested content."""
def init(_):
if not api_key:
raise SearxEngineAPIException("No API key provided")
if not 1 <= results_per_page <= 100:
raise ValueError("results_per_page must be between 1 and 100")
if search_type not in t.get_args(SearchType):
raise ValueError(f"Unsupported search type: {search_type}")
if content_mode not in t.get_args(ContentMode):
raise ValueError(f"Unsupported content mode: {content_mode}")
if content_max_characters < 1:
raise ValueError("content_max_characters must be at least 1")
def _contents_payload() -> dict[str, t.Any]:
if content_mode == "text":
return {"text": {"maxCharacters": content_max_characters, "stripLinks": True}}
return {"highlights": {"maxCharacters": content_max_characters}}
def _extract_content(result: dict[str, t.Any]) -> str:
if content_mode == "text":
return html_to_text(result.get("text") or "")
return html_to_text(" ".join(result.get("highlights") or []))
def request(query: str, params: "OnlineParams") -> None:
"""Create the API request."""
body: dict[str, t.Any] = {
"query": query,
"type": search_type,
"numResults": results_per_page,
"contents": _contents_payload(),
}
# Apply SafeSearch if enabled
if params["safesearch"]:
body["moderation"] = True
# Apply region-aware results if specified
locale_parts = params["searxng_locale"].split("-")
region = locale_parts[-1]
if len(locale_parts) > 1:
body["userLocation"] = region.upper()
params["url"] = base_url
params["method"] = "POST"
params["headers"]["x-api-key"] = api_key
params["json"] = body
def _extract_published_date(value: str | None):
"""Extract and parse the published date from the API response.
Args:
value: Raw date string from the API
Returns:
Parsed datetime object or None if parsing fails
"""
if not value:
return None
try:
return parser.parse(value)
except (parser.ParserError, TypeError, OverflowError):
return None
def response(resp: "SXNG_Response") -> EngineResults:
"""Process the API response and return results."""
res = EngineResults()
for result in resp.json().get("results", []):
url = result.get("url")
if not url:
continue
res.add(
res.types.MainResult(
url=url,
title=html_to_text(result.get("title") or url),
content=_extract_content(result),
thumbnail=result.get("image") or "",
publishedDate=_extract_published_date(result.get("publishedDate")),
author=result.get("author") or "",
)
)
return res

View File

@@ -37,7 +37,7 @@ def response(resp):
for item in search_res:
img = 'https://s3.thehackerblog.com/findthatmeme/' + item['image_path']
thumb = 'https://s3.thehackerblog.com/findthatmeme/thumb/' + item.get('thumbnail', '')
date = datetime.strptime(item["updated_at"].split("T")[0], "%Y-%m-%d")
date = datetime.fromisoformat(item["updated_at"].split("T")[0])
formatted_date = datetime.fromtimestamp(date.timestamp())
results.append(

View File

@@ -47,7 +47,7 @@ def response(resp: "SXNG_Response"):
title=result["title"],
content=result["description"],
thumbnail=result["smallImageURL"],
publishedDate=datetime.strptime(result["status_since"], "%Y-%m-%d %H:%M:%S"),
publishedDate=datetime.fromisoformat(result["status_since"]),
metadata=f"Rank: {result['rank']} || {result['episode_count']} episodes",
)
)

View File

@@ -9,12 +9,15 @@ engines:
- :ref:`google scholar engine`
- :ref:`google autocomplete`
This implementation uses Nokia user agents to request an XML layout from Google.
The normal web version requires executing JavaScript to load the results and
therefore is currently not used here. See `Google discussion`_ for more
information on that topic.
.. _Google discussion: https://github.com/searxng/searxng/issues/6359
"""
import random
import re
import string
import time
import typing as t
from urllib.parse import unquote, urlencode
@@ -32,7 +35,6 @@ from searx.utils import (
eval_xpath_getindex,
eval_xpath_list,
extract_text,
gen_gsa_useragent,
)
if t.TYPE_CHECKING:
@@ -45,16 +47,16 @@ about = {
"official_api_documentation": "https://developers.google.com/custom-search/",
"use_official_api": False,
"require_api_key": False,
"results": "HTML",
"results": "XML",
}
# engine dependent config
categories = ["general", "web"]
paging = True
max_page = 50
"""`Google max 50 pages`_
"""Google supports up to 50 pages of results, see the `Google max_page discussion`_.
.. _Google max 50 pages: https://github.com/searxng/searxng/issues/2982
.. _Google max_page discussion: https://github.com/searxng/searxng/issues/2982
"""
time_range_support = True
language_support = True
@@ -65,38 +67,23 @@ time_range_dict = {"day": "d", "week": "w", "month": "m", "year": "y"}
# Filter results. 0: None, 1: Moderate, 2: Strict
filter_mapping = {0: "off", 1: "medium", 2: "high"}
# https://github.com/searxng/searxng/issues/6359
nokia_useragents = (
"Nokia7610/2.0 (5.0509.0) SymbianOS/7.0s Series60/2.1 Profile/MIDP-2.0 Configuration/CLDC-1.0",
"Nokia7610/2.0 (7.0642.0) SymbianOS/7.0s Series60/2.1 Profile/MIDP-2.0 Configuration/CLDC-1.0",
"Nokia6230/2.0 (05.50) Profile/MIDP-2.0 Configuration/CLDC-1.1",
"Nokia6230i/2.0 (03.80) Profile/MIDP-2.0 Configuration/CLDC-1.1",
"Nokia6280/2.0 (03.60) Profile/MIDP-2.0 Configuration/CLDC-1.1",
"NokiaN72/2.0617.1.0.3 Series60/2.8 Profile/MIDP-2.0 Configuration/CLDC-1.1",
)
# specific xpath variables
# ------------------------
# Suggestions are links placed in a *card-section*, we extract only the text
# from the links not the links itself.
suggestion_xpath = '//div[contains(@class, "gGQDvd iIWm4b")]//a'
_arcid_range = string.ascii_letters + string.digits + "_-"
_arcid_random: tuple[str, int] | None = None
def ui_async(start: int) -> str:
"""Format of the response from UI's async request.
- ``arc_id:<...>,use_ac:true,_fmt:prog``
The arc_id is random generated every hour.
"""
global _arcid_random # pylint: disable=global-statement
use_ac = "use_ac:true"
# _fmt:html returns a HTTP 500 when user search for celebrities like
# '!google natasha allegri' or '!google chris evans'
_fmt = "_fmt:prog"
# create a new random arc_id every hour
if not _arcid_random or (int(time.time()) - _arcid_random[1]) > 3600:
_arcid_random = ("".join(random.choices(_arcid_range, k=23)), int(time.time()))
arc_id = f"arc_id:srp_{_arcid_random[0]}_1{start:02}"
return ",".join([arc_id, use_ac, _fmt])
suggestion_xpath = '//table[contains(@class, "HExoMb")]//a[contains(@class, "ZWRArf")]'
def get_google_info(params: "OnlineParams", eng_traits: EngineTraits) -> dict[str, t.Any]:
@@ -128,19 +115,11 @@ def get_google_info(params: "OnlineParams", eng_traits: EngineTraits) -> dict[st
A instance of :py:obj:`babel.core.Locale` build from the
``searxng_locale`` value.
subdomain:
Google subdomain :py:obj:`google_domains` that fits to the country
code.
params:
Py-Dictionary with additional request arguments (can be passed to
:py:func:`urllib.parse.urlencode`).
- ``hl`` parameter: specifies the interface language of user interface.
- ``lr`` parameter: restricts search results to documents written in
a particular language.
- ``cr`` parameter: restricts search results to documents
originating in a particular country.
- ``ie`` parameter: sets the character encoding scheme that should
be used to interpret the query string ('utf8').
- ``oe`` parameter: sets the character encoding scheme that should
@@ -157,7 +136,6 @@ def get_google_info(params: "OnlineParams", eng_traits: EngineTraits) -> dict[st
ret_val: dict[str, t.Any] = {
"language": None,
"country": None,
"subdomain": None,
"params": {},
"headers": {},
"cookies": {},
@@ -170,7 +148,7 @@ def get_google_info(params: "OnlineParams", eng_traits: EngineTraits) -> dict[st
except babel.core.UnknownLocaleError:
locale = None
eng_lang = eng_traits.get_language(sxng_locale, "lang_en")
eng_lang = eng_traits.get_language(sxng_locale) or "lang_en"
lang_code = eng_lang.split("_")[-1] # lang_zh-TW --> zh-TW / lang_en --> en
country = eng_traits.get_region(sxng_locale, eng_traits.all_locale)
@@ -185,7 +163,6 @@ def get_google_info(params: "OnlineParams", eng_traits: EngineTraits) -> dict[st
ret_val["language"] = eng_lang
ret_val["country"] = country
ret_val["locale"] = locale
ret_val["subdomain"] = eng_traits.custom["supported_domains"].get(country.upper(), "www.google.com")
# hl parameter:
# The hl parameter specifies the interface language (host language) of
@@ -197,7 +174,7 @@ def get_google_info(params: "OnlineParams", eng_traits: EngineTraits) -> dict[st
# https://developers.google.com/custom-search/docs/xml_results_appendices#interfaceLanguages
# https://github.com/searxng/searxng/issues/2515#issuecomment-1607150817
ret_val["params"]["hl"] = f"{lang_code}-{country}"
ret_val["params"]["hl"] = f"{lang_code}"
# lr parameter:
# The lr (language restrict) parameter restricts search results to
@@ -224,6 +201,8 @@ def get_google_info(params: "OnlineParams", eng_traits: EngineTraits) -> dict[st
# specify a region (country) only if a region is given in the selected
# locale --> https://github.com/searxng/searxng/issues/2672
if country is not None:
ret_val["params"]["cr"] = ""
if len(sxng_locale.split("-")) > 1:
ret_val["params"]["cr"] = "country" + country
@@ -268,7 +247,6 @@ def get_google_info(params: "OnlineParams", eng_traits: EngineTraits) -> dict[st
# HTTP headers
ret_val["headers"]["Accept"] = "*/*"
ret_val["headers"]["User-Agent"] = gen_gsa_useragent()
# Cookies
@@ -302,88 +280,77 @@ def detect_google_sorry(resp: "SXNG_Response"):
raise SearxEngineCaptchaException()
def request(query: str, params: "OnlineParams") -> None:
"""Google search request"""
# pylint: disable=line-too-long
start = (params["pageno"] - 1) * 10
google_info = get_google_info(params, traits)
# https://www.google.de/search?q=corona&hl=de&lr=lang_de&start=0&tbs=qdr%3Ad&safe=medium
query_url = (
"https://"
+ google_info["subdomain"]
+ "/search"
+ "?"
+ urlencode(
{
"q": query,
**google_info["params"],
"filter": "0",
"start": start,
# 'vet': '12ahUKEwik3ZbIzfn7AhXMX_EDHbUDBh0QxK8CegQIARAC..i',
# 'ved': '2ahUKEwik3ZbIzfn7AhXMX_EDHbUDBh0Q_skCegQIARAG',
# 'cs' : 1,
# 'sa': 'N',
# 'yv': 3,
# 'prmd': 'vin',
# 'ei': 'GASaY6TxOcy_xc8PtYeY6AE',
# 'sa': 'N',
# 'sstk': 'AcOHfVkD7sWCSAheZi-0tx_09XDO55gTWY0JNq3_V26cNN-c8lfD45aZYPI8s_Bqp8s57AHz5pxchDtAGCA_cikAWSjy9kw3kgg'
# formally known as use_mobile_ui
# "asearch": "arc",
# "async": str_async,
}
)
)
if params["time_range"] in time_range_dict:
query_url += "&" + urlencode({"tbs": "qdr:" + time_range_dict[params["time_range"]]})
if params["safesearch"]:
query_url += "&" + urlencode({"safe": filter_mapping[params["safesearch"]]})
params["url"] = query_url
params["cookies"] = google_info["cookies"]
params["headers"].update(google_info["headers"])
def unwrap_google_url(raw_url: str) -> str:
# remove redirector from url
if raw_url.startswith("/url?q="):
return unquote(raw_url[7:].split("&sa=U")[0])
return raw_url
# regex match to get image map that is found inside the returned javascript:
# (function(){var s='...';var i=['...'] ...}
RE_DATA_IMAGE = re.compile(r"(data:image[^']*?)'[^']*?'((?:dimg|pimg|tsuid)[^']*)")
def parse_url_images(text: str):
data_image_map = {}
for image_url, img_id in RE_DATA_IMAGE.findall(text):
data_image_map[img_id] = image_url.encode('utf-8').decode("unicode-escape")
logger.debug("data:image objects --> %s", list(data_image_map.keys()))
return data_image_map
def response(resp: "SXNG_Response"):
"""Get response from google's search request"""
# pylint: disable=too-many-branches, too-many-statements
def wml_dom(resp: "SXNG_Response"):
detect_google_sorry(resp)
data_image_map = parse_url_images(resp.text)
text = resp.text
if text.lstrip().startswith("<?xml"):
text = text.split("?>", 1)[-1]
return html.fromstring(text)
def google_request(
query: str,
params: "OnlineParams",
extra_args: dict[str, t.Any] | None = None,
*,
eng_traits: EngineTraits | None = None,
use_time_range: bool = True,
use_safesearch: bool = True,
safesearch_map: dict[int, str] | None = None,
use_locales: bool = True,
) -> None:
google_info = get_google_info(params, eng_traits or traits)
if not use_locales:
google_info["params"].pop("lr")
google_info["params"].pop("cr")
start = (params["pageno"] - 1) * 10
args: dict[str, t.Any] = {
"q": query,
"sca_esv": "1",
**google_info["params"],
**(extra_args or {}),
}
if start:
args["start"] = start
if use_time_range and params["time_range"] in time_range_dict:
args["tbs"] = "qdr:" + time_range_dict[params["time_range"]]
if use_safesearch and params["safesearch"]:
args["safe"] = (safesearch_map or filter_mapping)[params["safesearch"]]
params["url"] = f"https://www.google.com/wml/search?{urlencode(args)}"
params["headers"]["User-Agent"] = random.choice(nokia_useragents)
def request(query: str, params: "OnlineParams") -> None:
google_request(query, params)
def response(resp: "SXNG_Response") -> EngineResults:
results = EngineResults()
# convert the text to dom
dom = html.fromstring(resp.text)
dom = wml_dom(resp)
# parse results
for result in eval_xpath_list(dom, '//a[@data-ved and not(@class)]'):
# pylint: disable=too-many-nested-blocks
for result in eval_xpath_list(dom, '//div[contains(@class, "zMzFAb")]'):
try:
title_tag = eval_xpath_getindex(result, './/div[@style]', 0, default=None)
title_tag = eval_xpath_getindex(
result, './/a[contains(@class, "fuLhoc")]//span[contains(@class, "CVA68e")]', 0, default=None
)
if title_tag is None:
# this not one of the common google results *section*
logger.debug("ignoring item from the result_xpath list: missing title")
continue
title = extract_text(title_tag)
raw_url = result.get("href")
raw_url = eval_xpath_getindex(result, './/a[contains(@class, "fuLhoc")]/@href', 0, default=None)
if raw_url is None:
logger.debug(
'ignoring item from the result_xpath list: missing url of title "%s"',
@@ -391,30 +358,19 @@ def response(resp: "SXNG_Response"):
)
continue
if raw_url.startswith('/url?q='):
url = unquote(raw_url[7:].split("&sa=U")[0]) # remove the google redirector
else:
url = raw_url
content_nodes = eval_xpath(result, '../..//div[contains(@class, "ilUpNd H66NU aSRlid")]')
for item in content_nodes:
for script in item.xpath(".//script"):
script.getparent().remove(script)
content = extract_text(content_nodes[0])
# Images that are NOT the favicon
xpath_image = eval_xpath_getindex(result, './/img', index=0, default=None)
thumbnail = None
if xpath_image is not None:
thumbnail = xpath_image.get("src")
if thumbnail.startswith("data:image"):
img_id = xpath_image.get("id")
if img_id:
thumbnail = data_image_map.get(img_id)
results.append({"url": url, "title": title, "content": content or '', "thumbnail": thumbnail})
url = unwrap_google_url(raw_url)
content = extract_text(
eval_xpath(result, './/div[contains(@class, "taTFJ")]//span[contains(@class, "FrIlee")]')
)
thumbnail = eval_xpath_getindex(result, './/img[contains(@src, "encrypted-tbn")]/@src', 0, default=None)
results.add(
results.types.MainResult(
url=url,
title=title or "",
content=content or "",
thumbnail=thumbnail or "",
)
)
except Exception as e: # pylint: disable=broad-except
logger.error(e, exc_info=True)
@@ -422,10 +378,8 @@ def response(resp: "SXNG_Response"):
# parse suggestion
for suggestion in eval_xpath_list(dom, suggestion_xpath):
# append suggestion
results.append({"suggestion": extract_text(suggestion)})
results.add(results.types.LegacyResult(suggestion=extract_text(suggestion)))
# return results
return results
@@ -458,14 +412,12 @@ skip_countries = [
]
def fetch_traits(engine_traits: EngineTraits, add_domains: bool = True):
def fetch_traits(engine_traits: EngineTraits):
"""Fetch languages from Google."""
# pylint: disable=import-outside-toplevel, too-many-branches
from searx.network import get # see https://github.com/searxng/searxng/issues/762
engine_traits.custom["supported_domains"] = {}
resp = get("https://www.google.com/preferences", timeout=5)
if not resp.ok:
raise RuntimeError("Response from Google preferences is not OK.")
@@ -516,22 +468,3 @@ def fetch_traits(engine_traits: EngineTraits, add_domains: bool = True):
# alias regions
engine_traits.regions["zh-CN"] = "HK"
# supported domains
if add_domains:
resp = get("https://www.google.com/supported_domains", timeout=5)
if not resp.ok:
raise RuntimeError("Response from Google supported domains is not OK.")
for domain in resp.text.split():
domain = domain.strip()
if not domain or domain in [
".google.com",
]:
continue
region = domain.split(".")[-1].upper()
engine_traits.custom["supported_domains"][region] = "www" + domain
if region == "HK":
# There is no google.cn, we use .com.hk for zh-CN
engine_traits.custom["supported_domains"]["CN"] = "www" + domain

185
searx/engines/google_cse.py Normal file
View File

@@ -0,0 +1,185 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Google Custom Search Engine"""
import datetime
import typing as t
from json import loads
from urllib.parse import urlencode
from searx.enginelib import EngineCache
from searx.exceptions import SearxEngineAPIException, SearxEngineTooManyRequestsException
from searx.network import get
from searx.result_types import EngineResults, Result, MainResult, Image
from searx.engines.google import fetch_traits # pylint: disable=unused-import
from searx.engines.google import filter_mapping, get_google_info
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
about = {
"website": "https://www.google.com",
"wikidata_id": "Q2233943",
"official_api_documentation": "https://developers.google.com/custom-search/docs/element",
"use_official_api": False,
"require_api_key": False,
"results": "JSONP",
"description": "Platform for creating custom search engines based on Google Search.",
}
categories = ["general", "web"]
paging = True
max_page = 5
page_size = 20
time_range_support = True
language_support = True
safesearch = True
GoogleCategType = t.Literal["", "image"]
google_categ: GoogleCategType = ""
"""Google CSE category. Set to ``""`` for web search."""
CX = "partner-pub-8993703457585266:4862972284" # blackle.com
CACHE: EngineCache
def setup(engine_settings: dict[str, t.Any]) -> bool:
global CACHE # pylint: disable=global-statement
if google_categ not in t.get_args(GoogleCategType):
raise ValueError("invalid google cse category: %s" % google_categ)
CACHE = EngineCache(engine_settings["name"])
return True
def _cse_token() -> dict[str, str]:
token: dict[str, str] = CACHE.get(CX)
if token:
return token
resp = get(f"https://www.google.com/cse/cse.js?cx={CX}", timeout=10)
if not resp.ok:
raise SearxEngineAPIException("failed to obtain cse token")
end = resp.text.rfind("});")
start = resp.text.rfind("({")
opts: dict[str, str] = loads(resp.text[start + 1 : end + 1])
cse_tok = opts.get("cse_token")
if not cse_tok:
raise SearxEngineAPIException("failed to obtain cse token")
exp = opts.get("exp")
token = {
"cse_tok": cse_tok,
"cselibv": opts.get("cselibVersion", ""),
"exp": ",".join(exp) if exp else "",
}
CACHE.set(CX, token, expire=3600)
return token
def _get_start_and_end_date_str(time_range: str) -> tuple[str, str]:
time_range_map = {"day": 1, "week": 7, "month": 30, "year": 365}
end_date = datetime.datetime.now()
start_date = end_date - datetime.timedelta(days=time_range_map[time_range])
return start_date.strftime("%Y%m%d"), end_date.strftime("%Y%m%d")
def request(query: str, params: "OnlineParams") -> None:
token = _cse_token()
google_info = get_google_info(params, traits)
args = {
"rsz": "filtered_cse",
"num": str(page_size),
"hl": google_info["params"]["hl"],
"cselibv": token["cselibv"],
"cx": CX,
"q": query,
"safe": filter_mapping[params["safesearch"]],
"cse_tok": token["cse_tok"],
"callback": "_",
"rurl": "",
"searchtype": google_categ,
}
if params["time_range"]:
start_date, end_date = _get_start_and_end_date_str(params["time_range"])
args["sort"] = f"date:r:{start_date}:{end_date}"
if google_info["country"] not in (None, "ZZ"):
args["gl"] = google_info["country"]
if token["exp"]:
args["exp"] = token["exp"]
start = (params["pageno"] - 1) * page_size
if start:
args["start"] = str(start)
params["url"] = "https://cse.google.com/cse/element/v1?" + urlencode(args)
params["cookies"] = google_info["cookies"]
params["headers"].update(google_info["headers"])
params["headers"]["Referer"] = "https://cse.google.com/"
def response(resp: "SXNG_Response") -> EngineResults:
json_resp = resp.text[resp.text.find("{") : resp.text.rfind("}") + 1]
data = loads(json_resp)
# not the real types, but a sufficient approximation
item: dict[str, str]
error: dict[str, str | int]
if error := data.get("error"):
message = error.get("message", "unknown error")
if error.get("code") == 429:
raise SearxEngineTooManyRequestsException(message=f"google cse: {message}")
raise SearxEngineAPIException(f"google cse: {message}")
results = EngineResults()
for item in data.get("results", []):
res: Result | None
if google_categ == "":
res = web_item(item)
elif google_categ == "image":
res = img_item(item)
if res is not None:
results.add(res)
return results
def web_item(item: dict[str, str]) -> MainResult | None:
url = item.get("unescapedUrl")
if not url:
return None
return MainResult(
url=url,
title=item.get("titleNoFormatting", ""),
content=item.get("contentNoFormatting", ""),
thumbnail=item.get("richSnippet", {}).get("cseThumbnail", {}).get("src", ""), # type: ignore
)
def img_item(item: dict[str, str]) -> Image | None:
resolution = ""
if item.get("height") and item.get("width"):
resolution = f"{item['width']}x{item['height']}"
return Image(
url=item["originalContextUrl"],
title=item.get("titleNoFormatting", ""),
content=item.get("contentNoFormatting", ""),
img_src=item["unescapedUrl"],
thumbnail_src=item["tbUrl"],
resolution=resolution,
img_format=item["fileFormat"].split("/")[-1],
)

View File

@@ -1,122 +1,75 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""This is the implementation of the Google Images engine using the internal
Google API used by the Google Go Android app.
"""Google Images: see :py:obj:`searx.engines.google`."""
This internal API offer results in
- JSON (``_fmt:json``)
- Protobuf_ (``_fmt:pb``)
- Protobuf_ compressed? (``_fmt:pc``)
- HTML (``_fmt:html``)
- Protobuf_ encoded in JSON (``_fmt:jspb``).
.. _Protobuf: https://en.wikipedia.org/wiki/Protocol_Buffers
"""
from urllib.parse import urlencode
from json import loads
import typing as t
from urllib.parse import parse_qs, unquote, urlparse
from searx.engines.google import fetch_traits # pylint: disable=unused-import
from searx.engines.google import (
get_google_info,
time_range_dict,
detect_google_sorry,
)
from searx.engines.google import google_request, wml_dom
from searx.result_types import EngineResults
from searx.utils import eval_xpath_list
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
# about
about = {
"website": 'https://images.google.com',
"wikidata_id": 'Q521550',
"official_api_documentation": 'https://developers.google.com/custom-search',
"website": "https://images.google.com",
"wikidata_id": "Q521550",
"official_api_documentation": "https://developers.google.com/custom-search",
"use_official_api": False,
"require_api_key": False,
"results": 'JSON',
"results": "XML",
}
# engine dependent config
categories = ['images', 'web']
categories = ["images", "web"]
paging = True
max_page = 50
"""`Google max 50 pages`_
"""Google supports up to 50 pages of results, see the `Google max_page discussion`_.
.. _Google max 50 pages: https://github.com/searxng/searxng/issues/2982
.. _Google max_page discussion: https://github.com/searxng/searxng/issues/2982
"""
time_range_support = True
language_support = True
safesearch = True
filter_mapping = {0: 'images', 1: 'active', 2: 'active'}
filter_mapping = {0: "images", 1: "active", 2: "active"}
def request(query, params):
"""Google-Image search request"""
google_info = get_google_info(params, traits)
query_url = (
'https://'
+ google_info['subdomain']
+ '/search'
+ '?'
+ urlencode({'q': query, 'tbm': "isch", **google_info['params'], 'asearch': 'isch'})
# don't urlencode this because wildly different AND bad results
# pagination uses Zero-based numbering
+ f'&async=_fmt:json,p:1,ijn:{params["pageno"] - 1}'
def request(query: str, params: "OnlineParams") -> None:
google_request(
query,
params,
{"tbm": "isch"},
eng_traits=traits,
safesearch_map=filter_mapping,
use_locales=False,
)
if params['time_range'] in time_range_dict:
query_url += '&' + urlencode({'tbs': 'qdr:' + time_range_dict[params['time_range']]})
if params['safesearch']:
query_url += '&' + urlencode({'safe': filter_mapping[params['safesearch']]})
params['url'] = query_url
params['cookies'] = google_info['cookies']
params['headers'].update(google_info['headers'])
# this ua will allow getting ~50 results instead of 10. #1641
params['headers']['User-Agent'] = (
'NSTN/3.60.474802233.release Dalvik/2.1.0 (Linux; U; Android 12;' f' {google_info.get("country", "US")}) gzip'
def response(resp: "SXNG_Response") -> EngineResults:
results = EngineResults()
dom = wml_dom(resp)
for link in eval_xpath_list(dom, '//a[contains(@href, "/imgres?")]'):
qs = parse_qs(urlparse(link.get("href", "")).query)
img_src = qs.get("imgurl", [""])[0]
url = qs.get("imgrefurl", [""])[0]
if not img_src or not url:
continue
width, height = qs.get("w", [""])[0], qs.get("h", [""])[0]
tbnid = qs.get("tbnid", [""])[0]
results.add(
results.types.Image(
url=url,
title=unquote(urlparse(img_src).path.rsplit("/", 1)[-1]) or urlparse(url).netloc,
img_src=img_src,
thumbnail_src=f"https://encrypted-tbn0.gstatic.com/images?q=tbn:{tbnid}",
resolution=f"{width} x {height}" if width and height else "",
)
)
return params
def response(resp):
"""Get response from google's search request"""
results = []
detect_google_sorry(resp)
json_start = resp.text.find('{"ischj":')
json_data = loads(resp.text[json_start:])
for item in json_data["ischj"].get("metadata", []):
result_item = {
'url': item["result"]["referrer_url"],
'title': item["result"]["page_title"],
'content': item["text_in_grid"]["snippet"],
'source': item["result"]["site_title"],
'resolution': f'{item["original_image"]["width"]} x {item["original_image"]["height"]}',
'img_src': item["original_image"]["url"],
'thumbnail_src': item["thumbnail"]["url"],
'template': 'images.html',
}
author = item["result"].get('iptc', {}).get('creator')
if author:
result_item['author'] = ', '.join(author)
copyright_notice = item["result"].get('iptc', {}).get('copyright_notice')
if copyright_notice:
result_item['source'] += ' | ' + copyright_notice
freshness_date = item["result"].get("freshness_date")
if freshness_date:
result_item['source'] += ' | ' + freshness_date
file_size = item.get('gsa', {}).get('file_size')
if file_size:
result_item['source'] += ' (%s)' % file_size
results.append(result_item)
return results

View File

@@ -1,324 +1,91 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""This is the implementation of the Google News engine.
"""Google News: see :py:obj:`searx.engines.google`."""
Google News has a different region handling compared to Google WEB.
- the ``ceid`` argument has to be set (:py:obj:`ceid_list`)
- the hl_ argument has to be set correctly (and different to Google WEB)
- the gl_ argument is mandatory
If one of this argument is not set correctly, the request is redirected to
CONSENT dialog::
https://consent.google.com/m?continue=
The google news API ignores some parameters from the common :ref:`google API`:
- num_ : the number of search results is ignored / there is no paging all
results for a query term are in the first response.
- save_ : is ignored / Google-News results are always *SafeSearch*
.. _hl: https://developers.google.com/custom-search/docs/xml_results#hlsp
.. _gl: https://developers.google.com/custom-search/docs/xml_results#glsp
.. _num: https://developers.google.com/custom-search/docs/xml_results#numsp
.. _save: https://developers.google.com/custom-search/docs/xml_results#safesp
"""
import typing as t
import json
import base64
from urllib.parse import urlencode
from lxml import html
import babel
from searx import locales
from searx.engines.google import fetch_traits # pylint: disable=unused-import
from searx.engines.google import google_request, unwrap_google_url, wml_dom
from searx.result_types import EngineResults
from searx.utils import (
eval_xpath,
eval_xpath_list,
eval_xpath_getindex,
eval_xpath_list,
extract_text,
)
from searx.engines.google import fetch_traits as _fetch_traits # pylint: disable=unused-import
from searx.engines.google import (
get_google_info,
detect_google_sorry,
)
from searx.enginelib.traits import EngineTraits
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
# about
about = {
"website": "https://news.google.com",
"website": "https://www.google.com",
"wikidata_id": "Q12020",
"official_api_documentation": "https://developers.google.com/custom-search",
"use_official_api": False,
"require_api_key": False,
"results": "HTML",
"results": "XML",
}
# engine dependent config
categories = ["news"]
paging = False
paging = True
max_page = 50
"""Google supports up to 50 pages of results, see the `Google max_page discussion`_.
.. _Google max_page discussion: https://github.com/searxng/searxng/issues/2982
"""
time_range_support = False
language_support = True
# Google-News results are always *SafeSearch*. Option 'safesearch' is set to
# False here.
#
# safesearch : results are identical for safesearch=0 and safesearch=2
safesearch = True
base_url: str = "https://news.google.com"
safesearch = False
def request(query: str, params: "OnlineParams") -> None:
"""Google-News search request"""
sxng_locale = params.get("searxng_locale", "en-US")
ceid: str = locales.get_engine_locale(
sxng_locale, traits.custom["ceid"], default="US:en"
) # pyright: ignore[reportAssignmentType]
google_info = get_google_info(params, traits)
google_info["subdomain"] = "news.google.com" # google news has only one domain
ceid_region, ceid_lang = ceid.split(":")
ceid_lang, ceid_suffix = (
ceid_lang.split(":")
+ [
"",
]
)[:2]
google_info["params"]["hl"] = ceid_lang
if ceid_suffix and ceid_suffix not in ["Hans", "Hant"]:
if ceid_region.lower() == ceid_lang:
google_info["params"]["hl"] = ceid_lang + "-" + ceid_region
else:
google_info["params"]["hl"] = ceid_lang + "-" + ceid_suffix
elif ceid_region.lower() != ceid_lang:
if ceid_region in ["AT", "BE", "CH", "IL", "SA", "IN", "BD", "PT"]:
google_info["params"]["hl"] = ceid_lang
else:
google_info["params"]["hl"] = ceid_lang + "-" + ceid_region
google_info["params"]["lr"] = "lang_" + ceid_lang.split("-")[0]
google_info["params"]["gl"] = ceid_region
query_url = (
"https://"
+ google_info["subdomain"]
+ "/search?"
+ urlencode(
{"q": query, **google_info["params"]},
)
# ceid includes a ':' character which must not be urlencoded
+ ("&ceid=%s" % ceid)
google_request(
query,
params,
{"tbm": "nws"},
eng_traits=traits,
use_time_range=False,
use_safesearch=False,
use_locales=False,
)
params["url"] = query_url
params["cookies"] = google_info["cookies"]
params["headers"].update(google_info["headers"])
def _span_text(link, css_class: str):
return extract_text(
eval_xpath_getindex(link, f'.//span[contains(@class, "{css_class}")]', 0, default=None),
allow_none=True,
)
def response(resp: "SXNG_Response") -> EngineResults:
"""Get response from google's search request"""
res = EngineResults()
detect_google_sorry(resp)
# convert the text to dom
dom = html.fromstring(resp.text)
for result in eval_xpath_list(dom, "//div[@jslog and @data-n-tid and @jsdata]"):
url: str = eval_xpath_getindex(result, "./a[@target='_blank']/@href", 0, default=0)
if not url:
continue
if url.startswith("./"):
url = base_url + url[1:]
# The real URL is often encoded in the "jslog" attribute
jslog: str | None = eval_xpath_getindex(result, "./a[@target='_blank']/@jslog", 0, default=None)
# Try to extract the real URL from jslog
real_url: str | None = None
if jslog:
# jslog format is usually: "95014; 5:<base64>; track:click,vis". We
# want the second part (index 1) after splitting by ";"
parts: list[str] = jslog.split(";")
if len(parts) > 1:
b64_data: str = parts[1].split(":")[-1].strip()
# Pad base64 if necessary
b64_data += "=" * (-len(b64_data) % 4)
decoded_data: list[str | None] = json.loads(base64.b64decode(b64_data).decode("utf-8"))
# The URL is typically the last element in the decoded array
if (
isinstance(decoded_data, list)
and isinstance(decoded_data[-1], str)
and decoded_data[-1].startswith("http")
):
real_url = decoded_data[-1]
if real_url:
url = real_url
else:
logger.error(f"no real-url found: {url}")
results = EngineResults()
seen = set()
for link in eval_xpath_list(wml_dom(resp), '//a[contains(@href, "/url?q=")]'):
href = link.get("href")
if not href:
continue
title = extract_text(eval_xpath(result, "./h4")) or ""
url = unwrap_google_url(href)
if url in seen or "google.com/search" in url:
continue
# The pub_date is mostly a string like 'yesterday', not a real timezone
# date or time. Therefore we can't use publishedDate and place the
# *pub* sting into the content.
title = _span_text(link, "M3vVJe") or _span_text(link, "fuLhoc")
if not title:
continue
pub_date = extract_text(eval_xpath(result, ".//time"))
pub_origin = extract_text(eval_xpath(result, ".//div[contains(@class, 'vr1PYe')]"))
content = " / ".join([x for x in [pub_origin, pub_date] if x])
source = _span_text(link, "dXDvrc")
pub_date = _span_text(link, "YVIcad")
thumbnail = eval_xpath_getindex(link, './/img[contains(@src, "encrypted-tbn")]/@src', 0, default=None)
thumbnail: str = eval_xpath_getindex(result, ".//figure/img/@src", 0, default="")
if thumbnail and thumbnail.startswith("/"):
thumbnail = base_url + thumbnail
res.add(
res.types.MainResult(
seen.add(url)
results.add(
results.types.MainResult(
url=url,
title=title,
content=content,
thumbnail=thumbnail,
content=" / ".join(x for x in [source, pub_date] if x),
thumbnail=thumbnail or "",
)
)
return res
ceid_list = [
"AE:ar",
"AR:es-419",
"AT:de",
"AU:en",
"BD:bn",
"BE:fr",
"BE:nl",
"BG:bg",
"BR:pt-419",
"BW:en",
"CA:en",
"CA:fr",
"CH:de",
"CH:fr",
"CL:es-419",
"CN:zh-Hans",
"CO:es-419",
"CU:es-419",
"CZ:cs",
"DE:de",
"EE:et",
"EG:ar",
"ES:ca",
"ES:es",
"ET:en",
"FI:fi",
"FR:fr",
"GB:en",
"GH:en",
"GR:el",
"HK:zh-Hant",
"HU:hu",
"ID:en",
"ID:id",
"IE:en",
"IL:en",
"IL:he",
"IN:bn",
"IN:en",
"IN:gu",
"IN:hi",
"IN:ml",
"IN:mr",
"IN:pa",
"IN:ta",
"IN:te",
"IT:it",
"JP:ja",
"KE:en",
"KR:ko",
"LB:ar",
"LT:lt",
"LV:en",
"LV:lv",
"MA:fr",
"MY:en",
"MY:ms",
"NA:en",
"NG:en",
"NL:nl",
"NO:no",
"NZ:en",
"PH:en",
"PK:en",
"PL:pl",
"RO:ro",
"RS:sr",
"RU:ru",
"SA:ar",
"SE:sv",
"SG:en",
"SI:sl",
"SK:sk",
"SN:fr",
"TH:th",
"TR:tr",
"TZ:en",
"UA:ru",
"UA:uk",
"UG:en",
"US:en",
"VN:vi",
"ZA:en",
"ZW:en",
]
"""List of region/language combinations supported by Google News. Values of the
``ceid`` argument of the Google News REST API."""
_skip_values = [
"ET:en", # english (ethiopia)
"ID:en", # english (indonesia)
"LV:en", # english (latvia)
]
_ceid_locale_map = {"NO:no": "nb-NO"}
def fetch_traits(engine_traits: EngineTraits):
_fetch_traits(engine_traits, add_domains=False)
engine_traits.custom["ceid"] = {}
for ceid in ceid_list:
if ceid in _skip_values:
continue
region, lang = ceid.split(":")
x = lang.split("-")
if len(x) > 1:
if x[1] not in ["Hant", "Hans"]:
lang = x[0]
sxng_locale = _ceid_locale_map.get(ceid, lang + "-" + region)
try:
locale = babel.Locale.parse(sxng_locale, sep="-")
except babel.UnknownLocaleError:
print("ERROR: %s -> %s is unknown by babel" % (ceid, sxng_locale))
continue
engine_traits.custom["ceid"][locales.region_tag(locale)] = ceid
return results

View File

@@ -77,8 +77,6 @@ def request(query: str, params: "OnlineParams") -> None:
"""Google-Scholar search request"""
google_info = get_google_info(params, traits)
# subdomain is: scholar.google.xy
google_info["subdomain"] = google_info["subdomain"].replace("www.", "scholar.")
args = {
"q": query,
@@ -89,7 +87,7 @@ def request(query: str, params: "OnlineParams") -> None:
}
args.update(time_range_args(params))
params["url"] = "https://" + google_info["subdomain"] + "/scholar?" + urlencode(args)
params["url"] = "https://scholar.google.com/scholar?" + urlencode(args)
params["cookies"] = google_info["cookies"]
params["headers"].update(google_info["headers"])

View File

@@ -1,185 +1,87 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""This is the implementation of the Google Videos engine.
"""Google Videos: see :py:obj:`searx.engines.google`."""
.. admonition:: Content-Security-Policy (CSP)
This engine needs to allow images from the `data URLs`_ (prefixed with the
``data:`` scheme)::
Header set Content-Security-Policy "img-src 'self' data: ;"
.. _data URLs:
https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs
"""
import re
from urllib.parse import urlencode, urlparse, parse_qs, unquote
from lxml import html
from searx.utils import (
eval_xpath_list,
eval_xpath_getindex,
extract_text,
)
import typing as t
from searx.engines.google import fetch_traits # pylint: disable=unused-import
from searx.engines.google import (
get_google_info,
time_range_dict,
filter_mapping,
suggestion_xpath,
detect_google_sorry,
ui_async,
from searx.engines.google import google_request, unwrap_google_url, wml_dom
from searx.result_types import EngineResults
from searx.utils import (
eval_xpath_getindex,
eval_xpath_list,
extract_text,
get_embeded_stream_url,
parse_duration_string,
)
from searx.utils import get_embeded_stream_url
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
# about
about = {
"website": 'https://www.google.com',
"wikidata_id": 'Q219885',
"official_api_documentation": 'https://developers.google.com/custom-search',
"website": "https://www.google.com",
"wikidata_id": "Q219885",
"official_api_documentation": "https://developers.google.com/custom-search",
"use_official_api": False,
"require_api_key": False,
"results": 'HTML',
"results": "XML",
}
# engine dependent config
categories = ['videos', 'web']
categories = ["videos", "web"]
paging = True
max_page = 50
"""Google supports up to 50 pages of results, see the `Google max_page discussion`_.
.. _Google max_page discussion: https://github.com/searxng/searxng/issues/2982
"""
language_support = True
time_range_support = True
safesearch = True
# =26;[3,"dimg_ZNMiZPCqE4apxc8P3a2tuAQ_137"]a87;data:image/jpeg;base64,/9j/4AAQSkZJRgABA
# ...6T+9Nl4cnD+gr9OK8I56/tX3l86nWYw//2Q==26;
RE_DATA_IMAGE = re.compile(r'"(dimg_[^"]*)"[^;]*;(data:image[^;]*;[^;]*);?')
def parse_data_images(text: str):
data_image_map = {}
for img_id, data_image in RE_DATA_IMAGE.findall(text):
end_pos = data_image.rfind("=")
if end_pos > 0:
data_image = data_image[: end_pos + 1]
data_image_map[img_id] = data_image
logger.debug("data:image objects --> %s", list(data_image_map.keys()))
return data_image_map
def request(query, params):
"""Google-Video search request"""
google_info = get_google_info(params, traits)
start = (params['pageno'] - 1) * 10
query_url = (
'https://'
+ google_info['subdomain']
+ '/search'
+ "?"
+ urlencode(
{
'q': query,
'tbm': "vid",
'start': start,
**google_info['params'],
'asearch': 'arc',
'async': ui_async(start),
}
)
def request(query: str, params: "OnlineParams") -> None:
google_request(
query,
params,
{"tbm": "vid"},
eng_traits=traits,
use_locales=False,
)
if params['time_range'] in time_range_dict:
query_url += '&' + urlencode({'tbs': 'qdr:' + time_range_dict[params['time_range']]})
if 'safesearch' in params:
query_url += '&' + urlencode({'safe': filter_mapping[params['safesearch']]})
params['url'] = query_url
params['cookies'] = google_info['cookies']
params['headers'].update(google_info['headers'])
return params
def response(resp: "SXNG_Response") -> EngineResults:
results = EngineResults()
def response(resp):
"""Get response from google's search request"""
results = []
detect_google_sorry(resp)
data_image_map = parse_data_images(resp.text)
# convert the text to dom
dom = html.fromstring(resp.text)
result_divs = eval_xpath_list(dom, '//div[contains(@class, "MjjYud")]')
# parse results
for result in result_divs:
for result in eval_xpath_list(wml_dom(resp), '//div[contains(@class, "zMzFAb")]'):
title = extract_text(
eval_xpath_getindex(result, './/h3[contains(@class, "LC20lb")] | .//div[@role="heading"]', 0, default=None),
eval_xpath_getindex(result, './/span[contains(@class, "CVA68e")]', 0, default=None),
allow_none=True,
)
url = eval_xpath_getindex(
result, './/a[@jsname="UWckNb"]/@href | .//a[contains(@href, "/url?q=")]/@href', 0, default=None
raw_url = eval_xpath_getindex(result, './/a[contains(@class, "fuLhoc")]/@href', 0, default=None)
if not title or not raw_url:
continue
url = unwrap_google_url(raw_url)
thumbnail = eval_xpath_getindex(result, './/img[contains(@class, "SygO9d")]/@src', 0, default="")
if "/default.jpg" in thumbnail:
thumbnail = thumbnail.split("?")[0].replace("/default.jpg", "/hqdefault.jpg")
length = None
for span in eval_xpath_list(result, './/span[contains(@class, "YVIcad")]'):
length = parse_duration_string(extract_text(span) or "")
if length:
break
results.add(
results.types.MainResult(
url=url,
title=title,
thumbnail=thumbnail,
length=length,
iframe_src=get_embeded_stream_url(url) or "",
template="videos.html",
)
if url and url.startswith('/url?q='):
url = unquote(url[7:].split('&sa=U')[0])
content = extract_text(
eval_xpath_getindex(result, './/div[contains(@class, "ITZIwc")]', 0, default=None), allow_none=True
)
pub_info = extract_text(
eval_xpath_getindex(
result, './/div[contains(@class, "gqF9jc")] | .//div[contains(@class, "WRu9Cd")]', 0, default=None
),
allow_none=True,
)
# Broader XPath to find any <img> element
thumbnail = eval_xpath_getindex(result, './/img/@src', 0, default=None)
duration = extract_text(
eval_xpath_getindex(result, './/span[contains(@class, "k1U36b")]', 0, default=None), allow_none=True
)
video_id = eval_xpath_getindex(result, './/div[@jscontroller="rTuANe"]/@data-vid', 0, default=None)
# Fallback for video_id from URL if not found via XPath
if not video_id and url and 'youtube.com' in url:
parsed_url = urlparse(url)
video_id = parse_qs(parsed_url.query).get('v', [None])[0]
# Handle thumbnail
if thumbnail and thumbnail.startswith('data:image'):
img_id = eval_xpath_getindex(result, './/img/@id', 0, default=None)
if img_id and img_id in data_image_map:
thumbnail = data_image_map[img_id]
else:
thumbnail = None
if not thumbnail and video_id:
thumbnail = f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg"
# Handle video embed URL
embed_url = None
if video_id:
embed_url = get_embeded_stream_url(f"https://www.youtube.com/watch?v={video_id}")
elif url:
embed_url = get_embeded_stream_url(url)
# Only append results with valid title and url
if title and url:
results.append(
{
'url': url,
'title': title,
'content': content or '',
'author': pub_info,
'thumbnail': thumbnail,
'length': duration,
'iframe_src': embed_url,
'template': 'videos.html',
}
)
# parse suggestion
for suggestion in eval_xpath_list(dom, suggestion_xpath):
results.append({'suggestion': extract_text(suggestion)})
return results

View File

@@ -14,8 +14,11 @@ from urllib.parse import urlencode
import typing as t
from searx.exceptions import SearxEngineAccessDeniedException
from searx.enginelib import EngineCache
from searx.network import get
from searx.exceptions import SearxEngineAPIException, SearxEngineAccessDeniedException
from searx.result_types import EngineResults
from searx.utils import gen_useragent
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
@@ -38,14 +41,46 @@ heexy_categ = "web"
"""Category to search in. Can be either "web" or "image"."""
base_url = "https://seapi.heexy.org"
base_url = "https://heexy.org"
api_url = "https://seapi.heexy.org"
safe_search_map = {0: "off", 1: "on", 2: "on"}
CACHE: EngineCache
"""Cache for storing the ``X-Data-Cacheft`` token (acts like an API key)."""
def setup(engine_settings: dict[str, t.Any]) -> bool:
global CACHE # pylint: disable=global-statement
def init(_):
if heexy_categ not in ("web", "image"):
raise ValueError("invalid search category: %s" % heexy_categ)
CACHE = EngineCache(engine_settings["name"])
return True
def _get_api_token(query: str) -> str:
"""The API token is independent of the search query. We just need any query
to obtain it initially, and don't hardcode it here to decrease chances of
getting blocked. The token must be passed as ``X-Data-Cacheft`` header."""
cached_token: str = CACHE.get("token")
if cached_token:
return cached_token
resp = get(
f"{base_url}/search?q={query}", headers={"User-Agent": gen_useragent(), "Accept-Language": "en-US,en:q=0.9"}
)
if not resp.ok:
raise SearxEngineAPIException("failed to obtain request token: invalid response code")
token = resp.cookies["cacheft"]
if not token:
raise SearxEngineAPIException("failed to obtain request token: no token found")
CACHE.set("token", token, expire=3 * 60)
return token
def request(query: str, params: "OnlineParams") -> None:
args = {
@@ -56,8 +91,10 @@ def request(query: str, params: "OnlineParams") -> None:
if params["searxng_locale"] != "all":
args["lang"] = params["searxng_locale"].split("-")[0]
params["url"] = f"{base_url}/search/{heexy_categ}?{urlencode(args)}"
params["headers"]["Origin"] = base_url
params["url"] = f"{api_url}/search/{heexy_categ}?{urlencode(args)}"
params["headers"]["Origin"] = api_url
params["cookies"]["cacheft"] = _get_api_token(query)
def response(resp: "SXNG_Response"):

View File

@@ -91,7 +91,7 @@ def response(resp) -> EngineResults:
published_date = None
try:
published_date = datetime.strptime(entry["createdAt"], "%Y-%m-%dT%H:%M:%S.%fZ")
published_date = datetime.fromisoformat(entry["createdAt"].rstrip("Z"))
except (ValueError, TypeError):
pass

View File

@@ -43,7 +43,7 @@ def _result(video: dict[str, typing.Any], album_info: dict[str, typing.Any]):
release_time = album_info.get("releaseTime", {}).get("value")
if release_time:
try:
published_date = datetime.strptime(release_time, "%Y-%m-%d")
published_date = datetime.fromisoformat(release_time)
except (ValueError, TypeError):
pass

89
searx/engines/jina.py Normal file
View File

@@ -0,0 +1,89 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Jina is a search AI and part of Elastic, the company behind ElasticSearch.
The engine requires an API key, you can get one from the
`API dashboard <https://jina.ai/api-dashboard/>`_ without signup.
.. code:: yaml
- name: jina
engine: jina
shortcut: ji
api_key: "jina_..."
jina_engine: reader
inactive: false
By default, Jina's own index is used. You can change that by setting a different :py:obj:`jina_engine`.
"""
import typing as t
from urllib.parse import urlencode
from dateutil import 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://jina.ai",
"wikidata_id": None,
"official_api_documentation": "https://s.jina.ai/docs",
"use_official_api": True,
"require_api_key": True,
"results": "JSON",
}
categories = ["general"]
paging = True
jina_engine = "reader"
"""Search mode. Currently supported values are 'reader', 'google' and 'bing'."""
base_url = "https://s.jina.ai"
api_key: str | None = None
def setup(_):
if not api_key:
raise ValueError("missing api key")
def request(query: str, params: "OnlineParams"):
# setting 'no-content' pushes the response time down to a third
args = {"q": query, "page": params["pageno"], "engine": jina_engine, "respondWith": "no-content"}
params["url"] = f"{base_url}/?{urlencode(args)}"
params["headers"].update(
{
"Accept": "application/json",
"Authorization": f"Bearer {api_key}",
}
)
def response(resp: "SXNG_Response"):
res = EngineResults()
json_resp: dict[str, t.Any] = resp.json()
result: dict[str, str]
for result in json_resp["data"]:
published_date = None
if result.get("date"):
try:
published_date = parser.parse(result["date"])
except parser.ParserError:
pass
res.add(
res.types.MainResult(
url=result["url"],
title=result["title"],
content=result["description"],
publishedDate=published_date,
)
)
return res

View File

@@ -42,15 +42,14 @@ To enable Kagi, add the following to the ``engines`` seciton of
.. _Api Portal: https://help.kagi.com/kagi/api/overview.html
"""
from datetime import datetime, timedelta
import typing as t
import html
from searx.extended_types import SXNG_Response
from searx.result_types import EngineResults
from searx.utils import parse_duration_string
from searx.utils import html_to_text, parse_duration_string
if t.TYPE_CHECKING:
from searx.search.processors import OnlineParams
@@ -77,7 +76,12 @@ kagi_categ: t.Literal["search", "images", "news", "videos"] = "search"
base_url = "https://kagi.com"
safe_search_map = {0: False, 1: True, 2: True}
time_range_to_days_map: dict[TimeRangeType, int] = {"day": 1, "week": 7, "month": 30, "year": 365}
time_range_to_days_map: dict[TimeRangeType, int] = {
"day": 1,
"week": 7,
"month": 30,
"year": 365,
}
api_key = ""
"""Kagi API key. Required for using this engine."""
@@ -135,9 +139,13 @@ def response(resp: "SXNG_Response") -> EngineResults:
if kagi_categ in ("images", "videos"):
# the JSON key is "image" for "images" and "video" for "videos"
json_results = json_data["data"][kagi_categ[:-1]]
json_results = json_data["data"].get(kagi_categ[:-1])
else:
json_results = json_data["data"][kagi_categ]
json_results = json_data["data"].get(kagi_categ)
# if no results were found, the response doesn't contain the results field
if not json_results:
return res
for result in json_results:
published_date: datetime | None = None
@@ -148,8 +156,8 @@ def response(resp: "SXNG_Response") -> EngineResults:
res.add(
res.types.MainResult(
url=result["url"],
title=html.unescape(result["title"]),
content=html.unescape(result["snippet"]),
title=html_to_text(result.get("title", "no title available")),
content=html_to_text(result.get("snippet", "")),
thumbnail=result.get("image", {}).get("url") or "",
publishedDate=published_date,
)
@@ -158,15 +166,15 @@ def response(resp: "SXNG_Response") -> EngineResults:
res.add(
res.types.Image(
url=result["url"],
title=html.unescape(result.get("title")),
title=html_to_text(result.get("title", "no title available")),
img_src=result.get("image", {}).get("url"),
resolution=f"{result['image']['width']}x{result['image']['height']}",
resolution=f"{result.get('image', {}).get('width')}x{result.get('image', {}).get('height')}",
thumbnail_src=result.get("props", {}).get("thumbnail", {}).get("url"),
)
)
elif kagi_categ == "videos":
length: timedelta | None = None
if result["props"].get("duration"):
if result.get("props", {}).get("duration"):
length = parse_duration_string(result["props"]["duration"])
res.add(
@@ -174,11 +182,11 @@ def response(resp: "SXNG_Response") -> EngineResults:
{
"template": "videos.html",
"url": result["url"],
"title": html.unescape(result["title"]),
"content": html.unescape(result["snippet"]),
"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["props"].get("creator_name"),
"author": result.get("props", {}).get("creator_name"),
"length": length,
}
)

66
searx/engines/keenable.py Normal file
View File

@@ -0,0 +1,66 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Keenable is a fast web search with keyless mode support"""
import typing as t
from datetime import datetime
from searx.extended_types import SXNG_Response
from searx.result_types import EngineResults
from searx.utils import searxng_useragent
if t.TYPE_CHECKING:
from searx.search.processors import OnlineParams
about = {
"website": "https://keenable.ai",
"official_api_documentation": "https://docs.keenable.ai",
"use_official_api": True,
"require_api_key": False,
"results": "JSON",
}
api_key = ""
""" Optional API Key. You can create a key at `the official website
<https://keenable.ai/signup>'_ if you need higher rate limits."""
categories = ["general"]
base_url = "https://api.keenable.ai"
keenable_mode = "pro"
def request(query: str, params: "OnlineParams"):
if api_key:
params["url"] = f"{base_url}/v1/search"
params["headers"]["X-API-KEY"] = api_key
else:
params["url"] = f"{base_url}/v1/search/public"
params["method"] = "POST"
params["headers"]["X-Keenable-Title"] = searxng_useragent()
params["json"] = {"query": query, "mode": keenable_mode}
def response(resp: "SXNG_Response") -> EngineResults:
res = EngineResults()
results: list[dict[str, str]] = resp.json()["results"] # type: ignore[reportAny]
for result in results:
published = None
pub = result.get("published_at")
if pub:
try:
published = datetime.fromisoformat(pub.rstrip("Z"))
except ValueError:
pass
res.add(
res.types.MainResult(
url=result["url"],
title=result["title"],
content=result["description"] or result["snippet"],
publishedDate=published,
)
)
return res

View File

@@ -92,7 +92,7 @@ def _get_communities(json):
'title': result['community']['title'],
'content': markdown_to_text(result['community'].get('description', '')),
'thumbnail': result['community'].get('icon', result['community'].get('banner')),
'publishedDate': datetime.strptime(counts['published'][:19], '%Y-%m-%dT%H:%M:%S'),
'publishedDate': datetime.fromisoformat(counts['published'][:19]),
'metadata': metadata,
}
)
@@ -141,7 +141,7 @@ def _get_posts(json):
'title': result['post']['name'],
'content': content,
'thumbnail': thumbnail,
'publishedDate': datetime.strptime(result['post']['published'][:19], '%Y-%m-%dT%H:%M:%S'),
'publishedDate': datetime.fromisoformat(result['post']['published'][:19]),
'metadata': metadata,
}
)
@@ -170,7 +170,7 @@ def _get_comments(json):
'url': result['comment']['ap_id'],
'title': result['post']['name'],
'content': markdown_to_text(result['comment']['content']),
'publishedDate': datetime.strptime(result['comment']['published'][:19], '%Y-%m-%dT%H:%M:%S'),
'publishedDate': datetime.fromisoformat(result['comment']['published'][:19]),
'metadata': metadata,
}
)

62
searx/engines/magnific.py Normal file
View File

@@ -0,0 +1,62 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Magnific_ is a database for images.
.. _Magnific: https://www.magnific.com
"""
from urllib.parse import urlencode
import typing as t
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.magnific.com",
"wikidata_id": "Q104211654",
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": "JSON",
}
base_url = "https://www.magnific.com"
categories = ["images"]
paging = True
free_images_only = True
"""
Whether to only load images that may be used for free, without a Magnific account.
"""
def request(query: str, params: "OnlineParams") -> None:
args = {"term": query, "filters[ai-generated][excluded]": 1, "page": params["pageno"], "locale": "en"}
if free_images_only:
args["filters[license]"] = "free"
params["headers"]["Referer"] = f"{base_url}/search"
params["url"] = f"{base_url}/api/regular/search?{urlencode(args)}"
def response(resp: "SXNG_Response"):
res = EngineResults()
result: dict[str, t.Any] # TBH: dict[str, t.Any]
for result in resp.json()["items"]:
res.add(
res.types.Image(
title=result["name"],
url=result["url"],
thumbnail_src=result["preview"]["url"],
img_src=result["preview"]["url"],
resolution=f"{result['preview']['width']}x{result['preview']['height']}",
)
)
return res

View File

@@ -6,6 +6,27 @@ Lofgren .
.. _Marginalia Search:
https://about.marginalia-search.com/
.. _marginalia filters:
Marginalia Filters
=================
Custom filters enable server-side customization of Marginalia search results.
Filter definitions are written in XML and scoped to an API key. Filters can
not be used with the public API key ``public``. The
`Marginalia Filter Editor`_ can be used to create custom filters with a GUI.
Alternatively, filters can be written manually in XML. To associate a filter
definition with an API key, upload the XML data to the ``/filter/<NAME>`` API
endpoint, where ``<NAME>`` is the name for the newly created filter. For more
information, see the `Marginalia filters announcement blogpost`_ and the
official `Marginalia API documentation`_.
.. _Marginalia Filter Editor: https://marginalia-search.com/filters
.. _Marginalia filters announcement blogpost: https://www.marginalia.nu/log/a_127_index_filtering/
.. _Marginalia API documentation: https://about.marginalia-search.com/article/api/
Configuration
=============
@@ -13,6 +34,10 @@ The engine has the following required settings:
- :py:obj:`api_key`
The engine has the following optional settings:
- :py:obj:`filter_name`
You can configure a Marginalia engine by:
.. code:: yaml
@@ -21,6 +46,7 @@ You can configure a Marginalia engine by:
engine: marginalia
shortcut: mar
api_key: ...
filter_name: ...
Implementations
===============
@@ -29,6 +55,8 @@ Implementations
import typing as t
from urllib.parse import urlencode
from searx.network import get
from searx.utils import searxng_useragent
from searx.result_types import EngineResults
from searx.extended_types import SXNG_Response
@@ -54,6 +82,8 @@ api_key = None
https://about.marginalia-search.com/article/api/
"""
filter_name: str | None = None
"""The name of the custom filter to apply to each search."""
class ApiSearchResult(t.TypedDict):
@@ -83,6 +113,25 @@ class ApiSearchResults(t.TypedDict):
results: list[ApiSearchResult]
def _marginalia_headers() -> dict[str, t.Any]:
return {
"User-Agent": searxng_useragent(),
"API-Key": api_key,
}
def _get_filter_names() -> list[str]:
resp = get(f"{base_url}/filter", headers=_marginalia_headers())
if resp.ok:
filter_names = resp.json()
else:
filter_names = []
if not isinstance(filter_names, list):
raise TypeError("marginalia api returned invalid filter list format")
return filter_names
def request(query: str, params: dict[str, t.Any]):
query_params = {
@@ -91,10 +140,11 @@ def request(query: str, params: dict[str, t.Any]):
"nsfw": min(params["safesearch"], 1),
"query": query,
}
if filter_name:
query_params["filter"] = filter_name
params["url"] = f"{base_url}/search?{urlencode(query_params)}"
params["headers"]["User-Agent"] = searxng_useragent()
params["headers"]["API-Key"] = api_key
params["headers"].update(_marginalia_headers())
def response(resp: SXNG_Response):
@@ -114,14 +164,18 @@ def response(resp: SXNG_Response):
return res
def init(engine_settings: dict[str, t.Any]):
def init(_: dict[str, t.Any]):
_api_key = engine_settings.get("api_key")
if not _api_key:
if not api_key:
logger.error("missing api_key: see https://about.marginalia-search.com/article/api")
return False
if _api_key == "public":
if api_key == "public":
logger.error("invalid api_key (%s): see https://about.marginalia-search.com/article/api", api_key)
elif filter_name:
filter_names: list[str] = _get_filter_names()
if filter_name not in filter_names:
logger.error(f"invalid value for filter_name: '{filter_name}'")
return False
return True

View File

@@ -60,7 +60,7 @@ def response(resp):
'title': result['username'] + f" ({result['followers_count']} followers)",
'content': result['note'],
'thumbnail': result.get('avatar'),
'publishedDate': datetime.strptime(result['created_at'][:10], "%Y-%m-%d"),
'publishedDate': datetime.fromisoformat(result['created_at'][:10]),
}
)
elif mastodon_type == "hashtags":

View File

@@ -99,21 +99,33 @@ def parse_general(data):
dom = html.fromstring(data)
for item in eval_xpath_list(dom, "//ul[contains(@class, 'lst_total')]/li[contains(@class, 'bx')]"):
thumbnail = None
for item in eval_xpath_list(dom, "//div[contains(@class, 'fds-web-normal-doc-root')]"):
thumbnail = extract_text(
eval_xpath(
item,
".//div[contains(@class, 'sds-comps-image') and not(contains(@class, 'sds-comps-image-circle'))]/img/@src",
)
)
title = extract_text(eval_xpath(item, ".//span[contains(@class, 'sds-comps-text-type-headline1')]"))
url = None
try:
thumbnail = eval_xpath_getindex(item, ".//div[contains(@class, 'thumb_single')]//img/@data-lazysrc", 0)
url = eval_xpath_getindex(
item, ".//a[starts-with(@href, 'http') and not(contains(@href, 'keep.naver.com'))]/@href", 0
)
except (ValueError, TypeError, SearxEngineXPathException):
pass
content = extract_text(eval_xpath(item, ".//span[contains(@class, 'sds-comps-text-type-body1')]"))
if title and url:
results.add(
MainResult(
title=extract_text(eval_xpath(item, ".//a[contains(@class, 'link_tit')]")),
url=eval_xpath_getindex(item, ".//a[contains(@class, 'link_tit')]/@href", 0),
content=extract_text(
eval_xpath(item, ".//div[contains(@class, 'total_dsc_wrap')]//a[contains(@class, 'api_txt_lines')]")
),
thumbnail=thumbnail,
title=title,
url=url,
content=content or "",
thumbnail=thumbnail or "",
)
)
@@ -173,7 +185,7 @@ def parse_news(data):
title=title,
url=url,
content=content,
thumbnail=thumbnail,
thumbnail=thumbnail or "",
)
)
@@ -196,7 +208,7 @@ def parse_videos(data):
length = None
try:
length = parse_duration_string(extract_text(eval_xpath(item, ".//span[contains(@class, 'time')]")))
length = parse_duration_string(extract_text(eval_xpath(item, ".//span[contains(@class, 'time')]")) or "")
except (ValueError, TypeError):
pass

View File

@@ -0,0 +1,66 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Neocities_ is open source software for creating blogs.
.. _Neocities : https://github.com/neocities/neocities
"""
from urllib.parse import urlencode
import typing as t
from lxml import html
from searx.utils import eval_xpath, eval_xpath_list, extract_text
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from extended_types import SXNG_Response
from search.processors import OnlineParams
# Engine metadata
about = {
"website": "https://neocities.org/",
"wikidata_id": "Q17071099",
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": "HTML",
}
# Engine configuration
categories = ["general", "blogs"]
paging = True
# Search URL
base_url = "https://neocities.org"
results_xpath = "//div[@class='result-item']"
url_xpath = './/div[@class="result-url"]/a/@href'
title_xpath = './/h3[@class="result-title"]/a/text()'
content_xpath = './/p[@class="result-snippet"]//text()'
screenshot_xpath = './/a[@class="result-screenshot"]/img/@src'
def request(query: str, params: "OnlineParams") -> None:
query_params: dict[str, t.Any] = {"q": query}
if params['pageno'] > 1:
offset = (params["pageno"] - 1) * 100
query_params["start"] = offset
params["url"] = f"{base_url}/search?{urlencode(query_params)}"
def response(resp: "SXNG_Response") -> EngineResults:
results = EngineResults()
dom = html.fromstring(resp.text)
for result in eval_xpath_list(dom, results_xpath):
results.add(
results.types.MainResult(
url=extract_text(eval_xpath(result, url_xpath)),
title=extract_text(eval_xpath(result, title_xpath)) or "",
content=extract_text(eval_xpath(result, content_xpath)) or "",
thumbnail=base_url + (extract_text(eval_xpath(result, screenshot_xpath)) or ""),
)
)
return results

View File

@@ -0,0 +1,92 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Neosearch_ aims to be a privacy-first alternative to Google.
.. _Neosearch: https://neosearch.org/About
"""
from json import loads
import typing as t
from urllib.parse import urlencode
from searx.extended_types import SXNG_Response
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from searx.enginelib.traits import EngineTraits
from searx.search.processors import OnlineParams
traits: EngineTraits
about = {
"website": "https://neosearch.org",
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": "JSON",
}
base_url = "https://neosearch.org"
categories = ["general"]
paging = False
def request(query: str, params: "OnlineParams"):
args = {"q": query, "generate": "auto"}
countrycode = params["searxng_locale"].split("-")[-1].upper()
if countrycode in traits.custom["countrycodes"]:
args["loc"] = countrycode
params["url"] = f"{base_url}/search?{urlencode(args)}"
def response(resp: "SXNG_Response") -> EngineResults:
res = EngineResults()
# first line contains something like `{"location": "de"}`
# second line contains the actual results
json_resp = loads(resp.text.splitlines()[-1])
for lens in json_resp["lenses"]:
for category in lens["categories"]:
for result in category["links"]:
if not result["url"]:
continue
res.add(
res.types.MainResult(
url=result["url"],
title=result["title"],
content=result["snippet"] or result["description"],
)
)
for suggestion in json_resp.get("suggestions", []):
res.add(res.types.LegacyResult(suggestion=suggestion))
return res
def fetch_traits(engine_traits: "EngineTraits") -> None:
# pylint: disable=import-outside-toplevel
from searx.network import get
from searx.utils import extr, js_obj_str_to_python
from babel.core import get_global
resp = get(base_url)
locations_js_raw = extr(resp.text, "const LOCATIONS = ", ";")
if not locations_js_raw:
raise RuntimeError("failed to find locations in neosearch HTML response")
locations: list[dict[str, str]] = js_obj_str_to_python(locations_js_raw)
babel_reg_list = get_global("territory_languages").keys()
countrycodes: list[str] = []
for loc in locations:
_reg = loc["code"].upper()
if _reg not in babel_reg_list:
print(f"ERROR: region tag {_reg} is unknown by babel")
continue
countrycodes.append(_reg)
countrycodes.sort()
engine_traits.custom["countrycodes"] = countrycodes

View File

@@ -44,7 +44,7 @@ def response(resp) -> EngineResults:
cve_id = item["cve"]["id"]
description = item["cve"]["descriptions"][0]["value"]
date = datetime.strptime(item["cve"]["published"], "%Y-%m-%dT%H:%M:%S.%f")
date = datetime.fromisoformat(item["cve"]["published"])
# Extract severity (Low, Medium, High, or Critical) and CVSS score, if available
info = item["cve"].get("metrics", {}).get("cvssMetricV31", [{}])[0].get("cvssData", {})

View File

@@ -76,7 +76,7 @@ def response(resp):
release_time = item["release_time"]
duration = item["duration"]
release_date = datetime.strptime(release_time.split("T")[0], "%Y-%m-%d")
release_date = datetime.fromisoformat(release_time.split("T")[0])
formatted_date = datetime.fromtimestamp(release_date.timestamp())
url = f"https://odysee.com/{name}:{claim_id}"

63
searx/engines/picjumbo.py Normal file
View File

@@ -0,0 +1,63 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Picjumbo_ provides free stock photos.
.. _Picjumbo: https://picjumbo.com
"""
from urllib.parse import urlparse, urlunparse
import typing as t
from lxml import html
from searx.result_types import EngineResults
from searx.utils import eval_xpath, eval_xpath_list, extract_text
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
about = {
"website": "https://picjumbo.com",
"wikidata_id": None,
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": "HTML",
}
base_url = "https://picjumbo.com"
categories = ["images"]
paging = True
def request(query: str, params: "OnlineParams") -> None:
params["url"] = f"{base_url}/search/{query}/page/{params['pageno']}"
def _get_max_res_url(url: str) -> str:
"""Get the maximum resolution of the image based on the thumbnail URL."""
parsed_url = urlparse(url)
max_res_url = parsed_url._replace(query="w=10000&quality=100")
return urlunparse(max_res_url)
def response(resp: "SXNG_Response"):
res = EngineResults()
doc = html.fromstring(resp.text)
for result in eval_xpath_list(doc, "//div[contains(@class, 'photo_query')]/div[contains(@class, 'photo_item')]"):
thumbnail = extract_text(eval_xpath(result, ".//img[contains(@class, 'image')]/@src")) or ""
res.add(
res.types.Image(
url=extract_text(eval_xpath(result, ".//a[contains(@class, 'image')]/@href")) or "",
title=extract_text(eval_xpath(result, ".//h3")) or "",
content=extract_text(eval_xpath(result, ".//meta[@itemprop='keywords']/@content")) or "",
thumbnail_src=thumbnail,
img_src=_get_max_res_url(thumbnail),
)
)
return res

View File

@@ -54,7 +54,7 @@ def response(resp: "SXNG_Response"):
title=result["title"],
content=result["description"],
thumbnail=result["image_url"],
publishedDate=datetime.strptime(result["created_at"], "%Y-%m-%d %H:%M:%S"),
publishedDate=datetime.fromisoformat(result["created_at"]),
metadata=" | ".join(metadata),
)
)

View File

@@ -1,305 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Presearch supports the search types listed in :py:obj:`search_type` (general,
images, videos, news).
Configured ``presarch`` engines:
.. code:: yaml
- name: presearch
engine: presearch
search_type: search
categories: [general, web]
- name: presearch images
...
search_type: images
categories: [images, web]
- name: presearch videos
...
search_type: videos
categories: [general, web]
- name: presearch news
...
search_type: news
categories: [news, web]
.. hint::
By default Presearch's video category is intentionally placed into::
categories: [general, web]
Search type ``video``
=====================
The results in the video category are most often links to pages that contain a
video, for instance many links from Preasearch's video category link content
from facebook (aka Meta) or Twitter (aka X). Since these are not real links to
video streams SearXNG can't use the video template for this and if SearXNG can't
use this template, then the user doesn't want to see these hits in the videos
category.
Languages & Regions
===================
In Presearch there are languages for the UI and regions for narrowing down the
search. If we set "auto" for the region in the WEB-UI of Presearch and cookie
``use_local_search_results=false``, then the defaults are set for both (the
language and the region) from the ``Accept-Language`` header.
Since the region is already "auto" by default, we only need to set the
``use_local_search_results`` cookie and send the ``Accept-Language`` header. We
have to set these values in both requests we send to Presearch; in the first
request to get the request-ID from Presearch and in the final request to get the
result list.
The time format returned by Presearch varies depending on the language set.
Multiple different formats can be supported by using ``dateutil`` parser, but
it doesn't support formats such as "N time ago", "vor N time" (German),
"Hace N time" (Spanish). Because of this, the dates are simply joined together
with the rest of other metadata.
Implementations
===============
"""
from urllib.parse import urlencode, urlparse
from searx import locales
from searx.network import get
from searx.utils import gen_useragent, html_to_text, parse_duration_string
about = {
"website": "https://presearch.io",
"wikidata_id": "Q7240905",
"official_api_documentation": "https://docs.presearch.io/nodes/api",
"use_official_api": False,
"require_api_key": False,
"results": "JSON",
}
paging = True
safesearch = True
time_range_support = True
categories = ["general", "web"] # general, images, videos, news
# HTTP2 requests immediately get blocked by a CAPTCHA
enable_http2 = False
search_type = "search"
"""must be any of ``search``, ``images``, ``videos``, ``news``"""
base_url = "https://presearch.com"
safesearch_map = {0: 'false', 1: 'true', 2: 'true'}
def init(_):
if search_type not in ['search', 'images', 'videos', 'news']:
raise ValueError(f'presearch search_type: {search_type}')
def _get_request_id(query, params):
args = {
"q": query,
"page": params["pageno"],
}
if params["time_range"]:
args["time"] = params["time_range"]
url = f"{base_url}/{search_type}?{urlencode(args)}"
headers = {
'User-Agent': gen_useragent(),
'Cookie': (
f"b=1;"
f" presearch_session=;"
f" use_local_search_results=false;"
f" use_safe_search={safesearch_map[params['safesearch']]}"
),
}
if params['searxng_locale'] != 'all':
l = locales.get_locale(params['searxng_locale'])
# Presearch narrows down the search by region. In SearXNG when the user
# does not set a region (e.g. 'en-CA' / canada) we cannot hand over a region.
# We could possibly use searx.locales.get_official_locales to determine
# in which regions this language is an official one, but then we still
# wouldn't know which region should be given more weight / Presearch
# performs an IP-based geolocation of the user, we don't want that in
# SearXNG ;-)
if l and l.territory:
headers['Accept-Language'] = f"{l.language}-{l.territory},{l.language};" "q=0.9,*;" "q=0.5"
resp = get(url, headers=headers, timeout=5)
for line in resp.text.split("\n"):
if "window.searchId = " in line:
return line.split("= ")[1][:-1].replace('"', ""), resp.cookies
raise RuntimeError("Couldn't find any request id for presearch")
def request(query, params):
request_id, cookies = _get_request_id(query, params)
params["headers"]["Accept"] = "application/json"
params["url"] = f"{base_url}/results?id={request_id}"
params["cookies"] = cookies
return params
def _strip_leading_strings(text):
for x in ['wikipedia', 'google']:
if text.lower().endswith(x):
text = text[: -len(x)]
return text.strip()
def _fix_title(title, url):
"""
Titles from Presearch shows domain + title without spacing, and HTML
This function removes these 2 issues.
Transforming "translate.google.co.in<em>Google</em> Translate" into "Google Translate"
"""
parsed_url = urlparse(url)
domain = parsed_url.netloc
title = html_to_text(title)
# Fixes issue where domain would show up in the title
# translate.google.co.inGoogle Translate -> Google Translate
if (
title.startswith(domain)
and len(title) > len(domain)
and not title.startswith(domain + "/")
and not title.startswith(domain + " ")
):
title = title.removeprefix(domain)
return title
def parse_search_query(json_results):
results = []
if not json_results:
return results
for item in json_results.get('specialSections', {}).get('topStoriesCompact', {}).get('data', []):
result = {
'url': item['link'],
'title': _fix_title(item['title'], item['link']),
'thumbnail': item['image'],
'content': '',
'metadata': item.get('source'),
}
results.append(result)
for item in json_results.get('standardResults', []):
result = {
'url': item['link'],
'title': _fix_title(item['title'], item['link']),
'content': html_to_text(item['description']),
}
results.append(result)
info = json_results.get('infoSection', {}).get('data')
if info:
attributes = []
for item in info.get('about', []):
text = html_to_text(item)
if ':' in text:
# split text into key / value
label, value = text.split(':', 1)
else:
# In other languages (tested with zh-TW) a colon is represented
# by a different symbol --> then we split at the first space.
label, value = text.split(' ', 1)
label = label[:-1]
value = _strip_leading_strings(value)
attributes.append({'label': label, 'value': value})
content = []
for item in [info.get('subtitle'), info.get('description')]:
if not item:
continue
item = _strip_leading_strings(html_to_text(item))
if item:
content.append(item)
results.append(
{
'infobox': info['title'],
'id': info['title'],
'img_src': info.get('image'),
'content': ' | '.join(content),
'attributes': attributes,
}
)
return results
def response(resp):
results = []
json_resp = resp.json()
if search_type == 'search':
results = parse_search_query(json_resp.get('results', {}))
elif search_type == 'images':
for item in json_resp.get('images', []):
results.append(
{
'template': 'images.html',
'title': html_to_text(item['title']),
'url': item.get('link'),
'img_src': item.get('image'),
'thumbnail_src': item.get('thumbnail'),
}
)
elif search_type == 'videos':
# The results in the video category are most often links to pages that contain
# a video and not to a video stream --> SearXNG can't use the video template.
for item in json_resp.get('videos', []):
duration = item.get('duration')
if duration:
duration = parse_duration_string(duration)
results.append(
{
'title': html_to_text(item['title']),
'url': item.get('link'),
'content': item.get('description', ''),
'thumbnail': item.get('image'),
'length': duration,
}
)
elif search_type == 'news':
for item in json_resp.get('news', []):
source = item.get('source')
# Bug on their end, time sometimes returns "</a>"
time = html_to_text(item.get('time')).strip()
metadata = [source]
if time != "":
metadata.append(time)
results.append(
{
'title': html_to_text(item['title']),
'url': item.get('link'),
'content': html_to_text(item.get('description', '')),
'metadata': ' / '.join(metadata),
'thumbnail': item.get('image'),
}
)
return results

View File

@@ -140,7 +140,7 @@ def response(resp):
results.append(
{
'template': 'images.html',
'url': _clean_url(f"{about['website']}/images/{result['objectID']}"),
'url': _clean_url(f"{pdia_base_url}/images/{result['objectID']}"),
'img_src': _clean_url(base_image_url),
'thumbnail_src': _clean_url(base_image_url + THUMBNAIL_SUFFIX),
'title': f"{result['title'].strip()} by {result['artist']} {result.get('displayYear', '')}",

View File

@@ -292,7 +292,7 @@ def parse_news_uchq(data):
results = []
for item in data.get('feed', []):
try:
published_date = datetime.strptime(item.get('time'), "%Y-%m-%d")
published_date = datetime.fromisoformat(item.get('time'))
except (ValueError, TypeError):
# Sometime Quark will return non-standard format like "1天前", set published_date as None
published_date = None

View File

@@ -38,6 +38,7 @@ Implementations
"""
import random
import typing as t
from datetime import (
@@ -50,6 +51,7 @@ from urllib.parse import urlencode
import babel
from flask_babel import gettext # pyright: ignore[reportUnknownVariableType]
from searx.enginelib import EngineCache
from searx.enginelib.traits import EngineTraits
from searx.exceptions import (
SearxEngineAccessDeniedException,
@@ -89,6 +91,11 @@ qwant_categ: str = None # pyright: ignore[reportAssignmentType]
safesearch = True
# tgp seems to be short for "test group" - its actual value doesn't matter, as
# long as it's sent and at the correct position in the query params and doesn't
# change too frequently
test_group_value = random.randint(1, 3)
# fmt: off
qwant_news_locales = [
"ca_ad", "ca_es", "ca_fr", "co_fr", "de_at", "de_ch", "de_de", "en_au",
@@ -99,9 +106,19 @@ qwant_news_locales = [
]
# fmt: on
base_url = "https://www.qwant.com"
api_url = "https://api.qwant.com/v3/search/"
"""URL of Qwant's API (JSON)"""
CACHE: EngineCache
"""Cache for storing the ``datadome`` cookie."""
def setup(engine_settings: dict[str, t.Any]) -> bool:
global CACHE # pylint: disable=global-statement
CACHE = EngineCache(engine_settings["name"])
return True
def request(query: str, params: "OnlineParams") -> None:
"""Qwant search request"""
@@ -114,26 +131,38 @@ def request(query: str, params: "OnlineParams") -> None:
results_per_page = 10
if qwant_categ == "images":
results_per_page = 50
args = {
"q": query,
"count": results_per_page,
"locale": q_locale,
"offset": (params["pageno"] - 1) * results_per_page,
"tgp": test_group_value,
"device": "desktop",
"safesearch": params["safesearch"],
"tgp": 1,
"display": True,
"llm": True,
# True would be encoded to "True", instead of "true", which makes the request
# easier to detect and block
"displayed": "true",
"llm": "true",
}
params["raise_for_httperror"] = False
params["url"] = f"{api_url}{qwant_categ}?{urlencode(args)}"
params["cookies"]["datadome"] = CACHE.get("datadome")
params["headers"].update({"Accept": "application/json", "Referer": f"{base_url}/", "Origin": base_url})
def response(resp: "SXNG_Response") -> EngineResults:
"""Parse results from Qwant's API"""
# pylint: disable=too-many-locals, too-many-branches, too-many-statements
# cache datadome cookie - changes on each request
datadome = resp.cookies.get("datadome")
if datadome:
CACHE.set("datadome", datadome)
res = EngineResults()
# Try to load JSON result
@@ -150,8 +179,8 @@ def response(resp: "SXNG_Response") -> EngineResults:
error_code = data.get("error_code")
if error_code == 24:
raise SearxEngineTooManyRequestsException()
if search_results.get("data", {}).get("error_data", {}).get("captchaUrl") is not None:
raise SearxEngineCaptchaException()
if search_results.get("url") is not None:
raise SearxEngineCaptchaException(suspended_time=0)
if resp.status_code == 403:
raise SearxEngineAccessDeniedException()
msg = ",".join(data.get("message", ["unknown"]))
@@ -190,7 +219,6 @@ def response(resp: "SXNG_Response") -> EngineResults:
mainline_items: list[dict[str, t.Any]] = row.get("items", [])
for item in mainline_items:
title: str = item.get("title", "")
res_url: str = item.get("url", "")
pub_date: datetime | None = None
@@ -290,7 +318,7 @@ def fetch_traits(engine_traits: EngineTraits):
from searx.utils import extr
resp = get(
about["website"], # pyright: ignore[reportArgumentType]
base_url, # pyright: ignore[reportArgumentType]
timeout=5,
)
if not resp.ok:

View File

@@ -1,74 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Reddit"""
import json
from datetime import datetime
from urllib.parse import urlencode, urljoin, urlparse
# about
about = {
"website": 'https://www.reddit.com/',
"wikidata_id": 'Q1136',
"official_api_documentation": 'https://www.reddit.com/dev/api',
"use_official_api": True,
"require_api_key": False,
"results": 'JSON',
}
# engine dependent config
categories = ['social media']
page_size = 25
# search-url
base_url = 'https://www.reddit.com/'
search_url = base_url + 'search.json?{query}'
def request(query, params):
query = urlencode({'q': query, 'limit': page_size})
params['url'] = search_url.format(query=query)
return params
def response(resp):
img_results = []
text_results = []
search_results = json.loads(resp.text)
# return empty array if there are no results
if 'data' not in search_results:
return []
posts = search_results.get('data', {}).get('children', [])
# process results
for post in posts:
data = post['data']
# extract post information
params = {'url': urljoin(base_url, data['permalink']), 'title': data['title']}
# if thumbnail field contains a valid URL, we need to change template
thumbnail = data['thumbnail']
url_info = urlparse(thumbnail)
# netloc & path
if url_info[1] != '' and url_info[2] != '':
params['img_src'] = data['url']
params['thumbnail_src'] = thumbnail
params['template'] = 'images.html'
img_results.append(params)
else:
created = datetime.fromtimestamp(data['created_utc'])
content = data['selftext']
if len(content) > 500:
content = content[:500] + '...'
params['content'] = content
params['publishedDate'] = created
text_results.append(params)
# show images first and text results second
return img_results + text_results

View File

@@ -58,7 +58,7 @@ def response(resp):
title = extract_text(result_dom.xpath(title_xpath))
p_date = extract_text(result_dom.xpath(published_date))
# fix offset date for line 644 webapp.py check
fixed_date = datetime.strptime(p_date, '%Y-%m-%dT%H:%M:%S%z')
fixed_date = datetime.fromisoformat(p_date)
earned = extract_text(result_dom.xpath(earned_xpath))
views = extract_text(result_dom.xpath(views_xpath))
rumbles = extract_text(result_dom.xpath(rumbles_xpath))

View File

@@ -0,0 +1,96 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""SearchZee is a small, indie project, web and news results pulled from
independent search infrastructure."""
import typing as t
from urllib.parse import urlencode
from searx.exceptions import SearxEngineAPIException
from searx.extended_types import SXNG_Response
from searx.network import get
from searx.result_types import EngineResults
from searx.utils import extr, html_to_text
from searx.enginelib import EngineCache
if t.TYPE_CHECKING:
from searx.search.processors import OnlineParams
about = {
"website": "https://searchzee.com",
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": "JSON",
"description": (
"SearchZee is a small, indie project, the web and news results"
" are pulled from an independent search infrastructure."
),
}
categories: list[str] = None # type: ignore[reportAssignmentType]
paging = True
SearchzeeCategType = t.Literal["web", "news"]
searchzee_categ: SearchzeeCategType = None # type: ignore[reportAssignmentType]
CACHE: EngineCache
"""Cache for storing the scraped API Token."""
base_url = "https://searchzee.com"
# only supports for news
time_range_map = {"day": "pd", "week": "pw", "month": "pm", "year": "py"}
def setup(engine_settings: dict[str, t.Any]) -> bool:
if searchzee_categ not in t.get_args(SearchzeeCategType):
raise ValueError("invalid category: %s" % searchzee_categ)
global CACHE # pylint: disable=global-statement
CACHE = EngineCache(engine_settings["name"]) # type: ignore[reportAny]
return True
def _obtain_api_token() -> str:
token: str | None = CACHE.get("token") # type: ignore[reportAny]
if token:
return token
token_resp = get(
f"{base_url}/app.js",
)
if not token_resp.ok:
raise SearxEngineAPIException("failed to obtain api key")
token = extr(token_resp.text, "const SEARCHZEE_API_TOKEN = \"", "\";")
CACHE.set("token", token, expire=3600)
return token
def request(query: str, params: "OnlineParams"):
params["headers"]["X-SearchZee-Token"] = _obtain_api_token()
args = {"q": query, "type": searchzee_categ, "offset": params["pageno"] - 1}
if params["time_range"]:
args["freshness"] = time_range_map[params["time_range"]]
params["url"] = f"{base_url}/api/search?{urlencode(args)}"
def response(resp: "SXNG_Response") -> EngineResults:
res = EngineResults()
results: list[dict[str, str]] = resp.json()["results"] # type: ignore[reportAny]
for result in results:
res.add(
res.types.MainResult(
url=result["url"],
title=html_to_text(result["title"]),
content=html_to_text(result["summary"]),
thumbnail=result.get("thumbnail") or "",
)
)
return res

View File

@@ -120,7 +120,7 @@ def response(resp: "SXNG_Response") -> EngineResults:
publishedDate: datetime | None
if "pubDate" in result:
publishedDate = datetime.strptime(result["pubDate"], "%Y-%m-%d")
publishedDate = datetime.fromisoformat(result["pubDate"])
else:
publishedDate = None

View File

@@ -0,0 +1,62 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Shopify stock photos provides royalty-free images, intended for use with
Shopify.
"""
import typing as t
from urllib.parse import urlencode
from lxml import html
from searx.result_types import EngineResults
from searx.utils import eval_xpath, eval_xpath_list, extract_text
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
about = {
"website": "https://www.shopify.com/stock-photos",
"wikidata_id": None,
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": "HTML",
}
base_url = "https://www.shopify.com"
categories = ["images"]
paging = True
def request(query: str, params: "OnlineParams") -> None:
args = {"q": query, "page": params["pageno"]}
params["url"] = f"{base_url}/stock-photos/photos/search?{urlencode(args)}"
def _get_download_url(url: str) -> str:
"""Get the link to the full quality image."""
query_start = url.find("?")
return url[:query_start] + "/download?quality=premium"
def response(resp: "SXNG_Response"):
res = EngineResults()
doc = html.fromstring(resp.text)
for result in eval_xpath_list(doc, "//div[contains(@class, 'js-masonry-grid')]/div"):
url = base_url + (extract_text(eval_xpath(result, ".//a[contains(@class, 'photo-tile')]/@href")) or "")
res.add(
res.types.Image(
url=url,
title=extract_text(eval_xpath(result, ".//p[contains(@class, 'photo-tile__title')]")) or "",
thumbnail_src=extract_text(eval_xpath(result, ".//img[contains(@class, 'photo-card__image')]/@src"))
or "",
img_src=_get_download_url(url),
)
)
return res

View File

@@ -95,7 +95,8 @@ def _parse_date(text):
date_match = re.search(r"(\d{4}-\d{1,2}-\d{1,2})", text)
if date_match:
try:
return datetime.strptime(date_match.group(1), "%Y-%m-%d")
y, m, d = date_match.group(1).split("-")
return datetime(year=int(y), month=int(m), day=int(d))
except (ValueError, TypeError):
pass
return None

View File

@@ -54,7 +54,7 @@ def response(resp):
published_date = None
if entry.get("date") and entry.get("duration"):
try:
published_date = datetime.strptime(entry['date'], "%Y-%m-%d")
published_date = datetime.fromisoformat(entry['date'])
except (ValueError, TypeError):
published_date = None

View File

@@ -134,7 +134,7 @@ def response(resp: "SXNG_Response") -> EngineResults:
return str(record.get(k, ""))
for record in json_data["records"]:
published = datetime.strptime(record["publicationDate"], "%Y-%m-%d")
published = datetime.fromisoformat(record["publicationDate"])
authors: list[str] = [" ".join(author["creator"].split(", ")[::-1]) for author in record["creators"]]
pdf_url = ""

View File

@@ -0,0 +1,55 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Stocksnap_ is a search engine for CC0-licensed images.
.. _Stocksnap: https://stocksnap.io
"""
import typing as t
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://stocksnap.io",
"wikidata_id": None,
"official_api_documentation": None,
"use_official_api": False,
"require_api_key": False,
"results": "JSON",
}
# otherwise all requests get blocked, probably HTTP2 fingerprinting
enable_http2 = False
base_url = "https://stocksnap.io"
cdn_url = "https://cdn.stocksnap.io"
categories = ["images"]
paging = True
def request(query: str, params: "OnlineParams") -> None:
params["url"] = f"{base_url}/api/search-photos/{query}/relevance/desc/{params['pageno']}"
def response(resp: "SXNG_Response"):
res = EngineResults()
result: dict[str, str] # TBH: dict[str, t.Any]
for result in resp.json()["results"]:
slug = "-".join(result['keywords'][:1]) + "-" + result["img_id"]
res.add(
res.types.Image(
title=result["tags"],
url=f"{base_url}/photo/{slug}",
thumbnail_src=f"{cdn_url}/img-thumbs/280h/{result['img_id']}.jpg",
img_src=f"{cdn_url}/img-thumbs/960w/{result['img_id']}.jpg",
img_format="JPEG",
resolution=f"{result['img_width']}x{result['img_height']}",
)
)
return res

View File

@@ -9,6 +9,7 @@ import codecs
import hashlib
import json
import random
import string
from datetime import datetime
from urllib.parse import urlencode
@@ -43,8 +44,8 @@ paging = True
base_url = "https://api.swisscows.com"
CAESAR_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
NONCE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
CAESAR_ALPHABET = string.ascii_uppercase
NONCE_ALPHABET = string.ascii_letters + string.digits + "-._~"
time_range_map = {"day": "Day", "week": "Week", "month": "Month", "year": "Year"}
@@ -92,7 +93,7 @@ def generate_nonce(length: int = 32) -> str:
"""
Generate a random char sequence with the given length.
"""
return "".join([random.choice(NONCE_ALPHABET) for _ in range(length)])
return "".join(random.choices(NONCE_ALPHABET, k=length))
def caesar_shift_with_switch_case(s: str, offset: int = 13) -> str:

View File

@@ -81,7 +81,7 @@ def _story(item):
return {
'title': item['title'],
'thumbnail': item.get('teaserImage', {}).get('imageVariants', {}).get('16x9-256'),
'publishedDate': datetime.strptime(item['date'][:19], '%Y-%m-%dT%H:%M:%S'),
'publishedDate': datetime.fromisoformat(item['date'][:19]),
'content': item.get('firstSentence'),
'url': item['shareURL'] if use_source_url else item['detailsweb'],
}
@@ -103,7 +103,7 @@ def _video(item):
'template': 'videos.html',
'title': title,
'thumbnail': item.get('teaserImage', {}).get('imageVariants', {}).get('16x9-256'),
'publishedDate': datetime.strptime(item['date'][:19], '%Y-%m-%dT%H:%M:%S'),
'publishedDate': datetime.fromisoformat(item['date'][:19]),
'content': item.get('firstSentence', ''),
'iframe_src': video_url,
'url': url,

View File

@@ -117,7 +117,7 @@ def _obtain_session_code() -> str:
if not code:
raise SearxEngineAPIException("failed to obtain session code")
CACHE.set("session", code, expire=60 * 24 * 60) # cookie is valid for two months
CACHE.set("session", code, expire=60 * 24 * 60 * 60) # cookie is valid for two months
return code

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