Skip to content

Commit 5eabb8f

Browse files
committed
well this is gonna make it feel even more magic, support lang and time, hell fucking yes
1 parent 406eaee commit 5eabb8f

8 files changed

Lines changed: 183 additions & 53 deletions

File tree

src/server/extensions/compatibility-layer/searx/bridge/actions.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
ANY_TIME_FILTER = (None, "any")
66
URL_SCHEMES = ("http://", "https://")
7+
DEFAULT_TRANSLATE = ("English", "en", "english")
78

89

910
def _agent_from(payload):
@@ -23,13 +24,10 @@ def _params(payload):
2324
"category": payload.get("category") or "general",
2425
"engine_data": {},
2526
"data": {},
26-
"from_lang": "en",
27-
"to_lang": "en",
27+
"from_lang": list(DEFAULT_TRANSLATE),
28+
"to_lang": list(DEFAULT_TRANSLATE),
2829
"from": "USD",
2930
"to": "USD",
30-
"lang_region": "en-US",
31-
"language_all": False,
32-
"wiki_netloc": "en.wikipedia.org",
3331
"search_urls": {
3432
"data:image": "",
3533
"http": query if str(query).startswith(URL_SCHEMES) else "",
@@ -39,6 +37,10 @@ def _params(payload):
3937
}
4038

4139

40+
def _sent(mapping):
41+
return {str(key): str(value) for key, value in (mapping or {}).items() if value is not None}
42+
43+
4244
def _echo_params(payload):
4345
params = _params(payload)
4446
echo = payload.get("request") or {}
@@ -60,8 +62,8 @@ def build_request(payload):
6062
return {
6163
"url": params.get("url"),
6264
"method": params.get("method") or "GET",
63-
"headers": params.get("headers") or {},
64-
"cookies": params.get("cookies") or {},
65+
"headers": _sent(params.get("headers")),
66+
"cookies": _sent(params.get("cookies")),
6567
"data": data,
6668
}
6769

src/server/extensions/compatibility-layer/searx/bridge/engine.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import importlib.util
2+
import json
23
import os
34
import sys
45

@@ -9,15 +10,9 @@
910
GENERAL_CATEGORY = "general"
1011
FALLBACK_TYPE = "web"
1112
FALLBACK_CATEGORY = "other"
13+
TRAITS_SUFFIX = ".traits.json"
1214

1315
ENGINE_DEFAULTS = {
14-
"wiki_netloc": "en.wikipedia.org",
15-
"language": "en-US",
16-
"language_all": False,
17-
"lang_region": "en-US",
18-
"brave_category": "search",
19-
"google_play_category": "apps",
20-
"ceid": "US:en",
2116
"play_categ": "apps",
2217
}
2318

@@ -36,13 +31,22 @@ def _import(path, name):
3631
return mod
3732

3833

39-
def _adopt(mod):
34+
def _traits(path):
35+
found = os.path.splitext(path)[0] + TRAITS_SUFFIX
36+
try:
37+
with open(found, "r", encoding="utf-8") as handle:
38+
return EngineTraits(json.load(handle))
39+
except (OSError, ValueError):
40+
return EngineTraits()
41+
42+
43+
def _adopt(mod, path):
4044
if not hasattr(mod, "logger"):
4145
setattr(mod, "logger", Logger())
4246
if not hasattr(mod, "CACHE"):
4347
setattr(mod, "CACHE", EngineCache())
4448
if not hasattr(mod, "traits"):
45-
setattr(mod, "traits", EngineTraits())
49+
setattr(mod, "traits", _traits(path))
4650
about = getattr(mod, "about", {})
4751
website = str(about.get("website", "")).rstrip("/") if isinstance(about, dict) else ""
4852
base = getattr(mod, "base_url", None)
@@ -94,7 +98,7 @@ def load(path):
9498
engines = sys.modules.get("searx.engines")
9599
if engines is not None:
96100
getattr(engines, "engines", {})[code_of(path)] = mod
97-
_adopt(mod)
101+
_adopt(mod, path)
98102
_setup(mod, name)
99103
return mod
100104

src/server/extensions/compatibility-layer/searx/bridge/runtime.py

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -113,33 +113,42 @@ def delete(self, key):
113113

