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 CHANGED
@@ -21,9 +21,9 @@ Opens `http://localhost:3000`. That's it.
21
21
 
22
22
  ## What You Get
23
23
 
24
- **Search** — DuckDuckGo + Wikipedia out of the box. Add Brave, Mojeek, Yandex, Marginalia, Ahmia, or your own SearXNG for more coverage. Engine picker lets you mix and match per-search.
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/
@@ -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', label: 'Chutes.ai TEE', api_base: 'https://llm.chutes.ai/v1', keyRequired: true, defaultModel: 'deepseek-ai/DeepSeek-V3.2-TEE' },
269
- { id: 'anthropic',label: 'Anthropic', api_base: 'https://api.anthropic.com/v1', keyRequired: true, defaultModel: 'claude-3-5-haiku-latest' },
270
- { id: 'openai', label: 'OpenAI', api_base: 'https://api.openai.com/v1', keyRequired: true, defaultModel: 'gpt-4o-mini' },
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', 'brave', 'startpage', 'qwant', 'mojeek', 'bing', 'google', 'yahoo'] },
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', 'startpage', 'qwant',
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(-4).map(s => el('div', { className: 'ai-step' }, s)),
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', label: 'Followers' },
723
- { key: 'following', label: 'Following' },
724
- { key: 'repos', label: 'Repos' },
725
- { key: 'karma', label: 'Karma' },
726
- { key: 'posts', label: 'Posts' },
727
- { key: 'subscribers',label: 'Subscribers' },
728
- { key: 'likes', label: 'Likes' },
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 yandexCfg = cfg.yandex || {};
1288
- const ahmiaCfg = cfg.ahmia || {};
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'),
@@ -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: 4;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termsearch",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Personal search engine for Termux/Linux/macOS — zero-config, privacy-first, AI-optional",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,8 +49,20 @@ function termuxStatus() {
49
49
 
50
50
  function termuxEnable() {
51
51
  fs.mkdirSync(TERMUX_BOOT_DIR, { recursive: true });
52
- const bin = findBin();
53
- const sh = `#!/data/data/com.termux/files/usr/bin/sh\n# TermSearch autostart\n${bin} start --fg &\n`;
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
 
@@ -49,6 +49,18 @@ export const DEFAULTS = {
49
49
  api_base: 'https://api.mojeek.com',
50
50
  },
51
51
 
52
+ startpage: {
53
+ enabled: true,
54
+ },
55
+
56
+ qwant: {
57
+ enabled: true,
58
+ },
59
+
60
+ ecosia: {
61
+ enabled: true,
62
+ },
63
+
52
64
  yandex: {
53
65
  enabled: true,
54
66
  },
@@ -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: false,
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: false,
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: false,
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
- _searchCache.set(cacheKey, response, cfg.search.cache_ttl_search_ms);
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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/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(/&amp;/g, '&').replace(/&nbsp;/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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/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 };