termsearch 0.3.12 → 0.3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/frontend/dist/app.js +69 -20
- package/frontend/dist/style.css +4 -1
- package/package.json +1 -1
- package/src/autostart/manager.js +14 -2
- package/src/config/defaults.js +12 -0
- package/src/search/engine.js +32 -4
- package/src/search/providers/ecosia.js +83 -0
- package/src/search/providers/qwant.js +72 -0
- package/src/search/providers/startpage.js +106 -0
- package/src/server.js +6 -0
package/README.md
CHANGED
|
@@ -21,9 +21,9 @@ Opens `http://localhost:3000`. That's it.
|
|
|
21
21
|
|
|
22
22
|
## What You Get
|
|
23
23
|
|
|
24
|
-
**Search** — DuckDuckGo
|
|
24
|
+
**Search** — DuckDuckGo, Wikipedia, Startpage, Qwant, Ecosia, GitHub, Yandex, Marginalia, Ahmia out of the box. Add Brave, Mojeek, or your own SearXNG for more coverage. Engine picker lets you mix and match per-search, and the new web scrapers can be toggled in Settings.
|
|
25
25
|
|
|
26
|
-
**AI Summaries** — Connect any OpenAI-compatible endpoint (Ollama, LM Studio, llama.cpp, Chutes.ai, Anthropic, OpenAI). 2-phase agentic flow: AI picks sources, reads pages, synthesizes an answer. Session memory carries context across queries.
|
|
26
|
+
**AI Summaries** — Connect any OpenAI-compatible endpoint (Ollama, LM Studio, llama.cpp, Chutes.ai, OpenRoute.ai, Anthropic, OpenAI). 2-phase agentic flow: AI picks sources, reads pages, synthesizes an answer. Session memory carries context across queries.
|
|
27
27
|
|
|
28
28
|
**Social Profiler** — Paste a GitHub/Bluesky/Reddit/Twitter URL or @handle, get a profile card with stats, top repos, similar accounts.
|
|
29
29
|
|
|
@@ -35,7 +35,7 @@ Opens `http://localhost:3000`. That's it.
|
|
|
35
35
|
|
|
36
36
|
| Level | Config | What works |
|
|
37
37
|
|-------|--------|------------|
|
|
38
|
-
| **0** | None | DuckDuckGo + Wikipedia + Yandex + Marginalia + Ahmia |
|
|
38
|
+
| **0** | None | DuckDuckGo + Wikipedia + Startpage + Qwant + Ecosia + GitHub + Yandex + Marginalia + Ahmia |
|
|
39
39
|
| **1** | API keys (Settings) | + Brave, Mojeek |
|
|
40
40
|
| **2** | AI endpoint (Settings) | + AI summaries, query refinement, session memory |
|
|
41
41
|
| **3** | SearXNG URL | + 40 engines via your SearXNG instance |
|
|
@@ -67,6 +67,7 @@ Configure in **Settings > AI** from the browser. Presets auto-fill endpoint and
|
|
|
67
67
|
| LM Studio | `localhost:1234/v1` | no | — |
|
|
68
68
|
| llama.cpp | `localhost:8080/v1` | no | — |
|
|
69
69
|
| Chutes.ai TEE | `llm.chutes.ai/v1` | yes | `DeepSeek-V3.2-TEE` |
|
|
70
|
+
| OpenRoute.ai | `openroute.ai/api/v1` | yes | `deepseek/deepseek-chat` |
|
|
70
71
|
| Anthropic | `api.anthropic.com/v1` | yes | `claude-3-5-haiku-latest` |
|
|
71
72
|
| OpenAI | `api.openai.com/v1` | yes | `gpt-4o-mini` |
|
|
72
73
|
| Custom | any OpenAI-compatible | optional | — |
|
|
@@ -75,7 +76,9 @@ Load Models button auto-discovers available models from the endpoint.
|
|
|
75
76
|
|
|
76
77
|
## Search Engines
|
|
77
78
|
|
|
78
|
-
**Zero-config** (no API key): DuckDuckGo, Wikipedia, GitHub, Yandex, Ahmia, Marginalia
|
|
79
|
+
**Zero-config** (no API key): DuckDuckGo, Wikipedia, Startpage, Qwant, Ecosia, GitHub, Yandex, Ahmia, Marginalia
|
|
80
|
+
|
|
81
|
+
**Toggles in Settings**: Startpage, Qwant, Ecosia, Yandex, Ahmia, Marginalia
|
|
79
82
|
|
|
80
83
|
**API key** (toggle in Settings): Brave Search, Mojeek
|
|
81
84
|
|
|
@@ -107,7 +110,7 @@ src/
|
|
|
107
110
|
config/ manager + defaults + env overrides
|
|
108
111
|
search/
|
|
109
112
|
engine.js fan-out, merge, rank, health tracking
|
|
110
|
-
providers/ ddg, wikipedia, brave, mojeek, searxng, github, yandex, ahmia, marginalia
|
|
113
|
+
providers/ ddg, wikipedia, startpage, qwant, ecosia, brave, mojeek, searxng, github, yandex, ahmia, marginalia
|
|
111
114
|
ranking.js source diversity ranking
|
|
112
115
|
cache.js tiered L1+L2 cache
|
|
113
116
|
ai/
|
package/frontend/dist/app.js
CHANGED
|
@@ -265,13 +265,14 @@ const AI_PRESETS = [
|
|
|
265
265
|
{ id: 'ollama', label: 'LocalHost — Ollama', api_base: 'http://127.0.0.1:11434/v1', keyRequired: false, defaultModel: 'qwen3.5:4b' },
|
|
266
266
|
{ id: 'lmstudio', label: 'LocalHost — LM Studio', api_base: 'http://127.0.0.1:1234/v1', keyRequired: false, defaultModel: '' },
|
|
267
267
|
{ id: 'llamacpp', label: 'LocalHost — llama.cpp', api_base: 'http://127.0.0.1:8080/v1', keyRequired: false, defaultModel: '' },
|
|
268
|
-
{ id: 'chutes',
|
|
269
|
-
{ id: '
|
|
270
|
-
{ id: '
|
|
268
|
+
{ id: 'chutes', label: 'Chutes.ai TEE', api_base: 'https://llm.chutes.ai/v1', keyRequired: true, defaultModel: 'deepseek-ai/DeepSeek-V3.2-TEE' },
|
|
269
|
+
{ id: 'openroute', label: 'OpenRoute.ai', api_base: 'https://openroute.ai/api/v1', keyRequired: true, defaultModel: 'deepseek/deepseek-chat' },
|
|
270
|
+
{ id: 'anthropic', label: 'Anthropic', api_base: 'https://api.anthropic.com/v1', keyRequired: true, defaultModel: 'claude-3-5-haiku-latest' },
|
|
271
|
+
{ id: 'openai', label: 'OpenAI', api_base: 'https://api.openai.com/v1', keyRequired: true, defaultModel: 'gpt-4o-mini' },
|
|
271
272
|
];
|
|
272
273
|
|
|
273
274
|
const ENGINE_GROUPS = [
|
|
274
|
-
{ label: 'Web Core', items: ['duckduckgo', 'wikipedia', '
|
|
275
|
+
{ label: 'Web Core', items: ['duckduckgo', 'wikipedia', 'startpage', 'qwant', 'ecosia', 'brave', 'mojeek', 'bing', 'google', 'yahoo'] },
|
|
275
276
|
{ label: 'Uncensored', items: ['yandex', 'marginalia', 'ahmia'] },
|
|
276
277
|
{ label: 'Code & Dev', items: ['github', 'github-api', 'hackernews', 'reddit'] },
|
|
277
278
|
{ label: 'Media', items: ['youtube', 'sepiasearch'] },
|
|
@@ -288,7 +289,7 @@ const ENGINE_PRESETS = [
|
|
|
288
289
|
|
|
289
290
|
// ─── Engine availability (requires config) ────────────────────────────────
|
|
290
291
|
const SEARXNG_ROUTED = new Set([
|
|
291
|
-
'bing', 'google', 'yahoo',
|
|
292
|
+
'bing', 'google', 'yahoo',
|
|
292
293
|
'youtube', 'reddit', 'hackernews', 'sepiasearch',
|
|
293
294
|
'wikidata', 'crossref', 'openalex', 'openlibrary',
|
|
294
295
|
'mastodon users', 'mastodon hashtags', 'tootfinder',
|
|
@@ -303,6 +304,9 @@ function isEngineAvailable(engine) {
|
|
|
303
304
|
if (engine === 'yandex') return cfg.yandex?.enabled !== false;
|
|
304
305
|
if (engine === 'ahmia') return cfg.ahmia?.enabled !== false;
|
|
305
306
|
if (engine === 'marginalia') return cfg.marginalia?.enabled !== false;
|
|
307
|
+
if (engine === 'startpage') return (state.config?.startpage?.enabled) !== false;
|
|
308
|
+
if (engine === 'qwant') return (state.config?.qwant?.enabled) !== false;
|
|
309
|
+
if (engine === 'ecosia') return (state.config?.ecosia?.enabled) !== false;
|
|
306
310
|
if (SEARXNG_ROUTED.has(engine)) return Boolean(cfg.searxng?.enabled && cfg.searxng?.url);
|
|
307
311
|
return true; // duckduckgo, wikipedia, github, github-api — sempre disponibili
|
|
308
312
|
}
|
|
@@ -592,7 +596,7 @@ function renderAiPanel() {
|
|
|
592
596
|
// Steps
|
|
593
597
|
const showSteps = isLoading && state.aiSteps.length > 0;
|
|
594
598
|
const stepsEl = showSteps ? el('div', { className: 'ai-steps' },
|
|
595
|
-
...state.aiSteps.slice(-
|
|
599
|
+
...state.aiSteps.slice(-2).map(s => el('div', { className: 'ai-step' }, s)),
|
|
596
600
|
) : null;
|
|
597
601
|
|
|
598
602
|
// Content
|
|
@@ -708,9 +712,17 @@ function ProfilerPanel(data) {
|
|
|
708
712
|
info.append(nameEl);
|
|
709
713
|
if (profile.handle) info.append(el('div', { className: 'profile-handle' }, '@' + profile.handle));
|
|
710
714
|
if (profile.bio) info.append(el('div', { className: 'profile-bio' }, profile.bio));
|
|
715
|
+
if (profile.blog) {
|
|
716
|
+
const safeBlog = sanitizeHttpUrl(profile.blog.startsWith('http') ? profile.blog : 'https://' + profile.blog);
|
|
717
|
+
if (safeBlog) info.append(el('a', { className: 'profile-blog', href: safeBlog, target: '_blank', rel: 'noopener' }, profile.blog.replace(/^https?:\/\//, '')));
|
|
718
|
+
}
|
|
711
719
|
|
|
712
720
|
// Extra metadata
|
|
713
721
|
const extras = [profile.location, profile.company].filter(Boolean);
|
|
722
|
+
if (profile.createdAt) {
|
|
723
|
+
const d = new Date(profile.createdAt);
|
|
724
|
+
if (!isNaN(d)) extras.push('Joined ' + d.toLocaleDateString(undefined, { year: 'numeric', month: 'short' }));
|
|
725
|
+
}
|
|
714
726
|
if (extras.length) {
|
|
715
727
|
info.append(el('div', { style: 'font-size:10px;color:var(--text3);margin-top:4px' }, extras.join(' · ')));
|
|
716
728
|
}
|
|
@@ -719,13 +731,17 @@ function ProfilerPanel(data) {
|
|
|
719
731
|
|
|
720
732
|
// Stats
|
|
721
733
|
const statsFields = [
|
|
722
|
-
{ key: 'followers',
|
|
723
|
-
{ key: 'following',
|
|
724
|
-
{ key: 'repos',
|
|
725
|
-
{ key: '
|
|
726
|
-
{ key: '
|
|
727
|
-
{ key: '
|
|
728
|
-
{ key: '
|
|
734
|
+
{ key: 'followers', label: 'Followers' },
|
|
735
|
+
{ key: 'following', label: 'Following' },
|
|
736
|
+
{ key: 'repos', label: 'Repos' },
|
|
737
|
+
{ key: 'publicRepos', label: 'Repos' },
|
|
738
|
+
{ key: 'karma', label: 'Karma' },
|
|
739
|
+
{ key: 'linkKarma', label: 'Link↑' },
|
|
740
|
+
{ key: 'commentKarma', label: 'Comment↑' },
|
|
741
|
+
{ key: 'posts', label: 'Posts' },
|
|
742
|
+
{ key: 'postsCount', label: 'Posts' },
|
|
743
|
+
{ key: 'subscribers', label: 'Subscribers' },
|
|
744
|
+
{ key: 'likes', label: 'Likes' },
|
|
729
745
|
];
|
|
730
746
|
const statsData = statsFields.filter(f => profile[f.key] != null);
|
|
731
747
|
if (statsData.length) {
|
|
@@ -1132,6 +1148,10 @@ function renderApp() {
|
|
|
1132
1148
|
|
|
1133
1149
|
const main = el('div', { className: 'main' });
|
|
1134
1150
|
|
|
1151
|
+
// Profiler panel appears first (above AI panel)
|
|
1152
|
+
const profPanel = ProfilerPanel(state.profilerData || (state.profilerLoading ? { target: null, profile: null } : null));
|
|
1153
|
+
if (profPanel) main.append(profPanel);
|
|
1154
|
+
|
|
1135
1155
|
// AI panel placeholder
|
|
1136
1156
|
const aiPanel = el('div', { id: 'ai-panel', className: 'panel panel-ai', style: 'display:none' });
|
|
1137
1157
|
main.append(aiPanel);
|
|
@@ -1152,10 +1172,6 @@ function renderApp() {
|
|
|
1152
1172
|
main.append(meta);
|
|
1153
1173
|
}
|
|
1154
1174
|
|
|
1155
|
-
// 1. Profiler
|
|
1156
|
-
const profPanel = ProfilerPanel(state.profilerData || (state.profilerLoading ? { target: null, profile: null } : null));
|
|
1157
|
-
if (profPanel) main.append(profPanel);
|
|
1158
|
-
|
|
1159
1175
|
// 2. Torrent panel (only if there are magnets)
|
|
1160
1176
|
const torPanel = TorrentPanel(state.torrentData);
|
|
1161
1177
|
if (torPanel) main.append(torPanel);
|
|
@@ -1284,8 +1300,11 @@ async function renderSettings() {
|
|
|
1284
1300
|
const brave = cfg.brave || {};
|
|
1285
1301
|
const mojeek = cfg.mojeek || {};
|
|
1286
1302
|
const searxng = cfg.searxng || {};
|
|
1287
|
-
const
|
|
1288
|
-
const
|
|
1303
|
+
const startpageCfg = cfg.startpage || {};
|
|
1304
|
+
const qwantCfg = cfg.qwant || {};
|
|
1305
|
+
const ecosiaCfg = cfg.ecosia || {};
|
|
1306
|
+
const yandexCfg = cfg.yandex || {};
|
|
1307
|
+
const ahmiaCfg = cfg.ahmia || {};
|
|
1289
1308
|
const marginaliaCfg = cfg.marginalia || {};
|
|
1290
1309
|
const detectedPreset = detectPresetFromBase(ai.api_base);
|
|
1291
1310
|
|
|
@@ -1464,6 +1483,9 @@ async function renderSettings() {
|
|
|
1464
1483
|
brave: { enabled: isChecked('brave-enabled') },
|
|
1465
1484
|
mojeek: { enabled: isChecked('mojeek-enabled') },
|
|
1466
1485
|
searxng:{ url: val('searxng-url'), enabled: isChecked('searxng-enabled') },
|
|
1486
|
+
startpage: { enabled: isChecked('startpage-enabled') },
|
|
1487
|
+
qwant: { enabled: isChecked('qwant-enabled') },
|
|
1488
|
+
ecosia: { enabled: isChecked('ecosia-enabled') },
|
|
1467
1489
|
yandex: { enabled: isChecked('yandex-enabled') },
|
|
1468
1490
|
ahmia: { enabled: isChecked('ahmia-enabled') },
|
|
1469
1491
|
marginalia: { enabled: isChecked('marginalia-enabled') },
|
|
@@ -1599,7 +1621,7 @@ async function renderSettings() {
|
|
|
1599
1621
|
el('label', { className: 'form-label', for: 'ai-base' }, 'API Endpoint'),
|
|
1600
1622
|
makeInput('ai-base', ai.api_base, 'http://localhost:11434/v1'),
|
|
1601
1623
|
el('div', { className: 'form-hint' },
|
|
1602
|
-
'Included presets: LocalHost (Ollama · LM Studio · llama.cpp) · Chutes.ai TEE · Anthropic · OpenAI',
|
|
1624
|
+
'Included presets: LocalHost (Ollama · LM Studio · llama.cpp) · Chutes.ai TEE · OpenRoute.ai · Anthropic · OpenAI',
|
|
1603
1625
|
el('br', {}),
|
|
1604
1626
|
'You can also keep custom OpenAI-compatible endpoints.',
|
|
1605
1627
|
),
|
|
@@ -1707,6 +1729,33 @@ async function renderSettings() {
|
|
|
1707
1729
|
el('div', { id: 'provider-test-searxng', style: 'display:none' }),
|
|
1708
1730
|
),
|
|
1709
1731
|
|
|
1732
|
+
// Web Scrapers (zero-config)
|
|
1733
|
+
el('div', { style: 'padding:10px 0;border-bottom:1px solid var(--border2)' },
|
|
1734
|
+
el('div', { style: 'font-size:11px;color:var(--text2);margin-bottom:8px;letter-spacing:0.04em;text-transform:uppercase' }, 'Web Scrapers (zero-config)'),
|
|
1735
|
+
el('div', { className: 'toggle-row' },
|
|
1736
|
+
el('span', { className: 'toggle-label' }, 'Startpage (Google proxy, no key)'),
|
|
1737
|
+
el('label', { className: 'toggle' },
|
|
1738
|
+
el('input', { type: 'checkbox', id: 'startpage-enabled', ...(startpageCfg.enabled !== false ? { checked: '' } : {}) }),
|
|
1739
|
+
el('span', { className: 'toggle-slider' }),
|
|
1740
|
+
),
|
|
1741
|
+
),
|
|
1742
|
+
el('div', { className: 'toggle-row', style: 'margin-top:6px' },
|
|
1743
|
+
el('span', { className: 'toggle-label' }, 'Qwant (EU index, no key)'),
|
|
1744
|
+
el('label', { className: 'toggle' },
|
|
1745
|
+
el('input', { type: 'checkbox', id: 'qwant-enabled', ...(qwantCfg.enabled !== false ? { checked: '' } : {}) }),
|
|
1746
|
+
el('span', { className: 'toggle-slider' }),
|
|
1747
|
+
),
|
|
1748
|
+
),
|
|
1749
|
+
el('div', { className: 'toggle-row', style: 'margin-top:6px' },
|
|
1750
|
+
el('span', { className: 'toggle-label' }, 'Ecosia (Bing-based, no key)'),
|
|
1751
|
+
el('label', { className: 'toggle' },
|
|
1752
|
+
el('input', { type: 'checkbox', id: 'ecosia-enabled', ...(ecosiaCfg.enabled !== false ? { checked: '' } : {}) }),
|
|
1753
|
+
el('span', { className: 'toggle-slider' }),
|
|
1754
|
+
),
|
|
1755
|
+
),
|
|
1756
|
+
el('div', { className: 'form-hint', style: 'margin-top:6px' }, 'HTML scrapers — active by default. May hit CAPTCHA under heavy use.'),
|
|
1757
|
+
),
|
|
1758
|
+
|
|
1710
1759
|
// Uncensored / Alternative
|
|
1711
1760
|
el('div', { style: 'padding:10px 0' },
|
|
1712
1761
|
el('div', { style: 'font-size:11px;color:var(--text2);margin-bottom:8px;letter-spacing:0.04em;text-transform:uppercase' }, 'Uncensored / Alternative'),
|
package/frontend/dist/style.css
CHANGED
|
@@ -688,9 +688,10 @@ a:hover { color: var(--link-h); }
|
|
|
688
688
|
}
|
|
689
689
|
.ai-content.ai-content-collapsed {
|
|
690
690
|
display: -webkit-box;
|
|
691
|
-
-webkit-line-clamp:
|
|
691
|
+
-webkit-line-clamp: 5;
|
|
692
692
|
-webkit-box-orient: vertical;
|
|
693
693
|
overflow: hidden;
|
|
694
|
+
max-height: 8.5em;
|
|
694
695
|
}
|
|
695
696
|
|
|
696
697
|
/* Sources */
|
|
@@ -740,6 +741,8 @@ a:hover { color: var(--link-h); }
|
|
|
740
741
|
.profile-name:hover { color: var(--prof-text); }
|
|
741
742
|
.profile-handle { font-size: 11px; color: var(--text3); margin-top: 1px; }
|
|
742
743
|
.profile-bio { font-size: 12px; color: var(--text2); margin-top: 6px; line-height: 1.5; }
|
|
744
|
+
.profile-blog { display: block; font-size: 11px; color: var(--accent); margin-top: 3px; text-decoration: none; opacity: 0.85; }
|
|
745
|
+
.profile-blog:hover { opacity: 1; text-decoration: underline; }
|
|
743
746
|
|
|
744
747
|
.profile-stats { display: flex; flex-wrap: wrap; gap: 16px; padding-top: 12px; border-top: 1px solid var(--prof-dim); }
|
|
745
748
|
.stat { display: flex; flex-direction: column; gap: 1px; }
|
package/package.json
CHANGED
package/src/autostart/manager.js
CHANGED
|
@@ -49,8 +49,20 @@ function termuxStatus() {
|
|
|
49
49
|
|
|
50
50
|
function termuxEnable() {
|
|
51
51
|
fs.mkdirSync(TERMUX_BOOT_DIR, { recursive: true });
|
|
52
|
-
|
|
53
|
-
const
|
|
52
|
+
// Termux:Boot runs scripts with a minimal PATH — resolve everything to absolute paths now
|
|
53
|
+
const prefix = process.env.PREFIX || '/data/data/com.termux/files/usr';
|
|
54
|
+
const homeDir = os.homedir();
|
|
55
|
+
const nodeBin = process.execPath; // absolute path to node binary
|
|
56
|
+
const binPath = findBin(); // absolute path to termsearch script
|
|
57
|
+
const sh = [
|
|
58
|
+
'#!/data/data/com.termux/files/usr/bin/sh',
|
|
59
|
+
'# TermSearch autostart (generated by termsearch autostart enable)',
|
|
60
|
+
`export HOME="${homeDir}"`,
|
|
61
|
+
`export PREFIX="${prefix}"`,
|
|
62
|
+
`export PATH="${prefix}/bin:${homeDir}/.npm-global/bin:\$PATH"`,
|
|
63
|
+
`"${nodeBin}" "${binPath}" start --fg &`,
|
|
64
|
+
'',
|
|
65
|
+
].join('\n');
|
|
54
66
|
fs.writeFileSync(TERMUX_BOOT_FILE, sh, { mode: 0o755 });
|
|
55
67
|
}
|
|
56
68
|
|
package/src/config/defaults.js
CHANGED
package/src/search/engine.js
CHANGED
|
@@ -12,6 +12,9 @@ import * as github from './providers/github.js';
|
|
|
12
12
|
import * as yandex from './providers/yandex.js';
|
|
13
13
|
import * as ahmia from './providers/ahmia.js';
|
|
14
14
|
import * as marginalia from './providers/marginalia.js';
|
|
15
|
+
import * as startpage from './providers/startpage.js';
|
|
16
|
+
import * as qwant from './providers/qwant.js';
|
|
17
|
+
import * as ecosia from './providers/ecosia.js';
|
|
15
18
|
|
|
16
19
|
let _searchCache = null;
|
|
17
20
|
let _docCache = null;
|
|
@@ -52,6 +55,10 @@ export const ALLOWED_ENGINES = new Set([
|
|
|
52
55
|
'1337x',
|
|
53
56
|
'piratebay',
|
|
54
57
|
'nyaa',
|
|
58
|
+
// native scrapers
|
|
59
|
+
'startpage',
|
|
60
|
+
'qwant',
|
|
61
|
+
'ecosia',
|
|
55
62
|
// uncensored / alternative index engines
|
|
56
63
|
'yandex',
|
|
57
64
|
'ahmia',
|
|
@@ -97,6 +104,24 @@ const PROVIDER_REGISTRY = {
|
|
|
97
104
|
run: searxng.search,
|
|
98
105
|
defaultProvider: true,
|
|
99
106
|
},
|
|
107
|
+
startpage: {
|
|
108
|
+
aliases: new Set(['startpage']),
|
|
109
|
+
enabled: (cfg) => cfg?.startpage?.enabled !== false,
|
|
110
|
+
run: startpage.search,
|
|
111
|
+
defaultProvider: true,
|
|
112
|
+
},
|
|
113
|
+
qwant: {
|
|
114
|
+
aliases: new Set(['qwant']),
|
|
115
|
+
enabled: (cfg) => cfg?.qwant?.enabled !== false,
|
|
116
|
+
run: qwant.search,
|
|
117
|
+
defaultProvider: true,
|
|
118
|
+
},
|
|
119
|
+
ecosia: {
|
|
120
|
+
aliases: new Set(['ecosia']),
|
|
121
|
+
enabled: (cfg) => cfg?.ecosia?.enabled !== false,
|
|
122
|
+
run: ecosia.search,
|
|
123
|
+
defaultProvider: true,
|
|
124
|
+
},
|
|
100
125
|
github: {
|
|
101
126
|
aliases: new Set(['github', 'github-api']),
|
|
102
127
|
enabled: (_cfg) => true,
|
|
@@ -107,19 +132,19 @@ const PROVIDER_REGISTRY = {
|
|
|
107
132
|
aliases: new Set(['yandex']),
|
|
108
133
|
enabled: (cfg) => cfg?.yandex?.enabled !== false,
|
|
109
134
|
run: yandex.search,
|
|
110
|
-
defaultProvider:
|
|
135
|
+
defaultProvider: true,
|
|
111
136
|
},
|
|
112
137
|
ahmia: {
|
|
113
138
|
aliases: new Set(['ahmia']),
|
|
114
139
|
enabled: (cfg) => cfg?.ahmia?.enabled !== false,
|
|
115
140
|
run: ahmia.search,
|
|
116
|
-
defaultProvider:
|
|
141
|
+
defaultProvider: true,
|
|
117
142
|
},
|
|
118
143
|
marginalia: {
|
|
119
144
|
aliases: new Set(['marginalia']),
|
|
120
145
|
enabled: (cfg) => cfg?.marginalia?.enabled !== false,
|
|
121
146
|
run: marginalia.search,
|
|
122
|
-
defaultProvider:
|
|
147
|
+
defaultProvider: true,
|
|
123
148
|
},
|
|
124
149
|
};
|
|
125
150
|
|
|
@@ -485,7 +510,10 @@ export async function search({ query, lang = 'en-US', safe = '1', page = 1, cate
|
|
|
485
510
|
};
|
|
486
511
|
});
|
|
487
512
|
|
|
488
|
-
|
|
513
|
+
// Don't cache empty results — likely a transient block/CAPTCHA; retry on next request
|
|
514
|
+
if (response.results.length > 0) {
|
|
515
|
+
_searchCache.set(cacheKey, response, cfg.search.cache_ttl_search_ms);
|
|
516
|
+
}
|
|
489
517
|
return response;
|
|
490
518
|
}
|
|
491
519
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Ecosia HTML scraper — Bing-powered, no API key required
|
|
2
|
+
// Privacy-focused, plants trees with ad revenue
|
|
3
|
+
|
|
4
|
+
const ECOSIA_ENDPOINT = 'https://www.ecosia.org/search';
|
|
5
|
+
|
|
6
|
+
const USER_AGENTS = [
|
|
7
|
+
'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
|
|
8
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
function randomUA() {
|
|
12
|
+
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function ent(s) {
|
|
16
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ').replace(/&#\d+;/g, '').trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseEcosia(html) {
|
|
20
|
+
const results = [];
|
|
21
|
+
|
|
22
|
+
// Primary: result__body blocks (standard Ecosia layout)
|
|
23
|
+
const blockRe = /class="[^"]*result(?:__body|-body)[^"]*"[^>]*>([\s\S]*?)(?=class="[^"]*result(?:__body|-body)|class="[^"]*pagination|<\/main)/gi;
|
|
24
|
+
let m;
|
|
25
|
+
while ((m = blockRe.exec(html)) !== null && results.length < 15) {
|
|
26
|
+
const block = m[1];
|
|
27
|
+
// Title anchor — skip ecosia.org internal links
|
|
28
|
+
const aMatch = block.match(/<a[^>]+href="(https?:\/\/(?!(?:[^/]*\.)?ecosia\.org)[^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
29
|
+
if (!aMatch) continue;
|
|
30
|
+
const url = aMatch[1];
|
|
31
|
+
if (url.length > 500) continue;
|
|
32
|
+
const title = ent(aMatch[2].replace(/<[^>]+>/g, ''));
|
|
33
|
+
if (!title) continue;
|
|
34
|
+
const snipM = block.match(/class="[^"]*(?:result-snippet|result__description|result__body-text|result__content)[^"]*"[^>]*>([\s\S]*?)<\/(?:p|div|span)>/i);
|
|
35
|
+
const snippet = snipM ? ent(snipM[1].replace(/<[^>]+>/g, '')).slice(0, 300) : '';
|
|
36
|
+
results.push({ title, url, snippet, engine: 'ecosia', score: 0 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Fallback: external links that have a heading nearby
|
|
40
|
+
if (results.length === 0) {
|
|
41
|
+
const linkRe = /<a[^>]+href="(https?:\/\/(?!(?:[^/]*\.)?ecosia\.org)[^"]+)"[^>]*>[\s]*<(?:h[1-4]|strong)[^>]*>([\s\S]*?)<\/(?:h[1-4]|strong)>/gi;
|
|
42
|
+
while ((m = linkRe.exec(html)) !== null && results.length < 15) {
|
|
43
|
+
const url = m[1];
|
|
44
|
+
const title = ent(m[2].replace(/<[^>]+>/g, ''));
|
|
45
|
+
if (!title || url.length > 500) continue;
|
|
46
|
+
results.push({ title, url, snippet: '', engine: 'ecosia', score: 0 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function search({ query, lang = 'en-US', safe = '1', page = 1, timeoutMs = 12000 }) {
|
|
54
|
+
const params = new URLSearchParams({ q: query, c: 'web' });
|
|
55
|
+
if (page > 1) params.set('p', String(page - 1));
|
|
56
|
+
|
|
57
|
+
const ac = new AbortController();
|
|
58
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
59
|
+
try {
|
|
60
|
+
const r = await fetch(`${ECOSIA_ENDPOINT}?${params}`, {
|
|
61
|
+
headers: {
|
|
62
|
+
'User-Agent': randomUA(),
|
|
63
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
64
|
+
'Accept-Language': lang.slice(0, 2) + ',en;q=0.5',
|
|
65
|
+
'Referer': 'https://www.ecosia.org/',
|
|
66
|
+
'DNT': '1',
|
|
67
|
+
},
|
|
68
|
+
signal: ac.signal,
|
|
69
|
+
});
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
if (!r.ok) return { results: [], _meta: { error: `ecosia_http_${r.status}` } };
|
|
72
|
+
const html = await r.text();
|
|
73
|
+
if (html.length < 2000 || html.includes('cf-challenge') || html.includes('captcha')) {
|
|
74
|
+
return { results: [], _meta: { error: 'ecosia_blocked' } };
|
|
75
|
+
}
|
|
76
|
+
const results = parseEcosia(html);
|
|
77
|
+
if (results.length === 0) return { results: [], _meta: { empty: true, skipHealth: true } };
|
|
78
|
+
return { results, _meta: {} };
|
|
79
|
+
} catch {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
return { results: [], _meta: { error: 'ecosia_unreachable' } };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Qwant JSON API — no API key required, semi-public endpoint
|
|
2
|
+
// Independent EU index; privacy-focused; results from own crawler + Bing blend
|
|
3
|
+
|
|
4
|
+
const QWANT_API = 'https://api.qwant.com/v3/search/web';
|
|
5
|
+
|
|
6
|
+
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
|
|
7
|
+
|
|
8
|
+
function localeParam(lang) {
|
|
9
|
+
const map = {
|
|
10
|
+
'en-US': 'en_US', 'en-GB': 'en_GB',
|
|
11
|
+
'it-IT': 'it_IT', 'de-DE': 'de_DE', 'fr-FR': 'fr_FR',
|
|
12
|
+
'es-ES': 'es_ES', 'pt-PT': 'pt_PT', 'nl-NL': 'nl_NL',
|
|
13
|
+
'pl-PL': 'pl_PL', 'ru-RU': 'ru_RU', 'ja-JP': 'ja_JP',
|
|
14
|
+
};
|
|
15
|
+
return map[lang] || 'en_US';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stripTags(s) {
|
|
19
|
+
return s.replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/ /g, ' ').trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function search({ query, lang = 'en-US', safe = '1', page = 1, timeoutMs = 10000 }) {
|
|
23
|
+
const offset = (Math.max(1, Number(page)) - 1) * 10;
|
|
24
|
+
const params = new URLSearchParams({
|
|
25
|
+
q: query,
|
|
26
|
+
count: '10',
|
|
27
|
+
locale: localeParam(lang),
|
|
28
|
+
offset: String(offset),
|
|
29
|
+
safesearch: safe === '0' ? '0' : '1',
|
|
30
|
+
t: 'web',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const ac = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
35
|
+
try {
|
|
36
|
+
const r = await fetch(`${QWANT_API}?${params}`, {
|
|
37
|
+
headers: {
|
|
38
|
+
'Accept': 'application/json',
|
|
39
|
+
'User-Agent': UA,
|
|
40
|
+
'Referer': 'https://www.qwant.com/',
|
|
41
|
+
'Accept-Language': 'en-US,en;q=0.5',
|
|
42
|
+
},
|
|
43
|
+
signal: ac.signal,
|
|
44
|
+
});
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
if (!r.ok) return { results: [], _meta: { error: `qwant_http_${r.status}` } };
|
|
47
|
+
const json = await r.json();
|
|
48
|
+
if (json.status !== 'success') return { results: [], _meta: { error: `qwant_api_${json.status || 'error'}` } };
|
|
49
|
+
|
|
50
|
+
// Flatten mainline sections of type "web"
|
|
51
|
+
const mainline = json?.data?.result?.items?.mainline || [];
|
|
52
|
+
const results = [];
|
|
53
|
+
for (const section of mainline) {
|
|
54
|
+
if (section.type !== 'web') continue;
|
|
55
|
+
for (const item of (section.items || [])) {
|
|
56
|
+
const url = String(item.url || '').trim();
|
|
57
|
+
const title = stripTags(String(item.title || ''));
|
|
58
|
+
const snippet = stripTags(String(item.desc || item.snippet || '')).slice(0, 300);
|
|
59
|
+
if (!url.startsWith('http') || !title) continue;
|
|
60
|
+
results.push({ title, url, snippet, engine: 'qwant', score: 0 });
|
|
61
|
+
if (results.length >= 10) break;
|
|
62
|
+
}
|
|
63
|
+
if (results.length >= 10) break;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (results.length === 0) return { results: [], _meta: { empty: true, skipHealth: true } };
|
|
67
|
+
return { results, _meta: {} };
|
|
68
|
+
} catch {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
return { results: [], _meta: { error: 'qwant_unreachable' } };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Startpage HTML scraper — proxies Google index, privacy-first, no API key required
|
|
2
|
+
// Uses /sp/search POST form (maintained for non-JS clients)
|
|
3
|
+
|
|
4
|
+
const SP_ENDPOINT = 'https://www.startpage.com/sp/search';
|
|
5
|
+
|
|
6
|
+
const USER_AGENTS = [
|
|
7
|
+
'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
|
|
8
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0',
|
|
9
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function randomUA() {
|
|
13
|
+
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function langParam(lang) {
|
|
17
|
+
const map = {
|
|
18
|
+
'en-US': 'english', 'en-GB': 'english',
|
|
19
|
+
'it-IT': 'italian', 'de-DE': 'german', 'fr-FR': 'french',
|
|
20
|
+
'es-ES': 'spanish', 'pt-PT': 'portuguese', 'ru-RU': 'russian',
|
|
21
|
+
'nl-NL': 'dutch', 'pl-PL': 'polish',
|
|
22
|
+
};
|
|
23
|
+
return map[lang] || 'english';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ent(s) {
|
|
27
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ').replace(/&#\d+;/g, '').trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseStartpage(html) {
|
|
31
|
+
const results = [];
|
|
32
|
+
|
|
33
|
+
// Primary pattern: w-gl__result blocks (classic Startpage layout)
|
|
34
|
+
const blockRe = /<(?:article|section|div)[^>]+class="[^"]*w-gl__result[^"]*"[^>]*>([\s\S]*?)(?=<(?:article|section|div)[^>]+class="[^"]*w-gl__result|<\/section|id="pagination|id="new-feature-banner)/gi;
|
|
35
|
+
let m;
|
|
36
|
+
while ((m = blockRe.exec(html)) !== null && results.length < 15) {
|
|
37
|
+
const block = m[1];
|
|
38
|
+
const aMatch = block.match(/<a[^>]+class="[^"]*(?:w-gl__result-title-anchor|result-title)[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
39
|
+
if (!aMatch) continue;
|
|
40
|
+
const url = aMatch[1];
|
|
41
|
+
if (!url.startsWith('http') || url.includes('startpage.com')) continue;
|
|
42
|
+
const title = ent(aMatch[2].replace(/<[^>]+>/g, ''));
|
|
43
|
+
if (!title) continue;
|
|
44
|
+
const snipM = block.match(/class="[^"]*(?:w-gl__result-description|result-desc)[^"]*"[^>]*>([\s\S]*?)<\/[a-z]+>/i);
|
|
45
|
+
const snippet = snipM ? ent(snipM[1].replace(/<[^>]+>/g, '')).slice(0, 300) : '';
|
|
46
|
+
results.push({ title, url, snippet, engine: 'startpage', score: 0 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Fallback: any external link preceded by a heading tag
|
|
50
|
+
if (results.length === 0) {
|
|
51
|
+
const linkRe = /<a[^>]+href="(https?:\/\/(?!(?:[^/]*\.)?startpage\.com)[^"]+)"[^>]*>[\s\S]*?<(?:h[1-4]|strong)[^>]*>([\s\S]*?)<\/(?:h[1-4]|strong)>/gi;
|
|
52
|
+
while ((m = linkRe.exec(html)) !== null && results.length < 15) {
|
|
53
|
+
const url = m[1];
|
|
54
|
+
const title = ent(m[2].replace(/<[^>]+>/g, ''));
|
|
55
|
+
if (!title || url.length > 500) continue;
|
|
56
|
+
results.push({ title, url, snippet: '', engine: 'startpage', score: 0 });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function search({ query, lang = 'en-US', safe = '1', page = 1, timeoutMs = 12000 }) {
|
|
64
|
+
const startat = (Math.max(1, Number(page)) - 1) * 10;
|
|
65
|
+
const body = new URLSearchParams({
|
|
66
|
+
query,
|
|
67
|
+
language: langParam(lang),
|
|
68
|
+
startat: String(startat),
|
|
69
|
+
cat: 'web',
|
|
70
|
+
cmd: 'process_search',
|
|
71
|
+
nj: '1',
|
|
72
|
+
abp: '1',
|
|
73
|
+
t: 'device',
|
|
74
|
+
with_date: '',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const ac = new AbortController();
|
|
78
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
79
|
+
try {
|
|
80
|
+
const r = await fetch(SP_ENDPOINT, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
'User-Agent': randomUA(),
|
|
84
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
85
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
86
|
+
'Accept-Language':'en-US,en;q=0.5',
|
|
87
|
+
'Origin': 'https://www.startpage.com',
|
|
88
|
+
'Referer': 'https://www.startpage.com/',
|
|
89
|
+
},
|
|
90
|
+
body: body.toString(),
|
|
91
|
+
signal: ac.signal,
|
|
92
|
+
});
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
if (!r.ok) return { results: [], _meta: { error: `startpage_http_${r.status}` } };
|
|
95
|
+
const html = await r.text();
|
|
96
|
+
if (html.length < 2000 || html.includes('captcha') || html.includes('robot-check')) {
|
|
97
|
+
return { results: [], _meta: { error: 'startpage_blocked' } };
|
|
98
|
+
}
|
|
99
|
+
const results = parseStartpage(html);
|
|
100
|
+
if (results.length === 0) return { results: [], _meta: { empty: true, skipHealth: true } };
|
|
101
|
+
return { results, _meta: {} };
|
|
102
|
+
} catch {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
return { results: [], _meta: { error: 'startpage_unreachable' } };
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/server.js
CHANGED
|
@@ -76,6 +76,12 @@ function shutdown(signal) {
|
|
|
76
76
|
}
|
|
77
77
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
78
78
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
79
|
+
process.on('uncaughtException', (err) => {
|
|
80
|
+
console.error('[termsearch] uncaughtException:', err);
|
|
81
|
+
});
|
|
82
|
+
process.on('unhandledRejection', (reason) => {
|
|
83
|
+
console.error('[termsearch] unhandledRejection:', reason);
|
|
84
|
+
});
|
|
79
85
|
|
|
80
86
|
export default app;
|
|
81
87
|
export { port, host };
|