114114
class TraitCustom(dict):
115115
def __missing__(self, key):
116-
if key == "supported_domains":
117-
value = {"US": "www.google.com", "GB": "www.google.co.uk"}
118-
elif key == "ui_lang":
119-
value = {"en-US": "en-us", "en-GB": "en-gb", "en": "en-us"}
120-
elif key == "ceid":
121-
value = {"en-US": "US:en", "en-GB": "GB:en", "en": "US:en"}
122-
else:
123-
value = {}
124-
self[key] = value
125-
return value
116+
self[key] = {}
117+
return self[key]
118+
119+
120+
def match_locale(table, locale, default=None):
121+
if not isinstance(table, dict) or not table or not locale:
122+
return default
123+
key = str(locale).replace("_", "-")
124+
exact = table.get(key)
125+
if exact is not None:
126+
return exact
127+
lang, _, region = key.partition("-")
128+
by_lang = table.get(lang)
129+
if by_lang is not None:
130+
return by_lang
131+
for known in sorted(table):
132+
if region and known.rpartition("-")[2].upper() == region.upper():
133+
return table[known]
134+
return default
126135

127136

128137
class EngineTraits:
129-
def __init__(self):
130-
self.languages = {"en": "lang_en"}
131-
self.regions = {"en-US": "US", "en-GB": "GB"}
132-
self.all_locale = "US"
133-
self.custom = TraitCustom({"supported_domains": {"US": "www.google.com", "GB": "www.google.co.uk"}})
138+
def __init__(self, data=None):
139+
found = data if isinstance(data, dict) else {}
140+
self.languages = found.get("languages") or {}
141+
self.regions = found.get("regions") or {}
142+
self.all_locale = found.get("all_locale")
143+
self.custom = TraitCustom(found.get("custom") or {})
134144

135145
def get_language(self, locale, default=None):
136-
if not locale or locale == "all":
137-
return default or "lang_en"
138-
key = str(locale).replace("_", "-")
139-
return self.languages.get(key) or self.languages.get(key.split("-")[0]) or default or "lang_en"
146+
return self._pick(self.languages, locale, default)
140147

141148
def get_region(self, locale, default=None):
149+
return self._pick(self.regions, locale, default)
150+
151+
def _pick(self, table, locale, default):
142152
if not locale or locale == "all":
143-
return default or self.all_locale
144-
key = str(locale).replace("_", "-")
145-
return self.regions.get(key) or key.split("-")[-1].upper() or default or self.all_locale
153+
return self.all_locale if self.all_locale is not None else default
154+
return match_locale(table, locale, default)

src/server/extensions/compatibility-layer/searx/bridge/shims.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
Translations,
2727
WeatherAnswer,
2828
)
29-
from .runtime import EngineCache, EngineTraits, Logger, user_agent
29+
from .runtime import EngineCache, EngineTraits, Logger, match_locale, user_agent
3030

3131
SETTINGS = {
3232
"general": {"debug": False},
@@ -144,11 +144,7 @@ def _locales():
144144
region_tag=lambda locale, *a, **k: str(locale).replace("_", "-"),
145145
get_official_locales=lambda country, languages=None, regional=True, *a, **k: [f"en-{country}"],
146146
LOCALE_BEST_MATCH={},
147-
get_engine_locale=lambda locale, traits=None, default=None: (
148-
(traits or {}).get(locale, default or locale or "en-US")
149-
if isinstance(traits, dict)
150-
else (default or locale or "en-US")
151-
),
147+
get_engine_locale=lambda locale, traits=None, default=None: match_locale(traits, locale, default),
152148
)
153149

154150

src/server/extensions/compatibility-layer/searx/catalog.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,21 @@ import { PythonLib } from "./python-deps";
44
export const SEARX_SOURCE_BASE_URL =
55
"https://raw.githubusercontent.com/searxng/searxng/master/searx/engines";
66

7+
export const SEARX_TRAITS_URL =
8+
"https://raw.githubusercontent.com/searxng/searxng/master/searx/data/engine_traits.json";
9+
710
const { Babel, DateUtil, Lxml } = PythonLib;
811

