Degoog Search engines
Build your own custom search backends for web, images, videos or anything you can think of.
Where engines live
You can drop your custom engines right into
data/engines/ or whatever you set
DEGOOG_ENGINES_DIR to. Each engine is just a file or folder with
an entry file that exports an object or class containing a
name and executeSearch.
Compatibility layers
Compatibility layers let Degoog load engines written for another search-engine runtime and expose them as normal Degoog engines. These engines still run through Degoog's engine registry, settings list, bang shortcuts, configured transports, and result pipeline.
Compatibility engines are marked in Settings then Engines with a small badge
showing the layer name, for example searx.
SearXNG Python engines
Degoog can run SearXNG-style Python engine files as normal Degoog engines. It does not run a SearXNG instance. Degoog asks the Python engine to build the request and parse the response, and performs every HTTP fetch itself through its own transports, proxies and timeouts.
Installing them
Go to Settings then Engines and use
Add from SearXNG. The modal lists the
80 engines currently verified to return results through the
layer. Installing pulls the file from upstream into
data/searx/engines/ and refreshes the engine list straight away,
so no restart is needed.
Files can also be dropped into data/searx/engines/ by hand, or
into DEGOOG_SEARX_ENGINES_DIR when that environment variable is
set. A hand-dropped file needs a restart, and it still has to be on the
verified list to load.
The filename becomes the bang shortcut, so ebay.py becomes
!ebay.
Shared files
Some SearXNG engines are built on top of another engine's code.
google_cse.py imports from google.py, and
apple_maps.py reaches into OpenStreetMap, Wikidata and Wikipedia.
Degoog resolves these automatically: the install modal shows a
+N shared files badge, asks for confirmation, and downloads
everything the engine needs alongside it.
Shared files that are not themselves offered as engines (bing,
google, json_engine, openstreetmap,
wikidata, wikipedia, yahoo) are loaded
as libraries only. They never appear as engines, never run searches, and are
removed again once no installed engine depends on them.
What behaves exactly like a native engine
SearXNG engines are registered in the same list as everything else, so the following work with no special handling:
- Score and result aggregation. They run through the identical scoring and de-duplication pipeline, so a custom score weights them the same way it weights a store engine.
-
Safe Search. Each one gets the same
safeSearchdropdown described below, defaulting tomoderatefor image and video engines andofffor the rest. -
Proxies and VPN routing. Every outbound request goes
through
context.fetch, including the extra token and metadata calls some engines make while building a request. Global proxies and per-engine proxy overrides both apply. - Transports, timeouts and custom user agents, plus the engine type override, enable and disable toggles, and bang shortcuts.
- Paging. Engines that declare no paging support are skipped past page one rather than repeating their first page.
- Time filters. Sent only to engines that declare support. A custom date range collapses onto the nearest bucket SearXNG understands (day, week, month or year).
Known trade-offs
This is a compatibility layer over code written for a different application, so it is not a perfect match:
- Image filters beyond Safe Search (size, colour, type, layout) are not translated. A SearXNG image engine drops out of the results while one of those filters is active, rather than returning unfiltered images.
- Failure reporting is coarser. HTTP blocks and rate limits are classified correctly, but an error raised inside the Python parser is reported as a generic network failure.
- Upstream bugs come along for the ride. These files track SearXNG's master branch and are not patched by Degoog. An engine can break when the site it scrapes changes.
- Each search spawns a short-lived Python process, so these engines carry slightly more overhead than a native one.
Engines that fail to load are logged at boot with the underlying Python error, separately from the list of engines skipped on purpose.
Engine contract
What you need:
- name: The display name shown in Settings then Engines.
-
executeSearch(query, page?, timeFilter?, context)
(async): This needs to return a Promise that resolves to an array of
results. Each result is an object holding a
title,url,snippet,source, and optionally athumbnailandduration. Make sure you usecontext.fetchfor all your outbound HTTP requests so your engine respects any proxy settings the user has configured.
Optional extras:
-
bangShortcut: Lets users type a shortcut like
!shortcut queryorquery !shortcutto search only this engine (like settingbangShortcut: "ecosia"so users can type!ecosia linux). -
settingsSchema and configure(settings):
These work just like they do for plugins. You will get a Configure button in
Settings then Engines, and the values are securely saved in
data/plugin-settings.json.
Your SettingField shape needs a key, label, and
type (which can be text, password, url, toggle, textarea, select,
or urllist). You can also add optional properties like required,
placeholder, description, secret,
options (if using select), and default.
If you have sensitive fields like API keys or tokens, mark them with
secret: true. The UI will never expose the saved value to the
user and will just show a Set or Not set indicator
instead. When saving, if the field value equals the special
"__SET__" string, the existing stored value stays put. This
ensures a page reload never wipes out a key you already entered.
HTTP method
Your engines are completely free to use more than just GET requests. Because
you control the fetch call inside
executeSearch, you can use any method like POST and set whatever
headers or body your API needs. Here is what a GraphQL engine using POST looks
like:
async executeSearch(query, page = 1, _timeFilter, context) {
const doFetch = context?.fetch ?? fetch;
const response = await doFetch("https://graphql.example.com", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: MY_QUERY, variables: { search: query, page } }),
});
context?.sentinel?.(response, this.name);
const data = await response.json();
// parse and return array of { title, url, snippet, source }
}
Search types
Export a type to declare which tab(s) your engine belongs to. The
default is "web". Any other string automatically creates a
dedicated tab on the search results page, all engines sharing that type run
together when the tab is selected.
type can be a single string or an array of strings. When an array
is used, the engine participates in every listed tab simultaneously.
export const type = "books" // creates a Books tab
export const type = ["web", "karakeep"] // shows in Web tab AND Karakeep tab
Users can also override the type from the engine's Advanced settings using the
Engine type override field. Comma-separated values are
supported for multiple types, e.g. web,karakeep. A runtime
override always takes precedence over the exported type.
Image filters
Image engines (engines whose type includes "images")
can declare exactly which filters they support by exporting a
filters object. Each key is a filter group and each value is the
list of option values your engine actually translates into an upstream
request. degoog renders the image filter bar from the
union of the declared filters across every
enabled image engine, so users only ever see options that will really
take effect.
Exclusion: when a filter is active, any engine that does not
declare that exact value is dropped from that search (rather than
returning unfiltered results that pollute the grid). So if the user picks
color: red, only engines that list red under
color run; everything else sits that search out. Declare a group
honestly, only list values you map. This is why an engine that exports no
filters at all is excluded the moment any filter is active.
Requires degoog 0.23.0 or newer. Set
minDegoogVersion accordingly in your store manifest.
export const type = "images"
export const filters = {
size: ["small", "medium", "large", "wallpaper"],
color: ["red", "blue", "green", "transparent"],
type: ["photo", "clipart", "lineart", "animated"],
layout: ["square", "wide", "tall"],
nsfw: ["on", "moderate", "off"],
}
The recognised groups are size, color,
type, layout and nsfw (shown in the UI
as Safe mode, defaulting to moderate). Only
declare the values you map; e.g. an engine that supports nothing but a mature
toggle can export { nsfw: ["off"] }.
transparent is just another value. Some engines treat it as a
colour (Google), others as a type (Bing, DuckDuckGo). Put it under whichever
group your engine implements it in, and degoog will show it there. If two
enabled engines place it in different groups, it simply appears in both
dropdowns, and each engine applies the one it understands.
The user's selection arrives on context.imageFilter as a plain
object keyed by group, e.g. { color: "red", nsfw: "moderate" }.
Read only the keys you declared; anything you do not map is safely ignored.
Language & time filters
When someone selects a language or time filter in the UI, those values get
passed to every engine through the
context argument. You do not have to read them if you do not want
to. Engines that ignore these will just return unfiltered results.
| Field | Type | Description |
|---|---|---|
context.lang |
string | undefined |
The ISO 639 1 language code chosen by the user (like
"en", "de", or "it"). If it is
undefined, no language filter was selected.
|
context.buildAcceptLanguage() |
() => string |
Returns a ready to use Accept-Language header value derived
from context.lang. It safely falls back to
"en,en-US;q=0.9" when no language is set so you can pass it
directly to your fetch headers.
|
context.dateFrom |
string | undefined |
The start of a custom date range formatted as
YYYY-MM-DD. This is only set when the timeFilter is custom.
|
context.dateTo |
string | undefined |
The end of a custom date range formatted as
YYYY-MM-DD. This is only set when the timeFilter is custom.
|
context.extractImageUrl($el, baseUrl?, selectors?)
|
($el, baseUrl?, selectors?) => string
|
Easily extract an article image URL from a cheerio element. It checks
all the common attributes like src and
data src, normalizes URLs, and resolves relative paths
against your baseUrl. It even skips tiny images and
favicons. You can pass selectors to target a specific
thumbnail container first.
|
context.signProxyUrl(url) |
(url: string) => string |
Returns a signed /api/proxy/image URL for the given
external image URL. Use this when you include thumbnails in your results
so they load correctly regardless of the outgoing allowlist
configuration.
|
context.sentinel(response, name?) |
(response: { ok, status }, name?: string) => void
|
Call this right after every doFetch in your engine. If the
upstream returned a non-OK status (403, 429, 5xx, ...) it throws a
structured SentinelBreach with a status of
"blocked", "rate_limited" or
"network" that the orchestrator logs and surfaces back to
the UI as a real "engine blocked" signal instead of a silent 0 results.
Always prefer this to if (!response.ok) return [];.
|
context.engineError(status, message, opts?) |
(status, message, opts?: { httpStatus?, engine? }) => Error
|
Build the same structured SentinelBreach manually when you
detect a soft block (Cloudflare challenge page, consent interstitial,
captcha HTML, empty JSON envelope from a rate-limited API, ...). Valid
statuses: "blocked", "rate_limited",
"captcha", "interstitial",
"parse_error", "timeout",
"network".
|
Surfacing upstream failures
Engines used to silently return [] on a 403 or 429, which made
"engine got walled" indistinguishable from "no results for this query". Don't
do that anymore. Two rules:
-
After every
const response = await doFetch(...), callcontext?.sentinel?.(response, this.name);before reading the body. This converts non-OK HTTP into a structured throw the orchestrator understands. -
If you have an outer
try / catchthat returns[]on any error, re-throw structured engine errors so they reach the orchestrator:
Without the re-throw, your catch swallows the new signal and the UI is back to showing 0 results with no context.} catch (e) { if (e?.name === "SentinelBreach") throw e; return []; }
For soft blocks you detect yourself (Cloudflare challenge HTML, JavaScript consent interstitial, empty API envelope from a rate-limited backend, ...) build the error explicitly:
const html = await response.text();
if (html.includes("cf-challenge")) {
throw context.engineError(
"captcha",
`${this.name} hit a Cloudflare challenge`,
{ engine: this.name },
);
}
The timeFilter parameter passed to
executeSearch will be one of these options: "any",
"hour", "day", "week",
"month", "year", or "custom". If it is
"custom", just read context.dateFrom and
context.dateTo.
export default class MyEngine {
name = "My Search";
async executeSearch(query, page = 1, timeFilter, context) {
const lang = context?.lang;
const acceptLang = context?.buildAcceptLanguage?.() ?? "en,en-US;q=0.9";
const doFetch = context?.fetch ?? fetch;
const params = new URLSearchParams({ q: query });
if (lang) params.set("lang", lang);
if (timeFilter === "day") params.set("when", "24h");
else if (timeFilter === "week") params.set("when", "7d");
else if (timeFilter === "custom" && context?.dateFrom) {
params.set("from", context.dateFrom);
if (context.dateTo) params.set("to", context.dateTo);
}
const res = await doFetch(`https://example.com/search?${params}`, {
headers: { "Accept-Language": acceptLang },
});
// parse and return array of { title, url, snippet, source }
}
}
Safe Search
Our default engines like Brave and Bing include a
Safe Search dropdown in Settings then Engines. The default
behavior stays the same where Brave defaults to moderate and the
rest default to off. Users can easily raise or lower the filter
for each engine individually.
You can add this exact same setting to your custom engines by including a
select field in your settingsSchema and applying the
value in your executeSearch function:
settingsSchema = [
{
key: "safeSearch",
label: "Safe Search",
type: "select",
options: ["off", "moderate", "strict"],
description: "Filter explicit content from search results.",
},
];
safeSearch = "moderate";
configure(settings) {
if (typeof settings.safeSearch === "string") {
this.safeSearch = settings.safeSearch;
}
}
async executeSearch(query, page = 1, timeFilter, context) {
const params = new URLSearchParams({ q: query, safesearch: this.safeSearch });
// ...
}
Proxies
When someone enables a proxy for search, your engine requests can route through it. To make sure your engine plays nicely with proxies:
-
Use
context.fetchfor every outbound HTTP request instead of the globalfetch.
export default class MyEngine {
name = "My Search";
async executeSearch(query, page = 1, _timeFilter, context) {
const url = `https://www.example.com/search?q=${encodeURIComponent(query)}`;
const doFetch = context?.fetch ?? fetch;
const response = await doFetch(url, { headers: { "User-Agent": "my-engine/1.0" } });
const html = await response.text();
// parse and return array of { title, url, snippet, source, ... }
}
}
Every engine also features an
Outgoing HTTP client control under the
Advanced section in Settings then Engines. This setting is
stored per engine and can be set to fetch, system
curl, auto, or any custom
transport you install from the Store.
You should still use context.fetch in your custom engines so this
setting applies automatically.
Client exposure: The network indicator on extension cards in
Settings applies to plugins only, not search engines. Your
executeSearch calls always run on the server when you use
context.fetch. If your results include thumbnail or
imageUrl fields, the search theme may load those URLs in the
user's browser. Use context.signProxyUrl(url) so images are
served through the server instead. See
Declaring client exposure
for how plugins declare isClientExposed.
Set needsAppRestart = true if your engine cannot work correctly
until the Degoog server process restarts, for example because it registers
boot-time server behavior. When installed or updated via the Store, Degoog
will show a restart required warning on the extension and in the admin Server
tab until the instance owner restarts.
export const needsAppRestart = true;
Setup
Create your data/engines/ directory or set your
DEGOOG_ENGINES_DIR. Add a single file like
my-engine.js or a folder with an index.js file. The
engine ID will simply be the filename or folder name with an
-engine suffix (so my-engine becomes
my-engine-engine).
How settings work
-
Declare your
settingsSchemaso the Configure button appears in Settings then Engines. -
The user saves their preferences and the values are stored in
data/plugin-settings.json. -
Your
configure(settings)function is called right after saving and every time the server restarts. -
You can just return an empty array from
executeSearchif any required settings are missing.
Examples from the official store
-
Ecosia
is a web engine using
bangShortcutwhile correctly utilizingcontext.fetch. - Startpage is another web engine featuring optional Anonymous View settings.
- Internet Archive is a file type engine that works perfectly as a dependency for the File tab plugin.
If you plan on distributing your engine through the
Store, make sure to add a
screenshots/ folder inside your engine folder so the Store card
looks great.