termsearch 0.3.2 → 0.3.4

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
@@ -1,6 +1,6 @@
1
1
  # TermSearch - Personal Search Engine
2
2
 
3
- [![Status](https://img.shields.io/badge/Status-0.3.2-blue.svg)](#project-status)
3
+ [![Status](https://img.shields.io/badge/Status-0.3.3-blue.svg)](#project-status)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
  [![Node.js](https://img.shields.io/badge/Node.js-18%2B-green.svg)](https://nodejs.org)
6
6
  [![Target](https://img.shields.io/badge/Target-Termux%20%2F%20Linux%20%2F%20macOS-green.svg)](https://termux.dev)
@@ -24,7 +24,7 @@ Core capabilities:
24
24
 
25
25
  ## Project Status
26
26
 
27
- - Current line: `0.3.2`
27
+ - Current line: `0.3.3`
28
28
  - Core is MIT — zero required API keys
29
29
  - AI features are optional, configured via Settings page in browser
30
30
  - Tested on: Ubuntu 24.04, Termux (Android 15/16)
@@ -112,7 +112,7 @@ All providers use the OpenAI-compatible `/chat/completions` format. Leave API ke
112
112
  src/
113
113
  config/ config manager — load/save/defaults/env overrides
114
114
  search/
115
- providers/ DuckDuckGo, Wikipedia, Brave, Mojeek, SearXNG, GitHub API
115
+ providers/ DuckDuckGo, Wikipedia, Brave, Mojeek, SearXNG, GitHub API, Yandex, Ahmia, Marginalia
116
116
  engine.js fan-out, merge, rank, cache
117
117
  ranking.js source diversity ranking
118
118
  cache.js tiered cache (L1 Map + L2 disk JSON)
@@ -177,6 +177,8 @@ TERMSEARCH_AI_API_KEY=
177
177
  TERMSEARCH_AI_MODEL=glm-4.7
178
178
  TERMSEARCH_BRAVE_API_KEY=
179
179
  TERMSEARCH_MOJEEK_API_KEY=
180
+ TERMSEARCH_MARGINALIA_API_KEY=public
181
+ TERMSEARCH_MARGINALIA_API_BASE=https://api2.marginalia-search.com
180
182
  TERMSEARCH_SEARXNG_URL=
181
183
  TERMSEARCH_GITHUB_TOKEN=
182
184
  TERMSEARCH_INSTAGRAM_SESSION=
@@ -24,6 +24,17 @@
24
24
  "enabled": false,
25
25
  "api_key": ""
26
26
  },
27
+ "yandex": {
28
+ "enabled": true
29
+ },
30
+ "ahmia": {
31
+ "enabled": true
32
+ },
33
+ "marginalia": {
34
+ "enabled": true,
35
+ "api_key": "public",
36
+ "api_base": "https://api2.marginalia-search.com"
37
+ },
27
38
  "searxng": {
28
39
  "enabled": false,
29
40
  "url": ""
@@ -9,6 +9,16 @@ const state = {
9
9
  aiStatus: 'idle',
10
10
  aiError: null,
11
11
  aiMeta: null,
12
+ aiProgress: 0,
13
+ aiSteps: [],
14
+ aiSources: [],
15
+ aiExpanded: false,
16
+ aiStartTime: null,
17
+ aiLatencyMs: null,
18
+ aiSession: [], // { q, r }[] — ultimi 4 summary (passati all'AI come contesto)
19
+ aiLastQuery: null,
20
+ aiLastResults: null,
21
+ aiLastLang: null,
12
22
  profilerData: null,
13
23
  profilerLoading: false,
14
24
  torrentData: [],
@@ -35,6 +45,47 @@ const state = {
35
45
  })(),
36
46
  };
37
47
 
48
+ let mobileBarCleanup = null;
49
+
50
+ function clearMobileBarLayout() {
51
+ if (typeof mobileBarCleanup === 'function') {
52
+ mobileBarCleanup();
53
+ }
54
+ mobileBarCleanup = null;
55
+ document.documentElement.style.setProperty('--mobile-bar-height', '0px');
56
+ }
57
+
58
+ function bindMobileBarLayout(mobileBar) {
59
+ clearMobileBarLayout();
60
+ if (!mobileBar) return;
61
+
62
+ const media = window.matchMedia('(max-width: 640px)');
63
+ const root = document.documentElement;
64
+ const update = () => {
65
+ if (!media.matches || !mobileBar.isConnected) {
66
+ root.style.setProperty('--mobile-bar-height', '0px');
67
+ return;
68
+ }
69
+ const h = Math.ceil(mobileBar.getBoundingClientRect().height);
70
+ root.style.setProperty('--mobile-bar-height', `${h}px`);
71
+ };
72
+
73
+ const onResize = () => update();
74
+ window.addEventListener('resize', onResize);
75
+
76
+ let observer = null;
77
+ if (typeof ResizeObserver !== 'undefined') {
78
+ observer = new ResizeObserver(update);
79
+ observer.observe(mobileBar);
80
+ }
81
+
82
+ requestAnimationFrame(update);
83
+ mobileBarCleanup = () => {
84
+ window.removeEventListener('resize', onResize);
85
+ if (observer) observer.disconnect();
86
+ };
87
+ }
88
+
38
89
  function buildSearchHash(query, category = 'web') {
39
90
  const params = new URLSearchParams();
40
91
  if (query) params.set('q', query);
@@ -140,6 +191,16 @@ function setSelectedEngines(engines) {
140
191
  persistSelectedEngines();
141
192
  }
142
193
 
194
+ function sanitizeHttpUrl(raw) {
195
+ try {
196
+ const url = new URL(String(raw || '').trim());
197
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
198
+ return url.toString();
199
+ } catch {
200
+ return '';
201
+ }
202
+ }
203
+
143
204
  // ─── SVG Icons ────────────────────────────────────────────────────────────
144
205
  function svg(paths, size = 16, extra = '') {
145
206
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ${extra}>${paths}</svg>`;
@@ -200,17 +261,18 @@ const LANGS = [
200
261
  ];
201
262
 
202
263
  const AI_PRESETS = [
203
- { id: 'openai', label: 'OpenAI', api_base: 'https://api.openai.com/v1', keyRequired: true, defaultModel: 'gpt-4o-mini' },
204
- { id: 'chutes', label: 'Chutes.ai', api_base: 'https://llm.chutes.ai/v1', keyRequired: true, defaultModel: 'deepseek-ai/DeepSeek-V3.2-TEE' },
205
- { id: 'openrouter', label: 'OpenRoute/OpenRouter', api_base: 'https://openrouter.ai/api/v1', keyRequired: true, defaultModel: 'openai/gpt-4o-mini' },
206
- { id: 'anthropic', label: 'Anthropic', api_base: 'https://api.anthropic.com/v1', keyRequired: true, defaultModel: 'claude-3-5-haiku-latest' },
207
- { id: 'ollama', label: 'Ollama', api_base: 'http://127.0.0.1:11434/v1', keyRequired: false, defaultModel: 'qwen3.5:4b' },
208
- { id: 'lmstudio', label: 'LM Studio', api_base: 'http://127.0.0.1:1234/v1', keyRequired: false, defaultModel: '' },
209
- { id: 'llamacpp', label: 'llama.cpp server', api_base: 'http://127.0.0.1:8080/v1', keyRequired: false, defaultModel: '' },
264
+ { id: 'ollama', label: 'LocalHost — Ollama', api_base: 'http://127.0.0.1:11434/v1', keyRequired: false, defaultModel: 'qwen3.5:4b' },
265
+ { id: 'lmstudio', label: 'LocalHost — LM Studio', api_base: 'http://127.0.0.1:1234/v1', keyRequired: false, defaultModel: '' },
266
+ { id: 'llamacpp', label: 'LocalHost — llama.cpp', api_base: 'http://127.0.0.1:8080/v1', keyRequired: false, defaultModel: '' },
267
+ { id: 'chutes', label: 'Chutes.ai TEE', api_base: 'https://llm.chutes.ai/v1', keyRequired: true, defaultModel: 'deepseek-ai/DeepSeek-V3.2-TEE' },
268
+ { id: 'anthropic',label: 'Anthropic', api_base: 'https://api.anthropic.com/v1', keyRequired: true, defaultModel: 'claude-3-5-haiku-latest' },
269
+ { id: 'openai', label: 'OpenAI', api_base: 'https://api.openai.com/v1', keyRequired: true, defaultModel: 'gpt-4o-mini' },
270
+ { id: 'openrouter', label: 'OpenRoute/OpenRouter', api_base: 'https://openrouter.ai/api/v1', keyRequired: true, defaultModel: 'openai/gpt-4o-mini' },
210
271
  ];
211
272
 
212
273
  const ENGINE_GROUPS = [
213
274
  { label: 'Web Core', items: ['duckduckgo', 'wikipedia', 'brave', 'startpage', 'qwant', 'mojeek', 'bing', 'google', 'yahoo'] },
275
+ { label: 'Uncensored', items: ['yandex', 'marginalia', 'ahmia'] },
214
276
  { label: 'Code & Dev', items: ['github', 'github-api', 'hackernews', 'reddit'] },
215
277
  { label: 'Media', items: ['youtube', 'sepiasearch'] },
216
278
  { label: 'Research', items: ['wikidata', 'crossref', 'openalex', 'openlibrary'] },
@@ -419,37 +481,119 @@ function renderAiPanel() {
419
481
  if (!isActive) { panel.style.display = 'none'; return; }
420
482
  panel.style.display = 'block';
421
483
 
422
- const dotsClass = state.aiStatus === 'done' ? 'done' : state.aiStatus === 'error' ? 'error' : '';
423
- const statusText = state.aiStatus === 'loading' ? 'Thinking…' : state.aiStatus === 'streaming' ? 'Generating…' : state.aiStatus === 'done' ? 'AI Summary' : 'Error';
484
+ const isLoading = state.aiStatus === 'loading' || state.aiStatus === 'streaming';
485
+ const isDone = state.aiStatus === 'done';
486
+ const isError = state.aiStatus === 'error';
487
+ const dotsClass = isDone ? 'done' : isError ? 'error' : '';
424
488
 
489
+ // Row 1: dots + "AI" (always) + model + latency
425
490
  const dotsEl = el('div', { className: 'ai-dots' });
426
491
  ['violet', 'indigo', 'dim'].forEach(c => {
427
492
  dotsEl.append(el('div', { className: `ai-dot ${dotsClass || c}` }));
428
493
  });
494
+ const latMs = state.aiLatencyMs;
495
+ const latLabel = latMs != null ? (latMs < 1000 ? `${latMs}ms` : `${(latMs / 1000).toFixed(1)}s`) : null;
496
+ const row1 = el('div', { className: 'panel-header-left' },
497
+ dotsEl,
498
+ el('span', { className: 'panel-label' }, 'AI'),
499
+ state.aiMeta?.model ? el('span', { className: 'ai-model-label' }, state.aiMeta.model) : null,
500
+ latLabel ? el('span', { className: 'ai-latency-label' }, `· ${latLabel}`) : null,
501
+ );
429
502
 
430
- const header = el('div', { className: 'panel-header' },
431
- el('div', { className: 'panel-header-left' },
432
- dotsEl,
433
- el('span', { className: 'panel-label' }, statusText),
434
- state.aiMeta?.model ? el('span', { style: 'font-size:10px;color:var(--text3);margin-left:6px' }, state.aiMeta.model) : null,
435
- ),
503
+ // Row 2: status text (violet) + step/fetch meta
504
+ const statusText = isError ? 'Error'
505
+ : isDone ? 'Done'
506
+ : state.aiStatus === 'loading' ? 'Thinking…' : 'Generating…';
507
+ const statusColor = isError ? '#f87171' : isDone ? '#a78bfa' : '#a78bfa';
508
+ const lastStep = state.aiSteps.length > 0 ? state.aiSteps[state.aiSteps.length - 1] : null;
509
+ const metaText = isDone && state.aiMeta?.fetchedCount ? `· ${state.aiMeta.fetchedCount} pages read`
510
+ : isLoading && lastStep ? `· ${lastStep}` : '';
511
+ const row2 = el('div', { className: 'ai-status-row' },
512
+ el('span', { className: 'ai-status-text', style: `color:${statusColor}` }, statusText),
513
+ metaText ? el('span', { className: 'ai-status-meta' }, metaText) : null,
436
514
  );
437
515
 
438
- const content = el('div', { className: 'ai-content' });
439
- if (state.aiError) {
440
- content.style.color = '#f87171';
441
- content.textContent = state.aiError;
516
+ const chevronPath = state.aiExpanded ? '<polyline points="18 15 12 9 6 15"/>' : '<polyline points="6 9 12 15 18 9"/>';
517
+ const expandBtn = el('button', { className: 'ai-expand-btn', type: 'button', title: state.aiExpanded ? 'Collapse' : 'Expand' });
518
+ expandBtn.innerHTML = svg(chevronPath, 14);
519
+ expandBtn.onclick = () => { state.aiExpanded = !state.aiExpanded; renderAiPanel(); };
520
+
521
+ const headerInner = el('div', { className: 'ai-header-inner' }, row1, row2);
522
+ const header = el('div', { className: 'panel-header' }, headerInner, expandBtn);
523
+
524
+ // Progress bar
525
+ const showProgress = isLoading && state.aiProgress > 0;
526
+ const progressEl = showProgress ? el('div', { className: 'ai-progress-wrap' },
527
+ el('div', { className: 'ai-progress-bar', style: `width:${state.aiProgress}%` }),
528
+ ) : null;
529
+
530
+ // Steps
531
+ const showSteps = isLoading && state.aiSteps.length > 0;
532
+ const stepsEl = showSteps ? el('div', { className: 'ai-steps' },
533
+ ...state.aiSteps.slice(-4).map(s => el('div', { className: 'ai-step' }, s)),
534
+ ) : null;
535
+
536
+ // Content
537
+ const contentEl = el('div', { className: `ai-content${!state.aiExpanded && !isLoading ? ' ai-content-collapsed' : ''}` });
538
+ if (isError) {
539
+ contentEl.style.color = '#f87171';
540
+ contentEl.textContent = state.aiError;
442
541
  } else {
443
- content.textContent = state.aiSummary;
542
+ contentEl.textContent = state.aiSummary;
444
543
  }
445
544
 
446
- panel.innerHTML = '';
447
- panel.append(header, content);
545
+ // Sources (shown when expanded + done)
546
+ const showSources = isDone && state.aiExpanded && state.aiSources.length > 0;
547
+ const sourcesEl = showSources ? el('div', { className: 'ai-sources' },
548
+ ...state.aiSources.slice(0, 8).map((src, i) => {
549
+ const safeSrc = sanitizeHttpUrl(src);
550
+ if (!safeSrc) return null;
551
+ let label = src;
552
+ try {
553
+ const { hostname, pathname } = new URL(safeSrc);
554
+ const host = hostname.replace(/^www\./, '');
555
+ const segs = pathname.replace(/\/$/, '').split('/').filter(Boolean).slice(0, 2);
556
+ label = segs.length ? `${host} › ${segs.join('/')}` : host;
557
+ } catch {}
558
+ const a = el('a', { className: 'ai-source-pill', href: safeSrc, target: '_blank', rel: 'noopener noreferrer' }, `[${i + 1}] ${label}`);
559
+ return a;
560
+ }),
561
+ ) : null;
562
+
563
+ // Session memory (shown when expanded + more than 1 entry, all except current)
564
+ const prevSession = state.aiSession.slice(0, -1);
565
+ const sessionEl = (state.aiExpanded && prevSession.length > 0) ? el('div', { className: 'ai-session' },
566
+ el('p', { className: 'ai-session-label' }, 'Session'),
567
+ ...prevSession.map((item, i) =>
568
+ el('div', { className: 'ai-session-item' },
569
+ el('span', { className: 'ai-session-num' }, `${i + 1}.`),
570
+ el('span', { className: 'ai-session-q' }, item.q),
571
+ el('span', { className: 'ai-session-r' }, `→ ${item.r}`),
572
+ )
573
+ ),
574
+ ) : null;
448
575
 
449
- if (state.aiMeta?.fetchedCount) {
450
- panel.append(el('div', { className: 'ai-meta' }, `Read ${state.aiMeta.fetchedCount} pages`));
451
- }
452
- if (state.aiStatus === 'streaming') {
576
+ // Footer: retry + expand/collapse
577
+ const retryBtn = el('button', { className: 'ai-retry-btn', type: 'button' }, 'Retry');
578
+ retryBtn.onclick = () => {
579
+ if (state.aiLastQuery) startAiSummary(state.aiLastQuery, state.aiLastResults || [], state.aiLastLang || 'en-US');
580
+ };
581
+ const toggleBtn = el('button', { className: 'ai-toggle-btn', type: 'button' },
582
+ state.aiExpanded ? 'Show less' : 'Show more',
583
+ );
584
+ toggleBtn.onclick = () => { state.aiExpanded = !state.aiExpanded; renderAiPanel(); };
585
+ const footer = el('div', { className: 'ai-footer' }, retryBtn, toggleBtn);
586
+
587
+ panel.innerHTML = '';
588
+ panel.append(header);
589
+ if (progressEl) panel.append(progressEl);
590
+ if (stepsEl) panel.append(stepsEl);
591
+ panel.append(contentEl);
592
+ if (sourcesEl) panel.append(sourcesEl);
593
+ if (sessionEl) panel.append(sessionEl);
594
+ panel.append(footer);
595
+
596
+ if (state.aiStatus === 'streaming' && state.aiSummary.length < 60) {
453
597
  panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
454
598
  }
455
599
  }
@@ -793,13 +937,22 @@ async function doSearch(q, category = state.category) {
793
937
  async function startAiSummary(query, results, lang) {
794
938
  state.aiStatus = 'loading';
795
939
  state.aiSummary = '';
940
+ state.aiError = null;
941
+ state.aiProgress = 0;
942
+ state.aiSteps = [];
943
+ state.aiSources = [];
944
+ state.aiStartTime = Date.now();
945
+ state.aiLatencyMs = null;
946
+ state.aiLastQuery = query;
947
+ state.aiLastResults = results;
948
+ state.aiLastLang = lang;
796
949
  renderAiPanel();
797
950
 
798
951
  try {
799
952
  const r = await fetch('/api/ai-summary', {
800
953
  method: 'POST',
801
954
  headers: { 'Content-Type': 'application/json' },
802
- body: JSON.stringify({ query, lang, results: results.slice(0, 10), stream: true }),
955
+ body: JSON.stringify({ query, lang, results: results.slice(0, 10), stream: true, session: state.aiSession }),
803
956
  });
804
957
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
805
958
 
@@ -819,13 +972,28 @@ async function startAiSummary(query, results, lang) {
819
972
  if (!line.startsWith('data: ')) continue;
820
973
  try {
821
974
  const d = JSON.parse(line.slice(6));
822
- if (d.chunk) { state.aiSummary += d.chunk; renderAiPanel(); }
823
- else if (d.error) { state.aiStatus = 'error'; state.aiError = d.message || d.error; renderAiPanel(); }
824
- else if (d.model || d.sites) { state.aiStatus = 'done'; state.aiMeta = { fetchedCount: d.fetchedCount, model: d.model }; renderAiPanel(); }
975
+ if (d.chunk !== undefined) { state.aiSummary += d.chunk; renderAiPanel(); }
976
+ else if (d.progress !== undefined) { state.aiProgress = d.progress; renderAiPanel(); }
977
+ else if (d.step) { state.aiSteps = [...state.aiSteps.slice(-3), d.step]; renderAiPanel(); }
978
+ else if (d.error) { state.aiStatus = 'error'; state.aiError = d.message || d.error; renderAiPanel(); }
979
+ else if (d.model != null || d.sites != null) {
980
+ state.aiStatus = 'done';
981
+ state.aiProgress = 100;
982
+ state.aiSources = Array.isArray(d.sites) ? d.sites.map(sanitizeHttpUrl).filter(Boolean) : [];
983
+ state.aiMeta = { fetchedCount: d.fetchedCount, model: d.model };
984
+ state.aiLatencyMs = Date.now() - state.aiStartTime;
985
+ // Save to session memory (max 4 entries, skip if already saved for this query)
986
+ const lastEntry = state.aiSession[state.aiSession.length - 1];
987
+ if (state.aiSummary && (!lastEntry || lastEntry.q !== query)) {
988
+ const r = state.aiSummary.split(/[.!?\n]/)[0].slice(0, 100);
989
+ state.aiSession = [...state.aiSession.slice(-3), { q: query, r }];
990
+ }
991
+ renderAiPanel();
992
+ }
825
993
  } catch { /* ignore */ }
826
994
  }
827
995
  }
828
- if (state.aiStatus === 'streaming') { state.aiStatus = 'done'; renderAiPanel(); }
996
+ if (state.aiStatus === 'streaming') { state.aiStatus = 'done'; state.aiLatencyMs = Date.now() - state.aiStartTime; renderAiPanel(); }
829
997
  } catch (e) {
830
998
  state.aiStatus = 'error';
831
999
  state.aiError = e.message;
@@ -840,12 +1008,13 @@ function renderApp() {
840
1008
  app.innerHTML = '';
841
1009
 
842
1010
  if (!state.query) {
1011
+ clearMobileBarLayout();
843
1012
  renderHome(app);
844
1013
  return;
845
1014
  }
846
1015
 
847
1016
  // Results page
848
- const header = el('div', { className: 'header' },
1017
+ const header = el('div', { className: 'header hide-mobile' },
849
1018
  el('div', { className: 'logo-text', onClick: () => { state.query = ''; state.category = 'web'; navigate('#/'); renderApp(); } },
850
1019
  'Term', el('strong', {}, 'Search'),
851
1020
  ),
@@ -856,26 +1025,47 @@ function renderApp() {
856
1025
  el('button', { className: 'btn-icon', title: 'Toggle theme', onClick: toggleTheme }, iconEl('theme')),
857
1026
  ),
858
1027
  );
859
- const categoryBar = el('div', { className: 'category-tabs' });
1028
+
1029
+ const categoryBar = el('div', { className: 'category-tabs hide-mobile' });
860
1030
  const categories = [
861
1031
  { id: 'web', label: 'Web' },
862
1032
  { id: 'images', label: 'Images' },
863
1033
  { id: 'news', label: 'News' },
864
1034
  ];
865
- categories.forEach((cat) => {
866
- categoryBar.append(el('button', {
867
- className: `cat-tab ${state.category === cat.id ? 'active' : ''}`,
868
- onClick: () => {
869
- if (state.category === cat.id) return;
870
- state.category = cat.id;
871
- navigate(buildSearchHash(state.query, state.category));
872
- if (state.query) doSearch(state.query, state.category);
873
- },
874
- type: 'button',
875
- }, cat.label));
876
- });
1035
+ const buildCatTabs = (container) => {
1036
+ categories.forEach((cat) => {
1037
+ container.append(el('button', {
1038
+ className: `cat-tab ${state.category === cat.id ? 'active' : ''}`,
1039
+ onClick: () => {
1040
+ if (state.category === cat.id) return;
1041
+ state.category = cat.id;
1042
+ navigate(buildSearchHash(state.query, state.category));
1043
+ if (state.query) doSearch(state.query, state.category);
1044
+ },
1045
+ type: 'button',
1046
+ }, cat.label));
1047
+ });
1048
+ };
1049
+ buildCatTabs(categoryBar);
877
1050
  categoryBar.append(EnginePicker());
878
1051
 
1052
+ const mobileTabs = el('div', { className: 'mobile-bar-tabs' });
1053
+ buildCatTabs(mobileTabs);
1054
+ const mobileBar = el('div', { className: 'mobile-bar' },
1055
+ el('div', { className: 'mobile-bar-search' }, SearchForm(state.query, (q, cat) => { state.query = q; doSearch(q, cat); })),
1056
+ mobileTabs,
1057
+ el('div', { className: 'mobile-bar-engine' }, EnginePicker()),
1058
+ el('div', { className: 'mobile-bar-row' },
1059
+ el('div', {
1060
+ className: 'mobile-logo',
1061
+ onClick: () => { state.query = ''; state.category = 'web'; navigate('#/'); renderApp(); },
1062
+ }, 'Term', el('strong', {}, 'Search')),
1063
+ LangPicker(),
1064
+ el('button', { className: 'btn-icon', title: 'Settings', onClick: () => navigate('#/settings') }, iconEl('settings')),
1065
+ el('button', { className: 'btn-icon', title: 'Toggle theme', onClick: toggleTheme }, iconEl('theme')),
1066
+ ),
1067
+ );
1068
+
879
1069
  const main = el('div', { className: 'main' });
880
1070
 
881
1071
  // AI panel placeholder
@@ -919,7 +1109,8 @@ function renderApp() {
919
1109
  if (socPanel) main.append(socPanel);
920
1110
  }
921
1111
 
922
- app.append(header, categoryBar, main);
1112
+ app.append(header, categoryBar, main, mobileBar);
1113
+ bindMobileBarLayout(mobileBar);
923
1114
  renderAiPanel();
924
1115
  }
925
1116
 
@@ -954,6 +1145,7 @@ async function renderSettings() {
954
1145
  const app = document.getElementById('app');
955
1146
  if (!app) return;
956
1147
  app.innerHTML = '';
1148
+ clearMobileBarLayout();
957
1149
 
958
1150
  let cfg = state.config;
959
1151
  if (!cfg) {
@@ -1278,7 +1470,7 @@ async function renderSettings() {
1278
1470
  el('label', { className: 'form-label', for: 'ai-base' }, 'API Endpoint'),
1279
1471
  makeInput('ai-base', ai.api_base, 'http://localhost:11434/v1'),
1280
1472
  el('div', { className: 'form-hint' },
1281
- 'Included presets: Chutes.ai · Anthropic · OpenAI · OpenRoute/OpenRouter · llama.cpp · Ollama · LM Studio',
1473
+ 'Included presets: LocalHost (Ollama · LM Studio · llama.cpp) · Chutes.ai TEE · Anthropic · OpenAI · OpenRoute/OpenRouter',
1282
1474
  el('br', {}),
1283
1475
  'You can also keep custom OpenAI-compatible endpoints.',
1284
1476
  ),
@@ -1395,7 +1587,7 @@ async function renderSettings() {
1395
1587
  // Server info
1396
1588
  el('div', { className: 'settings-section' },
1397
1589
  el('h2', {}, 'Server Info'),
1398
- el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.2')),
1590
+ el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.3')),
1399
1591
  el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Active providers'), el('span', { className: 'info-val' }, (health?.providers || []).join(', ') || 'none')),
1400
1592
  el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'AI'), el('span', { className: 'info-val' }, health?.ai_enabled ? `enabled (${health.ai_model})` : 'not configured')),
1401
1593
  el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'GitHub'), el('a', { href: 'https://github.com/DioNanos/termsearch', target: '_blank', className: 'info-val', style: 'color:var(--link)' }, 'DioNanos/termsearch')),
@@ -13,6 +13,7 @@
13
13
  --link: #8ab4f8;
14
14
  --link-h: #aecbfa;
15
15
  --url: #34a853;
16
+ --mobile-bar-height: 0px;
16
17
  --radius: 12px;
17
18
  --radius-sm: 8px;
18
19
  --radius-xs: 6px;
@@ -565,6 +566,26 @@ a:hover { color: var(--link-h); }
565
566
  .ai-dot.dim { background: #5b21b6; animation: pulse 1.4s ease-in-out 300ms infinite; }
566
567
  .ai-dot.done { background: #34d399; animation: none; }
567
568
  .ai-dot.error { background: #f87171; animation: none; }
569
+ .panel-ai .panel-header { cursor: default; align-items: flex-start; }
570
+ .ai-header-inner { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
571
+ .ai-status-row { display: flex; align-items: center; gap: 6px; }
572
+ .ai-status-text { font-size: 14px; font-weight: 500; }
573
+ .ai-status-meta { font-size: 10px; color: #4b5563; }
574
+ .ai-model-label { font-size: 10px; color: var(--text3); margin-left: 6px; }
575
+ .ai-latency-label{ font-size: 10px; color: #4b5563; margin-left: 4px; }
576
+ .ai-expand-btn { background: none; border: none; color: var(--text3); cursor: pointer; padding: 0; margin-left: 8px; margin-top: 2px; display: flex; align-items: center; flex-shrink: 0; }
577
+ .ai-expand-btn:hover { color: var(--text2); }
578
+
579
+ /* Progress bar */
580
+ .ai-progress-wrap { height: 3px; background: rgba(255,255,255,0.06); border-radius: 99px; margin: 8px 0; overflow: hidden; }
581
+ .ai-progress-bar { height: 100%; background: linear-gradient(90deg, #7c3aed, #6366f1); border-radius: 99px; transition: width 0.4s ease; }
582
+
583
+ /* Steps */
584
+ .ai-steps { margin: 6px 0 4px; display: flex; flex-direction: column; gap: 3px; }
585
+ .ai-step { font-size: 10px; color: #4b5563; display: flex; align-items: center; gap: 6px; }
586
+ .ai-step::before { content: ''; display: inline-block; width: 4px; height: 4px; border-radius: 50%; background: #6366f1; flex-shrink: 0; }
587
+
588
+ /* Content */
568
589
  .ai-content {
569
590
  font-size: 14px;
570
591
  color: #d1d5db;
@@ -575,9 +596,40 @@ a:hover { color: var(--link-h); }
575
596
  border: 1px solid rgba(129,140,248,0.22);
576
597
  border-radius: var(--radius-sm);
577
598
  padding: 10px;
578
- max-height: 280px;
579
- overflow: auto;
599
+ overflow: hidden;
580
600
  }
601
+ .ai-content.ai-content-collapsed {
602
+ display: -webkit-box;
603
+ -webkit-line-clamp: 4;
604
+ -webkit-box-orient: vertical;
605
+ overflow: hidden;
606
+ }
607
+
608
+ /* Sources */
609
+ .ai-sources { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
610
+ .ai-source-pill {
611
+ font-size: 10px; color: #6b7280;
612
+ border: 1px solid #1f2937; border-radius: 99px;
613
+ padding: 2px 8px; text-decoration: none;
614
+ white-space: nowrap; overflow: hidden; max-width: 200px; text-overflow: ellipsis;
615
+ transition: color 0.15s, border-color 0.15s;
616
+ }
617
+ .ai-source-pill:hover { color: #a78bfa; border-color: #4c1d95; }
618
+
619
+ /* Session memory */
620
+ .ai-session { margin-top: 10px; padding-top: 8px; border-top: 1px solid #1b1b2d; }
621
+ .ai-session-label { font-size: 10px; color: #374151; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
622
+ .ai-session-item { display: flex; align-items: baseline; gap: 5px; margin-bottom: 3px; flex-wrap: wrap; }
623
+ .ai-session-num { font-size: 9px; color: #374151; flex-shrink: 0; }
624
+ .ai-session-q { font-size: 10px; color: #4b5563; }
625
+ .ai-session-r { font-size: 10px; color: #374151; }
626
+
627
+ /* Footer */
628
+ .ai-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
629
+ .ai-retry-btn { font-size: 12px; color: #a78bfa; background: none; border: none; cursor: pointer; padding: 0; }
630
+ .ai-retry-btn:hover { color: #c4b5fd; }
631
+ .ai-toggle-btn { font-size: 11px; color: #4b5563; background: none; border: none; cursor: pointer; padding: 0; }
632
+ .ai-toggle-btn:hover { color: #9ca3af; }
581
633
  .ai-meta { font-size: 11px; color: var(--text3); margin-top: 8px; }
582
634
 
583
635
  /* Profiler panel */
@@ -865,14 +917,70 @@ a:hover { color: var(--link-h); }
865
917
  }
866
918
  .footer-link:hover { color: var(--text2); }
867
919
 
920
+ /* ─── Mobile bottom bar ───────────────────────────────────────────────────── */
921
+ .mobile-bar {
922
+ display: none;
923
+ }
924
+ .mobile-logo {
925
+ font-size: 15px;
926
+ font-weight: 300;
927
+ letter-spacing: -0.2px;
928
+ color: var(--text);
929
+ white-space: nowrap;
930
+ user-select: none;
931
+ cursor: pointer;
932
+ }
933
+ .mobile-logo strong {
934
+ font-weight: 700;
935
+ background: linear-gradient(90deg, #a78bfa, #818cf8);
936
+ -webkit-background-clip: text;
937
+ -webkit-text-fill-color: transparent;
938
+ background-clip: text;
939
+ }
940
+ @media (max-width: 640px) {
941
+ .mobile-bar {
942
+ display: flex;
943
+ flex-direction: column;
944
+ position: fixed;
945
+ bottom: 0; left: 0; right: 0;
946
+ background: var(--bg);
947
+ border-top: 1px solid var(--border2);
948
+ box-shadow: 0 -12px 28px rgba(0,0,0,0.45);
949
+ z-index: 220;
950
+ padding-bottom: max(6px, env(safe-area-inset-bottom, 0px));
951
+ transform: translateZ(0);
952
+ }
953
+ }
954
+ .mobile-bar-search { padding: 8px 12px 4px; }
955
+ .mobile-bar-search .search-form { width: 100%; }
956
+ .mobile-bar-tabs {
957
+ display: flex;
958
+ align-items: center;
959
+ gap: 4px;
960
+ padding: 2px 12px 4px;
961
+ overflow-x: auto;
962
+ scrollbar-width: none;
963
+ }
964
+ .mobile-bar-tabs::-webkit-scrollbar { display: none; }
965
+ .mobile-bar-engine { padding: 0 12px 4px; }
966
+ .mobile-bar-row {
967
+ display: flex;
968
+ align-items: center;
969
+ gap: 6px;
970
+ padding: 4px 12px 2px;
971
+ }
972
+ .mobile-bar-row .lang-wrap { margin-left: auto; }
973
+
868
974
  /* ─── Responsive ──────────────────────────────────────────────────────────── */
869
975
  @media (max-width: 640px) {
870
- .header { padding: 8px 12px; gap: 8px; }
871
- .category-tabs { top: 48px; padding: 7px 12px 9px; gap: 5px; flex-wrap: wrap; }
976
+ /* Hide desktop header + category bar mobile uses bottom bar */
977
+ .header.hide-mobile, .category-tabs.hide-mobile { display: none !important; }
978
+ .main { padding-bottom: calc(20px + var(--mobile-bar-height, 0px) + env(safe-area-inset-bottom, 0px)); }
979
+
872
980
  .cat-tab { font-size: 10px; padding: 4px 8px; }
873
- .engine-picker { width: 100%; margin-left: 0; }
874
- .engine-picker-summary { width: 100%; justify-content: space-between; }
875
- .engine-picker-body { width: calc(100vw - 24px); right: -6px; }
981
+ .mobile-bar-engine .engine-picker { width: 100%; margin-left: 0; }
982
+ .mobile-bar-engine .engine-picker-summary { width: 100%; justify-content: space-between; }
983
+ .engine-picker-body { width: calc(100vw - 24px); right: -6px; bottom: calc(100% + 8px); top: auto; }
876
984
  .logo-text { font-size: 15px; }
877
985
  .home-logo { font-size: 40px; }
878
986
  .home-tagline { letter-spacing: 0.08em; margin-bottom: 20px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termsearch",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Personal search engine for Termux/Linux/macOS — zero-config, privacy-first, AI-optional",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,8 +45,14 @@ export async function generateSummary({
45
45
  results = [],
46
46
  session = [],
47
47
  onToken = null,
48
+ onProgress = null,
49
+ onStep = null,
48
50
  docCache = null,
49
51
  }, aiConfig) {
52
+ const emit = (progress, step) => {
53
+ if (onProgress) onProgress(progress);
54
+ if (step && onStep) onStep(step);
55
+ };
50
56
  if (!aiConfig?.enabled || !aiConfig?.api_base || !aiConfig?.model) {
51
57
  return { error: 'ai_not_configured', message: 'AI not configured. Add endpoint in Settings.' };
52
58
  }
@@ -61,6 +67,7 @@ export async function generateSummary({
61
67
 
62
68
  try {
63
69
  // Phase 1: AI decides which URLs to fetch
70
+ emit(5, 'Analyzing query…');
64
71
  const phase1Prompt = buildFetchDecisionPrompt({
65
72
  query,
66
73
  results,
@@ -83,6 +90,15 @@ export async function generateSummary({
83
90
  const allResultUrls = results.slice(0, 10).map((r) => r.url).filter(Boolean);
84
91
  const { urls: urlsToFetch } = parseFetchDecision(phase1Result?.content, allResultUrls);
85
92
 
93
+ // Emit step per URL before batch fetch
94
+ emit(15, `Fetching ${urlsToFetch.length || allResultUrls.slice(0, 2).length} source(s)…`);
95
+ urlsToFetch.slice(0, 6).forEach((url) => {
96
+ try {
97
+ const host = new URL(url).hostname.replace(/^www\./, '');
98
+ if (onStep) onStep(`Reading: ${host}`);
99
+ } catch { if (onStep) onStep('Reading source…'); }
100
+ });
101
+
86
102
  // Fetch the selected URLs
87
103
  let documents = [];
88
104
  if (urlsToFetch.length > 0) {
@@ -99,6 +115,8 @@ export async function generateSummary({
99
115
  documents = fallback.filter((d) => d.status === 'ok' && d.content);
100
116
  }
101
117
 
118
+ emit(60, `Synthesizing from ${documents.length} page(s)…`);
119
+
102
120
  // Phase 2: synthesize summary
103
121
  const phase2Prompt = buildAgenticSummaryPrompt({ query, lang, results, documents, session });
104
122
 
@@ -107,6 +125,7 @@ export async function generateSummary({
107
125
 
108
126
  if (typeof onToken === 'function') {
109
127
  // Streaming mode
128
+ emit(65, 'Generating summary…');
110
129
  const streamResult = await stream(phase2Prompt, onToken, {
111
130
  ...ai,
112
131
  systemPrompt: 'You are a search assistant. Write your answer directly. Do not include reasoning or thinking.',
package/src/api/routes.js CHANGED
@@ -11,7 +11,7 @@ import { detectProfileTarget, scanProfile, PROFILER_PLATFORMS } from '../profile
11
11
  import { fetchBlueskyPosts, fetchBlueskyActors, fetchGdeltArticles } from '../social/search.js';
12
12
  import { scrapeTPB, scrape1337x, extractMagnetFromUrl } from '../torrent/scrapers.js';
13
13
 
14
- const APP_VERSION = '0.3.2';
14
+ const APP_VERSION = '0.3.3';
15
15
  const ALLOWED_CATEGORIES = new Set(['web', 'images', 'news']);
16
16
  const ALLOWED_LANGS = new Set(['auto', 'it-IT', 'en-US', 'es-ES', 'fr-FR', 'de-DE', 'pt-PT', 'ru-RU', 'zh-CN', 'ja-JP']);
17
17
 
@@ -409,7 +409,9 @@ export function createRouter(config, rateLimiters) {
409
409
  const result = await generateSummary(
410
410
  {
411
411
  query, lang, results, session,
412
- onToken: (chunk) => sendEvent('token', { chunk }),
412
+ onToken: (chunk) => sendEvent('token', { chunk }),
413
+ onProgress: (p) => sendEvent('progress', { progress: p }),
414
+ onStep: (text) => sendEvent('step', { step: text }),
413
415
  docCache: getDocCache(),
414
416
  },
415
417
  cfg.ai
@@ -450,7 +452,7 @@ export function createRouter(config, rateLimiters) {
450
452
  return sendJson(res, 400, { error: 'invalid_body' });
451
453
  }
452
454
  // Whitelist accepted config keys to prevent unexpected writes
453
- const allowed = ['port', 'host', 'ai', 'brave', 'mojeek', 'searxng', 'search', 'rate_limit'];
455
+ const allowed = ['port', 'host', 'ai', 'brave', 'mojeek', 'yandex', 'ahmia', 'marginalia', 'searxng', 'search', 'rate_limit'];
454
456
  const filtered = {};
455
457
  for (const key of allowed) {
456
458
  if (key in body) filtered[key] = body[key];
@@ -49,6 +49,20 @@ export const DEFAULTS = {
49
49
  api_base: 'https://api.mojeek.com',
50
50
  },
51
51
 
52
+ yandex: {
53
+ enabled: true,
54
+ },
55
+
56
+ ahmia: {
57
+ enabled: true,
58
+ },
59
+
60
+ marginalia: {
61
+ enabled: true,
62
+ api_key: 'public',
63
+ api_base: 'https://api2.marginalia-search.com',
64
+ },
65
+
52
66
  searxng: {
53
67
  enabled: false,
54
68
  url: '', // e.g. http://localhost:9090
@@ -91,6 +91,12 @@ class ConfigManager {
91
91
  if (process.env.TERMSEARCH_MOJEEK_API_KEY) {
92
92
  overrides.mojeek = { api_key: process.env.TERMSEARCH_MOJEEK_API_KEY, enabled: true };
93
93
  }
94
+ if (process.env.TERMSEARCH_MARGINALIA_API_KEY) {
95
+ overrides.marginalia = { api_key: process.env.TERMSEARCH_MARGINALIA_API_KEY, enabled: true };
96
+ }
97
+ if (process.env.TERMSEARCH_MARGINALIA_API_BASE) {
98
+ overrides.marginalia = { ...(overrides.marginalia || {}), api_base: process.env.TERMSEARCH_MARGINALIA_API_BASE, enabled: true };
99
+ }
94
100
  if (process.env.TERMSEARCH_SEARXNG_URL) {
95
101
  overrides.searxng = { url: process.env.TERMSEARCH_SEARXNG_URL, enabled: true };
96
102
  }
@@ -113,6 +119,9 @@ class ConfigManager {
113
119
  if (safePartial?.mojeek?.api_key && !safePartial?.mojeek?.hasOwnProperty('enabled')) {
114
120
  this._config.mojeek.enabled = Boolean(this._config.mojeek.api_key);
115
121
  }
122
+ if (safePartial?.marginalia?.api_key && !safePartial?.marginalia?.hasOwnProperty('enabled')) {
123
+ this._config.marginalia.enabled = Boolean(this._config.marginalia.api_key);
124
+ }
116
125
  if (safePartial?.searxng?.url && !safePartial?.searxng?.hasOwnProperty('enabled')) {
117
126
  this._config.searxng.enabled = Boolean(this._config.searxng.url);
118
127
  }
@@ -120,7 +129,7 @@ class ConfigManager {
120
129
  }
121
130
 
122
131
  _sanitizeSensitiveKeys(partial) {
123
- const sections = ['ai', 'brave', 'mojeek'];
132
+ const sections = ['ai', 'brave', 'mojeek', 'marginalia'];
124
133
  for (const section of sections) {
125
134
  const block = partial?.[section];
126
135
  if (!block || typeof block !== 'object' || !Object.prototype.hasOwnProperty.call(block, 'api_key')) continue;
@@ -173,6 +182,7 @@ class ConfigManager {
173
182
  ai: { ...c.ai, api_key: maskKey(c.ai.api_key) },
174
183
  brave: { ...c.brave, api_key: maskKey(c.brave.api_key) },
175
184
  mojeek: { ...c.mojeek, api_key: maskKey(c.mojeek.api_key) },
185
+ marginalia: { ...c.marginalia, api_key: maskKey(c.marginalia.api_key) },
176
186
  };
177
187
  }
178
188
  }
@@ -9,6 +9,9 @@ import * as brave from './providers/brave.js';
9
9
  import * as mojeek from './providers/mojeek.js';
10
10
  import * as searxng from './providers/searxng.js';
11
11
  import * as github from './providers/github.js';
12
+ import * as yandex from './providers/yandex.js';
13
+ import * as ahmia from './providers/ahmia.js';
14
+ import * as marginalia from './providers/marginalia.js';
12
15
 
13
16
  let _searchCache = null;
14
17
  let _docCache = null;
@@ -49,6 +52,10 @@ export const ALLOWED_ENGINES = new Set([
49
52
  '1337x',
50
53
  'piratebay',
51
54
  'nyaa',
55
+ // uncensored / alternative index engines
56
+ 'yandex',
57
+ 'ahmia',
58
+ 'marginalia',
52
59
  // local aliases for direct providers
53
60
  'ddg',
54
61
  'wiki',
@@ -96,6 +103,24 @@ const PROVIDER_REGISTRY = {
96
103
  run: github.search,
97
104
  defaultProvider: false,
98
105
  },
106
+ yandex: {
107
+ aliases: new Set(['yandex']),
108
+ enabled: (cfg) => cfg?.yandex?.enabled !== false,
109
+ run: yandex.search,
110
+ defaultProvider: false,
111
+ },
112
+ ahmia: {
113
+ aliases: new Set(['ahmia']),
114
+ enabled: (cfg) => cfg?.ahmia?.enabled !== false,
115
+ run: ahmia.search,
116
+ defaultProvider: false,
117
+ },
118
+ marginalia: {
119
+ aliases: new Set(['marginalia']),
120
+ enabled: (cfg) => cfg?.marginalia?.enabled !== false,
121
+ run: marginalia.search,
122
+ defaultProvider: false,
123
+ },
99
124
  };
100
125
 
101
126
  export function initCaches(dataDir, cfg) {
@@ -162,10 +187,7 @@ function resolveProviderPlan(cfg, requestedEngines = [], category = 'web') {
162
187
 
163
188
  const providers = [...explicitProviders].filter((name) => enabledProviders.includes(name));
164
189
  if (providers.length === 0) {
165
- return {
166
- providers: defaultProviders,
167
- searxEngines: category === 'web' && defaultProviders.includes('searxng') ? CURATED_WEB_ENGINES.slice() : [],
168
- };
190
+ return { providers: [], searxEngines: [] };
169
191
  }
170
192
 
171
193
  return { providers, searxEngines };
@@ -301,6 +323,7 @@ async function runProviderDetailed(name, args) {
301
323
  const responded = new Set();
302
324
  const failed = new Set();
303
325
  const failedDetails = [];
326
+ const skipHealth = new Set();
304
327
 
305
328
  if (name === 'searxng') {
306
329
  const unresponsive = Array.isArray(meta.unresponsive) ? meta.unresponsive.map((engine) => normalizeEngineName(engine)).filter(Boolean) : [];
@@ -327,9 +350,15 @@ async function runProviderDetailed(name, args) {
327
350
  failedDetails.push({ engine: name, reason: String(meta.error) });
328
351
  } else {
329
352
  responded.add(name);
353
+ if (results.length === 0 || meta.skipHealth === true || meta.empty === true) {
354
+ skipHealth.add(name);
355
+ }
330
356
  }
331
357
 
332
- for (const engine of responded) recordEngineOutcome(engine, true);
358
+ for (const engine of responded) {
359
+ if (skipHealth.has(engine)) continue;
360
+ recordEngineOutcome(engine, true);
361
+ }
333
362
  for (const detail of failedDetails) recordEngineOutcome(detail.engine, false, detail.reason);
334
363
 
335
364
  return {
@@ -558,5 +587,8 @@ export function getEnabledProviders(cfg) {
558
587
  if (cfg.mojeek?.enabled && cfg.mojeek?.api_key) providers.push('mojeek');
559
588
  if (cfg.searxng?.enabled && cfg.searxng?.url) providers.push('searxng');
560
589
  providers.push('github-api');
590
+ if (cfg?.yandex?.enabled !== false) providers.push('yandex');
591
+ if (cfg?.ahmia?.enabled !== false) providers.push('ahmia');
592
+ if (cfg?.marginalia?.enabled !== false) providers.push('marginalia');
561
593
  return providers;
562
594
  }
@@ -0,0 +1,61 @@
1
+ // Ahmia.fi — clearnet index of Tor hidden services (.onion)
2
+ // No API key required — results include .onion URLs (accessible via Tor Browser)
3
+
4
+ const AHMIA_ENDPOINT = 'https://ahmia.fi/search/';
5
+ const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
6
+
7
+ function parseAhmia(html) {
8
+ const results = [];
9
+ // Each result: <h4><a href="...">Title</a></h4> followed by <p>snippet</p>
10
+ const blockRe = /<h4[^>]*>([\s\S]*?)<\/h4>([\s\S]*?)(?=<h4|<\/ol|$)/gi;
11
+ let m;
12
+ while ((m = blockRe.exec(html)) !== null && results.length < 15) {
13
+ const titleBlock = m[1];
14
+ const afterBlock = m[2];
15
+
16
+ const aMatch = titleBlock.match(/<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
17
+ if (!aMatch) continue;
18
+
19
+ const url = aMatch[1];
20
+ if (!url.startsWith('http') || url.includes('ahmia.fi')) continue;
21
+
22
+ const title = aMatch[2].replace(/<[^>]+>/g, '').trim();
23
+ if (!title) continue;
24
+
25
+ const pMatch = afterBlock.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
26
+ const snippet = pMatch
27
+ ? pMatch[1].replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ').trim().slice(0, 300)
28
+ : '';
29
+
30
+ results.push({ title, url, snippet, engine: 'ahmia', score: 0 });
31
+ }
32
+ return results;
33
+ }
34
+
35
+ export async function search({ query, page = 1, timeoutMs = 12000 }) {
36
+ const params = new URLSearchParams({ q: query });
37
+ if (page > 1) params.set('page', String(page - 1));
38
+
39
+ const ac = new AbortController();
40
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
41
+ try {
42
+ const r = await fetch(`${AHMIA_ENDPOINT}?${params}`, {
43
+ headers: {
44
+ 'User-Agent': UA,
45
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
46
+ 'Accept-Language': 'en-US,en;q=0.5',
47
+ },
48
+ signal: ac.signal,
49
+ });
50
+ clearTimeout(timer);
51
+ if (!r.ok) return { results: [], _meta: { error: `ahmia_http_${r.status}` } };
52
+ const html = await r.text();
53
+ if (html.length < 500) return { results: [], _meta: { error: 'ahmia_unexpected_html' } };
54
+ const results = parseAhmia(html);
55
+ if (results.length === 0) return { results: [], _meta: { empty: true, skipHealth: true } };
56
+ return { results, _meta: {} };
57
+ } catch {
58
+ clearTimeout(timer);
59
+ return { results: [], _meta: { error: 'ahmia_unreachable' } };
60
+ }
61
+ }
@@ -0,0 +1,49 @@
1
+ // Marginalia Search provider (api2, key-based).
2
+ // Public key works with shared limits; user key can be configured.
3
+
4
+ const DEFAULT_API = 'https://api2.marginalia-search.com';
5
+
6
+ export async function search({ query, page = 1, timeoutMs = 10000, config }) {
7
+ const cfg = config || {};
8
+ const apiBase = String(cfg?.marginalia?.api_base || DEFAULT_API).replace(/\/$/, '');
9
+ const apiKey = String(cfg?.marginalia?.api_key || process.env.TERMSEARCH_MARGINALIA_API_KEY || 'public').trim() || 'public';
10
+ const enabled = cfg?.marginalia?.enabled !== false;
11
+ if (!enabled) return { results: [], _meta: { error: 'marginalia_disabled' } };
12
+
13
+ const params = new URLSearchParams({
14
+ query,
15
+ count: '10',
16
+ page: String(Math.max(1, Number(page) || 1)),
17
+ });
18
+
19
+ const ac = new AbortController();
20
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
21
+ try {
22
+ const r = await fetch(`${apiBase}/search?${params}`, {
23
+ headers: {
24
+ Accept: 'application/json',
25
+ 'User-Agent': 'TermSearch/1.0 (personal search)',
26
+ 'API-Key': apiKey,
27
+ },
28
+ signal: ac.signal,
29
+ });
30
+ clearTimeout(timer);
31
+ if (!r.ok) return { results: [], _meta: { error: `marginalia_http_${r.status}` } };
32
+ const data = await r.json();
33
+ const list = Array.isArray(data.results)
34
+ ? data.results
35
+ : (Array.isArray(data.result) ? data.result : []);
36
+ const results = list.slice(0, 15).map((item) => ({
37
+ title: String(item.title || item.url || '').trim(),
38
+ url: String(item.url || '').trim(),
39
+ snippet: String(item.description || item.snippet || '').trim(),
40
+ engine: 'marginalia',
41
+ score: 0,
42
+ })).filter((r) => r.url.startsWith('http'));
43
+ if (results.length === 0) return { results: [], _meta: { empty: true, skipHealth: true } };
44
+ return { results, _meta: {} };
45
+ } catch {
46
+ clearTimeout(timer);
47
+ return { results: [], _meta: { error: 'marginalia_unreachable' } };
48
+ }
49
+ }
@@ -0,0 +1,68 @@
1
+ // Yandex HTML scraper — no API key required
2
+ // Different political/content filtering than US engines; Russian/global index
3
+
4
+ const YANDEX_ENDPOINT = 'https://yandex.com/search/';
5
+ const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
6
+
7
+ function parseYandex(html) {
8
+ const results = [];
9
+
10
+ // Primary: OrganicTitle-Link class (standard desktop layout)
11
+ const titleRe = /<a[^>]+class="[^"]*OrganicTitle-Link[^"]*"[^>]+href="([^"#]+)"[^>]*>([\s\S]*?)<\/a>/gi;
12
+ let m;
13
+ while ((m = titleRe.exec(html)) !== null && results.length < 15) {
14
+ const url = m[1];
15
+ if (!url.startsWith('http') || url.includes('yandex.') || url.includes('ya.ru')) continue;
16
+ const title = m[2].replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').trim();
17
+ if (!title) continue;
18
+
19
+ // Look for snippet in the 3KB after the title match
20
+ const chunk = html.slice(m.index, m.index + 3000);
21
+ const snipM = chunk.match(/class="[^"]*(?:OrganicText|TextContainer|Organic-Text|organic__text)[^"]*"[^>]*>([\s\S]*?)<\/(?:div|span|p)>/i);
22
+ const snippet = snipM
23
+ ? snipM[1].replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ').trim().slice(0, 300)
24
+ : '';
25
+
26
+ results.push({ title, url, snippet, engine: 'yandex', score: 0 });
27
+ }
28
+
29
+ return results;
30
+ }
31
+
32
+ export async function search({ query, lang = 'en-US', page = 1, timeoutMs = 12000 }) {
33
+ const params = new URLSearchParams({
34
+ text: query,
35
+ p: String(Math.max(0, Number(page) - 1)),
36
+ numdoc: '10',
37
+ lr: '10417', // world region
38
+ });
39
+
40
+ const ac = new AbortController();
41
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
42
+ try {
43
+ const r = await fetch(`${YANDEX_ENDPOINT}?${params}`, {
44
+ headers: {
45
+ 'User-Agent': UA,
46
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
47
+ 'Accept-Language': 'en-US,en;q=0.5',
48
+ },
49
+ signal: ac.signal,
50
+ });
51
+ clearTimeout(timer);
52
+ if (!r.ok) return { results: [], _meta: { error: `yandex_http_${r.status}` } };
53
+ const html = await r.text();
54
+ // Explicit detection: Yandex can serve anti-bot pages.
55
+ if (html.includes('showcaptcha') || html.includes('robot-captcha')) {
56
+ return { results: [], _meta: { error: 'yandex_captcha' } };
57
+ }
58
+ if (html.length < 2000) {
59
+ return { results: [], _meta: { error: 'yandex_unexpected_html' } };
60
+ }
61
+ const results = parseYandex(html);
62
+ if (results.length === 0) return { results: [], _meta: { empty: true, skipHealth: true } };
63
+ return { results, _meta: {} };
64
+ } catch {
65
+ clearTimeout(timer);
66
+ return { results: [], _meta: { error: 'yandex_unreachable' } };
67
+ }
68
+ }