12+
/**
13+
* @fccview here - Hey! You thought it was magic? Well kinda.
14+
* However there's a big ass list of engines painfully manually tested and curated by yours truly.
15+
*
16+
* The biggest effort was made by the incredible SearxNG team however, I do not manintain these, nor I ever will, I just found a clever
17+
* way to route them within degoog to give my users the best experience possible.
18+
*
19+
* Big up to open source my friends <3
20+
*/
21+
922
export const SEARX_CATALOG: readonly SearxCatalogEntry[] = [
1023
{ code: "360search_videos", name: "360Search Videos", types: ["videos"], site: "https://tv.360kan.com" },
1124
{ code: "acfun", name: "Acfun", types: ["videos"], site: "https://www.acfun.cn", libs: [Lxml] },

src/server/extensions/compatibility-layer/searx/install.ts

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { logger } from "../../../utils/logger";
66
import {
77
SEARX_CATALOG,
88
SEARX_SOURCE_BASE_URL,
9+
SEARX_TRAITS_URL,
910
catalogDeps,
1011
catalogEntry,
1112
dependants,
@@ -19,6 +20,8 @@ import { LIB_PACKAGES, missingPythonLibs, type PythonLib } from "./python-deps";
1920
const NS = "searx-install";
2021
const PYCACHE_DIR = "__pycache__";
2122
const DOWNLOAD_TIMEOUT_MS = 20_000;
23+
const TRAITS_SUFFIX = ".traits.json";
24+
const SHORTEST_ALIAS = 4;
2225

2326
let _queue: Promise<unknown> = Promise.resolve();
2427

@@ -61,21 +64,72 @@ const _download = async (code: string): Promise<string> => {
6164
const _missingDeps = (code: string): string[] =>
6265
catalogDeps(code).filter((dep) => !_isInstalled(dep));
6366

64-
const _fetchFile = async (code: string, dir: string): Promise<void> => {
65-
const target = _enginePath(code);
67+
const _writeSwap = async (target: string, body: string): Promise<void> => {
6668
const tmp = `${target}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
6769
try {
68-
const source = await _download(code);
69-
await mkdir(dir, { recursive: true });
70-
await writeFile(tmp, source, "utf-8");
70+
await mkdir(resolve(searxEnginesDir()), { recursive: true });
71+
await writeFile(tmp, body, "utf-8");
7172
await rename(tmp, target);
72-
await _dropCache(code);
7373
} catch (err) {
7474
await unlink(tmp).catch(() => undefined);
7575
throw err;
7676
}
7777
};
7878

79+
const _fetchFile = async (code: string, dir: string): Promise<void> => {
80+
const source = await _download(code);
81+
await mkdir(dir, { recursive: true });
82+
await _writeSwap(_enginePath(code), source);
83+
await _dropCache(code);
84+
};
85+
86+
const _traitsPath = (code: string): string =>
87+
join(resolve(searxEnginesDir()), `${code}${TRAITS_SUFFIX}`);
88+
89+
const _slug = (value: string): string => value.toLowerCase().replace(/[^a-z0-9]/g, "");
90+
91+
const _traitsKey = (keys: readonly string[], code: string): string | undefined => {
92+
const want = _slug(code);
93+
const exact = keys.find((key) => _slug(key) === want);
94+
if (exact) return exact;
95+
const shorter = keys.find((key) => {
96+
const other = _slug(key);
97+
return other.length >= SHORTEST_ALIAS && want.startsWith(other);
98+
});
99+
if (shorter) return shorter;
100+
return keys.find(
101+
(key) => want.length >= SHORTEST_ALIAS && _slug(key).startsWith(want),
102+
);
103+
};
104+
105+
const _traitsBook = async (): Promise<Record<string, unknown>> => {
106+
const resp = await fetch(SEARX_TRAITS_URL, {
107+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
108+
});
109+
if (!resp.ok) throw new Error(`Traits download failed with HTTP ${resp.status}`);
110+
const book: unknown = await resp.json();
111+
if (!book || typeof book !== "object") throw new Error("Traits file was not an object");
112+
return book as Record<string, unknown>;
113+
};
114+
115+
const _saveTraits = async (code: string, book: Record<string, unknown>): Promise<void> => {
116+
const key = _traitsKey(Object.keys(book), code);
117+
const entry = key ? book[key] : undefined;
118+
await _writeSwap(_traitsPath(code), JSON.stringify(entry ?? {}));
119+
};
120+
121+
const _pullTraits = async (codes: readonly string[]): Promise<void> => {
122+
if (codes.length === 0) return;
123+
try {
124+
const book = await _traitsBook();
125+
for (const code of codes) await _saveTraits(code, book);
126+
logger.debug(NS, `stored SearX traits for ${codes.join(", ")}`);
127+
} catch (err) {
128+
const message = err instanceof Error ? err.message : String(err);
129+
logger.warn(NS, `could not fetch SearX engine traits: ${message}`);
130+
}
131+
};
132+
79133
const _orphanDeps = (code: string): string[] =>
80134
catalogDeps(code).filter(
81135
(dep) =>
@@ -113,6 +167,7 @@ const _pull = async (
113167
const dir = resolve(searxEnginesDir());
114168
try {
115169
for (const file of queue) await _fetchFile(file, dir);
170+
await _pullTraits(queue);
116171
logger.info(NS, `${verb} SearX engine ${engine} (${queue.join(", ")})`);
117172
} catch (err) {
118173
const message = err instanceof Error ? err.message : String(err);
@@ -140,6 +195,7 @@ export const uninstallSearx = async (code: string): Promise<void> => {
140195
try {
141196
for (const file of queue) {
142197
await unlink(_enginePath(file));
198+
await unlink(_traitsPath(file)).catch(() => undefined);
143199
await _dropCache(file);
144200
}
145201
logger.info(NS, `uninstalled SearX engine ${engine} (${queue.join(", ")})`);

tests/unit/searx-compat.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const withSearxEnv = async <T>(fn: (dir: string) => Promise<T>): Promise<T> => {
2222
process.env.DEGOOG_PLUGIN_SETTINGS_FILE = join(dir, "plugin-settings.json");
2323
process.env.DEGOOG_SERVER_SETTINGS_FILE = join(dir, "server-settings.json");
2424
delete process.env.DEGOOG_SEARX_ENGINES_DIR;
25-
process.env.DEGOOG_SEARX_EXTRA_ENGINES = "tiny,statics,pager";
25+
process.env.DEGOOG_SEARX_EXTRA_ENGINES = "tiny,statics,pager,traits";
2626
mkdirSync(process.env.DEGOOG_ENGINES_DIR, { recursive: true });
2727
mkdirSync(process.env.DEGOOG_TRANSPORTS_DIR, { recursive: true });
2828
mkdirSync(join(dir, "searx", "engines"), { recursive: true });
@@ -128,6 +128,32 @@ def response(resp):
128128
return [{"url": "https://cache.example/a", "title": "hit", "content": "c"}]
129129
`;
130130

131+
const TRAITS_ENGINE = `about = {"website": "https://traits.example"}
132+
base_url = "https://traits.example"
133+
categories = ["general"]
134+
paging = False
135+
136+
def request(query, params):
137+
lang = traits.get_language(params["searxng_locale"], "lang_en")
138+
region = traits.get_region(params["searxng_locale"], traits.all_locale)
139+
host = traits.custom["supported_domains"].get(str(region).upper(), "fallback.example")
140+
params["url"] = base_url + "/?lang=" + str(lang) + "&region=" + str(region) + "&host=" + host
141+
142+
def response(resp):
143+
return [{"url": "https://traits.example/a", "title": "hit", "content": "c"}]
144+
`;
145+
146+
const TRAITS_FILE = JSON.stringify({
147+
languages: { de: "lang_de" },
148+
regions: { "de-DE": "DE" },
149+
all_locale: "ZZ",
150+
custom: { supported_domains: { DE: "www.example.de", ZZ: "www.example.com" } },
151+
});
152+
153+
const writeTraits = (dir: string, name: string, body: string): void => {
154+
writeFileSync(join(dir, "searx", "engines", `${name}.traits.json`), body);
155+
};
156+
131157
const okFetch = async (): Promise<Response> =>
132158
new Response("<html></html>", { status: 200 });
133159

@@ -209,6 +235,28 @@ describe("SearX engine parity with native engines", () => {
209235
});
210236
});
211237

238+
test("engine traits are loaded from the sidecar traits file", async () => {
239+
await withSearxEnv(async (dir) => {
240+
writeEngine(dir, "traits", TRAITS_ENGINE);
241+
writeTraits(dir, "traits", TRAITS_FILE);
242+
const { initEngines, getEngineMap } = await import(
243+
"../../src/server/extensions/engines/registry"
244+
);
245+
await initEngines(true);
246+
let seen = "";
247+
await getEngineMap()["searx-traits-engine"].executeSearch("q", 1, "any", {
248+
lang: "de-DE",
249+
fetch: async (url: string) => {
250+
seen = url;
251+
return new Response("<html></html>", { status: 200 });
252+
},
253+
});
254+
expect(seen).toBe(
255+
"https://traits.example/?lang=lang_de&region=DE&host=www.example.de",
256+
);
257+
});
258+
});
259+
212260
test("a custom date range collapses onto the nearest supported bucket", async () => {
213261
await withSearxEnv(async (dir) => {
214262
writeEngine(dir, "pager", PAGER_ENGINE);

0 commit comments

Comments
 (0)