search-web-api 1.0.13
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 +307 -0
- package/demo/index.ts +38 -0
- package/demo/openapi.ts +402 -0
- package/demo/routes/autocomplete.ts +124 -0
- package/demo/routes/search.ts +61 -0
- package/docs/AUTOCOMPLETE.md +296 -0
- package/docs/CATEGORY_SEARCH.md +265 -0
- package/package.json +32 -0
- package/src/autocomplete/autocomplete-ai-next-word-predictor.ts +47 -0
- package/src/autocomplete/autocomplete-search-engine-backends.ts +231 -0
- package/src/category-registry.ts +6 -0
- package/src/config/search-engine-constants.ts +216 -0
- package/src/constants.ts +6 -0
- package/src/engine-descriptions.ts +7 -0
- package/src/engine-status.ts +7 -0
- package/src/engine.ts +6 -0
- package/src/registry/search-engine-category-registry.ts +200 -0
- package/src/registry/search-engine-descriptions.ts +121 -0
- package/src/registry/search-engine-status-tracker.ts +240 -0
- package/src/result-container.ts +8 -0
- package/src/search/search-engines-registry-list.ts +205 -0
- package/src/search/search-query-executor.ts +168 -0
- package/src/search/search-result-container.ts +421 -0
- package/src/search-web-types.ts +7 -0
- package/src/search.ts +10 -0
- package/src/sources/academic/arxiv.ts +72 -0
- package/src/sources/academic/core.ts +65 -0
- package/src/sources/academic/crossref.ts +75 -0
- package/src/sources/academic/doaj.ts +63 -0
- package/src/sources/academic/google_scholar.ts +53 -0
- package/src/sources/academic/openalex.ts +75 -0
- package/src/sources/academic/pubmed.ts +96 -0
- package/src/sources/academic/semantic_scholar.ts +77 -0
- package/src/sources/academic/wikidata.ts +44 -0
- package/src/sources/general/baidu.ts +59 -0
- package/src/sources/general/bing.ts +30 -0
- package/src/sources/general/brave.ts +54 -0
- package/src/sources/general/duckduckgo.ts +49 -0
- package/src/sources/general/google.ts +68 -0
- package/src/sources/general/mojeek.ts +56 -0
- package/src/sources/general/qwant.ts +37 -0
- package/src/sources/general/startpage.ts +44 -0
- package/src/sources/general/yahoo.ts +41 -0
- package/src/sources/general/yandex.ts +56 -0
- package/src/sources/images/bing_images.ts +68 -0
- package/src/sources/images/deviantart.ts +71 -0
- package/src/sources/images/flickr.ts +132 -0
- package/src/sources/images/google_images.ts +101 -0
- package/src/sources/images/imgur.ts +58 -0
- package/src/sources/images/openclipart.ts +52 -0
- package/src/sources/images/pixabay.ts +56 -0
- package/src/sources/images/unsplash.ts +38 -0
- package/src/sources/images/wallhaven.ts +50 -0
- package/src/sources/it/crates.ts +41 -0
- package/src/sources/it/dockerhub.ts +47 -0
- package/src/sources/it/github.ts +37 -0
- package/src/sources/it/gitlab.ts +55 -0
- package/src/sources/it/npm.ts +36 -0
- package/src/sources/it/packagist.ts +43 -0
- package/src/sources/it/pypi.ts +30 -0
- package/src/sources/it/rubygems.ts +43 -0
- package/src/sources/it/stackoverflow.ts +39 -0
- package/src/sources/maps/apple_maps.ts +105 -0
- package/src/sources/maps/openstreetmap.ts +34 -0
- package/src/sources/maps/photon.ts +77 -0
- package/src/sources/news/bing_news.ts +95 -0
- package/src/sources/news/google_news.ts +80 -0
- package/src/sources/news/hackernews.ts +94 -0
- package/src/sources/news/yahoo_news.ts +77 -0
- package/src/sources/shopping/ebay.ts +96 -0
- package/src/sources/social/mastodon.ts +46 -0
- package/src/sources/social/medium.ts +52 -0
- package/src/sources/social/reddit.ts +48 -0
- package/src/sources/social/soundcloud.ts +64 -0
- package/src/sources/social/twitter.ts +56 -0
- package/src/sources/specialized/annas_archive.ts +97 -0
- package/src/sources/specialized/archive.ts +48 -0
- package/src/sources/specialized/genius.ts +43 -0
- package/src/sources/specialized/goodreads.ts +62 -0
- package/src/sources/specialized/imdb.ts +55 -0
- package/src/sources/specialized/openlibrary.ts +59 -0
- package/src/sources/specialized/wikipedia.ts +37 -0
- package/src/sources/specialized/wttr.ts +98 -0
- package/src/sources/torrents/1337x.ts +43 -0
- package/src/sources/torrents/eztv.ts +47 -0
- package/src/sources/torrents/kickass.ts +63 -0
- package/src/sources/torrents/nyaa.ts +53 -0
- package/src/sources/torrents/solidtorrents.ts +68 -0
- package/src/sources/torrents/thepiratebay.ts +57 -0
- package/src/sources/torrents/yts.ts +54 -0
- package/src/sources/videos/bing_videos.ts +91 -0
- package/src/sources/videos/dailymotion.ts +101 -0
- package/src/sources/videos/invidious.ts +86 -0
- package/src/sources/videos/peertube.ts +76 -0
- package/src/sources/videos/vimeo.ts +67 -0
- package/src/sources/videos/youtube.ts +78 -0
- package/src/suggest-next-words/autocomplete-ai.ts +38 -0
- package/src/suggest-next-words/autocomplete-search-engines.ts +384 -0
- package/src/suggest-next-words/misspelled-typos-8k.json +1 -0
- package/src/types/search-engine-interface.ts +27 -0
- package/src/types/search-result-types.ts +405 -0
- package/test/api.test.ts +128 -0
- package/test/autocomplete-ai.test.ts +20 -0
- package/test/autocomplete-engines.test.ts +131 -0
- package/test/engine-health-suite.test.ts +350 -0
- package/test/search.test.ts +69 -0
- package/test/sources-unit.test.ts +1152 -0
- package/test/sources.test.ts +182 -0
- package/test/test-utils.ts +81 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Query Executor — The main `Search` class that orchestrates a query
|
|
3
|
+
* across any subset of the registered engine adapters. It initialises engine
|
|
4
|
+
* health tracking and category registration on construction, runs engines in
|
|
5
|
+
* parallel with configurable per-engine and per-category weights, feeds results
|
|
6
|
+
* into a `ResultContainer` for deduplication and scoring, and returns a
|
|
7
|
+
* deduplicated, ranked list of `MergedResult` objects.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { engineStatusTracker } from "../registry/search-engine-status-tracker.js";
|
|
11
|
+
import { categoryRegistry, CATEGORIES } from "../registry/search-engine-category-registry.js";
|
|
12
|
+
import { ResultContainer } from "./search-result-container.js";
|
|
13
|
+
import { ALL_ENGINES } from "./search-engines-registry-list.js";
|
|
14
|
+
import { MergedResult, CategoryWeight, EngineMetadata } from "../types/search-result-types.js";
|
|
15
|
+
|
|
16
|
+
export class Search {
|
|
17
|
+
private engines: EngineMetadata[] = ALL_ENGINES;
|
|
18
|
+
|
|
19
|
+
constructor() {
|
|
20
|
+
for (const engine of this.engines) {
|
|
21
|
+
engineStatusTracker.initEngine(engine.name, engine.categories);
|
|
22
|
+
categoryRegistry.registerEngine({
|
|
23
|
+
name: engine.name,
|
|
24
|
+
categories: engine.categories,
|
|
25
|
+
request: async () => {},
|
|
26
|
+
response: async () => [],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run a search query against the specified engines or categories.
|
|
33
|
+
*
|
|
34
|
+
* @param query - Search terms to query.
|
|
35
|
+
* @param pageno - Result page number (default 1).
|
|
36
|
+
* @param engineNames - If provided, restrict to these engine names.
|
|
37
|
+
* @param categories - If provided (and engineNames is empty), restrict to these categories.
|
|
38
|
+
* @returns Deduplicated, scored, and grouped results.
|
|
39
|
+
*/
|
|
40
|
+
async search(
|
|
41
|
+
query: string,
|
|
42
|
+
pageno: number = 1,
|
|
43
|
+
engineNames?: string[],
|
|
44
|
+
categories?: string[],
|
|
45
|
+
): Promise<MergedResult[]> {
|
|
46
|
+
const resultContainer = new ResultContainer();
|
|
47
|
+
const encodedQuery = query.trim();
|
|
48
|
+
|
|
49
|
+
const engineWeights: { [key: string]: number } = {
|
|
50
|
+
google: 1.5,
|
|
51
|
+
bing: 1.3,
|
|
52
|
+
duckduckgo: 1.2,
|
|
53
|
+
brave: 1.1,
|
|
54
|
+
startpage: 1.1,
|
|
55
|
+
google_scholar: 1.4,
|
|
56
|
+
semantic_scholar: 1.3,
|
|
57
|
+
arxiv: 1.3,
|
|
58
|
+
};
|
|
59
|
+
resultContainer.setEngineWeights(engineWeights);
|
|
60
|
+
|
|
61
|
+
const categoryWeights: CategoryWeight = {};
|
|
62
|
+
for (const [key, config] of Object.entries(CATEGORIES)) {
|
|
63
|
+
categoryWeights[key] = config.defaultWeight;
|
|
64
|
+
}
|
|
65
|
+
resultContainer.setCategoryWeights(categoryWeights);
|
|
66
|
+
|
|
67
|
+
let enginesToUse: EngineMetadata[];
|
|
68
|
+
|
|
69
|
+
if (engineNames && engineNames.length > 0) {
|
|
70
|
+
enginesToUse = this.engines.filter((e) => engineNames.includes(e.name));
|
|
71
|
+
} else if (categories && categories.length > 0) {
|
|
72
|
+
enginesToUse = this.engines.filter((e) =>
|
|
73
|
+
e.categories.some((cat) => categories.includes(cat)),
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
enginesToUse = this.engines;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const promises = enginesToUse
|
|
80
|
+
.filter((engine) => {
|
|
81
|
+
if (!engineStatusTracker.isEngineHealthy(engine.name)) {
|
|
82
|
+
console.log(`Skipping unhealthy engine: ${engine.name}`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
})
|
|
87
|
+
.map(async (engine) => {
|
|
88
|
+
const startTime = Date.now();
|
|
89
|
+
try {
|
|
90
|
+
const engineResults = await engine.fn(encodedQuery, pageno);
|
|
91
|
+
const responseTime = Date.now() - startTime;
|
|
92
|
+
engineStatusTracker.recordSuccess(engine.name, responseTime, query);
|
|
93
|
+
|
|
94
|
+
if (engineResults && Array.isArray(engineResults)) {
|
|
95
|
+
for (const result of engineResults) {
|
|
96
|
+
if (!result.category && engine.categories.length > 0) {
|
|
97
|
+
result.category = engine.categories[0];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
resultContainer.extend(engine.name, engineResults);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const responseTime = Date.now() - startTime;
|
|
104
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
105
|
+
engineStatusTracker.recordFailure(engine.name, errorMessage, responseTime, query);
|
|
106
|
+
console.error(`Error in engine ${engine.name}:`, errorMessage);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await Promise.all(promises);
|
|
111
|
+
|
|
112
|
+
resultContainer.close();
|
|
113
|
+
|
|
114
|
+
const orderedResults = resultContainer.getOrderedResults();
|
|
115
|
+
|
|
116
|
+
const stats = resultContainer.getStats();
|
|
117
|
+
console.log(
|
|
118
|
+
`Search "${query}": ${stats.totalResults} results, ${stats.duplicatesMerged} merged, avg ${stats.engineCoverage.toFixed(1)} engines/result`,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
return orderedResults;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Search across multiple categories and combine the results.
|
|
126
|
+
*
|
|
127
|
+
* @param query - Search terms.
|
|
128
|
+
* @param categories - Categories to include (e.g. `['general', 'news', 'academic']`).
|
|
129
|
+
* @param pageno - Page number (default 1).
|
|
130
|
+
*/
|
|
131
|
+
async searchByCategories(
|
|
132
|
+
query: string,
|
|
133
|
+
categories: string[],
|
|
134
|
+
pageno: number = 1,
|
|
135
|
+
): Promise<MergedResult[]> {
|
|
136
|
+
return this.search(query, pageno, undefined, categories);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Return all registered engine names. */
|
|
140
|
+
getEngines(): string[] {
|
|
141
|
+
return this.engines.map((e) => e.name);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Return engine names that belong to the given category. */
|
|
145
|
+
getEnginesByCategory(category: string): string[] {
|
|
146
|
+
return this.engines.filter((e) => e.categories.includes(category)).map((e) => e.name);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Return all registered category names. */
|
|
150
|
+
getCategories(): string[] {
|
|
151
|
+
return categoryRegistry.getCategories();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Return per-category engine counts and totals. */
|
|
155
|
+
getCategoryStats() {
|
|
156
|
+
return categoryRegistry.getStats();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Return the current health status for a named engine. */
|
|
160
|
+
getEngineStatus(engineName: string) {
|
|
161
|
+
return engineStatusTracker.getStatus(engineName);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Return health status records for all registered engines. */
|
|
165
|
+
getAllEngineStatuses() {
|
|
166
|
+
return engineStatusTracker.getAllStatuses();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Result Container — Central aggregator for results returned by multiple
|
|
3
|
+
* search engine adapters. Handles URL-based deduplication, merging of duplicate
|
|
4
|
+
* results (choosing longer content, preferring HTTPS URLs, unioning engine lists),
|
|
5
|
+
* position-weighted scoring with per-engine and per-category multipliers, and a
|
|
6
|
+
* two-pass category-grouping algorithm that clusters similar results together in
|
|
7
|
+
* the final output. Also tracks infoboxes, suggestions, answers, corrections,
|
|
8
|
+
* unresponsive engines, and per-engine timing metrics.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { EngineResult } from "../types/search-engine-interface.js";
|
|
12
|
+
import {
|
|
13
|
+
PriorityType,
|
|
14
|
+
MergedResult,
|
|
15
|
+
EngineWeight,
|
|
16
|
+
CategoryWeight,
|
|
17
|
+
Infobox,
|
|
18
|
+
UnresponsiveEngine,
|
|
19
|
+
Timing,
|
|
20
|
+
EngineData,
|
|
21
|
+
} from "../types/search-result-types.js";
|
|
22
|
+
|
|
23
|
+
export class ResultContainer {
|
|
24
|
+
private mainResultsMap: Map<string, MergedResult> = new Map();
|
|
25
|
+
private mainResultsSorted: MergedResult[] | null = null;
|
|
26
|
+
private closed: boolean = false;
|
|
27
|
+
private engineWeights: EngineWeight = {};
|
|
28
|
+
private categoryWeights: CategoryWeight = {};
|
|
29
|
+
|
|
30
|
+
public infoboxes: Infobox[] = [];
|
|
31
|
+
public suggestions: Set<string> = new Set();
|
|
32
|
+
public answers: Map<string, any> = new Map();
|
|
33
|
+
public corrections: Set<string> = new Set();
|
|
34
|
+
|
|
35
|
+
public unresponsiveEngines: Set<UnresponsiveEngine> = new Set();
|
|
36
|
+
public timings: Timing[] = [];
|
|
37
|
+
public engineData: EngineData = {};
|
|
38
|
+
|
|
39
|
+
private numberofResults: number[] = [];
|
|
40
|
+
public paging: boolean = false;
|
|
41
|
+
public redirectUrl: string | null = null;
|
|
42
|
+
|
|
43
|
+
public onResult: (result: any) => boolean = () => true;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Configure engine weights for scoring. Higher weight = more influence from this engine.
|
|
47
|
+
*/
|
|
48
|
+
setEngineWeights(weights: EngineWeight) {
|
|
49
|
+
this.engineWeights = weights;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Configure category weights for scoring. Applied as a multiplier on top of engine weights.
|
|
54
|
+
*/
|
|
55
|
+
setCategoryWeights(weights: CategoryWeight) {
|
|
56
|
+
this.categoryWeights = weights;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private getEngineWeight(engineName: string): number {
|
|
60
|
+
return this.engineWeights[engineName] || 1.0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private getCategoryWeight(category: string): number {
|
|
64
|
+
return this.categoryWeights[category] || 1.0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compute a deduplication hash for a result based on its normalised URL and title prefix.
|
|
69
|
+
*/
|
|
70
|
+
private calculateResultHash(result: EngineResult): string {
|
|
71
|
+
const url = result.url || result.link || "";
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const urlObj = new URL(url);
|
|
75
|
+
let normalized = urlObj.hostname.replace(/^www\./, "") + urlObj.pathname;
|
|
76
|
+
normalized = normalized.replace(/\/$/, "").toLowerCase();
|
|
77
|
+
|
|
78
|
+
const titlePart = (result.title || "").toLowerCase().replace(/\s+/g, "");
|
|
79
|
+
return `${normalized}:${titlePart.substring(0, 50)}`;
|
|
80
|
+
} catch {
|
|
81
|
+
const fallback = (result.title || result.content || url).toLowerCase();
|
|
82
|
+
return fallback.replace(/\s+/g, "").substring(0, 100);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Merge a new engine result into an existing MergedResult, preferring longer
|
|
88
|
+
* content, non-empty optional fields, and HTTPS URLs.
|
|
89
|
+
*/
|
|
90
|
+
private mergeTwoResults(origin: MergedResult, other: EngineResult): void {
|
|
91
|
+
if ((other.content || "").length > (origin.content || "").length) {
|
|
92
|
+
origin.content = other.content;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if ((other.title || "").length > (origin.title || "").length) {
|
|
96
|
+
origin.title = other.title;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!origin.img_src && other.img_src) origin.img_src = other.img_src;
|
|
100
|
+
if (!origin.thumbnail && other.thumbnail) origin.thumbnail = other.thumbnail;
|
|
101
|
+
if (!origin.publishedDate && other.publishedDate) origin.publishedDate = other.publishedDate;
|
|
102
|
+
if (!origin.author && other.author) origin.author = other.author;
|
|
103
|
+
|
|
104
|
+
const originUrl = origin.url || origin.link || "";
|
|
105
|
+
const otherUrl = other.url || other.link || "";
|
|
106
|
+
if (originUrl && !originUrl.startsWith("https://") && otherUrl.startsWith("https://")) {
|
|
107
|
+
origin.url = other.url;
|
|
108
|
+
origin.link = other.link;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (other.engine && !origin.engines.includes(other.engine)) {
|
|
112
|
+
origin.engines.push(other.engine);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Add results from an engine. Ignored after the container is closed.
|
|
118
|
+
*/
|
|
119
|
+
extend(engineName: string, results: EngineResult[]): void {
|
|
120
|
+
if (this.closed) {
|
|
121
|
+
console.warn("ResultContainer is closed, ignoring results from", engineName);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let mainCount = 0;
|
|
126
|
+
|
|
127
|
+
for (const result of results) {
|
|
128
|
+
if (!this.onResult(result)) continue;
|
|
129
|
+
|
|
130
|
+
if ((result as any).suggestion) {
|
|
131
|
+
this.suggestions.add((result as any).suggestion);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if ((result as any).answer) {
|
|
136
|
+
const answer = (result as any).answer;
|
|
137
|
+
this.answers.set(answer, { engine: engineName, answer });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if ((result as any).correction) {
|
|
142
|
+
this.corrections.add((result as any).correction);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if ((result as any).infobox) {
|
|
147
|
+
this.mergeInfobox(result as any, engineName);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if ((result as any).number_of_results) {
|
|
152
|
+
this.numberofResults.push((result as any).number_of_results);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if ((result as any).engine_data) {
|
|
157
|
+
const key = (result as any).key || "data";
|
|
158
|
+
if (!this.engineData[engineName]) this.engineData[engineName] = {};
|
|
159
|
+
this.engineData[engineName][key] = (result as any).engine_data;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
mainCount++;
|
|
164
|
+
this.mergeMainResult(result, mainCount, engineName);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private mergeInfobox(infoboxResult: any, engineName: string): void {
|
|
169
|
+
const newInfobox: Infobox = {
|
|
170
|
+
...infoboxResult,
|
|
171
|
+
engine: engineName,
|
|
172
|
+
engines: new Set([engineName]),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
if (newInfobox.id) {
|
|
176
|
+
const existing = this.infoboxes.find((ib) => ib.id === newInfobox.id);
|
|
177
|
+
if (existing) {
|
|
178
|
+
this.mergeTwoInfoboxes(existing, newInfobox);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.infoboxes.push(newInfobox);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private mergeTwoInfoboxes(origin: Infobox, other: Infobox): void {
|
|
187
|
+
other.engines.forEach((e) => origin.engines.add(e));
|
|
188
|
+
|
|
189
|
+
if (other.urls) {
|
|
190
|
+
if (!origin.urls) origin.urls = [];
|
|
191
|
+
|
|
192
|
+
for (const url2 of other.urls) {
|
|
193
|
+
const exists = origin.urls.some(
|
|
194
|
+
(url1) =>
|
|
195
|
+
(url2.entity && url1.entity === url2.entity) || url1.url === url2.url,
|
|
196
|
+
);
|
|
197
|
+
if (!exists) origin.urls.push(url2);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (other.img_src && !origin.img_src) origin.img_src = other.img_src;
|
|
202
|
+
|
|
203
|
+
if (other.attributes) {
|
|
204
|
+
if (!origin.attributes) origin.attributes = [];
|
|
205
|
+
|
|
206
|
+
const existingLabels = new Set(
|
|
207
|
+
origin.attributes.map((a) => a.label || a.entity).filter(Boolean),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
for (const attr of other.attributes) {
|
|
211
|
+
const key = attr.label || attr.entity;
|
|
212
|
+
if (key && !existingLabels.has(key)) origin.attributes.push(attr);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (other.content && (!origin.content || other.content.length > origin.content.length)) {
|
|
217
|
+
origin.content = other.content;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private mergeMainResult(result: EngineResult, position: number, engineName: string): void {
|
|
222
|
+
const resultHash = this.calculateResultHash(result);
|
|
223
|
+
const existing = this.mainResultsMap.get(resultHash);
|
|
224
|
+
|
|
225
|
+
if (!existing) {
|
|
226
|
+
const mergedResult: MergedResult = {
|
|
227
|
+
...result,
|
|
228
|
+
engine: engineName,
|
|
229
|
+
engines: [engineName],
|
|
230
|
+
positions: [position],
|
|
231
|
+
score: 0,
|
|
232
|
+
priority: (result as any).priority || "normal",
|
|
233
|
+
resultHash,
|
|
234
|
+
};
|
|
235
|
+
this.mainResultsMap.set(resultHash, mergedResult);
|
|
236
|
+
} else {
|
|
237
|
+
this.mergeTwoResults(existing, result);
|
|
238
|
+
existing.positions.push(position);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Score a result using position-weighted scoring with engine and category multipliers.
|
|
244
|
+
*
|
|
245
|
+
* Algorithm:
|
|
246
|
+
* - weight = product of all engine weights × category weight × number of positions
|
|
247
|
+
* - low priority: contributes nothing
|
|
248
|
+
* - high priority: adds full weight per position
|
|
249
|
+
* - normal priority: adds weight / position (inverse-position decay)
|
|
250
|
+
*/
|
|
251
|
+
private calculateScore(result: MergedResult): number {
|
|
252
|
+
let weight = 1.0;
|
|
253
|
+
|
|
254
|
+
for (const engineName of result.engines) {
|
|
255
|
+
weight *= this.getEngineWeight(engineName);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (result.category) {
|
|
259
|
+
weight *= this.getCategoryWeight(result.category);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
weight *= result.positions.length;
|
|
263
|
+
|
|
264
|
+
let score = 0;
|
|
265
|
+
|
|
266
|
+
for (const position of result.positions) {
|
|
267
|
+
if (result.priority === "low") continue;
|
|
268
|
+
else if (result.priority === "high") score += weight;
|
|
269
|
+
else score += weight / position;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return score;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Freeze the container and compute final scores. Must be called before
|
|
277
|
+
* `getOrderedResults()`.
|
|
278
|
+
*/
|
|
279
|
+
close(): void {
|
|
280
|
+
if (this.closed) return;
|
|
281
|
+
|
|
282
|
+
this.closed = true;
|
|
283
|
+
|
|
284
|
+
for (const result of this.mainResultsMap.values()) {
|
|
285
|
+
result.score = this.calculateScore(result);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Return results sorted by score with a category-grouping pass that clusters
|
|
291
|
+
* similar results within a sliding window of 20 positions (max 8 per group).
|
|
292
|
+
*/
|
|
293
|
+
getOrderedResults(): MergedResult[] {
|
|
294
|
+
if (!this.closed) this.close();
|
|
295
|
+
|
|
296
|
+
if (this.mainResultsSorted) return this.mainResultsSorted;
|
|
297
|
+
|
|
298
|
+
const results = Array.from(this.mainResultsMap.values()).sort(
|
|
299
|
+
(a, b) => b.score - a.score,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const groupedResults: MergedResult[] = [];
|
|
303
|
+
const categoryPositions: Map<string, { index: number; count: number }> = new Map();
|
|
304
|
+
|
|
305
|
+
const maxCount = 8;
|
|
306
|
+
const maxDistance = 20;
|
|
307
|
+
|
|
308
|
+
for (const res of results) {
|
|
309
|
+
const hasImage = !!(res.thumbnail || res.img_src);
|
|
310
|
+
const categoryKey = `${res.category || "general"}:${res.template || "default"}:${hasImage ? "img" : "noimg"}`;
|
|
311
|
+
|
|
312
|
+
const group = categoryPositions.get(categoryKey);
|
|
313
|
+
|
|
314
|
+
if (
|
|
315
|
+
group &&
|
|
316
|
+
group.count > 0 &&
|
|
317
|
+
groupedResults.length - group.index < maxDistance
|
|
318
|
+
) {
|
|
319
|
+
const insertIndex = group.index;
|
|
320
|
+
groupedResults.splice(insertIndex, 0, res);
|
|
321
|
+
|
|
322
|
+
for (const value of categoryPositions.values()) {
|
|
323
|
+
if (value.index >= insertIndex) value.index += 1;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
group.count -= 1;
|
|
327
|
+
} else {
|
|
328
|
+
groupedResults.push(res);
|
|
329
|
+
categoryPositions.set(categoryKey, { index: groupedResults.length, count: maxCount });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
this.mainResultsSorted = groupedResults;
|
|
334
|
+
return this.mainResultsSorted;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Return all results without ordering (useful for debugging).
|
|
339
|
+
*/
|
|
340
|
+
getRawResults(): MergedResult[] {
|
|
341
|
+
return Array.from(this.mainResultsMap.values());
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Track an engine that failed to respond during this search.
|
|
346
|
+
*/
|
|
347
|
+
addUnresponsiveEngine(
|
|
348
|
+
engineName: string,
|
|
349
|
+
errorType: string,
|
|
350
|
+
suspended: boolean = false,
|
|
351
|
+
): void {
|
|
352
|
+
if (this.closed) {
|
|
353
|
+
console.error("Cannot add unresponsive engine after container is closed");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
this.unresponsiveEngines.add({ engine: engineName, errorType, suspended });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Record timing information for an engine request.
|
|
361
|
+
*/
|
|
362
|
+
addTiming(engineName: string, engineTime: number, pageLoadTime: number): void {
|
|
363
|
+
if (this.closed) {
|
|
364
|
+
console.error("Cannot add timing after container is closed");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.timings.push({ engine: engineName, total: engineTime, load: pageLoadTime });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Return the average number of results reported by engines, or 0 if fewer than
|
|
372
|
+
* the actual deduplicated count (indicating the average is not meaningful).
|
|
373
|
+
*/
|
|
374
|
+
getNumberOfResults(): number {
|
|
375
|
+
if (!this.closed) {
|
|
376
|
+
console.error("Call to getNumberOfResults before close()");
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (this.numberofResults.length === 0) return 0;
|
|
381
|
+
|
|
382
|
+
const sum = this.numberofResults.reduce((a, b) => a + b, 0);
|
|
383
|
+
const average = Math.floor(sum / this.numberofResults.length);
|
|
384
|
+
const actualResults = this.getOrderedResults().length;
|
|
385
|
+
return average < actualResults ? 0 : average;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Return all collected timing entries (only valid after `close()`).
|
|
390
|
+
*/
|
|
391
|
+
getTimings(): Timing[] {
|
|
392
|
+
if (!this.closed) {
|
|
393
|
+
console.error("Call to getTimings before close()");
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
return this.timings;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Return a summary of result counts, scores, deduplication, and metadata.
|
|
401
|
+
*/
|
|
402
|
+
getStats() {
|
|
403
|
+
const results = Array.from(this.mainResultsMap.values());
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
totalResults: results.length,
|
|
407
|
+
averageScore: results.reduce((sum, r) => sum + r.score, 0) / results.length || 0,
|
|
408
|
+
engineCoverage:
|
|
409
|
+
results.reduce((sum, r) => sum + r.engines.length, 0) / results.length || 0,
|
|
410
|
+
duplicatesMerged: results.filter((r) => r.engines.length > 1).length,
|
|
411
|
+
categories: [...new Set(results.map((r) => r.category).filter(Boolean))],
|
|
412
|
+
suggestions: this.suggestions.size,
|
|
413
|
+
answers: this.answers.size,
|
|
414
|
+
corrections: this.corrections.size,
|
|
415
|
+
infoboxes: this.infoboxes.length,
|
|
416
|
+
unresponsiveEngines: this.unresponsiveEngines.size,
|
|
417
|
+
numberOfResults: this.getNumberOfResults(),
|
|
418
|
+
paging: this.paging,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Web Types — Re-exports all shared TypeScript interfaces and types from
|
|
3
|
+
* the canonical types module. Covers EngineResult, MergedResult, Infobox,
|
|
4
|
+
* CategoryConfig, EngineMetadata, EngineStatus, EngineLog, and all supporting
|
|
5
|
+
* weight and tracking interfaces used across the search API.
|
|
6
|
+
*/
|
|
7
|
+
export * from "./types/search-result-types.js";
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search — Entry point re-exporting the Search orchestrator class and the
|
|
3
|
+
* complete ALL_ENGINES registry. The implementation lives in two focused modules:
|
|
4
|
+
* - `search/search-query-executor` — the Search class that runs queries in
|
|
5
|
+
* parallel, deduplicates via ResultContainer, and returns scored results.
|
|
6
|
+
* - `search/search-engines-registry-list` — the static catalogue of all 75+
|
|
7
|
+
* engine adapters organised by category.
|
|
8
|
+
*/
|
|
9
|
+
export { Search } from "./search/search-query-executor.js";
|
|
10
|
+
export { ALL_ENGINES } from "./search/search-engines-registry-list.js";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { parseHTML } from "linkedom";
|
|
2
|
+
import { EngineFunction } from "../../types/search-engine-interface.js";
|
|
3
|
+
|
|
4
|
+
export const arxiv: EngineFunction = async (
|
|
5
|
+
query: string,
|
|
6
|
+
page: number | undefined
|
|
7
|
+
) => {
|
|
8
|
+
const params = new URLSearchParams({
|
|
9
|
+
search_query: "all:" + query,
|
|
10
|
+
start: String(((page || 1) - 1) * 10),
|
|
11
|
+
max_results: "10",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const response = await fetch(
|
|
15
|
+
`https://export.arxiv.org/api/query?${params}`,
|
|
16
|
+
{
|
|
17
|
+
headers: {
|
|
18
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
|
|
19
|
+
Accept: "application/atom+xml",
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
if (!response.ok) return [];
|
|
25
|
+
|
|
26
|
+
const text = await response.text();
|
|
27
|
+
const { document } = parseHTML(text);
|
|
28
|
+
|
|
29
|
+
return Array.from(document.querySelectorAll("entry"))
|
|
30
|
+
.map((entry) => {
|
|
31
|
+
const title =
|
|
32
|
+
entry
|
|
33
|
+
.querySelector("title")
|
|
34
|
+
?.textContent?.replace(/\s+/g, " ")
|
|
35
|
+
.trim() || "";
|
|
36
|
+
const url = entry.querySelector("id")?.textContent?.trim() || "";
|
|
37
|
+
const abstract = (
|
|
38
|
+
entry
|
|
39
|
+
.querySelector("summary")
|
|
40
|
+
?.textContent?.replace(/\s+/g, " ")
|
|
41
|
+
.trim() || ""
|
|
42
|
+
).substring(0, 500);
|
|
43
|
+
const authors = Array.from(entry.querySelectorAll("author name"))
|
|
44
|
+
.map((n) => n.textContent?.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
const published = entry
|
|
47
|
+
.querySelector("published")
|
|
48
|
+
?.textContent?.split("T")[0];
|
|
49
|
+
const categories = Array.from(entry.querySelectorAll("category"))
|
|
50
|
+
.map((cat) => cat.getAttribute("term"))
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
const meta = [
|
|
53
|
+
authors.length &&
|
|
54
|
+
`Authors: ${authors.slice(0, 3).join(", ")}${
|
|
55
|
+
authors.length > 3 ? " et al." : ""
|
|
56
|
+
}`,
|
|
57
|
+
published && `Published: ${published}`,
|
|
58
|
+
categories.length &&
|
|
59
|
+
`Categories: ${categories.slice(0, 3).join(", ")}`,
|
|
60
|
+
]
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join(" | ");
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
url,
|
|
66
|
+
title,
|
|
67
|
+
content: meta ? `${meta}\n\n${abstract}` : abstract,
|
|
68
|
+
engine: "arxiv",
|
|
69
|
+
};
|
|
70
|
+
})
|
|
71
|
+
.filter((r) => r.url && r.title);
|
|
72
|
+
};
|