Degoog Docs

Degoog Search engines

Build your own custom search backends for web, images, videos or anything you can think of.

degoog-cli is now available. Scaffold engine boilerplate directly from your terminal - no copy-pasting, no guessing the folder structure. Install with curl -fsSL https://raw.githubusercontent.com/degoog-org/cli/main/install.sh | sh or grab it from github.com/degoog-org/cli.

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 safeSearch dropdown described below, defaulting to moderate for image and video engines and off for 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 a thumbnail and duration. Make sure you use context.fetch for 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 query or query !shortcut to search only this engine (like setting bangShortcut: "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:

  1. After every const response = await doFetch(...), call context?.sentinel?.(response, this.name); before reading the body. This converts non-OK HTTP into a structured throw the orchestrator understands.
  2. If you have an outer try / catch that returns [] on any error, re-throw structured engine errors so they reach the orchestrator:
    } catch (e) {
      if (e?.name === "SentinelBreach") throw e;
      return [];
    }
    Without the re-throw, your catch swallows the new signal and the UI is back to showing 0 results with no context.

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:

  1. Use context.fetch for every outbound HTTP request instead of the global fetch.
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

  1. Declare your settingsSchema so the Configure button appears in Settings then Engines.
  2. The user saves their preferences and the values are stored in data/plugin-settings.json.
  3. Your configure(settings) function is called right after saving and every time the server restarts.
  4. You can just return an empty array from executeSearch if any required settings are missing.

Examples from the official store

  • Ecosia is a web engine using bangShortcut while correctly utilizing context.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.