Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448ced4b18 | ||
|
|
c360868e5b | ||
|
|
b5ed73fcb6 | ||
|
|
28423d332d | ||
|
|
ef7bae9ddb | ||
|
|
cb74e7f810 | ||
|
|
54ca1d9cfb | ||
|
|
ef0ec03da0 | ||
|
|
629368e40c | ||
|
|
68647d0806 | ||
|
|
81371861f6 | ||
|
|
d47cb96bfb | ||
|
|
62366b05c0 | ||
|
|
1c10abe2d3 | ||
|
|
3afd949268 | ||
|
|
2a6fc07be3 | ||
|
|
decdfa6f7d | ||
|
|
8252c0bf6d | ||
|
|
47aa11475b | ||
|
|
0ce72dd2cc | ||
|
|
c6d7fe91d3 | ||
|
|
7847767832 | ||
|
|
d2209bfbb0 | ||
|
|
ca8e1582be | ||
|
|
243d3a9df2 | ||
|
|
ff8f5ddcc9 | ||
|
|
b00212a836 | ||
|
|
22c64b8d39 | ||
|
|
ad46ebd7d8 | ||
|
|
f4822b3fc4 | ||
|
|
9f042d2f67 | ||
|
|
815f58994a | ||
|
|
6a0ac6a391 | ||
|
|
bba4f64e8a | ||
|
|
619696aa6c | ||
|
|
2dc2b14628 | ||
|
|
6671d89bed | ||
|
|
d48c4b5554 | ||
|
|
44b98e6102 | ||
|
|
7a3e41d420 | ||
|
|
ce913b8934 | ||
|
|
19ffbcd306 | ||
|
|
e652ca7d29 | ||
|
|
26b90797e0 | ||
|
|
a5659a5364 | ||
|
|
a9d9900334 | ||
|
|
4e2c1ea7f4 | ||
|
|
f5035873ad | ||
|
|
c11cbda117 | ||
|
|
bad451b3da | ||
|
|
896ec8d3af | ||
|
|
12f8b6515c | ||
|
|
d8ae3abd59 | ||
|
|
487f519227 | ||
|
|
3cd69d30e2 | ||
|
|
783a094625 | ||
|
|
dcf1d477ae | ||
|
|
d63bbef7a0 | ||
|
|
4d9c982cda | ||
|
|
2ed96e6fcf | ||
|
|
019460e07d | ||
|
|
8a8b4eaec0 | ||
|
|
b3c3895294 | ||
|
|
df67eb6c26 | ||
|
|
f372096fb1 | ||
|
|
52284fd320 | ||
|
|
49064747a2 | ||
|
|
591ad564a9 | ||
|
|
2e624bed40 | ||
|
|
2e41cf7407 | ||
|
|
ce48993997 | ||
|
|
e40de2d992 | ||
|
|
fdd8525b1a | ||
|
|
bcd2b709fe | ||
|
|
e75fd22d09 | ||
|
|
e831fc2a1c | ||
|
|
c0014f015e | ||
|
|
fe5bdd1bf2 |
@@ -0,0 +1,41 @@
|
||||
// @ts-check
|
||||
// Closes issues and prs whose authors/agents don't accept the ai policy
|
||||
// https://github.com/searxng/searxng/blob/master/AI_POLICY.rst
|
||||
|
||||
const boxCocRegex = /\[\s*[Xx]\s*\].*AI Policy/;
|
||||
const refCocRegex = /\[AI Policy\](?::\s*|\()https:\/\/github\.com\/searxng\/searxng\/.*AI_POLICY/;
|
||||
|
||||
export default async ({ github, context }) => {
|
||||
const item = context.payload.issue ?? context.payload.pull_request;
|
||||
if (!item?.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (boxCocRegex.test(item.body) && refCocRegex.test(item.body)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = context.payload.issue ? "issue" : "pull request";
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: item.number,
|
||||
body: `Hello! Thank you for your contribution.
|
||||
|
||||
Unfortunately your ${type} was closed as the AI Policy has not been accepted.
|
||||
Please open a new ${type} after confirming your contribution aligns with our AI Policy.`,
|
||||
...context.repo
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
issue_number: item.number,
|
||||
labels: ["invalid:slop"],
|
||||
...context.repo
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
issue_number: item.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
...context.repo
|
||||
});
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
// Closes issues and prs whose authors/agents don't accept the ai policy
|
||||
// https://github.com/searxng/searxng/blob/master/AI_POLICY.rst
|
||||
|
||||
module.exports = async ({ github, context }) => {
|
||||
const item = context.payload.pull_request || context.payload.issue;
|
||||
const body = item.body || '';
|
||||
const kind = context.payload.pull_request ? 'pull request' : 'issue';
|
||||
|
||||
// https://github.com/searxng/searxng/pull/6476#discussion_r3683782481
|
||||
const hasBox = /\[[Xx]\].*AI Policy/.test(body);
|
||||
const hasRef = /\[AI Policy\](?::\s*|\()https:\/\/github\.com\/searxng\/searxng\/.*AI_POLICY/.test(body);
|
||||
if (hasBox && hasRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body:
|
||||
'Hello! Thank you for your contribution.\n\n' +
|
||||
`Unfortunately your ${kind} was closed as the AI Policy has not been accepted.\n\n` +
|
||||
`Please open a new ${kind} after confirming your contribution aligns with our AI Policy.`,
|
||||
});
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
labels: ['invalid:slop'],
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
};
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check AI Policy
|
||||
name: Check
|
||||
# for issues with an author who has not contributed before
|
||||
if: >-
|
||||
github.event.sender.type != 'Bot' &&
|
||||
@@ -34,5 +34,5 @@ jobs:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const script = require('./.github/scripts/ai_policy.cjs');
|
||||
await script({ github, context });
|
||||
const script = await import("${{ github.workspace }}/.github/scripts/aiPolicy.mjs")
|
||||
await script.default({ github, context });
|
||||
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
run: make container.build
|
||||
|
||||
- name: Upload container image
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: container-image-${{ matrix.arch }}
|
||||
path: "/var/tmp/searxng-podman/*.tar"
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
persist-credentials: "false"
|
||||
|
||||
- name: Download container image
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: container-image-${{ matrix.arch }}
|
||||
path: "/var/tmp/searxng-podman"
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
persist-credentials: "false"
|
||||
|
||||
- name: Download container images
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: container-image-*
|
||||
path: "/var/tmp/searxng-podman"
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
python-version: "${{ env.PYTHON_VERSION }}"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: "26"
|
||||
check-latest: "true"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
# yamllint disable rule:line-length
|
||||
name: Sync
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
codeberg:
|
||||
if: github.repository_owner == 'searxng'
|
||||
name: Codeberg
|
||||
runs-on: ubuntu-26.04-arm
|
||||
steps:
|
||||
- name: Sync
|
||||
env:
|
||||
ORIGIN: "github.com/searxng/searxng"
|
||||
DESTINATION: "codeberg.org/searxng/searxng"
|
||||
DESTINATION_USER: "${{ secrets.CODEBERG_USER }}"
|
||||
DESTINATION_TOKEN: "${{ secrets.CODEBERG_TOKEN }}"
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
git init --quiet --bare .sync
|
||||
cd .sync
|
||||
|
||||
git fetch --no-tags "https://${ORIGIN}.git" \
|
||||
'+refs/heads/master:refs/heads/master'
|
||||
|
||||
git push --force "https://${DESTINATION_USER}:${DESTINATION_TOKEN}@${DESTINATION}.git" \
|
||||
'refs/heads/master:refs/heads/master'
|
||||
@@ -25,25 +25,25 @@
|
||||
"dependencies": {
|
||||
"ionicons": "^8.1.0",
|
||||
"normalize.css": "8.0.1",
|
||||
"ol": "^10.10.0",
|
||||
"ol": "^10.11.0",
|
||||
"swiped-events": "1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.13",
|
||||
"@types/node": "^26.5.1",
|
||||
"browserslist": "^4.28.8",
|
||||
"@biomejs/biome": "2.5.15",
|
||||
"@types/node": "^26.6.5",
|
||||
"browserslist": "^4.29.4",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
"edge.js": "^6.5.1",
|
||||
"less": "^4.9.0",
|
||||
"mathjs": "^15.2.0",
|
||||
"sharp": "~0.35.4",
|
||||
"sort-package-json": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"sharp": "~0.35.5",
|
||||
"sort-package-json": "^4.1.0",
|
||||
"stylelint": "^17.16.0",
|
||||
"stylelint-config-standard-less": "^4.1.0",
|
||||
"stylelint-prettier": "^5.0.3",
|
||||
"svgo": "^4.1.0",
|
||||
"typescript": "~7.0.2",
|
||||
"vite": "^8.3.0",
|
||||
"vite": "^8.3.3",
|
||||
"vite-bundle-analyzer": "^1.3.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,67 +8,68 @@ export type KeyBindingLayout = "default" | "vim";
|
||||
type KeyBinding = {
|
||||
key: string;
|
||||
fun: (event: KeyboardEvent) => void;
|
||||
des: string;
|
||||
cat: string;
|
||||
des?: string;
|
||||
cat?: string;
|
||||
};
|
||||
|
||||
type HighlightResultElement = "down" | "up" | "visible" | "bottom" | "top";
|
||||
|
||||
/* common base for layouts */
|
||||
|
||||
const baseKeyBinding: Record<string, KeyBinding> = {
|
||||
Escape: {
|
||||
key: "ESC",
|
||||
fun: (event: KeyboardEvent) => removeFocus(event),
|
||||
des: "remove focus from the focused input",
|
||||
cat: "Control"
|
||||
des: settings.translations?.hotkeys_remove_focus,
|
||||
cat: settings.translations?.hotkeys_category_control
|
||||
},
|
||||
c: {
|
||||
key: "c",
|
||||
fun: () => copyURLToClipboard(),
|
||||
des: "copy url of the selected result to the clipboard",
|
||||
cat: "Results"
|
||||
des: settings.translations?.hotkeys_copy_url,
|
||||
cat: settings.translations?.hotkeys_category_results
|
||||
},
|
||||
h: {
|
||||
key: "h",
|
||||
fun: () => toggleHelp(keyBindings),
|
||||
des: "toggle help window",
|
||||
cat: "Other"
|
||||
des: settings.translations?.hotkeys_toggle_help,
|
||||
cat: settings.translations?.hotkeys_category_other
|
||||
},
|
||||
i: {
|
||||
key: "i",
|
||||
fun: () => searchInputFocus(),
|
||||
des: "focus on the search input",
|
||||
cat: "Control"
|
||||
des: settings.translations?.hotkeys_focus_search,
|
||||
cat: settings.translations?.hotkeys_category_control
|
||||
},
|
||||
n: {
|
||||
key: "n",
|
||||
fun: () => GoToNextPage(),
|
||||
des: "go to next page",
|
||||
cat: "Results"
|
||||
des: settings.translations?.hotkeys_next_page,
|
||||
cat: settings.translations?.hotkeys_category_results
|
||||
},
|
||||
o: {
|
||||
key: "o",
|
||||
fun: () => openResult(false),
|
||||
des: "open search result",
|
||||
cat: "Results"
|
||||
des: settings.translations?.hotkeys_open_result,
|
||||
cat: settings.translations?.hotkeys_category_results
|
||||
},
|
||||
p: {
|
||||
key: "p",
|
||||
fun: () => GoToPreviousPage(),
|
||||
des: "go to previous page",
|
||||
cat: "Results"
|
||||
des: settings.translations?.hotkeys_previous_page,
|
||||
cat: settings.translations?.hotkeys_category_results
|
||||
},
|
||||
r: {
|
||||
key: "r",
|
||||
fun: () => reloadPage(),
|
||||
des: "reload page from the server",
|
||||
cat: "Control"
|
||||
des: settings.translations?.hotkeys_reload_page,
|
||||
cat: settings.translations?.hotkeys_category_control
|
||||
},
|
||||
t: {
|
||||
key: "t",
|
||||
fun: () => openResult(true),
|
||||
des: "open the result in a new tab",
|
||||
cat: "Results"
|
||||
des: settings.translations?.hotkeys_open_new_tab,
|
||||
cat: settings.translations?.hotkeys_category_results
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,14 +79,14 @@ const keyBindingLayouts: Record<KeyBindingLayout, Record<string, KeyBinding>> =
|
||||
ArrowLeft: {
|
||||
key: "←",
|
||||
fun: () => highlightResult("up")(),
|
||||
des: "select previous search result",
|
||||
cat: "Results"
|
||||
des: settings.translations?.hotkeys_select_previous,
|
||||
cat: settings.translations?.hotkeys_category_results
|
||||
},
|
||||
ArrowRight: {
|
||||
key: "→",
|
||||
fun: () => highlightResult("down")(),
|
||||
des: "select next search result",
|
||||
cat: "Results"
|
||||
des: settings.translations?.hotkeys_select_next,
|
||||
cat: settings.translations?.hotkeys_category_results
|
||||
},
|
||||
...baseKeyBinding
|
||||
},
|
||||
@@ -95,56 +96,56 @@ const keyBindingLayouts: Record<KeyBindingLayout, Record<string, KeyBinding>> =
|
||||
b: {
|
||||
key: "b",
|
||||
fun: () => scrollPage(-window.innerHeight),
|
||||
des: "scroll one page up",
|
||||
cat: "Navigation"
|
||||
des: settings.translations?.hotkeys_page_up,
|
||||
cat: settings.translations?.hotkeys_category_navigation
|
||||
},
|
||||
d: {
|
||||
key: "d",
|
||||
fun: () => scrollPage(window.innerHeight / 2),
|
||||
des: "scroll half a page down",
|
||||
cat: "Navigation"
|
||||
des: settings.translations?.hotkeys_half_page_down,
|
||||
cat: settings.translations?.hotkeys_category_navigation
|
||||
},
|
||||
f: {
|
||||
key: "f",
|
||||
fun: () => scrollPage(window.innerHeight),
|
||||
des: "scroll one page down",
|
||||
cat: "Navigation"
|
||||
des: settings.translations?.hotkeys_page_down,
|
||||
cat: settings.translations?.hotkeys_category_navigation
|
||||
},
|
||||
g: {
|
||||
key: "g",
|
||||
fun: () => scrollPageTo(-document.body.scrollHeight, "top"),
|
||||
des: "scroll to the top of the page",
|
||||
cat: "Navigation"
|
||||
des: settings.translations?.hotkeys_scroll_top,
|
||||
cat: settings.translations?.hotkeys_category_navigation
|
||||
},
|
||||
j: {
|
||||
key: "j",
|
||||
fun: () => highlightResult("down")(),
|
||||
des: "select next search result",
|
||||
cat: "Results"
|
||||
des: settings.translations?.hotkeys_select_next,
|
||||
cat: settings.translations?.hotkeys_category_results
|
||||
},
|
||||
k: {
|
||||
key: "k",
|
||||
fun: () => highlightResult("up")(),
|
||||
des: "select previous search result",
|
||||
cat: "Results"
|
||||
des: settings.translations?.hotkeys_select_previous,
|
||||
cat: settings.translations?.hotkeys_category_results
|
||||
},
|
||||
u: {
|
||||
key: "u",
|
||||
fun: () => scrollPage(-window.innerHeight / 2),
|
||||
des: "scroll half a page up",
|
||||
cat: "Navigation"
|
||||
des: settings.translations?.hotkeys_half_page_up,
|
||||
cat: settings.translations?.hotkeys_category_navigation
|
||||
},
|
||||
v: {
|
||||
key: "v",
|
||||
fun: () => scrollPageTo(document.body.scrollHeight, "bottom"),
|
||||
des: "scroll to the bottom of the page",
|
||||
cat: "Navigation"
|
||||
des: settings.translations?.hotkeys_scroll_bottom,
|
||||
cat: settings.translations?.hotkeys_category_navigation
|
||||
},
|
||||
y: {
|
||||
key: "y",
|
||||
fun: () => copyURLToClipboard(),
|
||||
des: "copy url of the selected result to the clipboard",
|
||||
cat: "Results"
|
||||
des: settings.translations?.hotkeys_copy_url,
|
||||
cat: settings.translations?.hotkeys_category_results
|
||||
},
|
||||
...baseKeyBinding
|
||||
}
|
||||
@@ -344,6 +345,7 @@ const initHelpContent = (divElement: HTMLElement, keyBindings: typeof baseKeyBin
|
||||
|
||||
for (const binding of Object.values(keyBindings)) {
|
||||
const { cat } = binding;
|
||||
if (!cat) continue;
|
||||
categories[cat] ??= [];
|
||||
categories[cat].push(binding);
|
||||
}
|
||||
@@ -352,8 +354,8 @@ const initHelpContent = (divElement: HTMLElement, keyBindings: typeof baseKeyBin
|
||||
(a, b) => (categories[b]?.length ?? 0) - (categories[a]?.length ?? 0)
|
||||
);
|
||||
|
||||
let html = '<a href="#" class="close" aria-label="close" title="close">×</a>';
|
||||
html += "<h3>How to navigate SearXNG with hotkeys</h3>";
|
||||
let html = `<a href="#" class="close" aria-label="${settings.translations?.close}" title="${settings.translations?.close}">×</a>`;
|
||||
html += `<h3>${settings.translations?.hotkeys_navigation_title}</h3>`;
|
||||
html += "<table>";
|
||||
|
||||
for (const [i, categoryKey] of sortedCategoryKeys.entries()) {
|
||||
@@ -372,6 +374,7 @@ const initHelpContent = (divElement: HTMLElement, keyBindings: typeof baseKeyBin
|
||||
html += '<ul class="list-unstyled">';
|
||||
|
||||
for (const binding of bindings) {
|
||||
if (!binding.des) continue;
|
||||
html += `<li><kbd>${binding.key}</kbd> ${binding.des}</li>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { listen, mutable, settings } from "../toolkit.ts";
|
||||
import { assertElement } from "../util/assertElement.ts";
|
||||
|
||||
let imgTimeoutID: number;
|
||||
const loadErrorSrc = `${settings.theme_static_path}/img/img_load_error.svg`;
|
||||
|
||||
const imageLoader = (resultElement: HTMLElement): void => {
|
||||
if (imgTimeoutID) clearTimeout(imgTimeoutID);
|
||||
@@ -15,7 +16,7 @@ const imageLoader = (resultElement: HTMLElement): void => {
|
||||
// use thumbnail until full image loads
|
||||
const thumbnail = resultElement.querySelector<HTMLImageElement>(".image_thumbnail");
|
||||
if (thumbnail) {
|
||||
if (thumbnail.src === `${settings.theme_static_path}/img/img_load_error.svg`) return;
|
||||
if (thumbnail.src === loadErrorSrc) return;
|
||||
|
||||
imgElement.onerror = (): void => {
|
||||
imgElement.src = thumbnail.src;
|
||||
@@ -35,17 +36,27 @@ const imageLoader = (resultElement: HTMLElement): void => {
|
||||
}, 1000) as unknown as number;
|
||||
};
|
||||
|
||||
const imageThumbnails: NodeListOf<HTMLImageElement> = document.querySelectorAll<HTMLImageElement>(
|
||||
"#urls img.image_thumbnail, img.thumbnail"
|
||||
);
|
||||
for (const thumbnail of imageThumbnails) {
|
||||
if (thumbnail.complete && thumbnail.naturalWidth === 0) {
|
||||
thumbnail.src = `${settings.theme_static_path}/img/img_load_error.svg`;
|
||||
const hideBrokenThumbnail = (img: HTMLImageElement): void => {
|
||||
if (img.classList.contains("thumbnail")) {
|
||||
img.closest("a.thumbnail_link")?.remove();
|
||||
return;
|
||||
}
|
||||
if (img.classList.contains("image_thumbnail") && img.src !== loadErrorSrc) {
|
||||
img.src = loadErrorSrc;
|
||||
}
|
||||
};
|
||||
|
||||
thumbnail.onerror = (): void => {
|
||||
thumbnail.src = `${settings.theme_static_path}/img/img_load_error.svg`;
|
||||
};
|
||||
document.addEventListener(
|
||||
"error",
|
||||
(event: Event) => {
|
||||
const img = event.target;
|
||||
if (img instanceof HTMLImageElement) hideBrokenThumbnail(img);
|
||||
},
|
||||
true
|
||||
);
|
||||
// some images may have already failed
|
||||
for (const img of document.querySelectorAll<HTMLImageElement>("img.thumbnail, #urls img.image_thumbnail")) {
|
||||
if (img.complete && img.naturalWidth === 0) hideBrokenThumbnail(img);
|
||||
}
|
||||
|
||||
const copyUrlButton: HTMLButtonElement | null =
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
/*
|
||||
Layout of the Video result class
|
||||
*/
|
||||
|
||||
.result-videos {
|
||||
a.thumbnail_link {
|
||||
.ltr-float-left();
|
||||
.ltr-margin-right(1rem);
|
||||
.ltr-margin-left(0);
|
||||
width: 10rem !important;
|
||||
aspect-ratio: 16 / 9 !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embedded-video iframe {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 10px 0 0 0;
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,8 @@ article[data-vim-selected].category-social {
|
||||
font-size: 1rem;
|
||||
color: var(--color-result-url-font);
|
||||
flex-flow: row nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -305,22 +307,44 @@ article[data-vim-selected].category-social {
|
||||
|
||||
a.thumbnail_link {
|
||||
position: relative;
|
||||
margin-top: 0.6rem;
|
||||
.ltr-margin-right(1rem);
|
||||
.ltr-float-left();
|
||||
display: block;
|
||||
.ltr-float-right();
|
||||
.ltr-margin-left(0.5rem);
|
||||
width: 8rem;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: var(--color-result-border);
|
||||
|
||||
img.thumbnail_bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(2rem) brightness(0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
img.thumbnail {
|
||||
width: 7rem;
|
||||
height: unset; // remove height value that was needed for lazy loading
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thumbnail_length {
|
||||
.image-label-bottom-right();
|
||||
right: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.result_inner {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.break {
|
||||
clear: both;
|
||||
}
|
||||
@@ -407,32 +431,10 @@ article[data-vim-selected].category-social {
|
||||
padding: 10px 0 0 0;
|
||||
}
|
||||
|
||||
.result-videos {
|
||||
a.thumbnail_link img.thumbnail {
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embedded-video iframe {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 10px 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@supports not (aspect-ratio: 1 / 1) {
|
||||
// support older browsers which do not have aspect-ratio
|
||||
// https://caniuse.com/?search=aspect-ratio
|
||||
.result-videos .embedded-video iframe {
|
||||
height: calc(@results-width * 9 / 16);
|
||||
}
|
||||
}
|
||||
|
||||
.engines {
|
||||
.ltr-float-right();
|
||||
clear: both;
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
@@ -471,6 +473,8 @@ article[data-vim-selected].category-social {
|
||||
& > a {
|
||||
position: relative;
|
||||
outline: none;
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
@@ -930,10 +934,6 @@ summary.title {
|
||||
}
|
||||
|
||||
.result {
|
||||
.thumbnail {
|
||||
max-width: 98%;
|
||||
}
|
||||
|
||||
.url {
|
||||
span.url {
|
||||
display: block;
|
||||
@@ -1144,13 +1144,11 @@ summary.title {
|
||||
*/
|
||||
|
||||
@media screen and (max-width: @small-phone) {
|
||||
.result-videos {
|
||||
img.thumbnail {
|
||||
float: none !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow: inherit;
|
||||
.result {
|
||||
a.thumbnail_link {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 7rem;
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1165,3 +1163,4 @@ pre code {
|
||||
@import "result_types/paper.less";
|
||||
@import "result_types/file.less";
|
||||
@import "result_types/image.less";
|
||||
@import "result_types/video.less";
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="ionicon">
|
||||
<path
|
||||
d="M430.11 347.9c-6.6-6.1-16.3-7.6-24.6-9-11.5-1.9-15.9-4-22.6-10-14.3-12.7-14.3-31.1 0-43.8l30.3-26.9c46.4-41 46.4-108.2 0-149.2-34.2-30.1-80.1-45-127.8-45-55.7 0-113.9 20.3-158.8 60.1-83.5 73.8-83.5 194.7 0 268.5 41.5 36.7 97.5 55 152.9 55.4h1.7c55.4 0 110-17.9 148.8-52.4 14.4-12.7 11.99-36.6.1-47.7Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32px"
|
||||
/>
|
||||
<circle
|
||||
fill="currentColor"
|
||||
cx="144"
|
||||
cy="208"
|
||||
r="32"
|
||||
/>
|
||||
<circle
|
||||
fill="currentColor"
|
||||
cx="152"
|
||||
cy="311"
|
||||
r="32"
|
||||
/>
|
||||
<circle
|
||||
fill="currentColor"
|
||||
cx="224"
|
||||
cy="144"
|
||||
r="32"
|
||||
/>
|
||||
<circle
|
||||
fill="currentColor"
|
||||
cx="256"
|
||||
cy="367"
|
||||
r="48"
|
||||
/>
|
||||
<circle
|
||||
fill="currentColor"
|
||||
cx="328"
|
||||
cy="144"
|
||||
r="32"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1,6 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path d="M248 64C146.39 64 64 146.39 64 248s82.39 184 184 184 184-82.39 184-184S349.61 64 248 64z" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"/>
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M220 220h32v116"/>
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32" d="M208 340h88"/>
|
||||
<path d="M248 130a26 26 0 1026 26 26 26 0 00-26-26z" fill="currentColor" stroke="currentColor" stroke-miterlimit="10" stroke-width="1"/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M248 64C146.39 64 64 146.39 64 248s82.39 184 184 184 184-82.39 184-184S349.61 64 248 64z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M220 220h32v116"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32" d="M208 340h88"
|
||||
/>
|
||||
<path
|
||||
d="M248 130a26 26 0 1026 26 26 26 0 00-26-26z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 648 B After Width: | Height: | Size: 852 B |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="ionicon">
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
d="M108.56 342.78a60.34 60.34 0 1 0 60.56 60.44 60.63 60.63 0 0 0-60.56-60.44"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
d="M48 186.67v86.55c52 0 101.94 15.39 138.67 52.11s52 86.56 52 138.67h86.66c0-151.56-125.66-277.33-277.33-277.33"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
d="M48 48v86.56c185.25 0 329.22 144.08 329.22 329.44H464C464 234.66 277.67 48 48 48"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 625 B |
@@ -1,6 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path d="M368 415.86V72a24.07 24.07 0 00-24-24H72a24.07 24.07 0 00-24 24v352a40.12 40.12 0 0040 40h328" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"/>
|
||||
<path d="M416 464h0a48 48 0 01-48-48V128h72a24 24 0 0124 24v264a48 48 0 01-48 48z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"/>
|
||||
<path d="M240 128h64M240 192h64M112 256h192M112 320h192M112 384h192" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32"/>
|
||||
<path d="M176 208h-64a16 16 0 01-16-16v-64a16 16 0 0116-16h64a16 16 0 0116 16v64a16 16 0 01-16 16z" fill="currentColor" stroke="currentColor" stroke-linejoin="round" stroke-width="1" />
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M368 415.86V72a24.07 24.07 0 00-24-24H72a24.07 24.07 0 00-24 24v352a40.12 40.12 0 0040 40h328"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M416 464h0a48 48 0 01-48-48V128h72a24 24 0 0124 24v264a48 48 0 01-48 48z"
|
||||
/>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M240 128h64M240 192h64M112 256h192M112 320h192M112 384h192"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
d="M176 208h-64a16 16 0 01-16-16v-64a16 16 0 0116-16h64a16 16 0 0116 16v64a16 16 0 01-16 16z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 787 B After Width: | Height: | Size: 988 B |
@@ -0,0 +1,7 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M450-154v-309L180-619v309l270 156Zm60 0 270-156v-310L510-463v309Zm-60 69L150-258q-14-8-22-22t-8-30v-340q0-16 8-30t22-22l300-173q14-8 30-8t30 8l300 173q14 8 22 22t8 30v340q0 16-8 30t-22 22L510-85q-14 8-30 8t-30-8Zm194-525 102-59-266-154-102 59 266 154Zm-164 96 104-61-267-154-104 60 267 155Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="ionicon">
|
||||
<circle
|
||||
fill="currentColor"
|
||||
cx="256"
|
||||
cy="256.02"
|
||||
r="32"
|
||||
/>
|
||||
<path
|
||||
d="M184.25 192.25a96 96 0 0 0 0 127.52M327.77 319.77a96 96 0 0 0 0-127.52M133.28 141.28a168 168 0 0 0 0 229.44M378.72 370.72a168 168 0 0 0 0-229.44" fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32px"
|
||||
/>
|
||||
<path
|
||||
d="M435 416a240.34 240.34 0 0 0 0-320M77 96a240.34 240.34 0 0 0 0 320"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32px"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 741 B |
@@ -0,0 +1,11 @@
|
||||
<svg
|
||||
height="512"
|
||||
viewBox="0 -960 20480 20480"
|
||||
width="512"
|
||||
version="1.1"
|
||||
>
|
||||
<path
|
||||
d="M 19480,18100 V 2980 q 0,-693 -493.5,-1186.5 Q 18493,1300 17800,1300 H 8560 q -693,0 -1186.5,493.5 Q 6880,2287 6880,2980 v 357 q 504,231 924,567 420,336 756,756 V 2980 h 9240 v 11067 l -987,-987 H 8560 V 9700 q -336,420 -756,756 -420,336 -924,567 v 2037 q 0,693 493.5,1186.5 Q 7867,14740 8560,14740 h 7560 z M 16120,11380 H 12760 V 9700 h 3360 z M 5200,9700 q 1050,0 1785,-735 735,-735 735,-1785 0,-1050 -735,-1785 -735,-735 -1785,-735 -231,0 -441,42 -210,42 -399,105 V 460 H 1000 v 1680 h 1680 v 5040 q 0,1050 735,1785 735,735 1785,735 z M 16120,8860 H 10240 V 7180 h 5880 z m 0,-2520 H 10240 V 4660 h 5880 z m 1680,6720 V 2980 Z"
|
||||
stroke="currentColor"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 808 B |
@@ -0,0 +1,26 @@
|
||||
<svg
|
||||
viewBox="0 0 512 512"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="34.6274"
|
||||
d="M 256,469.53574 C 373.93232,469.53574 469.53574,373.93232 469.53574,256 469.53574,138.06479 373.93232,42.464261 256,42.464261 138.06479,42.464261 42.464263,138.06479 42.464263,256 42.464263,373.93232 138.06479,469.53574 256,469.53574 Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="34.6274"
|
||||
d="m 256,111.7191 a 144.28091,144.28091 0 1 1 0,288.56181"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-width="34.6274"
|
||||
d="m 256,180.97393 a 75.034727,75.034727 0 0 1 69.31543,46.31417 75.060698,75.060698 0 0 1 0,57.4238 75.092439,75.092439 0 0 1 -16.26334,24.34019 A 75.069355,75.069355 0 0 1 256,331.02607"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 979 B |
@@ -39,14 +39,17 @@ const simple_icons: IconSet[] = [
|
||||
alert: "alert-outline.svg",
|
||||
appstore: "apps-outline.svg",
|
||||
book: "book-outline.svg",
|
||||
camera: "camera-outline.svg",
|
||||
close: "close-outline.svg",
|
||||
download: "download-outline.svg",
|
||||
"ellipsis-vertical": "ellipsis-vertical-outline.svg",
|
||||
"file-tray-full": "file-tray-full-outline.svg",
|
||||
"file-tray-stacked": "file-tray-stacked-outline.svg",
|
||||
film: "film-outline.svg",
|
||||
globe: "globe-outline.svg",
|
||||
heart: "heart-outline.svg",
|
||||
image: "image-outline.svg",
|
||||
language: "language-outline.svg",
|
||||
layers: "layers-outline.svg",
|
||||
leecher: "arrow-down.svg",
|
||||
location: "location-outline.svg",
|
||||
@@ -58,13 +61,14 @@ const simple_icons: IconSet[] = [
|
||||
"navigate-up": "chevron-up-outline.svg",
|
||||
people: "people-outline.svg",
|
||||
play: "play-outline.svg",
|
||||
radio: "radio-outline.svg",
|
||||
rainy: "rainy-outline.svg",
|
||||
save: "save-outline.svg",
|
||||
school: "school-outline.svg",
|
||||
search: "search-outline.svg",
|
||||
seeder: "swap-vertical.svg",
|
||||
settings: "settings-outline.svg",
|
||||
tv: "tv-outline.svg"
|
||||
tv: "tv-outline.svg",
|
||||
videocam: "videocam-outline.svg"
|
||||
},
|
||||
svgo_opts: sxng_icon_opts
|
||||
},
|
||||
@@ -75,7 +79,13 @@ const simple_icons: IconSet[] = [
|
||||
base: resolve(HERE, "src/svg/ionicons"),
|
||||
set: {
|
||||
"information-circle": "information-circle-outline.svg",
|
||||
newspaper: "newspaper-outline.svg"
|
||||
lyrics: "song-lyric-music.svg",
|
||||
newspaper: "newspaper-outline.svg",
|
||||
palette: "color-palette-outline.svg",
|
||||
package: "package_2.svg",
|
||||
radio: "radio-outline.svg",
|
||||
rss: "logo-rss.svg",
|
||||
tor: "tor-browser.svg"
|
||||
},
|
||||
svgo_opts: sxng_icon_opts
|
||||
}
|
||||
|
||||
@@ -7,9 +7,18 @@ for the backend. Example output of this EDGE template:
|
||||
--}}
|
||||
{#
|
||||
Catalog of SVG symbols that can be inserted into the HTML output of a Jinja
|
||||
template. This file from:
|
||||
template. This file is generated by:
|
||||
|
||||
make static.build.commit
|
||||
|
||||
from template:
|
||||
|
||||
client/simple/tools/jinja_svg_catalog.html.edge
|
||||
|
||||
To modify the icon set, edit the script:
|
||||
|
||||
client/simple/theme_icons.ts
|
||||
|
||||
client/simple/tools/icon_catalog.edge.html
|
||||
#}
|
||||
|
||||
{%-
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Read the documentation before using the `docker-compose.yml` file:
|
||||
# https://docs.searxng.org/admin/installation-docker.html
|
||||
#
|
||||
# Additional ENVs:
|
||||
# Additional variables:
|
||||
# https://docs.searxng.org/admin/settings/settings_general.html#settings-general
|
||||
# https://docs.searxng.org/admin/settings/settings_server.html#settings-server
|
||||
|
||||
|
||||
@@ -1,4 +1,29 @@
|
||||
FROM localhost/searxng/searxng:builder AS builder
|
||||
FROM docker.io/searxng/base:searxng-builder AS builder
|
||||
|
||||
COPY ./requirements.txt ./requirements-server.txt ./
|
||||
|
||||
ENV UV_NO_MANAGED_PYTHON="true"
|
||||
|
||||
RUN --mount=type=cache,id=uv,target=/root/.cache/uv set -eux -o pipefail; \
|
||||
uv venv; \
|
||||
uv pip install --requirements ./requirements.txt --requirements ./requirements-server.txt; \
|
||||
uv cache prune --ci; \
|
||||
find ./.venv/lib/ -type f -exec strip --strip-unneeded {} + || true; \
|
||||
find ./.venv/lib/ -type d -name "__pycache__" -exec rm -rf {} +; \
|
||||
find ./.venv/lib/ -type f -name "*.pyc" -delete; \
|
||||
python -m compileall -q -f -j 0 --invalidation-mode=unchecked-hash ./.venv/lib/
|
||||
|
||||
COPY --exclude=./searx/version_frozen.py ./searx/ ./searx/
|
||||
|
||||
RUN set -eux -o pipefail; \
|
||||
python -m compileall -q -f -j 0 --invalidation-mode=unchecked-hash ./searx/; \
|
||||
find ./searx/static/ -type f \
|
||||
\( -name "*.html" -o -name "*.css" -o -name "*.js" -o -name "*.svg" \) \
|
||||
-exec gzip -9 -k {} + \
|
||||
-exec brotli -9 -k {} + \
|
||||
-exec gzip --test {}.gz + \
|
||||
-exec brotli --test {}.br +
|
||||
|
||||
FROM docker.io/searxng/base:searxng AS dist
|
||||
|
||||
COPY --chown=977:977 --from=builder /usr/local/searxng/.venv/ ./.venv/
|
||||
@@ -21,6 +46,8 @@ LABEL org.opencontainers.image.created="$CREATED" \
|
||||
org.opencontainers.image.url="https://searxng.org" \
|
||||
org.opencontainers.image.version="$VERSION"
|
||||
|
||||
# Extended from:
|
||||
# https://codeberg.org/searxng/base/src/branch/main/Dockerfile
|
||||
ENV __SEARXNG_VERSION="$VERSION" \
|
||||
__SEARXNG_SETTINGS_PATH="$__SEARXNG_CONFIG_PATH/settings.yml" \
|
||||
GRANIAN_PROCESS_NAME="searxng" \
|
||||
@@ -32,7 +59,6 @@ ENV __SEARXNG_VERSION="$VERSION" \
|
||||
GRANIAN_WORKERS_KILL_TIMEOUT="30s" \
|
||||
GRANIAN_BLOCKING_THREADS_IDLE_TIMEOUT="5m"
|
||||
|
||||
# "*_PATH" ENVs are defined in base images
|
||||
VOLUME $__SEARXNG_CONFIG_PATH
|
||||
VOLUME $__SEARXNG_DATA_PATH
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
FROM docker.io/searxng/base:searxng-builder AS builder
|
||||
|
||||
COPY ./requirements.txt ./requirements-server.txt ./
|
||||
|
||||
ENV UV_NO_MANAGED_PYTHON="true"
|
||||
|
||||
RUN --mount=type=cache,id=uv,target=/root/.cache/uv set -eux -o pipefail; \
|
||||
uv venv; \
|
||||
uv pip install --requirements ./requirements.txt --requirements ./requirements-server.txt; \
|
||||
uv cache prune --ci; \
|
||||
find ./.venv/lib/ -type f -exec strip --strip-unneeded {} + || true; \
|
||||
find ./.venv/lib/ -type d -name "__pycache__" -exec rm -rf {} +; \
|
||||
find ./.venv/lib/ -type f -name "*.pyc" -delete; \
|
||||
python -m compileall -q -f -j 0 --invalidation-mode=unchecked-hash ./.venv/lib/
|
||||
|
||||
COPY --exclude=./searx/version_frozen.py ./searx/ ./searx/
|
||||
|
||||
RUN set -eux -o pipefail; \
|
||||
python -m compileall -q -f -j 0 --invalidation-mode=unchecked-hash ./searx/; \
|
||||
find ./searx/static/ -type f \
|
||||
\( -name "*.html" -o -name "*.css" -o -name "*.js" -o -name "*.svg" \) \
|
||||
-exec gzip -9 -k {} + \
|
||||
-exec brotli -9 -k {} + \
|
||||
-exec gzip --test {}.gz + \
|
||||
-exec brotli --test {}.br +
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
valkey:
|
||||
container_name: searxng-valkey
|
||||
image: docker.io/valkey/valkey:9-alpine
|
||||
command: valkey-server --save 30 1 --loglevel warning
|
||||
command: valkey-server --save 300 1 --save 60 1000 --loglevel warning
|
||||
restart: always
|
||||
volumes:
|
||||
- valkey-data:/data/
|
||||
|
||||
@@ -13,7 +13,7 @@ check_file() {
|
||||
!!! "$target" is not a valid file, exiting...
|
||||
!!!
|
||||
EOF
|
||||
exit 127
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ check_directory() {
|
||||
!!! "$target" is not a valid directory, exiting...
|
||||
!!!
|
||||
EOF
|
||||
exit 127
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -283,12 +283,12 @@ container images are not officially supported):
|
||||
$ make container
|
||||
|
||||
$ docker images
|
||||
REPOSITORY TAG IMAGE ID SIZE
|
||||
localhost/searxng/searxng 2026.6.19-93f66bfb4 ... 265 MB
|
||||
localhost/searxng/searxng latest ... 265 MB
|
||||
localhost/searxng/searxng builder ... 687 MB
|
||||
docker.io/searxng/base searxng-builder ... 565 MB
|
||||
docker.io/searxng/base searxng ... 143 MB
|
||||
REPOSITORY TAG IMAGE ID SIZE
|
||||
localhost/searxng/searxng latest ... 271 MB
|
||||
localhost/searxng/searxng 2026.10.17-* ... 271 MB
|
||||
<none> <none> ... 677 MB
|
||||
docker.io/searxng/base searxng ... 144 MB
|
||||
docker.io/searxng/base searxng-builder ... 550 MB
|
||||
|
||||
Migrate from ``searxng-docker``
|
||||
===============================
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.. _result_types.video:
|
||||
|
||||
=============
|
||||
Video Results
|
||||
=============
|
||||
|
||||
.. automodule:: searx.result_types.video
|
||||
@@ -19,13 +19,13 @@ following types have been implemented so far ..
|
||||
main/paper
|
||||
main/file
|
||||
main/image
|
||||
main/video
|
||||
|
||||
The :ref:`LegacyResult <LegacyResult>` is used internally for the results that
|
||||
have not yet been typed. The templates can be used as orientation until the
|
||||
final typing is complete.
|
||||
|
||||
- :ref:`template default` / :py:obj:`Result`
|
||||
- :ref:`template videos`
|
||||
- :ref:`template torrent`
|
||||
- :ref:`template map`
|
||||
- :ref:`template packages`
|
||||
|
||||
@@ -129,29 +129,6 @@ audio_src : uri,
|
||||
URL of an embedded ``<audio controls>``.
|
||||
|
||||
|
||||
.. _template videos:
|
||||
|
||||
``videos.html``
|
||||
---------------
|
||||
|
||||
Displays result fields from:
|
||||
|
||||
- :ref:`macro result_header` and
|
||||
- :ref:`macro result_sub_header`
|
||||
|
||||
Additional fields used in the :origin:`videos.html
|
||||
<searx/templates/simple/result_templates/videos.html>`:
|
||||
|
||||
iframe_src : :py:class:`str`
|
||||
URL of an embedded ``<iframe>`` / the frame is collapsible.
|
||||
|
||||
The videos are displayed as small thumbnails in the main results list, there
|
||||
is an additional button to collaps/open the embeded video.
|
||||
|
||||
content : :py:class:`str`
|
||||
Description of the code fragment.
|
||||
|
||||
|
||||
.. _template torrent:
|
||||
|
||||
``torrent.html``
|
||||
|
||||
@@ -48,7 +48,7 @@ PATH="${PY_ENV}/bin:${REPO_ROOT}/node_modules/.bin:${GOROOT}/bin:${GOPATH}/bin:$
|
||||
|
||||
PYOBJECTS="searx"
|
||||
PY_SETUP_EXTRAS='[test]'
|
||||
GECKODRIVER_VERSION="v0.37.0"
|
||||
GECKODRIVER_VERSION="v0.37.1"
|
||||
# SPHINXOPTS=
|
||||
BLACK_OPTIONS=("--target-version" "py311" "--line-length" "120" "--skip-string-normalization")
|
||||
BLACK_TARGETS=("--exclude" "(searx/static|searx/languages.py)" "--include" 'searxng.msg|\.pyi?$' "searx" "searxng_extra" "tests")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
mock==5.2.0
|
||||
nose2[coverage_plugin]==0.16.0
|
||||
cov-core==1.15.0
|
||||
black==26.5.1
|
||||
pylint==4.0.8
|
||||
black==26.10.1
|
||||
pylint==4.1.3
|
||||
splinter==0.21.0
|
||||
selenium==4.49.0
|
||||
selenium==4.51.0
|
||||
Sphinx==8.2.3;python_version <= "3.11"
|
||||
Sphinx==9.1.0; python_version > "3.11"
|
||||
sphinx-issues==6.0.0
|
||||
@@ -18,11 +18,11 @@ myst-parser==5.0.0
|
||||
linuxdoc==20260504
|
||||
aiounittest==1.5.0
|
||||
yamllint==1.38.0
|
||||
wlc==2.2.0
|
||||
wlc==2.2.1
|
||||
coloredlogs==15.0.1
|
||||
docutils>=0.21.2;python_version <= "3.11"
|
||||
docutils>=0.22.4; python_version > "3.11"
|
||||
parameterized==0.9.0
|
||||
granian[reload]==2.8.3
|
||||
basedpyright==1.40.1
|
||||
granian[reload]==2.8.5
|
||||
basedpyright==1.40.2
|
||||
types-lxml==2026.2.16
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
granian==2.8.3
|
||||
granian[pname]==2.8.3
|
||||
granian==2.8.5
|
||||
granian[pname]==2.8.5
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
certifi==2026.7.22
|
||||
certifi==2026.10.23
|
||||
babel==2.18.0
|
||||
flask-babel==4.0.0
|
||||
flask==3.1.3
|
||||
jinja2==3.1.6
|
||||
lxml==6.1.3
|
||||
pygments==2.21.0
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dateutil==3.0.0
|
||||
pyyaml==6.0.3
|
||||
curl_cffi==0.16.3
|
||||
curl_cffi==0.17.0
|
||||
valkey==6.1.1
|
||||
markdown-it-py==4.2.0
|
||||
msgspec==0.21.1
|
||||
typer==0.27.2
|
||||
msgspec==0.22.0
|
||||
typer==0.27.3
|
||||
isodate==0.7.2
|
||||
whitenoise==6.12.0
|
||||
typing-extensions==4.16.0
|
||||
|
||||
@@ -28,19 +28,19 @@ if t.TYPE_CHECKING:
|
||||
from searx.extended_types import SXNG_Response
|
||||
|
||||
|
||||
def update_kwargs(**kwargs) -> None: # type: ignore
|
||||
def update_kwargs(kwargs) -> None: # type: ignore
|
||||
if 'timeout' not in kwargs:
|
||||
kwargs['timeout'] = settings['outgoing']['request_timeout']
|
||||
kwargs['raise_for_httperror'] = True
|
||||
|
||||
|
||||
def get(*args, **kwargs) -> "SXNG_Response": # type: ignore
|
||||
update_kwargs(**kwargs) # pyright: ignore[reportUnknownArgumentType]
|
||||
update_kwargs(kwargs)
|
||||
return http_get(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType]
|
||||
|
||||
|
||||
def post(*args, **kwargs) -> "SXNG_Response": # type: ignore
|
||||
update_kwargs(**kwargs) # pyright: ignore[reportUnknownArgumentType]
|
||||
update_kwargs(kwargs)
|
||||
return http_post(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType]
|
||||
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ class TrackerPatternsDB:
|
||||
continue
|
||||
|
||||
if resp.status_code != 200:
|
||||
log.warning(f"TRACKER_PATTERNS: ClearURL ignore HTTP {resp.status_code} {url}")
|
||||
log.warning("TRACKER_PATTERNS: ClearURL ignore HTTP %s %s", resp.status_code, url)
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
],
|
||||
"ua": "Mozilla/5.0 ({os}; rv:{version}) Gecko/20100101 Firefox/{version}",
|
||||
"versions": [
|
||||
"154.0",
|
||||
"153.0"
|
||||
"157.0",
|
||||
"156.0"
|
||||
]
|
||||
}
|
||||
@@ -52,7 +52,7 @@ def response(resp) -> EngineResults:
|
||||
published_date = None
|
||||
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
res.types.Video(
|
||||
url=entry["play_url"],
|
||||
title=html_to_text(entry["title"]),
|
||||
content=html_to_text(entry["description"]),
|
||||
|
||||
@@ -33,7 +33,7 @@ about = {
|
||||
base_url = "https://500px.com"
|
||||
api_url = "https://api-neo.500px.com"
|
||||
|
||||
categories = ["stock_images"]
|
||||
categories = ["stock images"]
|
||||
paging = True
|
||||
|
||||
results_per_page = 30
|
||||
|
||||
@@ -115,26 +115,26 @@ def load_engine(engine_data: dict[str, t.Any]) -> "Engine | types.ModuleType | N
|
||||
logger.error('An engine does not have a "name" field')
|
||||
return None
|
||||
if '_' in engine_name:
|
||||
logger.error('Engine name contains underscore: "{}"'.format(engine_name))
|
||||
logger.error('Engine name contains underscore: "%s"', engine_name)
|
||||
return None
|
||||
|
||||
if engine_name.lower() != engine_name:
|
||||
logger.warning('Engine name is not lowercase: "{}", converting to lowercase'.format(engine_name))
|
||||
logger.warning('Engine name is not lowercase: "%s", converting to lowercase', engine_name)
|
||||
engine_name = engine_name.lower()
|
||||
engine_data['name'] = engine_name
|
||||
|
||||
# load_module
|
||||
module_name = engine_data.get('engine')
|
||||
if module_name is None:
|
||||
logger.error('The "engine" field is missing for the engine named "{}"'.format(engine_name))
|
||||
logger.error('The "engine" field is missing for the engine named "%s"', engine_name)
|
||||
return None
|
||||
try:
|
||||
engine = load_module(module_name + '.py', ENGINE_DIR)
|
||||
except (SyntaxError, KeyboardInterrupt, SystemExit, SystemError, ImportError, RuntimeError):
|
||||
logger.exception('Fatal exception in engine "{}"'.format(module_name))
|
||||
logger.exception('Fatal exception in engine "%s"', module_name)
|
||||
sys.exit(1)
|
||||
except BaseException:
|
||||
logger.exception('Cannot load engine "{}"'.format(module_name))
|
||||
logger.exception('Cannot load engine "%s"', module_name)
|
||||
return None
|
||||
|
||||
check_engine_module(engine)
|
||||
@@ -247,7 +247,7 @@ def is_missing_required_attributes(engine: "Engine | types.ModuleType"):
|
||||
missing = False
|
||||
for engine_attr in dir(engine):
|
||||
if not engine_attr.startswith('_') and getattr(engine, engine_attr) is None:
|
||||
logger.error('Missing engine config attribute: "{0}.{1}"'.format(engine.name, engine_attr))
|
||||
logger.error('Missing engine config attribute: "%s.%s"', engine.name, engine_attr)
|
||||
missing = True
|
||||
return missing
|
||||
|
||||
@@ -277,12 +277,12 @@ def call_engine_setup(engine: "Engine | types.ModuleType", engine_data: dict[str
|
||||
if setup_func is None:
|
||||
setup_ok = True
|
||||
elif not callable(setup_func):
|
||||
logger.error(f"engine's setup method isn't a callable (is of type: {type(setup_func)})")
|
||||
logger.error("engine's setup method isn't a callable (is of type: %s)", type(setup_func))
|
||||
else:
|
||||
try:
|
||||
setup_ok = engine.setup(engine_data)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.exception(f"(PID {os.getpid()}) {engine.name}: engine SETUP failed, exception: {e}")
|
||||
logger.exception("(PID %s) %s: engine SETUP failed, exception: %s", os.getpid(), engine.name, e)
|
||||
setup_ok = False
|
||||
|
||||
# The evaluation of the return value is analogous to Engine.init
|
||||
@@ -290,18 +290,18 @@ def call_engine_setup(engine: "Engine | types.ModuleType", engine_data: dict[str
|
||||
setup_ok = True
|
||||
|
||||
if not setup_ok:
|
||||
logger.error(f"(PID {os.getpid()}) {engine.name}: engine setup was not successful")
|
||||
logger.error("(PID %s) %s: engine setup was not successful", os.getpid(), engine.name)
|
||||
return setup_ok
|
||||
|
||||
|
||||
def register_engine(engine: "Engine | types.ModuleType"):
|
||||
if engine.name in engines:
|
||||
logger.error('Engine config error: ambiguous name: {0}'.format(engine.name))
|
||||
logger.error('Engine config error: ambiguous name: %s', engine.name)
|
||||
sys.exit(1)
|
||||
engines[engine.name] = engine
|
||||
|
||||
if engine.shortcut in engine_shortcuts:
|
||||
logger.error('Engine config error: ambiguous shortcut: {0}'.format(engine.shortcut))
|
||||
logger.error('Engine config error: ambiguous shortcut: %s', engine.shortcut)
|
||||
sys.exit(1)
|
||||
engine_shortcuts[engine.shortcut] = engine.name
|
||||
|
||||
@@ -327,7 +327,9 @@ def load_engines(engine_list: list[dict[str, t.Any]]):
|
||||
# if an engine can't be loaded (if for example the engine is missing
|
||||
# tor or some other requirements) its set to inactive!
|
||||
logger.error(
|
||||
f"(PID {os.getpid()}) {engine_data.get('name', '???')}: can't register engine (loading engine failed)"
|
||||
"(PID %s) %s: can't register engine (loading engine failed)",
|
||||
os.getpid(),
|
||||
engine_data.get("name", "???"),
|
||||
)
|
||||
engine_data["inactive"] = True
|
||||
return engines
|
||||
|
||||
@@ -37,8 +37,7 @@ def obtain_token():
|
||||
)
|
||||
token['value'] = loads(actual_token.text)['authInfo']['access_token']
|
||||
token['last_updated'] = update_time
|
||||
# pylint: disable=bare-except
|
||||
except:
|
||||
except: # pylint: disable=bare-except
|
||||
pass
|
||||
return token
|
||||
|
||||
|
||||
@@ -118,27 +118,30 @@ Implementations
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import typing as t
|
||||
from urllib.parse import (
|
||||
urlencode,
|
||||
urlparse,
|
||||
)
|
||||
from collections.abc import Callable
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from dateutil import parser
|
||||
|
||||
from searx import locales
|
||||
from searx import locales, logger
|
||||
from searx.enginelib.traits import EngineTraits
|
||||
from searx.extended_types import SXNG_Response
|
||||
from searx.result_types import EngineResults
|
||||
from searx.exceptions import SearxEngineResponseException
|
||||
from searx.result_types import EngineResults, MainResult, Video
|
||||
from searx.result_types.image import Image
|
||||
from searx.utils import (
|
||||
eval_xpath_getindex,
|
||||
eval_xpath_list,
|
||||
extract_text,
|
||||
get_embedded_stream_url,
|
||||
html_to_text,
|
||||
js_find_matching_bracket,
|
||||
js_obj_str_to_json_str,
|
||||
js_obj_str_to_python,
|
||||
js_split_args,
|
||||
js_find_variable_positions,
|
||||
)
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from searx.extended_types import SXNG_Response
|
||||
|
||||
about = {
|
||||
"website": "https://search.brave.com/",
|
||||
"wikidata_id": "Q22906900",
|
||||
@@ -193,6 +196,16 @@ time_range_map: dict[str, str] = {
|
||||
"year": "py",
|
||||
}
|
||||
|
||||
IIFE_RE = re.compile(
|
||||
r"""
|
||||
\(function\s*\( # (function(
|
||||
(?P<params>[^)]*) # formal parameters
|
||||
\)\s*\{\s*return\s* # ){ return
|
||||
(?P<body>\{) # the object literal starts
|
||||
""",
|
||||
re.VERBOSE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def request(query: str, params: dict[str, t.Any]) -> None:
|
||||
|
||||
@@ -212,12 +225,10 @@ def request(query: str, params: dict[str, t.Any]) -> None:
|
||||
if brave_category == "goggles":
|
||||
args["goggles_id"] = Goggles
|
||||
|
||||
params["headers"]["Accept-Encoding"] = "gzip, deflate"
|
||||
params["url"] = f"{base_url}{brave_category}?{urlencode(args)}"
|
||||
logger.debug("url %s", params["url"])
|
||||
|
||||
# set properties in the cookies
|
||||
|
||||
params["cookies"]["safesearch"] = safesearch_map.get(params["safesearch"], "off")
|
||||
# the useLocation is IP based, we use cookie "country" for the region
|
||||
params["cookies"]["useLocation"] = "0"
|
||||
@@ -240,176 +251,228 @@ def _extract_published_date(published_date_raw: str | None):
|
||||
return None
|
||||
|
||||
|
||||
def extract_iife_object(data_src: str, match: re.Match[str]) -> tuple[str, int]:
|
||||
"""
|
||||
Extract and substitute the object literal from a JavaScript IIFE.
|
||||
Returns the transformed object literal and the position immediately after the IIFE call.
|
||||
"""
|
||||
body_start = match.start("body")
|
||||
body_end = js_find_matching_bracket(data_src, body_start, "{", "}")
|
||||
obj_str = data_src[body_start : body_end + 1]
|
||||
|
||||
# 1. Locate the argument call execution signature block: ...)(arg1, arg2)
|
||||
call_start = data_src.find("(", body_end)
|
||||
if call_start == -1:
|
||||
raise ValueError("IIFE missing call arguments")
|
||||
call_end = js_find_matching_bracket(data_src, call_start, "(", ")")
|
||||
|
||||
# 2. map arguments
|
||||
args = js_split_args(data_src[call_start + 1 : call_end])
|
||||
params = [p.strip() for p in match["params"].split(",") if p.strip()]
|
||||
mapping = dict(zip(params, args))
|
||||
|
||||
# 3. substitute idents
|
||||
variables = js_find_variable_positions(obj_str)
|
||||
for variable_name, pos in reversed(variables):
|
||||
if variable_name in mapping:
|
||||
value = mapping[variable_name]
|
||||
obj_str = obj_str[:pos] + value + obj_str[pos + len(variable_name) :]
|
||||
|
||||
return obj_str, call_end + 1
|
||||
|
||||
|
||||
def extract_json_data(text: str) -> dict[str, t.Any]:
|
||||
"""
|
||||
Extract the SvelteKit data payload that Brave embeds in the page.
|
||||
|
||||
Handles:
|
||||
- plain-object form
|
||||
- pure IIFE form
|
||||
- mixed form (IIFE + plain object) that appears on some news pages
|
||||
"""
|
||||
marker = "data: ["
|
||||
start = text.find(marker)
|
||||
if start == -1:
|
||||
raise ValueError("Brave data payload not found (no 'data: [')")
|
||||
|
||||
array_start = start + len(marker) - 1 # Points directly at '['
|
||||
array_end = js_find_matching_bracket(text, array_start, "[", "]")
|
||||
data_src = text[array_start + 1 : array_end]
|
||||
|
||||
objects: list[str] = []
|
||||
pos = 0
|
||||
n = len(data_src)
|
||||
|
||||
while pos < n:
|
||||
# Move past whitespace and commas safely
|
||||
while pos < n and data_src[pos] in " \t\n\r,":
|
||||
pos += 1
|
||||
if pos >= n:
|
||||
break
|
||||
|
||||
# Case 1: IIFE Object Signature Matches
|
||||
if m := IIFE_RE.match(data_src, pos):
|
||||
obj_str, next_pos = extract_iife_object(data_src, m)
|
||||
objects.append(obj_str)
|
||||
pos = next_pos
|
||||
# Brave sometimes emits an extra ')' after the IIFE call
|
||||
while pos < n and data_src[pos] in " \t\n\r)":
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
# Case 2: Standard raw plain JSON mapping block {type:"data", ...}
|
||||
if data_src[pos] == "{":
|
||||
obj_end = js_find_matching_bracket(data_src, pos, "{", "}")
|
||||
objects.append(data_src[pos : obj_end + 1])
|
||||
pos = obj_end + 1
|
||||
continue
|
||||
|
||||
raise ValueError(f"unexpected token in data array at offset {pos}: {data_src[pos : pos + 40]!r}")
|
||||
|
||||
if not objects:
|
||||
raise ValueError("no data objects found inside Brave payload")
|
||||
|
||||
combined = '{"data":[' + ",".join(objects) + "]}"
|
||||
json_str = js_obj_str_to_json_str(combined)
|
||||
return json.loads(json_str)
|
||||
|
||||
|
||||
def response(resp: "SXNG_Response") -> EngineResults:
|
||||
# delegate the response to the appropriate parser based on search type
|
||||
|
||||
match brave_category:
|
||||
case "search" | "goggles":
|
||||
return _parse_results(parse_search_result, resp)
|
||||
case "news":
|
||||
return _parse_results(parse_news_result, resp)
|
||||
case "images":
|
||||
return _parse_results(parse_image_result, resp)
|
||||
case "videos":
|
||||
return _parse_results(parse_video_result, resp)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported brave category: {brave_category}") # pyright: ignore[reportUnreachable]
|
||||
|
||||
|
||||
def parse_search_result(result: dict[str, t.Any]) -> MainResult:
|
||||
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
|
||||
return MainResult(
|
||||
template="default.html",
|
||||
title=result.get("title", ""),
|
||||
content=html_to_text(result.get("description", "")),
|
||||
url=result.get("url", ""),
|
||||
publishedDate=_extract_published_date(result.get("page_age")),
|
||||
pubdate=result.get("age", ""),
|
||||
thumbnail=thumbnail.get("src", "") if thumbnail and not thumbnail.get("logo") else "",
|
||||
)
|
||||
|
||||
|
||||
def _parse_secondary_items(json_data: dict[str, t.Any], results: EngineResults):
|
||||
# video results utilize same schema as video search -> re-use _parse_video_result
|
||||
body_resp: dict[str, t.Any] = _get_response_data(json_data)
|
||||
videos_resp: dict[str, t.Any] = body_resp.get("videos", {})
|
||||
if videos_resp and "results" in videos_resp:
|
||||
for result in videos_resp.get("results", []):
|
||||
results.add(parse_video_result(result))
|
||||
# related queries -> suggestion
|
||||
query: dict[str, t.Any] = body_resp.get("query", {})
|
||||
if query and "related_queries" in query:
|
||||
for x in query.get("related_queries", []):
|
||||
suggestion = " ".join(val[1] for val in x)
|
||||
results.add(results.types.LegacyResult(suggestion=suggestion))
|
||||
|
||||
|
||||
def parse_news_result(result: dict[str, t.Any]) -> MainResult:
|
||||
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
|
||||
return MainResult(
|
||||
title=result.get("title", ""),
|
||||
content=result.get("description", ""),
|
||||
url=result.get("url"),
|
||||
publishedDate=_extract_published_date(result.get("age")),
|
||||
pubdate=result.get("age", ""),
|
||||
thumbnail=thumbnail.get("src", "") if thumbnail else "",
|
||||
)
|
||||
|
||||
|
||||
def parse_image_result(result: dict[str, t.Any]) -> Image:
|
||||
properties: dict[str, t.Any] = result.get("properties", {})
|
||||
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
|
||||
width, height = properties.get("width"), properties.get("height")
|
||||
|
||||
return Image(
|
||||
title=result.get("title", ""),
|
||||
url=result.get("url"),
|
||||
img_src=properties.get("url", ""),
|
||||
thumbnail_src=thumbnail.get("src", "") if thumbnail else "",
|
||||
source=result.get("source", ""),
|
||||
resolution=f"{width}x{height}" if width and height else "",
|
||||
)
|
||||
|
||||
|
||||
def parse_video_result(result: dict[str, t.Any]) -> Video:
|
||||
video: dict[str, t.Any] = result.get("video", {})
|
||||
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
|
||||
|
||||
return Video(
|
||||
title=result.get("title", ""),
|
||||
url=result.get("url"),
|
||||
content=result.get("description", ""),
|
||||
length=video.get("duration"),
|
||||
publishedDate=_extract_published_date(result.get("age")),
|
||||
pubdate=result.get("age", ""),
|
||||
views=video.get("views", ""),
|
||||
thumbnail=thumbnail.get("src", "") if thumbnail else "",
|
||||
)
|
||||
|
||||
|
||||
def _get_response_data(json_data: dict[str, t.Any], category: str | None = None) -> dict[str, t.Any]:
|
||||
"""Navigate the Brave embedded JSON to the category-specific response object."""
|
||||
# Brave’s structure is mostly consistent but has a couple of quirks:
|
||||
# - most categories live under data[1].data.body.response.<category>
|
||||
# - news omits the intermediate "body" key
|
||||
try:
|
||||
data: dict[str, t.Any] = json_data["data"][1]["data"]
|
||||
|
||||
if data.get("noResults"): # Boolean Value
|
||||
return {}
|
||||
|
||||
if category == "news":
|
||||
return data["response"]["news"]
|
||||
|
||||
body_resp = data["body"]["response"]
|
||||
if category in ("search", "goggles"):
|
||||
return body_resp["web"]
|
||||
# images / videos / secondary items
|
||||
return body_resp
|
||||
except (KeyError, IndexError, TypeError) as e:
|
||||
raise SearxEngineResponseException(f"Unexpected Brave JSON structure for category {category!r}") from e
|
||||
|
||||
|
||||
def _parse_results(parse_func: Callable[..., MainResult | Image], resp: "SXNG_Response") -> EngineResults:
|
||||
"""Extract json data and loop through result list
|
||||
The suppled :py.obj:`parse_func` parses individual result items
|
||||
General search / goggle search relies on :py.obj:`_parse_secondary_items` for mixed result-types"""
|
||||
# Example script source containing the data:
|
||||
#
|
||||
# kit.start(app, element, {
|
||||
# node_ids: [0, 19],
|
||||
# data: [{type:"data",data: .... ["q","goggles_id"],route:1,url:1}}]
|
||||
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
# form: null,
|
||||
# error: null
|
||||
# });
|
||||
start = text.index("data: [{")
|
||||
newline = text.index("\n", start)
|
||||
end = text.rindex("}}]", start, newline)
|
||||
js_obj_str = "{" + text[start:end] + "}}]}"
|
||||
# js_obj_str = js_obj_str.replace("\xa0", "") # remove ASCII for
|
||||
# js_obj_str = js_obj_str.replace(r"\u003C", "<").replace(r"\u003c", "<") # fix broken HTML tags in strings
|
||||
json_str = js_obj_str_to_json_str(js_obj_str)
|
||||
data: dict[str, t.Any] = json.loads(json_str)
|
||||
return data
|
||||
|
||||
|
||||
def response(resp: SXNG_Response) -> EngineResults:
|
||||
|
||||
if brave_category in ("search", "goggles"):
|
||||
return _parse_search(resp)
|
||||
|
||||
if brave_category in ("news"):
|
||||
return _parse_news(resp)
|
||||
|
||||
# Example script source containing the data:
|
||||
#
|
||||
# kit.start(app, element, {
|
||||
# node_ids: [0, 19],
|
||||
# data: [{type:"data",data: .... ["q","goggles_id"],route:1,url:1}}]
|
||||
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
results = EngineResults()
|
||||
json_data: dict[str, t.Any] = extract_json_data(resp.text)
|
||||
json_resp: dict[str, t.Any] = json_data["data"][1]["data"]["body"]["response"]
|
||||
json_resp: dict[str, t.Any] = _get_response_data(json_data, brave_category)
|
||||
if not json_resp:
|
||||
# if _get_response_data returns {} - indicates it was parsed successfully but had "noResults" = True
|
||||
return results
|
||||
|
||||
if brave_category == "images":
|
||||
return _parse_images(json_resp)
|
||||
if brave_category == "videos":
|
||||
return _parse_videos(json_resp)
|
||||
json_results: list[dict[str, t.Any]] = json_resp["results"]
|
||||
for result in json_results:
|
||||
results.add(parse_func(result))
|
||||
|
||||
raise ValueError(f"Unsupported brave category: {brave_category}")
|
||||
# general search / goggle might have secondary items
|
||||
if brave_category in ("search", "goggles"):
|
||||
_parse_secondary_items(json_data, results)
|
||||
|
||||
|
||||
def _parse_search(resp: SXNG_Response) -> EngineResults:
|
||||
res = EngineResults()
|
||||
dom = resp.html()
|
||||
|
||||
for result in eval_xpath_list(dom, "//div[contains(@class, 'snippet ')]"):
|
||||
url: str | None = eval_xpath_getindex(result, ".//a/@href", 0, default=None)
|
||||
title_tag = eval_xpath_getindex(result, ".//div[contains(@class, 'title')]", 0, default=None)
|
||||
if url is None or title_tag is None or not urlparse(url).netloc: # partial url likely means it's an ad
|
||||
continue
|
||||
|
||||
content: str = ""
|
||||
pub_date = None
|
||||
|
||||
# there are other classes like 'site-name-content' we don't want to match,
|
||||
# however only using contains(@class, 'content') would e.g. also match `site-name-content`
|
||||
# thus, we explicitly also require the spaces as class separator
|
||||
_content = eval_xpath_getindex(
|
||||
result,
|
||||
".//div[contains(concat(' ', @class, ' '), ' content ')]",
|
||||
0,
|
||||
default="",
|
||||
)
|
||||
if len(_content):
|
||||
content = extract_text(_content) # type: ignore
|
||||
_pub_date = extract_text(
|
||||
eval_xpath_getindex(_content, ".//span[contains(@class, 't-secondary')]", 0, default="")
|
||||
)
|
||||
if _pub_date:
|
||||
pub_date = _extract_published_date(_pub_date)
|
||||
content = content.lstrip(_pub_date).strip("- \n\t")
|
||||
|
||||
thumbnail: str = eval_xpath_getindex(result, ".//a[contains(@class, 'thumbnail')]//img/@src", 0, default="")
|
||||
|
||||
item = res.types.LegacyResult(
|
||||
template="default.html",
|
||||
url=url,
|
||||
title=extract_text(title_tag),
|
||||
content=content,
|
||||
publishedDate=pub_date,
|
||||
thumbnail=thumbnail,
|
||||
)
|
||||
res.add(item)
|
||||
|
||||
video_tag = eval_xpath_getindex(
|
||||
result,
|
||||
".//div[contains(@class, 'video-snippet') and @data-macro='video']",
|
||||
0,
|
||||
default=[],
|
||||
)
|
||||
if len(video_tag):
|
||||
# In my tests a video tag in the WEB search was most often not a
|
||||
# video, except the ones from youtube ..
|
||||
iframe_src = get_embedded_stream_url(url)
|
||||
if iframe_src:
|
||||
item["iframe_src"] = iframe_src
|
||||
item["template"] = "videos.html"
|
||||
|
||||
for suggestion in eval_xpath_list(dom, "//a[contains(@class, 'related-query')]"):
|
||||
res.append(res.types.LegacyResult({"suggestion": extract_text(suggestion)}))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _parse_news(resp: SXNG_Response) -> EngineResults:
|
||||
res = EngineResults()
|
||||
dom = resp.html()
|
||||
|
||||
for result in eval_xpath_list(dom, "//div[@data-type='news']"):
|
||||
url = eval_xpath_getindex(result, ".//a/@href", 0, default=None)
|
||||
if url is None:
|
||||
continue
|
||||
|
||||
title = eval_xpath_list(result, ".//div[contains(@class, 'title')]")
|
||||
content = eval_xpath_list(result, ".//div[contains(@class, 'description')]")
|
||||
thumbnail = eval_xpath_getindex(result, ".//a[contains(@class, 'thumbnail')]//img/@src", 0, default="")
|
||||
|
||||
item = res.types.LegacyResult(
|
||||
template="default.html",
|
||||
url=url,
|
||||
title=extract_text(title),
|
||||
thumbnail=thumbnail,
|
||||
content=extract_text(content),
|
||||
)
|
||||
res.add(item)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _parse_images(json_resp: dict[str, t.Any]) -> EngineResults:
|
||||
res = EngineResults()
|
||||
|
||||
for result in json_resp["results"]:
|
||||
item = res.types.LegacyResult(
|
||||
template="images.html",
|
||||
url=result["url"],
|
||||
title=result["title"],
|
||||
source=result["source"],
|
||||
img_src=result["properties"]["url"],
|
||||
thumbnail_src=result["thumbnail"]["src"],
|
||||
)
|
||||
res.add(item)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _parse_videos(json_resp: dict[str, t.Any]) -> EngineResults:
|
||||
res = EngineResults()
|
||||
|
||||
for result in json_resp["results"]:
|
||||
item = res.types.LegacyResult(
|
||||
template="videos.html",
|
||||
url=result["url"],
|
||||
title=result["title"],
|
||||
content=result["description"],
|
||||
length=result["video"]["duration"],
|
||||
duration=result["video"]["duration"],
|
||||
publishedDate=_extract_published_date(result["age"]),
|
||||
)
|
||||
if result["thumbnail"] is not None:
|
||||
item["thumbnail"] = result["thumbnail"]["src"]
|
||||
|
||||
res.add(item)
|
||||
|
||||
return res
|
||||
return results
|
||||
|
||||
|
||||
def fetch_traits(engine_traits: EngineTraits):
|
||||
@@ -447,10 +510,9 @@ def fetch_traits(engine_traits: EngineTraits):
|
||||
# silently ignore unknown languages
|
||||
continue
|
||||
|
||||
conflict = engine_traits.custom["ui_lang"].get(sxng_tag) # type: ignore
|
||||
if conflict:
|
||||
if conflict := engine_traits.custom["ui_lang"].get(sxng_tag):
|
||||
if conflict != ui_lang:
|
||||
print("CONFLICT: babel %s --> %s, %s" % (sxng_tag, conflict, ui_lang))
|
||||
print(f"CONFLICT: babel {sxng_tag} --> {conflict}, {ui_lang}")
|
||||
continue
|
||||
engine_traits.custom["ui_lang"][sxng_tag] = ui_lang
|
||||
|
||||
@@ -477,15 +539,14 @@ def fetch_traits(engine_traits: EngineTraits):
|
||||
for lang_tag in babel.languages.get_official_languages(country_tag, de_facto=True):
|
||||
lang_tag = lang_map.get(lang_tag, lang_tag)
|
||||
try:
|
||||
sxng_tag = region_tag(babel.Locale.parse("%s_%s" % (lang_tag, country_tag.upper())))
|
||||
sxng_tag = region_tag(babel.Locale.parse(f"{lang_tag}_{country_tag.upper()}"))
|
||||
except babel.UnknownLocaleError:
|
||||
# silently ignore unknown languages
|
||||
continue
|
||||
# print("%-20s: %s <-- %s" % (v["label"], country_tag, sxng_tag))
|
||||
|
||||
conflict = engine_traits.regions.get(sxng_tag)
|
||||
if conflict:
|
||||
if conflict != country_tag:
|
||||
print("CONFLICT: babel %s --> %s, %s" % (sxng_tag, conflict, country_tag))
|
||||
continue
|
||||
if conflict and conflict != country_tag:
|
||||
print(f"CONFLICT: babel {sxng_tag} --> {conflict}, {country_tag}")
|
||||
continue
|
||||
engine_traits.regions[sxng_tag] = country_tag
|
||||
|
||||
@@ -27,11 +27,10 @@ The API supports paging and time filters.
|
||||
import typing as t
|
||||
|
||||
from urllib.parse import urlencode
|
||||
from dateutil import parser
|
||||
|
||||
from searx.engines.brave import parse_video_result
|
||||
from searx.exceptions import SearxEngineAPIException
|
||||
from searx.result_types import EngineResults
|
||||
from searx.utils import html_to_text
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from searx.extended_types import SXNG_Response
|
||||
@@ -52,6 +51,7 @@ api_key: str = ""
|
||||
categories = ["general", "web"]
|
||||
paging = True
|
||||
safesearch = True
|
||||
safesearch_map = {2: "strict", 1: "moderate", 0: "off"}
|
||||
time_range_support = True
|
||||
|
||||
results_per_page: int = 20
|
||||
@@ -85,52 +85,21 @@ def request(query: str, params: "OnlineParams") -> None:
|
||||
if params["time_range"]:
|
||||
search_args["time_range"] = time_range_map.get(params["time_range"])
|
||||
|
||||
# Apply SafeSearch if enabled
|
||||
if params["safesearch"]:
|
||||
search_args["safesearch"] = "strict"
|
||||
# Apply SafeSearch
|
||||
search_args["safesearch"] = safesearch_map.get(params["safesearch"], "off")
|
||||
|
||||
params["url"] = f"{base_url}?{urlencode(search_args)}"
|
||||
params["headers"]["X-Subscription-Token"] = api_key
|
||||
params["headers"]["Accept"] = "application/json"
|
||||
|
||||
|
||||
def _extract_published_date(published_date_raw: str):
|
||||
"""Extract and parse the published date from the API response.
|
||||
|
||||
Args:
|
||||
published_date_raw: Raw date string from the API
|
||||
|
||||
Returns:
|
||||
Parsed datetime object or None if parsing fails
|
||||
"""
|
||||
if not published_date_raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
return parser.parse(published_date_raw)
|
||||
except parser.ParserError:
|
||||
return None
|
||||
|
||||
|
||||
def response(resp: "SXNG_Response") -> EngineResults:
|
||||
"""Process the API response and return results."""
|
||||
res = EngineResults()
|
||||
data = resp.json()
|
||||
|
||||
for result in (data.get("web") or {}).get("results", []):
|
||||
thumbnail_obj = result.get("thumbnail")
|
||||
thumbnail = ""
|
||||
if thumbnail_obj and not thumbnail_obj.get("logo", False):
|
||||
thumbnail = thumbnail_obj.get("src") or ""
|
||||
|
||||
res.add(
|
||||
res.types.MainResult(
|
||||
url=result["url"],
|
||||
title=html_to_text(result["title"]),
|
||||
content=html_to_text(result.get("description", "")),
|
||||
publishedDate=_extract_published_date(result.get("age")),
|
||||
thumbnail=thumbnail,
|
||||
),
|
||||
)
|
||||
res = EngineResults()
|
||||
results_json = (data.get("web") or {}).get("results", [])
|
||||
for result in results_json:
|
||||
res.add(parse_video_result(result))
|
||||
|
||||
return res
|
||||
|
||||
@@ -1,59 +1,63 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""media.ccc.de"""
|
||||
|
||||
import typing as t
|
||||
import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from dateutil import parser
|
||||
import dateutil.parser
|
||||
|
||||
from searx.result_types import EngineResults
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from searx.extended_types import SXNG_Response
|
||||
from searx.search.processors import OnlineParams
|
||||
|
||||
about = {
|
||||
'website': 'https://media.ccc.de',
|
||||
'official_api_documentation': 'https://github.com/voc/voctoweb',
|
||||
'use_official_api': True,
|
||||
'require_api_key': False,
|
||||
'results': 'JSON',
|
||||
"website": "https://media.ccc.de",
|
||||
"official_api_documentation": "https://github.com/voc/voctoweb",
|
||||
"use_official_api": True,
|
||||
"require_api_key": False,
|
||||
"results": "JSON",
|
||||
}
|
||||
categories = ['videos']
|
||||
categories = ["videos"]
|
||||
paging = True
|
||||
|
||||
api_url = "https://api.media.ccc.de"
|
||||
|
||||
|
||||
def request(query, params):
|
||||
args = {'q': query, 'page': params['pageno']}
|
||||
params['url'] = f"{api_url}/public/events/search?{urlencode(args)}"
|
||||
|
||||
return params
|
||||
def request(query: str, params: "OnlineParams"):
|
||||
args = {"q": query, "page": params["pageno"]}
|
||||
params["url"] = f"{api_url}/public/events/search?{urlencode(args)}"
|
||||
|
||||
|
||||
def response(resp):
|
||||
results = []
|
||||
def response(resp: "SXNG_Response") -> EngineResults:
|
||||
res = EngineResults()
|
||||
|
||||
for item in resp.json()['events']:
|
||||
for item in resp.json()["events"]:
|
||||
publishedDate = None
|
||||
if item.get('date'):
|
||||
publishedDate = parser.parse(item['date'])
|
||||
if item.get("date"):
|
||||
publishedDate = dateutil.parser.parse(item["date"])
|
||||
|
||||
iframe_src = None
|
||||
for rec in item['recordings']:
|
||||
if rec['mime_type'].startswith('video'):
|
||||
for rec in item["recordings"]:
|
||||
if rec["mime_type"].startswith("video"):
|
||||
if not iframe_src:
|
||||
iframe_src = rec['recording_url']
|
||||
elif rec['mime_type'] == 'video/mp4':
|
||||
iframe_src = rec["recording_url"]
|
||||
elif rec["mime_type"] == "video/mp4":
|
||||
# prefer mp4 (minimal data rates)
|
||||
iframe_src = rec['recording_url']
|
||||
iframe_src = rec["recording_url"]
|
||||
|
||||
results.append(
|
||||
{
|
||||
'template': 'videos.html',
|
||||
'url': item['frontend_link'],
|
||||
'title': item['title'],
|
||||
'content': item['description'],
|
||||
'thumbnail': item['thumb_url'],
|
||||
'publishedDate': publishedDate,
|
||||
'length': datetime.timedelta(seconds=item['length']),
|
||||
'iframe_src': iframe_src,
|
||||
}
|
||||
res.add(
|
||||
res.types.Video(
|
||||
url=item["frontend_link"],
|
||||
title=item["title"],
|
||||
content=item["description"] or "",
|
||||
thumbnail=item["thumb_url"],
|
||||
publishedDate=publishedDate,
|
||||
length=datetime.timedelta(seconds=item["length"]),
|
||||
iframe_src=iframe_src or "",
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
return res
|
||||
|
||||
@@ -169,7 +169,7 @@ def _get_results_from_process(res: EngineResults, cmd, pageno):
|
||||
for raw_result in raw_results:
|
||||
result = __parse_single_result(raw_result)
|
||||
if result is None:
|
||||
_command_logger.debug('skipped result:', raw_result)
|
||||
_command_logger.debug('skipped result: %s', raw_result)
|
||||
continue
|
||||
|
||||
if start <= count and count <= end: # pylint: disable=chained-comparison
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
"""
|
||||
|
||||
import typing as t
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import html
|
||||
|
||||
from searx.enginelib import EngineCache
|
||||
from searx.exceptions import SearxEngineAPIException
|
||||
from searx.network import post
|
||||
from searx.utils import format_duration, html_to_text, humanize_number
|
||||
from searx.utils import html_to_text, humanize_number
|
||||
from searx.result_types import EngineResults
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
@@ -54,7 +54,11 @@ def _obtain_token() -> str:
|
||||
token = CACHE.get("token")
|
||||
if token:
|
||||
return token
|
||||
resp = post(f"{base_url}/api/token/refresh", headers={"Origin": base_url}, cookies={"dp_api_token": "1"})
|
||||
resp = post(
|
||||
f"{base_url}/api/token/refresh",
|
||||
headers={"Origin": base_url, "Sec-Fetch-Mode": "cors"},
|
||||
cookies={"dp_api_token": "1"},
|
||||
)
|
||||
if not resp.ok:
|
||||
raise SearxEngineAPIException("failed to obtain dogpile token")
|
||||
token = resp.json()["token"]
|
||||
@@ -65,6 +69,7 @@ def _obtain_token() -> str:
|
||||
def request(query: str, params: "OnlineParams"):
|
||||
params["url"] = f"{base_url}/api/{dogpile_categ}"
|
||||
params["headers"]["Origin"] = base_url
|
||||
params["headers"]["Sec-Fetch-Mode"] = "cors"
|
||||
params["cookies"]["dp_api_token"] = "1"
|
||||
params["headers"]["x-dogpile-token"] = _obtain_token()
|
||||
|
||||
@@ -98,14 +103,13 @@ def response(resp: "SXNG_Response"):
|
||||
)
|
||||
elif dogpile_categ == "videos":
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
template="videos.html",
|
||||
res.types.Video(
|
||||
url=result["clickUrl"],
|
||||
title=html_to_text(result["title"]),
|
||||
content=html_to_text(result["description"]),
|
||||
thumbnail=result["thumbnailUrl"],
|
||||
publishedDate=datetime.fromisoformat(result["publishDate"]),
|
||||
length=format_duration(result["duration"]),
|
||||
length=timedelta(seconds=result["duration"]),
|
||||
views=humanize_number(result["viewCount"]),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -510,12 +510,13 @@ def response(resp: "SXNG_Response") -> EngineResults:
|
||||
and "Your user agent:" not in zero_click
|
||||
and "URL Decoded:" not in zero_click
|
||||
):
|
||||
res.add(
|
||||
res.types.Answer(
|
||||
answer=zero_click,
|
||||
url=eval_xpath_getindex(doc, '//div[@id="zero_click_abstract"]/a/@href', 0),
|
||||
if zero_click_url := eval_xpath_getindex(doc, '//div[@id="zero_click_abstract"]/a/@href', 0, None):
|
||||
res.add(
|
||||
res.types.Answer(
|
||||
answer=zero_click,
|
||||
url=zero_click_url,
|
||||
)
|
||||
)
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from searx.result_types import EngineResults, MainResult, LegacyResult, Image
|
||||
from searx.result_types import EngineResults, MainResult, Image, Video
|
||||
from searx.utils import html_to_text, gen_useragent, extr
|
||||
from searx.network import get # see https://github.com/searxng/searxng/issues/762
|
||||
|
||||
@@ -162,13 +162,11 @@ def _image_result(result):
|
||||
|
||||
|
||||
def _video_result(result):
|
||||
return LegacyResult(
|
||||
template='videos.html',
|
||||
return Video(
|
||||
url=result['content'],
|
||||
title=result['title'],
|
||||
content=result['description'],
|
||||
thumbnail=result['images'].get('small') or result['images'].get('medium'),
|
||||
source=result['provider'],
|
||||
length=result['duration'],
|
||||
metadata=result.get('uploader'),
|
||||
)
|
||||
|
||||
@@ -74,7 +74,6 @@ def _fetch_first_page_link(
|
||||
headers=headers,
|
||||
impersonate="firefox",
|
||||
default_headers=False,
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Findborg_ is a small search engine that gets its results from Brave.
|
||||
|
||||
.. _Findborg: https://www.findborg.com/about/
|
||||
"""
|
||||
|
||||
import typing as t
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from searx.engines.brave import parse_image_result, parse_news_result, parse_search_result, parse_video_result
|
||||
from searx.extended_types import SXNG_Response
|
||||
from searx.result_types import EngineResults
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from searx.search.processors import OnlineParams
|
||||
|
||||
about = {
|
||||
"website": "https://www.findborg.com",
|
||||
"official_api_documentation": None,
|
||||
"use_official_api": False,
|
||||
"require_api_key": False,
|
||||
"results": "JSON",
|
||||
}
|
||||
categories: list[str] = None # type: ignore[reportAssignmentType]
|
||||
time_range_support = True
|
||||
|
||||
FindborgCategType = t.Literal["search", "images", "videos", "news"]
|
||||
findborg_categ: FindborgCategType = None # type: ignore[reportAssignmentType]
|
||||
|
||||
|
||||
base_url = "https://www.findborg.com"
|
||||
|
||||
|
||||
def setup(_: dict[str, t.Any]):
|
||||
if findborg_categ not in t.get_args(FindborgCategType):
|
||||
raise ValueError("invalid category: %s" % findborg_categ)
|
||||
|
||||
|
||||
def request(query: str, params: "OnlineParams"):
|
||||
args = {"q": query, "type": findborg_categ}
|
||||
if params["time_range"]:
|
||||
args["time"] = params["time_range"]
|
||||
params["url"] = f"{base_url}/apis/proxy.php?{urlencode(args)}"
|
||||
|
||||
|
||||
def response(resp: "SXNG_Response") -> EngineResults:
|
||||
data: dict[str, t.Any] = resp.json()["data"] # type: ignore[reportAny]
|
||||
|
||||
res = EngineResults()
|
||||
match findborg_categ:
|
||||
case "search":
|
||||
for result in data["web"]["results"]:
|
||||
res.add(parse_search_result(result))
|
||||
case "news":
|
||||
for result in data["results"]:
|
||||
res.add(parse_news_result(result))
|
||||
case "videos":
|
||||
for result in data["results"]:
|
||||
res.add(parse_video_result(result))
|
||||
case "images":
|
||||
for result in data["results"]:
|
||||
res.add(parse_image_result(result))
|
||||
|
||||
return res
|
||||
@@ -92,8 +92,7 @@ def response(resp: "SXNG_Response") -> EngineResults:
|
||||
):
|
||||
video_src = extract_text(eval_xpath(result, ".//video/@src")) or ""
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
template="videos.html",
|
||||
res.types.Video(
|
||||
url=video_src,
|
||||
title=extract_text(eval_xpath(result, ".//div[contains(@class, 'caption')]/span")) or "",
|
||||
iframe_src=video_src or "",
|
||||
|
||||
@@ -153,16 +153,13 @@ def response(resp: "SXNG_Response") -> EngineResults:
|
||||
length = result["video"].get("duration")
|
||||
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
{
|
||||
"template": "videos.html",
|
||||
"url": result["url"],
|
||||
"title": html_to_text(result["title"]),
|
||||
"content": html_to_text(result["description"]),
|
||||
"thumbnail": result.get("thumbnail", {}).get("original"),
|
||||
"length": length,
|
||||
"publishedDate": published_date,
|
||||
}
|
||||
res.types.Video(
|
||||
url=result["url"],
|
||||
title=html_to_text(result["title"]),
|
||||
content=html_to_text(result["description"]),
|
||||
thumbnail=result.get("thumbnail", {}).get("original"),
|
||||
length=length,
|
||||
publishedDate=published_date,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ about = {
|
||||
"results": 'JSON',
|
||||
}
|
||||
|
||||
categories = ["stock_images"]
|
||||
categories = ["stock images"]
|
||||
|
||||
page_size = 15
|
||||
paging = True
|
||||
|
||||
@@ -73,12 +73,11 @@ def response(resp: "SXNG_Response") -> EngineResults:
|
||||
break
|
||||
|
||||
results.add(
|
||||
results.types.MainResult(
|
||||
results.types.Video(
|
||||
url=url,
|
||||
title=title,
|
||||
thumbnail=thumbnail,
|
||||
length=length,
|
||||
template="videos.html",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Iconify aggregates icons from different open source icon sets."""
|
||||
|
||||
import typing as t
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from searx.result_types import EngineResults
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from extended_types import SXNG_Response
|
||||
from search.processors.online import OnlineParams
|
||||
|
||||
|
||||
about = {
|
||||
"website": "https://iconify.design",
|
||||
"wikidata_id": None,
|
||||
"official_api_documentation": "https://iconify.design/docs/api/queries.html",
|
||||
"use_official_api": True,
|
||||
"results": "JSON",
|
||||
}
|
||||
|
||||
categories = ["images", "icons"]
|
||||
paging = True
|
||||
|
||||
base_url = "https://api.iconify.design"
|
||||
page_size = 20
|
||||
|
||||
|
||||
def request(query: str, params: "OnlineParams"):
|
||||
# actual number of results isn't exaxctly the same as the page size, only approximately
|
||||
args = {"query": query, "start": (params["pageno"] - 1) * page_size, "limit": page_size}
|
||||
params["url"] = f"{base_url}/search?{urlencode(args)}"
|
||||
|
||||
|
||||
def response(resp: "SXNG_Response") -> EngineResults:
|
||||
res = EngineResults()
|
||||
|
||||
for icon in resp.json()["icons"]:
|
||||
icon: str
|
||||
icon_source, icon_name = icon.split(":", 1)
|
||||
icon_url = f"{base_url}/{icon_source}/{icon_name}.svg"
|
||||
res.add(
|
||||
res.types.Image(
|
||||
url=icon_url,
|
||||
title=icon_name,
|
||||
img_src=icon_url,
|
||||
)
|
||||
)
|
||||
|
||||
return res
|
||||
@@ -178,17 +178,14 @@ def response(resp: "SXNG_Response") -> EngineResults:
|
||||
length = parse_duration_string(result["props"]["duration"])
|
||||
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
{
|
||||
"template": "videos.html",
|
||||
"url": result["url"],
|
||||
"title": html_to_text(result.get("title", "no title available")),
|
||||
"content": html_to_text(result.get("snippet", "")),
|
||||
"thumbnail": result.get("image", {}).get("url"),
|
||||
"publishedDate": published_date,
|
||||
"author": result.get("props", {}).get("creator_name"),
|
||||
"length": length,
|
||||
}
|
||||
res.types.Video(
|
||||
url=result["url"],
|
||||
title=html_to_text(result.get("title", "no title available")),
|
||||
content=html_to_text(result.get("snippet", "")),
|
||||
thumbnail=result.get("image", {}).get("url"),
|
||||
publishedDate=published_date,
|
||||
author=result.get("props", {}).get("creator_name"),
|
||||
length=length,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -157,8 +157,7 @@ def _news_results(doc: ElementType, res: EngineResults):
|
||||
def _video_results(doc: ElementType, res: EngineResults):
|
||||
for result in eval_xpath_list(doc, "//div[@id='mainResults']/div[contains(@class, 'mediaResult')]"):
|
||||
res.add(
|
||||
res.types.MainResult(
|
||||
template="videos.html",
|
||||
res.types.Video(
|
||||
url=extract_text(eval_xpath(result, "./@data-url")) or "",
|
||||
title=extract_text(eval_xpath(result, ".//div[contains(@class, 'mediaResultTitleVideo')]/a")) or "",
|
||||
content=extract_text(eval_xpath(result, ".//div[contains(@class, 'mediaResultDescription')]")) or "",
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
.. _Magnific: https://www.magnific.com
|
||||
"""
|
||||
|
||||
import re
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import typing as t
|
||||
|
||||
from searx.enginelib import EngineCache
|
||||
from searx.exceptions import SearxEngineAPIException
|
||||
from searx.network import get, post
|
||||
from searx.result_types import EngineResults
|
||||
from searx.utils import eval_xpath_getindex, extract_text
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from searx.extended_types import SXNG_Response
|
||||
@@ -26,7 +31,7 @@ about = {
|
||||
|
||||
base_url = "https://www.magnific.com"
|
||||
|
||||
categories = ["images"]
|
||||
categories = ["stock images"]
|
||||
paging = True
|
||||
|
||||
free_images_only = True
|
||||
@@ -34,6 +39,47 @@ free_images_only = True
|
||||
Whether to only load images that may be used for free, without a Magnific account.
|
||||
"""
|
||||
|
||||
# regular expressions for solving the captcha
|
||||
_NUMBER_RE = re.compile(r"\d+")
|
||||
_BM_VERIFY_RE = re.compile(r"\"bm-verify\":\s*\"(.*?)\"")
|
||||
_CAPTCHA_COOKIE_NAME = "ak_bmsc"
|
||||
|
||||
CACHE: EngineCache
|
||||
"""Cache for storing the cookie to bypass botblocking, obtained by solving a CAPTCHA."""
|
||||
|
||||
|
||||
def setup(engine_settings: dict[str, t.Any]):
|
||||
global CACHE # pylint: disable=global-statement
|
||||
CACHE = EngineCache(engine_settings["name"])
|
||||
|
||||
|
||||
def _solve_captcha(original_resp: "SXNG_Response") -> "SXNG_Response":
|
||||
"""Solves the Magnific CAPTCHA by summing all numbers in the response.
|
||||
E.g., the relevant part of the script that contains the numbers could look like
|
||||
``var i = 1789729970; var j = i + Number("1947" + "43160");``."""
|
||||
doc = original_resp.html()
|
||||
script = extract_text(eval_xpath_getindex(doc, "//script", 0)) or ""
|
||||
|
||||
bm_verify = _BM_VERIFY_RE.search(original_resp.text)
|
||||
if not bm_verify:
|
||||
raise SearxEngineAPIException("failed to extract bm verify token")
|
||||
bm_verify = bm_verify.group(1)
|
||||
|
||||
# challenge is just adding a bunch of number together (some are strings, some are ints)
|
||||
numbers = [int(m) for m in _NUMBER_RE.findall(script)]
|
||||
solution = sum(numbers)
|
||||
|
||||
challenge_resp = post(
|
||||
f"{base_url}/_sec/verify?provider=interstitial",
|
||||
json={"bm-verify": bm_verify, "pow": solution},
|
||||
cookies=original_resp.cookies,
|
||||
)
|
||||
|
||||
resp = get(original_resp.url, headers=original_resp.search_params["headers"], cookies=challenge_resp.cookies)
|
||||
# cookie is valid for 2 hours
|
||||
CACHE.set(_CAPTCHA_COOKIE_NAME, challenge_resp.cookies[_CAPTCHA_COOKIE_NAME], expire=7200)
|
||||
return resp
|
||||
|
||||
|
||||
def request(query: str, params: "OnlineParams") -> None:
|
||||
args = {"term": query, "filters[ai-generated][excluded]": 1, "page": params["pageno"], "locale": "en"}
|
||||
@@ -41,10 +87,18 @@ def request(query: str, params: "OnlineParams") -> None:
|
||||
args["filters[license]"] = "free"
|
||||
|
||||
params["headers"]["Referer"] = f"{base_url}/search"
|
||||
|
||||
# cookie that is obtained after solving the CAPTCHA
|
||||
if cookie := CACHE.get(_CAPTCHA_COOKIE_NAME):
|
||||
params["cookies"][_CAPTCHA_COOKIE_NAME] = cookie
|
||||
|
||||
params["url"] = f"{base_url}/api/regular/search?{urlencode(args)}"
|
||||
|
||||
|
||||
def response(resp: "SXNG_Response"):
|
||||
if resp.headers["content-type"] == "text/html":
|
||||
resp = _solve_captcha(resp)
|
||||
|
||||
res = EngineResults()
|
||||
|
||||
result: dict[str, t.Any] # TBH: dict[str, t.Any]
|
||||
|
||||
@@ -5,6 +5,7 @@ import typing as t
|
||||
import datetime
|
||||
|
||||
from searx.result_types import EngineResults
|
||||
from searx.utils import parse_duration_string
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from searx.extended_types import SXNG_Response
|
||||
@@ -67,13 +68,12 @@ def response(resp: "SXNG_Response") -> EngineResults:
|
||||
continue
|
||||
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
res.types.Video(
|
||||
url=video_url,
|
||||
title="%(channel)s: %(title)s (%(hms)s)" % item,
|
||||
length=item["hms"],
|
||||
length=parse_duration_string(item["hms"]),
|
||||
content="%(description)s" % item,
|
||||
iframe_src=video_url,
|
||||
template="videos.html",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ def parse_videos(data):
|
||||
for item in eval_xpath_list(dom, "//li[contains(@class, 'video_item')]"):
|
||||
url = eval_xpath_getindex(item, ".//a[contains(@class, 'info_title')]/@href", 0)
|
||||
|
||||
thumbnail = None
|
||||
thumbnail = ""
|
||||
try:
|
||||
thumbnail = eval_xpath_getindex(item, ".//img[contains(@class, 'thumb')]/@src", 0)
|
||||
except (ValueError, TypeError, SearxEngineXPathException):
|
||||
@@ -214,9 +214,8 @@ def parse_videos(data):
|
||||
pass
|
||||
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
template="videos.html",
|
||||
title=extract_text(eval_xpath(item, ".//a[contains(@class, 'info_title')]")),
|
||||
res.types.Video(
|
||||
title=extract_text(eval_xpath(item, ".//a[contains(@class, 'info_title')]")) or "",
|
||||
url=url,
|
||||
thumbnail=thumbnail,
|
||||
length=length,
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
.. _Odysee: https://github.com/OdyseeTeam/odysee-frontend
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import typing as t
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import babel
|
||||
|
||||
from searx.enginelib.traits import EngineTraits
|
||||
from searx.locales import language_tag
|
||||
from searx.utils import format_duration
|
||||
from searx.result_types import EngineResults
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from searx.extended_types import SXNG_Response
|
||||
from searx.search.processors import OnlineParams
|
||||
|
||||
# Engine metadata
|
||||
about = {
|
||||
@@ -34,7 +39,7 @@ categories = ["videos"]
|
||||
base_url = "https://lighthouse.odysee.tv/search"
|
||||
|
||||
|
||||
def request(query, params):
|
||||
def request(query: str, params: "OnlineParams"):
|
||||
time_range_dict = {
|
||||
"day": "today",
|
||||
"week": "thisweek",
|
||||
@@ -59,22 +64,17 @@ def request(query, params):
|
||||
query_params["time_filter"] = time_range_dict[params["time_range"]]
|
||||
|
||||
params["url"] = f"{base_url}?{urlencode(query_params)}"
|
||||
return params
|
||||
|
||||
|
||||
def response(resp):
|
||||
def response(resp: "SXNG_Response") -> EngineResults:
|
||||
res = EngineResults()
|
||||
data = resp.json()
|
||||
results = []
|
||||
|
||||
for item in data:
|
||||
name = item["name"]
|
||||
claim_id = item["claimId"]
|
||||
title = item["title"]
|
||||
thumbnail_url = item["thumbnail_url"]
|
||||
description = item["description"] or ""
|
||||
channel = item["channel"]
|
||||
release_time = item["release_time"]
|
||||
duration = item["duration"]
|
||||
|
||||
release_date = datetime.fromisoformat(release_time.split("T")[0])
|
||||
formatted_date = datetime.fromtimestamp(release_date.timestamp())
|
||||
@@ -82,23 +82,21 @@ def response(resp):
|
||||
url = f"https://odysee.com/{name}:{claim_id}"
|
||||
iframe_url = f"https://odysee.com/$/embed/{name}:{claim_id}"
|
||||
odysee_thumbnail = f"https://thumbnails.odycdn.com/optimize/s:390:0/quality:85/plain/{thumbnail_url}"
|
||||
formatted_duration = format_duration(duration)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"url": url,
|
||||
"content": description,
|
||||
"author": channel,
|
||||
"publishedDate": formatted_date,
|
||||
"length": formatted_duration,
|
||||
"thumbnail": odysee_thumbnail,
|
||||
"iframe_src": iframe_url,
|
||||
"template": "videos.html",
|
||||
}
|
||||
res.add(
|
||||
res.types.Video(
|
||||
title=item["title"],
|
||||
url=url,
|
||||
content=item["description"] or "",
|
||||
author=item["channel"],
|
||||
publishedDate=formatted_date,
|
||||
length=timedelta(seconds=item["duration"]),
|
||||
thumbnail=odysee_thumbnail,
|
||||
iframe_src=iframe_url,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
return res
|
||||
|
||||
|
||||
def fetch_traits(engine_traits: EngineTraits):
|
||||
|
||||
@@ -26,7 +26,7 @@ about = {
|
||||
}
|
||||
|
||||
base_url = "https://www.pexels.com"
|
||||
categories = ["stock_images"]
|
||||
categories = ["stock images"]
|
||||
|
||||
api_key = "H2jk9uKnhRmL6WPwh89zBezWvr"
|
||||
"""
|
||||
|
||||
@@ -26,7 +26,7 @@ about = {
|
||||
|
||||
base_url = "https://picjumbo.com"
|
||||
|
||||
categories = ["stock_images"]
|
||||
categories = ["stock images"]
|
||||
paging = True
|
||||
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ def request(query: str, params: "OnlineParams") -> None:
|
||||
params["url"] = f"{base_url}/search/secure/?{urlencode(args)}"
|
||||
else:
|
||||
params["url"] = f"{base_url}/{privacywall_category}/?{urlencode(args)}"
|
||||
params["headers"]["Referer"] = f"{base_url}/"
|
||||
|
||||
|
||||
def _general_results(doc: "ElementBase") -> EngineResults:
|
||||
@@ -146,8 +147,7 @@ def _video_results(doc: "ElementBase") -> EngineResults:
|
||||
thumbnail = _extract_thumbnail_url(extr(thumbnail_style, ":url(", ")"))
|
||||
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
template="videos.html",
|
||||
res.types.Video(
|
||||
url=url,
|
||||
title=extract_text(eval_xpath(result, ".//h2[contains(@class, 'video-card-title')]")) or "",
|
||||
content=extract_text(eval_xpath(result, ".//p")) or "",
|
||||
|
||||
@@ -292,7 +292,7 @@ def response(resp: "SXNG_Response") -> EngineResults:
|
||||
thumbnail = thumbnail.replace("https://s2.qwant.com", "https://s1.qwant.com", 1)
|
||||
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
res.types.Video(
|
||||
title=title,
|
||||
url=res_url,
|
||||
content=content,
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
.. _Resulthunter : https://resulthunter.com
|
||||
"""
|
||||
|
||||
import re
|
||||
import typing as t
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from searx.enginelib import EngineCache
|
||||
from searx import locales
|
||||
from searx.exceptions import SearxEngineResponseException
|
||||
from searx.exceptions import SearxEngineAPIException, SearxEngineResponseException
|
||||
from searx.network import get
|
||||
from searx.result_types import EngineResults
|
||||
from searx.utils import eval_xpath_list, eval_xpath, extract_text
|
||||
|
||||
@@ -40,18 +43,46 @@ base_url = "https://resulthunter.com"
|
||||
resulthunter_categ = "web"
|
||||
"""Supported categories are ``web`` and ``images``."""
|
||||
|
||||
CACHE: EngineCache
|
||||
"""Cache for storing search token and cookies."""
|
||||
|
||||
|
||||
_SEARCH_TOKEN_RE = re.compile(r"window.__rhSearchToken\s*=\s*'(.*)'")
|
||||
|
||||
|
||||
def setup(_: dict[str, str]) -> bool | None:
|
||||
global CACHE # pylint: disable=global-statement
|
||||
CACHE = EngineCache("resulthunter")
|
||||
|
||||
def setup(_: dict[str, t.Any]) -> bool | None:
|
||||
if resulthunter_categ not in ("web", "images"):
|
||||
raise ValueError("invalid category: %s" % resulthunter_categ)
|
||||
|
||||
|
||||
def _get_search_token() -> tuple[str, dict[str, str]]:
|
||||
if cached := CACHE.get("token"):
|
||||
return cached
|
||||
|
||||
resp = get(base_url)
|
||||
token = _SEARCH_TOKEN_RE.search(resp.text)
|
||||
if not token:
|
||||
raise SearxEngineAPIException("failed to find search token")
|
||||
token = token.group(1)
|
||||
|
||||
cookies = dict(resp.cookies)
|
||||
CACHE.set("token", (token, cookies))
|
||||
return token, cookies
|
||||
|
||||
|
||||
def request(query: str, params: "OnlineParams") -> None:
|
||||
search_token, cookies = _get_search_token()
|
||||
params["cookies"] = cookies
|
||||
|
||||
args = {
|
||||
"q": query,
|
||||
"search_type": resulthunter_categ,
|
||||
"offset": params["pageno"] - 1,
|
||||
"search_source": "other",
|
||||
"search_source": "header",
|
||||
"st": search_token,
|
||||
}
|
||||
|
||||
# uses Brave's engine traits
|
||||
|
||||
@@ -48,16 +48,21 @@ def response(resp: "SXNG_Response") -> EngineResults:
|
||||
|
||||
mainline = [s for s in data["search"]["regions"] if s["name"] == "mainline"][0]
|
||||
for group in mainline["groups"]:
|
||||
for result in group["results"]:
|
||||
if not ("url" in result or "clickUrl" in result):
|
||||
continue
|
||||
match group["result_type"]:
|
||||
case "web":
|
||||
for result in group["results"]:
|
||||
res.add(
|
||||
res.types.MainResult(
|
||||
url=result.get("url") or result.get("clickUrl"),
|
||||
title=html_to_text(result["title"]),
|
||||
content=html_to_text(result["description"]),
|
||||
)
|
||||
)
|
||||
case "aylf":
|
||||
for result in group["results"]:
|
||||
res.add(res.types.LegacyResult(suggestion=result["term"]))
|
||||
|
||||
res.add(
|
||||
res.types.MainResult(
|
||||
url=result.get("url") or result.get("clickUrl"),
|
||||
title=html_to_text(result["title"]),
|
||||
content=html_to_text(result["description"]),
|
||||
)
|
||||
)
|
||||
case _:
|
||||
pass
|
||||
|
||||
return res
|
||||
|
||||
@@ -25,7 +25,7 @@ about = {
|
||||
|
||||
base_url = "https://www.shopify.com"
|
||||
|
||||
categories = ["stock_images"]
|
||||
categories = ["stock images"]
|
||||
paging = True
|
||||
|
||||
|
||||
|
||||
@@ -7,10 +7,9 @@ results from Google.
|
||||
|
||||
import typing as t
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import datetime
|
||||
from dateutil import parser
|
||||
|
||||
from searx.utils import format_duration
|
||||
from searx.result_types import EngineResults
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
@@ -49,7 +48,7 @@ def request(query: str, params: "OnlineParams") -> None:
|
||||
params["url"] = f"{api_url}/api/v2/search/{startpagina_categ}/?{urlencode(args)}"
|
||||
|
||||
|
||||
def response(resp: "SXNG_Response"):
|
||||
def response(resp: "SXNG_Response") -> EngineResults:
|
||||
res = EngineResults()
|
||||
|
||||
json_resp = resp.json()
|
||||
@@ -81,13 +80,12 @@ def response(resp: "SXNG_Response"):
|
||||
)
|
||||
elif startpagina_categ == "videos":
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
template="videos.html",
|
||||
res.types.Video(
|
||||
url=result["original_url"],
|
||||
title=result["title"],
|
||||
content=result["description"],
|
||||
thumbnail=result["video"]["thumbnail_url"],
|
||||
length=format_duration(result["video"]["duration"]),
|
||||
length=datetime.timedelta(seconds=result["video"]["duration"]),
|
||||
)
|
||||
)
|
||||
elif startpagina_categ == "images":
|
||||
|
||||
@@ -25,7 +25,7 @@ about = {
|
||||
base_url = "https://stocksnap.io"
|
||||
cdn_url = "https://cdn.stocksnap.io"
|
||||
|
||||
categories = ["stock_images"]
|
||||
categories = ["stock images"]
|
||||
paging = True
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from urllib.parse import urlencode
|
||||
|
||||
from babel.core import get_global
|
||||
|
||||
from searx.result_types import EngineResults, LegacyResult # pyright: ignore[reportPrivateLocalImportUsage]
|
||||
from searx.result_types import EngineResults, Video
|
||||
from searx.utils import humanize_number, html_to_text
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
@@ -215,28 +215,24 @@ def request(query: str, params: "OnlineParams") -> None:
|
||||
params["url"] = f"{base_url}{base_path}?{urlencode(args)}"
|
||||
|
||||
|
||||
def _video_result(result: dict[str, str]) -> LegacyResult:
|
||||
def _video_result(result: dict[str, str]) -> Video:
|
||||
published_date = None
|
||||
if result.get("datePublished"):
|
||||
published_date = datetime.fromisoformat(result["datePublished"])
|
||||
|
||||
view_count = None
|
||||
view_count = ""
|
||||
if result.get("viewCount"):
|
||||
view_count = humanize_number(result["viewCount"]) # pyright: ignore[reportArgumentType]
|
||||
|
||||
return LegacyResult(
|
||||
{
|
||||
"template": "videos.html",
|
||||
"url": result["url"],
|
||||
"title": html_to_text(result.get("title") or result["name"]),
|
||||
"content": result["description"],
|
||||
"thumbnail": result.get("thumbnailUrl")
|
||||
or result.get("thumbnail", {}).get("url"), # pyright: ignore[reportAttributeAccessIssue]
|
||||
"length": result.get("duration"),
|
||||
"iframe_src": result.get("embedUrl"),
|
||||
"publishedDate": published_date,
|
||||
"views": view_count,
|
||||
}
|
||||
return Video(
|
||||
url=result["url"],
|
||||
title=html_to_text(result.get("title") or result["name"]),
|
||||
content=result["description"],
|
||||
thumbnail=result.get("thumbnailUrl") or result.get("thumbnail", {}).get("url"),
|
||||
length=result.get("duration"),
|
||||
iframe_src=result.get("embedUrl"),
|
||||
publishedDate=published_date,
|
||||
views=view_count,
|
||||
)
|
||||
|
||||
|
||||
@@ -272,14 +268,11 @@ def response(resp: "SXNG_Response") -> EngineResults:
|
||||
res.add(_video_result(video))
|
||||
elif result["type"] == "ImageObject":
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
{
|
||||
"template": "images.html",
|
||||
"url": result["url"],
|
||||
"thumbnail_src": result["thumbnail"]["url"],
|
||||
"img_src": result["contentUrl"],
|
||||
"title": result["name"],
|
||||
}
|
||||
res.types.Image(
|
||||
url=result["url"],
|
||||
thumbnail_src=result["thumbnail"]["url"],
|
||||
img_src=result["contentUrl"],
|
||||
title=result["name"],
|
||||
)
|
||||
)
|
||||
elif result["type"] == "video":
|
||||
|
||||
@@ -20,6 +20,8 @@ from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
import re
|
||||
|
||||
from searx.result_types import Video
|
||||
|
||||
about = {
|
||||
'website': "https://tagesschau.de",
|
||||
'wikidata_id': "Q703907",
|
||||
@@ -30,7 +32,7 @@ about = {
|
||||
}
|
||||
language = "de"
|
||||
|
||||
categories = ['general', 'news']
|
||||
categories = ['news']
|
||||
paging = True
|
||||
|
||||
results_per_page = 10
|
||||
@@ -87,7 +89,7 @@ def _story(item):
|
||||
}
|
||||
|
||||
|
||||
def _video(item):
|
||||
def _video(item) -> Video:
|
||||
streams = item['streams']
|
||||
video_url = streams.get('h264s') or streams.get('h264m') or streams.get('h264l') or streams.get('h264xl')
|
||||
title = item['title']
|
||||
@@ -99,12 +101,11 @@ def _video(item):
|
||||
# sometimes, only adaptive m3u8 streams are available, so video_url is None
|
||||
url = video_url or f"{base_url}/multimedia/video/{item['sophoraId']}.html"
|
||||
|
||||
return {
|
||||
'template': 'videos.html',
|
||||
'title': title,
|
||||
'thumbnail': item.get('teaserImage', {}).get('imageVariants', {}).get('16x9-256'),
|
||||
'publishedDate': datetime.fromisoformat(item['date'][:19]),
|
||||
'content': item.get('firstSentence', ''),
|
||||
'iframe_src': video_url,
|
||||
'url': url,
|
||||
}
|
||||
return Video(
|
||||
title=title,
|
||||
thumbnail=item.get('teaserImage', {}).get('imageVariants', {}).get('16x9-256'),
|
||||
publishedDate=datetime.fromisoformat(item['date'][:19]),
|
||||
content=item.get('firstSentence', ''),
|
||||
iframe_src=video_url,
|
||||
url=url,
|
||||
)
|
||||
|
||||
@@ -119,8 +119,7 @@ def _video_results(doc: ElementType, res: EngineResults):
|
||||
continue
|
||||
title_parts: list[ElementType] = eval_xpath(result, ".//a[starts-with(@class, 'tMMReshl')]")
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
template="videos.html",
|
||||
res.types.Video(
|
||||
url=url,
|
||||
title=" - ".join(extract_text(part) or "" for part in title_parts),
|
||||
thumbnail=extract_text(eval_xpath(result, ".//img/@src") or "") or "",
|
||||
|
||||
@@ -9,12 +9,11 @@ from json import loads
|
||||
import random
|
||||
import typing as t
|
||||
from urllib.parse import urlencode
|
||||
from dateutil import parser
|
||||
|
||||
from searx.result_types import EngineResults
|
||||
from searx.engines.brave import parse_image_result, parse_news_result, parse_search_result, parse_video_result
|
||||
from searx.exceptions import SearxEngineAPIException
|
||||
from searx.network import get
|
||||
from searx.utils import html_to_text
|
||||
from searx.result_types import EngineResults
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from searx.extended_types import SXNG_Response
|
||||
@@ -32,7 +31,8 @@ about = {
|
||||
paging = True
|
||||
|
||||
categories = ["general"]
|
||||
tusk_categ = "web"
|
||||
TuskCategType = t.Literal["web", "images", "videos", "news"]
|
||||
tusk_categ: TuskCategType = "web"
|
||||
"""Category to search in. Can be either "web", "images", "videos" or "news"."""
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ api_url = "https://api.tusksearch.com"
|
||||
|
||||
|
||||
def setup(_: dict[str, t.Any]) -> bool | None:
|
||||
if tusk_categ not in ("web", "images", "videos", "news"):
|
||||
if tusk_categ not in t.get_args(TuskCategType):
|
||||
raise ValueError("invalid search type: %s" % tusk_categ)
|
||||
|
||||
|
||||
@@ -98,70 +98,31 @@ def request(query: str, params: "OnlineParams") -> None:
|
||||
"Referer": "https://tusksearch.com/",
|
||||
"Origin": "https://tusksearch.com",
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def response(resp: "SXNG_Response"):
|
||||
res = EngineResults()
|
||||
|
||||
json_resp = resp.json()["results"]
|
||||
|
||||
if tusk_categ == "web":
|
||||
for result in (json_resp.get("web") or {}).get("results", []):
|
||||
res.add(
|
||||
res.types.MainResult(
|
||||
url=result["url"],
|
||||
title=html_to_text(result["title"]),
|
||||
content=html_to_text(result["description"]),
|
||||
thumbnail=(result["thumbnail"] or {}).get("src") or "",
|
||||
)
|
||||
)
|
||||
elif tusk_categ == "news":
|
||||
for result in (json_resp.get("news") or {}).get("results", []):
|
||||
publishedDate = None
|
||||
try:
|
||||
publishedDate = parser.parse(result["age"])
|
||||
except parser.ParserError:
|
||||
pass
|
||||
|
||||
res.add(
|
||||
res.types.MainResult(
|
||||
url=result["url"],
|
||||
title=html_to_text(result["title"]),
|
||||
content=html_to_text(result["description"]),
|
||||
thumbnail=result["thumbnail"]["src"],
|
||||
publishedDate=publishedDate,
|
||||
)
|
||||
)
|
||||
elif tusk_categ == "videos":
|
||||
for result in (json_resp.get("videos") or {}).get("results", []):
|
||||
publishedDate = None
|
||||
try:
|
||||
publishedDate = parser.parse(result["age"])
|
||||
except parser.ParserError:
|
||||
pass
|
||||
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
template="videos.html",
|
||||
url=result["url"],
|
||||
title=html_to_text(result["title"]),
|
||||
content=html_to_text(result["description"]),
|
||||
thumbnail=result["thumbnail"]["src"],
|
||||
publishedDate=publishedDate,
|
||||
length=result["video"].get("duration"),
|
||||
)
|
||||
)
|
||||
elif tusk_categ == "images":
|
||||
for result in json_resp:
|
||||
res.add(
|
||||
res.types.Image(
|
||||
url=result["url"],
|
||||
title=html_to_text(result["title"]),
|
||||
img_src=result["properties"]["url"],
|
||||
thumbnail_src=result["thumbnail"]["src"],
|
||||
)
|
||||
)
|
||||
res = EngineResults()
|
||||
match tusk_categ:
|
||||
case "web":
|
||||
results = (json_resp.get("web") or {}).get("results", [])
|
||||
for result in results:
|
||||
res.add(parse_search_result(result))
|
||||
case "news":
|
||||
results = (json_resp.get("news") or {}).get("results", [])
|
||||
for result in results:
|
||||
res.add(parse_news_result(result))
|
||||
case "videos":
|
||||
results = (json_resp.get("videos") or {}).get("results", [])
|
||||
for result in results:
|
||||
res.add(parse_video_result(result))
|
||||
case "images":
|
||||
for result in json_resp:
|
||||
res.add(parse_image_result(result))
|
||||
|
||||
return res
|
||||
|
||||
@@ -18,7 +18,7 @@ about = {
|
||||
|
||||
base_url = 'https://unsplash.com/'
|
||||
search_url = base_url + 'napi/search/photos?'
|
||||
categories = ["stock_images"]
|
||||
categories = ["stock images"]
|
||||
page_size = 20
|
||||
paging = True
|
||||
|
||||
|
||||
@@ -1,67 +1,98 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""
|
||||
Wikipedia (Web
|
||||
"""
|
||||
"""Vimeo (videos)"""
|
||||
|
||||
from urllib.parse import urlencode
|
||||
from json import loads
|
||||
from dateutil import parser
|
||||
import typing as t
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from searx.result_types import EngineResults
|
||||
from searx.network import get
|
||||
from searx.enginelib import EngineCache
|
||||
|
||||
from searx.utils import extr
|
||||
|
||||
# about
|
||||
# Engine metadata
|
||||
about = {
|
||||
"website": 'https://vimeo.com/',
|
||||
"wikidata_id": 'Q156376',
|
||||
"official_api_documentation": 'http://developer.vimeo.com/api',
|
||||
"use_official_api": False,
|
||||
"require_api_key": False,
|
||||
"results": 'HTML',
|
||||
"results": 'JSON',
|
||||
}
|
||||
|
||||
# engine dependent config
|
||||
categories = ['videos']
|
||||
|
||||
# Engine configuration
|
||||
paging = True
|
||||
categories = ['videos']
|
||||
results_per_page = 20
|
||||
|
||||
# search-url
|
||||
base_url = 'https://vimeo.com/'
|
||||
search_url = base_url + 'search/page:{pageno}?{query}'
|
||||
# Search URL
|
||||
base_url = "https://api.vimeo.com"
|
||||
|
||||
# Cache keys & expiration
|
||||
JWT_CACHE_KEY = "jwt"
|
||||
JWT_CACHE_EXPIRATION_SECONDS = 300
|
||||
|
||||
CACHE: EngineCache
|
||||
|
||||
|
||||
# do search-request
|
||||
def request(query, params):
|
||||
params['url'] = search_url.format(pageno=params['pageno'], query=urlencode({'q': query}))
|
||||
|
||||
return params
|
||||
def setup(engine_settings: dict[str, t.Any]) -> bool:
|
||||
global CACHE # pylint: disable=global-statement
|
||||
CACHE = EngineCache(engine_settings["name"])
|
||||
|
||||
|
||||
# get response from search-request
|
||||
def response(resp):
|
||||
results = []
|
||||
def fetch_json_web_token():
|
||||
|
||||
data = loads(extr(resp.text, 'var data = ', ';\n'))
|
||||
if json_web_token := CACHE.get(JWT_CACHE_KEY):
|
||||
return json_web_token
|
||||
|
||||
# parse results
|
||||
for result in data['filtered']['data']:
|
||||
result = result[result['type']]
|
||||
videoid = result['uri'].split('/')[-1]
|
||||
url = base_url + videoid
|
||||
title = result['name']
|
||||
thumbnail = result['pictures']['sizes'][-1]['link']
|
||||
publishedDate = parser.parse(result['created_time'])
|
||||
jwt_url = "https://vimeo.com/_next/jwt"
|
||||
|
||||
# append result
|
||||
results.append(
|
||||
{
|
||||
'url': url,
|
||||
'title': title,
|
||||
'content': '',
|
||||
'template': 'videos.html',
|
||||
'publishedDate': publishedDate,
|
||||
'iframe_src': "https://player.vimeo.com/video/" + videoid,
|
||||
'thumbnail': thumbnail,
|
||||
}
|
||||
jwt_headers = {
|
||||
'x-requested-with': "XMLHttpRequest",
|
||||
}
|
||||
|
||||
resp = get(url=jwt_url, headers=jwt_headers, timeout=5)
|
||||
json_web_token = resp.json()["token"]
|
||||
|
||||
CACHE.set(key=JWT_CACHE_KEY, value=json_web_token, expire=JWT_CACHE_EXPIRATION_SECONDS)
|
||||
|
||||
return json_web_token
|
||||
|
||||
|
||||
def request(query: str, params: "OnlineParams"):
|
||||
|
||||
json_web_token = fetch_json_web_token()
|
||||
|
||||
query_params = {
|
||||
"filter_type": "clip",
|
||||
"query": query,
|
||||
"page": params["pageno"],
|
||||
"per_page": results_per_page,
|
||||
}
|
||||
|
||||
params["url"] = f"{base_url}/search?{urlencode(query_params)}"
|
||||
params["headers"]["content-type"] = "application/json"
|
||||
params["headers"]["authorization"] = "jwt " + json_web_token
|
||||
params["headers"]["Accept"] = "application/vnd.vimeo.*+json;version=3.3"
|
||||
|
||||
|
||||
def response(resp: "SXNG_Response") -> EngineResults:
|
||||
results = EngineResults()
|
||||
search_res = resp.json()
|
||||
|
||||
for item in search_res["data"]:
|
||||
video_id = urlparse(item["clip"]["link"]).path.strip('/')
|
||||
|
||||
results.add(
|
||||
results.types.LegacyResult(
|
||||
template="videos.html",
|
||||
title=item["clip"]["name"],
|
||||
url=item["clip"]["link"],
|
||||
author=item["clip"]["user"]["name"],
|
||||
thumbnail=item["clip"]["pictures"]["base_link"],
|
||||
length=timedelta(seconds=item["clip"]["duration"]),
|
||||
publishedDate=datetime.fromisoformat(item["clip"]["created_time"]),
|
||||
iframe_src="https://player.vimeo.com/video/" + video_id,
|
||||
)
|
||||
)
|
||||
|
||||
# return results
|
||||
return results
|
||||
|
||||
@@ -85,8 +85,7 @@ def _video_results(doc: "ElementBase") -> EngineResults:
|
||||
for result in eval_xpath_list(doc, "//div[contains(@class, 'item video')]"):
|
||||
(
|
||||
res.add(
|
||||
res.types.MainResult(
|
||||
template="videos.html",
|
||||
res.types.Video(
|
||||
url=extract_text(eval_xpath(result, "./a/@href")) or "",
|
||||
title=extract_text(eval_xpath(result, "./a/@title")) or "",
|
||||
content=extract_text(eval_xpath(result, ".//div[contains(@class, 'abaslik')]")) or "",
|
||||
|
||||
@@ -181,8 +181,7 @@ def response(resp: "SXNG_Response") -> EngineResults:
|
||||
|
||||
if wc_search_type == "video":
|
||||
res.add(
|
||||
res.types.LegacyResult(
|
||||
template="videos.html",
|
||||
res.types.Video(
|
||||
title=title,
|
||||
url=url,
|
||||
content=content,
|
||||
|
||||
@@ -167,7 +167,7 @@ def get_results(
|
||||
infobox_id_lang: str | None = None
|
||||
infobox_urls: list[dict[str, str]] = []
|
||||
infobox_attributes: list[dict[str, str]] = []
|
||||
infobox_content = attribute_result.get("itemDescription", [])
|
||||
infobox_content: str = attribute_result.get("itemDescription", "")
|
||||
img_src: str | None = None
|
||||
img_src_priority = 0
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ about = {
|
||||
}
|
||||
|
||||
# engine dependent config
|
||||
categories = ["stock_images"]
|
||||
categories = ["stock images"]
|
||||
paging = False
|
||||
|
||||
# search-url
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""XPrivo"""
|
||||
|
||||
import json
|
||||
import typing as t
|
||||
|
||||
from searx.exceptions import SearxEngineAPIException, SearxEngineTooManyRequestsException
|
||||
from searx.network import raise_for_httperror
|
||||
from searx.result_types import EngineResults
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from searx.extended_types import SXNG_Response
|
||||
from searx.search.processors import OnlineParams
|
||||
|
||||
|
||||
about = {
|
||||
"website": 'https://www.xprivo.com',
|
||||
"wikidata_id": None,
|
||||
"official_api_documentation": None,
|
||||
"use_official_api": False,
|
||||
"require_api_key": False,
|
||||
"results": 'JSON',
|
||||
}
|
||||
|
||||
categories = ["general"]
|
||||
paging = True
|
||||
time_range_support = True
|
||||
|
||||
base_url = "https://www.xprivo.com"
|
||||
_page_size = 10 # not configurable
|
||||
|
||||
api_key = "API_KEY_XPRIVO"
|
||||
|
||||
XPrivoSearchType = t.Literal["all", "news", "videos"]
|
||||
xprivo_search_type: XPrivoSearchType = "all"
|
||||
|
||||
|
||||
def setup(_):
|
||||
if xprivo_search_type not in t.get_args(XPrivoSearchType):
|
||||
raise ValueError(f"invalid search type: {xprivo_search_type}")
|
||||
|
||||
|
||||
def request(query: str, params: "OnlineParams"):
|
||||
params["url"] = f"{base_url}/v1/searchengine/completions"
|
||||
params["headers"].update({"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"})
|
||||
|
||||
params["method"] = "POST"
|
||||
params["json"] = {
|
||||
"query": query,
|
||||
"mode": "classic",
|
||||
"search_type": xprivo_search_type,
|
||||
"time_filter": params["time_range"],
|
||||
"region": "",
|
||||
"offset": (params["pageno"] - 1) * _page_size,
|
||||
"source": "",
|
||||
}
|
||||
|
||||
params["raise_for_httperror"] = False
|
||||
|
||||
|
||||
def response(resp: "SXNG_Response") -> EngineResults:
|
||||
if resp.status_code in (400, 429):
|
||||
raise SearxEngineTooManyRequestsException()
|
||||
|
||||
raise_for_httperror(resp)
|
||||
|
||||
res = EngineResults()
|
||||
|
||||
# response is a Server-Side Events (SSE) stream
|
||||
# we search for a JSON object with the "results" key
|
||||
data_parts = [line for line in resp.text.split("\n") if line]
|
||||
for data_raw in data_parts:
|
||||
data_raw = data_raw.removeprefix("data: ")
|
||||
data: dict[str, t.Any] = json.loads(data_raw)
|
||||
if "results" in data:
|
||||
break
|
||||
else:
|
||||
raise SearxEngineAPIException("no results in response")
|
||||
|
||||
result: dict[str, str]
|
||||
for result in data["results"]:
|
||||
if xprivo_search_type == "videos":
|
||||
res.add(
|
||||
res.types.Video(
|
||||
url=result["url"],
|
||||
title=result["title"],
|
||||
content=result["snippet"],
|
||||
thumbnail=result.get("og_image") or "",
|
||||
)
|
||||
)
|
||||
else:
|
||||
res.add(
|
||||
res.types.MainResult(
|
||||
url=result["url"],
|
||||
title=result["title"],
|
||||
content=result["snippet"],
|
||||
thumbnail=result.get("og_image") or "",
|
||||
)
|
||||
)
|
||||
|
||||
return res
|
||||
@@ -48,6 +48,7 @@ def request(query: str, params: "OnlineParams") -> None:
|
||||
{
|
||||
"Referer": f"{web_base_url}/",
|
||||
"Origin": web_base_url,
|
||||
"x-csrf-protection": "1",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -32,10 +32,10 @@ def init():
|
||||
cfg_file = (settings_loader.get_user_cfg_folder() or pathlib.Path("/etc/searxng")) / "favicons.toml"
|
||||
if not cfg_file.exists():
|
||||
if is_active():
|
||||
logger.error(f"missing favicon config: {cfg_file}")
|
||||
logger.error("missing favicon config: %s", cfg_file)
|
||||
cfg_file = config.DEFAULT_CFG_TOML_PATH
|
||||
|
||||
logger.debug(f"load favicon config: {cfg_file}")
|
||||
logger.debug("load favicon config: %s", cfg_file)
|
||||
cfg = config.FaviconConfig.from_toml_file(cfg_file, use_cache=True)
|
||||
cache.init(cfg.cache)
|
||||
proxy.init(cfg.proxy)
|
||||
|
||||
@@ -352,7 +352,7 @@ CREATE TABLE IF NOT EXISTS blob_map (
|
||||
bytes_c = len(data or b"")
|
||||
if bytes_c > self.cfg.BLOB_MAX_BYTES:
|
||||
logger.info(
|
||||
"favicon of resolver: %s / authority: %s to big to cache (bytes: %s) " % (resolver, authority, bytes_c)
|
||||
"favicon of resolver: %s / authority: %s to big to cache (bytes: %s) ", resolver, authority, bytes_c
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ class Request(t.NamedTuple):
|
||||
|
||||
method: str
|
||||
url: str
|
||||
kwargs: dict[str, str] = {}
|
||||
kwargs: dict[str, str] = {} # pylint: disable=dangerous-default-value
|
||||
|
||||
@staticmethod
|
||||
def get(url: str, **kwargs: t.Any):
|
||||
|
||||
@@ -409,14 +409,6 @@ def initialize(
|
||||
if isinstance(network, str):
|
||||
NETWORKS[engine_name] = NETWORKS[network]
|
||||
|
||||
# the /image_proxy endpoint has a dedicated network.
|
||||
# same parameters than the default network, but HTTP/2 is disabled.
|
||||
# It decreases the CPU load average, and the total time is more or less the same
|
||||
if 'image_proxy' not in NETWORKS:
|
||||
image_proxy_params = default_params.copy()
|
||||
image_proxy_params['enable_http2'] = False
|
||||
NETWORKS['image_proxy'] = new_network(image_proxy_params, logger_name='image_proxy')
|
||||
|
||||
|
||||
@atexit.register
|
||||
def done():
|
||||
|
||||
@@ -27,10 +27,12 @@ Add Answer example
|
||||
Here is an example of a very simple plugin that adds a "Hello World" into the
|
||||
answer area:
|
||||
|
||||
In ``searx/plugins`` create a file named ``hello_world.py`` with the following content:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from flask_babel import gettext as _
|
||||
from searx.plugins import Plugin
|
||||
from searx.plugins import Plugin, PluginInfo
|
||||
from searx.result_types import Answer
|
||||
|
||||
class MyPlugin(Plugin):
|
||||
@@ -44,6 +46,23 @@ answer area:
|
||||
def post_search(self, request, search):
|
||||
return [ Answer(answer="Hello World") ]
|
||||
|
||||
You will then need to add your new plugin in the :ref:`settings.yml` file like this:
|
||||
|
||||
.. code:: yaml
|
||||
|
||||
plugins:
|
||||
...
|
||||
searx.plugins.hello_world.MyPlugin:
|
||||
active: true
|
||||
|
||||
|
||||
``searx.plugins.hello_world.MyPlugin`` is the Python import path of the new plugin we created.
|
||||
``searx.plugins.hello_world`` represents the file path, while ``MyPlugin`` represents the name of the plugin class.
|
||||
|
||||
If you don't want the plugin to be part of SearXNG's core (i.e. maintain it independently of SearXNG),
|
||||
you may also place the file at any other path as long as you update the reference to it in :ref:`settings.yml`.
|
||||
For more examples, see :ref:`settings external_plugins`.
|
||||
|
||||
.. _filter urls example:
|
||||
|
||||
Filter URLs example
|
||||
|
||||
@@ -19,9 +19,6 @@ if typing.TYPE_CHECKING:
|
||||
from searx.plugins import PluginCfg
|
||||
|
||||
|
||||
ahmia_blacklist: list = []
|
||||
|
||||
|
||||
def filter_url_field(result: "Result|LegacyResult", field_name: str, url_src: str) -> bool | str:
|
||||
"""Returns bool ``True`` to use URL unchanged (``False`` to ignore URL).
|
||||
If URL should be modified, the returned string is the new URL to use."""
|
||||
@@ -29,11 +26,15 @@ def filter_url_field(result: "Result|LegacyResult", field_name: str, url_src: st
|
||||
if field_name != "url":
|
||||
return True # use it unchanged
|
||||
|
||||
resolver_url = get_doi_resolver()
|
||||
if not resolver_url:
|
||||
return True # use it unchanged
|
||||
|
||||
doi = extract_doi(result.parsed_url)
|
||||
if doi and len(doi) < 50:
|
||||
for suffix in ("/", ".pdf", ".xml", "/full", "/meta", "/abstract"):
|
||||
doi = doi.removesuffix(suffix)
|
||||
new_url = get_doi_resolver() + doi
|
||||
new_url = resolver_url + doi
|
||||
if "doi" not in result:
|
||||
result["doi"] = doi
|
||||
log.debug("oa_doi_rewrite: [URL field: %s] %s -> %s", field_name, url_src, new_url)
|
||||
@@ -53,7 +54,7 @@ class SXNGPlugin(Plugin):
|
||||
id=self.id,
|
||||
name=gettext("Open Access DOI rewrite"),
|
||||
description=gettext("Avoid paywalls by redirecting to open-access versions of publications when available"),
|
||||
preference_section="general",
|
||||
preference_section=None,
|
||||
)
|
||||
|
||||
def on_result(
|
||||
@@ -83,7 +84,7 @@ def extract_doi(url):
|
||||
|
||||
def get_doi_resolver() -> str:
|
||||
doi_resolvers = get_setting("doi_resolvers")
|
||||
selected_resolver = sxng_request.preferences.get_value('doi_resolver')[0]
|
||||
if selected_resolver not in doi_resolvers:
|
||||
selected_resolver = get_setting("default_doi_resolver")
|
||||
selected_resolver = sxng_request.preferences.get_value("doi_resolver")
|
||||
if not selected_resolver or selected_resolver not in doi_resolvers:
|
||||
return ""
|
||||
return doi_resolvers[selected_resolver]
|
||||
|
||||
@@ -457,10 +457,10 @@ class Preferences:
|
||||
get_setting("ui.results_on_new_tab"),
|
||||
locked="results_on_new_tab" in self.cfg.lock,
|
||||
),
|
||||
'doi_resolver': MultipleChoiceSetting(
|
||||
[get_setting("default_doi_resolver")],
|
||||
"doi_resolver": EnumStringSetting(
|
||||
get_setting("default_doi_resolver"),
|
||||
locked="doi_resolver" in self.cfg.lock,
|
||||
choices=DOI_RESOLVERS,
|
||||
choices=["", *DOI_RESOLVERS],
|
||||
),
|
||||
'simple_style': EnumStringSetting(
|
||||
get_setting("ui.theme_args.simple_style"),
|
||||
@@ -536,6 +536,8 @@ class Preferences:
|
||||
self.plugins.parse_cookie(input_data.get('disabled_plugins', ''), input_data.get('enabled_plugins', ''))
|
||||
elif user_setting_name == 'tokens':
|
||||
self.tokens.parse(user_setting)
|
||||
if "oa_doi_rewrite" in self.plugins.choices:
|
||||
self.plugins.choices["oa_doi_rewrite"] = bool(self.get_value("doi_resolver"))
|
||||
|
||||
def parse_form(self, input_data: dict[str, str]):
|
||||
"""Parse formular (``<input>``) data from a ``flask.request.form``"""
|
||||
@@ -564,6 +566,8 @@ class Preferences:
|
||||
self.key_value_settings['categories'].parse_form(enabled_categories) # type: ignore
|
||||
self.engines.parse_form(disabled_engines)
|
||||
self.plugins.parse_form(disabled_plugins)
|
||||
if "oa_doi_rewrite" in self.plugins.choices:
|
||||
self.plugins.choices["oa_doi_rewrite"] = bool(self.get_value("doi_resolver"))
|
||||
|
||||
# cannot be used in case of engines or plugins
|
||||
def get_value(self, user_setting_name: str) -> t.Any:
|
||||
|
||||
@@ -28,6 +28,7 @@ __all__ = [
|
||||
"File",
|
||||
"Image",
|
||||
"ImageRef",
|
||||
"Video",
|
||||
]
|
||||
|
||||
import typing as t
|
||||
@@ -40,6 +41,7 @@ from .code import Code
|
||||
from .paper import Paper
|
||||
from .file import File
|
||||
from .image import Image, ImageRef
|
||||
from .video import Video
|
||||
|
||||
|
||||
class ResultList(list[Result | LegacyResult], abc.ABC):
|
||||
@@ -56,6 +58,7 @@ class ResultList(list[Result | LegacyResult], abc.ABC):
|
||||
Paper = Paper
|
||||
File = File
|
||||
Image = Image
|
||||
Video = Video
|
||||
MainResult = MainResult
|
||||
Result = Result
|
||||
Translations = Translations
|
||||
|
||||
@@ -31,7 +31,6 @@ from collections.abc import Callable
|
||||
import msgspec
|
||||
|
||||
from searx import logger
|
||||
from searx.utils import get_embedded_stream_url
|
||||
|
||||
log = logger.getChild("result_types")
|
||||
|
||||
@@ -386,7 +385,11 @@ class MainResult(Result): # pylint: disable=missing-class-docstring
|
||||
"""URL of a image that is displayed in the result item."""
|
||||
|
||||
iframe_src: str = ""
|
||||
"""URL of an embedded ``<iframe>`` / the frame is collapsible."""
|
||||
"""URL of an embedded ``<iframe>`` / the frame is collapsible.
|
||||
|
||||
To convert a standard video URL from a widely know video services into its
|
||||
embed format, have a look at :obj:`searx.utils.get_embedded_stream_url`.
|
||||
"""
|
||||
|
||||
audio_src: str = ""
|
||||
"""URL of an embedded ``<audio controls>``."""
|
||||
@@ -542,10 +545,6 @@ class LegacyResult(dict[str, t.Any]):
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
# TODO: move into typed video results class once it is implemented # pylint: disable=fixme
|
||||
if self.template == "videos.html" and self.url and not self.iframe_src:
|
||||
self.iframe_src = get_embedded_stream_url(self.url)
|
||||
|
||||
def __getattr__(self, name: str, default: t.Any = UNSET) -> t.Any:
|
||||
if default == UNSET and name not in self:
|
||||
raise AttributeError(f"LegacyResult object has no field named: {name}")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
"""Typification of the *video* results. Results of this type are rendered in
|
||||
the :origin:`videos.html <searx/templates/simple/result_templates/videos.html>`
|
||||
template.
|
||||
|
||||
.. autoclass:: Video
|
||||
:members:
|
||||
:show-inheritance:
|
||||
"""
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
__all__ = ["Video"]
|
||||
|
||||
import typing as t
|
||||
|
||||
from searx.utils import get_embedded_stream_url
|
||||
from ._base import MainResult
|
||||
|
||||
|
||||
@t.final
|
||||
class Video(MainResult, kw_only=True):
|
||||
"""Result type suitable for displaying videos."""
|
||||
|
||||
template: str = "videos.html"
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
|
||||
if self.url and not self.iframe_src:
|
||||
self.iframe_src = get_embedded_stream_url(self.url)
|
||||
@@ -182,6 +182,8 @@ class ResultContainer:
|
||||
|
||||
def close(self):
|
||||
self._closed = True
|
||||
# infobox with images first
|
||||
self.infoboxes.sort(key=lambda infobox: not infobox.img_src)
|
||||
|
||||
for result in self.main_results_map.values():
|
||||
result.score = calculate_score(result, result.priority)
|
||||
|
||||