@sylphx/lookout 0.1.0
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/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/lookout +11 -0
- package/docs/BRAND_PUBLISH.md +36 -0
- package/docs/COMPETITIVE.md +27 -0
- package/docs/EVIDENCE_CONTRACT.md +21 -0
- package/docs/IPPB.md +16 -0
- package/docs/POSITIONING.md +42 -0
- package/docs/PRODUCT_INDEPENDENCE.md +13 -0
- package/docs/PUBLISH.md +23 -0
- package/docs/TOOL_SURFACE.md +19 -0
- package/package.json +58 -0
- package/server.json +22 -0
- package/src/cache.ts +129 -0
- package/src/cli.ts +106 -0
- package/src/crawl.ts +204 -0
- package/src/doctor.ts +73 -0
- package/src/engine.ts +427 -0
- package/src/extract.ts +274 -0
- package/src/fetch.ts +145 -0
- package/src/mcp.ts +118 -0
- package/src/research.ts +98 -0
- package/src/robots.ts +97 -0
- package/src/sdk.ts +59 -0
- package/src/search.ts +356 -0
- package/src/sitemap.ts +13 -0
- package/src/ssrf.ts +69 -0
package/src/robots.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { webFetch } from './fetch.ts';
|
|
2
|
+
|
|
3
|
+
export type RobotsDecision = {
|
|
4
|
+
allowed: boolean;
|
|
5
|
+
source: string;
|
|
6
|
+
matchedRule?: string;
|
|
7
|
+
warning?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Minimal robots.txt parse for User-agent: *.
|
|
12
|
+
* Collects Allow and Disallow; longest matching rule wins (ties: Allow wins).
|
|
13
|
+
*/
|
|
14
|
+
export function parseRobotsTxt(body: string, path: string): RobotsDecision {
|
|
15
|
+
const lines = body.split(/\r?\n/).map((l) => l.trim());
|
|
16
|
+
let inStar = false;
|
|
17
|
+
const rules: { type: 'allow' | 'disallow'; value: string }[] = [];
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
if (!line || line.startsWith('#')) continue;
|
|
20
|
+
const ua = line.match(/^user-agent:\s*(.+)$/i);
|
|
21
|
+
if (ua) {
|
|
22
|
+
inStar = ua[1].trim() === '*';
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!inStar) continue;
|
|
26
|
+
const dis = line.match(/^disallow:\s*(.*)$/i);
|
|
27
|
+
if (dis) {
|
|
28
|
+
const rule = (dis[1] ?? '').trim();
|
|
29
|
+
if (rule.length) rules.push({ type: 'disallow', value: rule });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const allow = line.match(/^allow:\s*(.*)$/i);
|
|
33
|
+
if (allow) {
|
|
34
|
+
const rule = (allow[1] ?? '').trim();
|
|
35
|
+
if (rule.length) rules.push({ type: 'allow', value: rule });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let best: { type: 'allow' | 'disallow'; value: string } | null = null;
|
|
39
|
+
for (const rule of rules) {
|
|
40
|
+
if (rule.value === '/' || path.startsWith(rule.value) || path === rule.value) {
|
|
41
|
+
if (!best || rule.value.length > best.value.length) {
|
|
42
|
+
best = rule;
|
|
43
|
+
} else if (best && rule.value.length === best.value.length && rule.type === 'allow') {
|
|
44
|
+
best = rule;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!best) {
|
|
49
|
+
return { allowed: true, source: 'robots.txt' };
|
|
50
|
+
}
|
|
51
|
+
if (best.type === 'allow') {
|
|
52
|
+
return { allowed: true, source: 'robots.txt', matchedRule: `Allow: ${best.value}` };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
allowed: false,
|
|
56
|
+
source: 'robots.txt',
|
|
57
|
+
matchedRule: best.value,
|
|
58
|
+
warning: `robots Disallow: ${best.value}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const robotsCache = new Map<string, { body: string; at: number }>();
|
|
63
|
+
const ROBOTS_TTL_MS = 10 * 60 * 1000;
|
|
64
|
+
|
|
65
|
+
export async function checkRobotsAllowed(
|
|
66
|
+
url: string,
|
|
67
|
+
options: { respectRobots?: boolean } = {},
|
|
68
|
+
): Promise<RobotsDecision> {
|
|
69
|
+
if (options.respectRobots === false) {
|
|
70
|
+
return { allowed: true, source: 'respect_robots_off' };
|
|
71
|
+
}
|
|
72
|
+
let parsed: URL;
|
|
73
|
+
try {
|
|
74
|
+
parsed = new URL(url);
|
|
75
|
+
} catch {
|
|
76
|
+
return { allowed: false, source: 'invalid_url', warning: 'invalid url for robots check' };
|
|
77
|
+
}
|
|
78
|
+
const robotsUrl = `${parsed.origin}/robots.txt`;
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
let body: string | undefined;
|
|
81
|
+
const cached = robotsCache.get(robotsUrl);
|
|
82
|
+
if (cached && now - cached.at < ROBOTS_TTL_MS) {
|
|
83
|
+
body = cached.body;
|
|
84
|
+
} else {
|
|
85
|
+
const res = await webFetch(robotsUrl, { timeoutMs: 8_000, maxBytes: 100_000 });
|
|
86
|
+
if (!res.ok || !res.body) {
|
|
87
|
+
return {
|
|
88
|
+
allowed: true,
|
|
89
|
+
source: 'robots_missing',
|
|
90
|
+
warning: `robots.txt unavailable (${res.message ?? res.code ?? res.status})`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
body = res.body;
|
|
94
|
+
robotsCache.set(robotsUrl, { body, at: now });
|
|
95
|
+
}
|
|
96
|
+
return parseRobotsTxt(body, parsed.pathname || '/');
|
|
97
|
+
}
|
package/src/sdk.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lookout SDK — local-first web instrument (Sylphx).
|
|
3
|
+
* Surfaces: SDK · CLI · MCP share LookoutEngine semantics.
|
|
4
|
+
*/
|
|
5
|
+
import { LookoutEngine, type EngineOptions, type ToolEnvelope, CORE_TOOLS, ADVANCED_TOOLS } from './engine.ts';
|
|
6
|
+
|
|
7
|
+
export type LookoutOptions = EngineOptions;
|
|
8
|
+
export type { ToolEnvelope };
|
|
9
|
+
export { CORE_TOOLS, ADVANCED_TOOLS, LookoutEngine };
|
|
10
|
+
|
|
11
|
+
export class Lookout {
|
|
12
|
+
private readonly engine: LookoutEngine;
|
|
13
|
+
|
|
14
|
+
constructor(options: LookoutOptions = {}) {
|
|
15
|
+
this.engine = new LookoutEngine(options);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static create(options?: LookoutOptions): Lookout {
|
|
19
|
+
return new Lookout(options);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
search(query: string | string[], input: Record<string, unknown> = {}) {
|
|
23
|
+
return this.engine.handle('web_search', { ...input, query });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fetch(url: string, input: Record<string, unknown> = {}) {
|
|
27
|
+
return this.engine.handle('web_fetch', { ...input, url });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
extract(input: { url?: string; html?: string } & Record<string, unknown>) {
|
|
31
|
+
return this.engine.handle('web_extract', input);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
cache(input: Record<string, unknown> = {}) {
|
|
35
|
+
return this.engine.handle('web_cache', input);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Remove cache entries older than maxAgeMs (default from LOOKOUT_CACHE_MAX_AGE_MS or 24h). */
|
|
39
|
+
prune(maxAgeMs?: number) {
|
|
40
|
+
return this.engine.handle('web_cache', { op: 'prune', maxAgeMs });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Advanced: depth-limited same-origin crawl */
|
|
44
|
+
crawl(url: string, input: Record<string, unknown> = {}) {
|
|
45
|
+
return this.engine.handle('web_crawl', { ...input, url });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Advanced: search then extract top pages with cite spans */
|
|
49
|
+
research(query: string, input: Record<string, unknown> = {}) {
|
|
50
|
+
return this.engine.handle('web_research', { ...input, query });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Escape hatch for MCP-identical tool names (core + advanced). */
|
|
54
|
+
call(tool: string, input: Record<string, unknown> = {}) {
|
|
55
|
+
return this.engine.handle(tool, input);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default Lookout;
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight multi-adapter search without API keys.
|
|
3
|
+
* Primary: DuckDuckGo HTML. Fallbacks: Wikipedia, npm registry, HN Algolia (no key).
|
|
4
|
+
*/
|
|
5
|
+
import { webFetch } from './fetch.ts';
|
|
6
|
+
|
|
7
|
+
export type SearchHit = {
|
|
8
|
+
title: string;
|
|
9
|
+
url: string;
|
|
10
|
+
host?: string;
|
|
11
|
+
snippet: string;
|
|
12
|
+
engine: string;
|
|
13
|
+
score: number;
|
|
14
|
+
scoreExplain: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type SearchResult = {
|
|
18
|
+
ok: boolean;
|
|
19
|
+
query: string;
|
|
20
|
+
hits: SearchHit[];
|
|
21
|
+
route: string;
|
|
22
|
+
warnings: string[];
|
|
23
|
+
engines: { name: string; ok: boolean; message?: string }[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function decodeEntities(s: string): string {
|
|
27
|
+
return s
|
|
28
|
+
.replace(/&/g, '&')
|
|
29
|
+
.replace(/</g, '<')
|
|
30
|
+
.replace(/>/g, '>')
|
|
31
|
+
.replace(/"/g, '"')
|
|
32
|
+
.replace(/'/g, "'")
|
|
33
|
+
.replace(/<\/?b>/gi, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function hostnameOf(url: string): string {
|
|
37
|
+
try {
|
|
38
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
39
|
+
} catch {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
/** Unwrap DuckDuckGo redirect wrappers to the destination URL when present. */
|
|
46
|
+
export function normalizeResultUrl(raw: string): string {
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(raw);
|
|
49
|
+
// ddg redirect: https://duckduckgo.com/l/?uddg=<encoded>
|
|
50
|
+
const uddg = u.searchParams.get('uddg');
|
|
51
|
+
if (uddg) {
|
|
52
|
+
try {
|
|
53
|
+
return decodeURIComponent(uddg);
|
|
54
|
+
} catch {
|
|
55
|
+
return uddg;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// some lite results use //duckduckgo.com/l/?kh=-1&uddg=
|
|
59
|
+
if (u.hostname.includes('duckduckgo.com') && u.pathname.startsWith('/l/')) {
|
|
60
|
+
const q = u.searchParams.get('uddg');
|
|
61
|
+
if (q) {
|
|
62
|
+
try {
|
|
63
|
+
return decodeURIComponent(q);
|
|
64
|
+
} catch {
|
|
65
|
+
return q;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return raw;
|
|
70
|
+
} catch {
|
|
71
|
+
return raw;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseDuckDuckGoHtml(html: string): SearchHit[] {
|
|
76
|
+
const hits: SearchHit[] = [];
|
|
77
|
+
// classic HTML result blocks
|
|
78
|
+
const re =
|
|
79
|
+
/<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/(?:a|td|div)>|)/gi;
|
|
80
|
+
let m: RegExpExecArray | null;
|
|
81
|
+
while ((m = re.exec(html)) !== null) {
|
|
82
|
+
const url = normalizeResultUrl(decodeEntities(m[1] ?? ''));
|
|
83
|
+
const title = decodeEntities((m[2] ?? '').replace(/<[^>]+>/g, '')).trim();
|
|
84
|
+
const snippet = decodeEntities((m[3] ?? '').replace(/<[^>]+>/g, '')).trim();
|
|
85
|
+
if (!url.startsWith('http') || !title) continue;
|
|
86
|
+
hits.push({
|
|
87
|
+
title,
|
|
88
|
+
url,
|
|
89
|
+
host: hostnameOf(url),
|
|
90
|
+
snippet,
|
|
91
|
+
engine: 'duckduckgo_html',
|
|
92
|
+
score: Math.max(0.1, 1 - hits.length * 0.05),
|
|
93
|
+
scoreExplain: ['rank_position', `engine=duckduckgo_html`],
|
|
94
|
+
});
|
|
95
|
+
if (hits.length >= 10) break;
|
|
96
|
+
}
|
|
97
|
+
// lite.duckduckgo.com sometimes uses different markup
|
|
98
|
+
if (hits.length === 0) {
|
|
99
|
+
const liteRe = /<a[^>]+rel="nofollow"[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
100
|
+
while ((m = liteRe.exec(html)) !== null) {
|
|
101
|
+
const url = normalizeResultUrl(decodeEntities(m[1] ?? ''));
|
|
102
|
+
const title = decodeEntities((m[2] ?? '').replace(/<[^>]+>/g, '')).trim();
|
|
103
|
+
if (!title || title.length < 2) continue;
|
|
104
|
+
if (url.includes('duckduckgo.com')) continue;
|
|
105
|
+
hits.push({
|
|
106
|
+
title,
|
|
107
|
+
url,
|
|
108
|
+
host: hostnameOf(url),
|
|
109
|
+
snippet: '',
|
|
110
|
+
engine: 'duckduckgo_lite',
|
|
111
|
+
score: Math.max(0.1, 1 - hits.length * 0.05),
|
|
112
|
+
scoreExplain: ['rank_position', 'engine=duckduckgo_lite'],
|
|
113
|
+
});
|
|
114
|
+
if (hits.length >= 10) break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return hits;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function searchDuckDuckGo(query: string): Promise<{ hits: SearchHit[]; warning?: string }> {
|
|
121
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
122
|
+
const res = await webFetch(url, { timeoutMs: 15_000 });
|
|
123
|
+
if (!res.ok || !res.body) {
|
|
124
|
+
return { hits: [], warning: res.message ?? res.code ?? 'duckduckgo_failed' };
|
|
125
|
+
}
|
|
126
|
+
const hits = parseDuckDuckGoHtml(res.body);
|
|
127
|
+
if (hits.length === 0) {
|
|
128
|
+
return { hits: [], warning: 'duckduckgo_parse_empty' };
|
|
129
|
+
}
|
|
130
|
+
return { hits };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function parseWikipediaOpensearch(body: string): SearchHit[] {
|
|
134
|
+
const data = JSON.parse(body) as [string, string[], string[], string[]];
|
|
135
|
+
const titles = data[1] ?? [];
|
|
136
|
+
const snippets = data[2] ?? [];
|
|
137
|
+
const urls = data[3] ?? [];
|
|
138
|
+
return titles
|
|
139
|
+
.map((title, i) => ({
|
|
140
|
+
title,
|
|
141
|
+
url: urls[i] ?? '',
|
|
142
|
+
host: hostnameOf(urls[i] ?? ''),
|
|
143
|
+
snippet: snippets[i] ?? '',
|
|
144
|
+
engine: 'wikipedia_opensearch',
|
|
145
|
+
score: Math.max(0.05, 0.7 - i * 0.08),
|
|
146
|
+
scoreExplain: ['engine=wikipedia_opensearch', `rank=${i}`],
|
|
147
|
+
}))
|
|
148
|
+
.filter((h) => h.url.startsWith('http'));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function searchWikipedia(query: string): Promise<{ hits: SearchHit[]; warning?: string }> {
|
|
152
|
+
const url = `https://en.wikipedia.org/w/api.php?action=opensearch&search=${encodeURIComponent(query)}&limit=5&namespace=0&format=json`;
|
|
153
|
+
const res = await webFetch(url, { timeoutMs: 12_000 });
|
|
154
|
+
if (!res.ok || !res.body) {
|
|
155
|
+
return { hits: [], warning: res.message ?? 'wikipedia_failed' };
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const hits = parseWikipediaOpensearch(res.body);
|
|
159
|
+
return { hits };
|
|
160
|
+
} catch {
|
|
161
|
+
return { hits: [], warning: 'wikipedia_parse_error' };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
/** Free HN Algolia search — tech discussion without API keys. */
|
|
168
|
+
export function parseHnAlgoliaJson(body: string): SearchHit[] {
|
|
169
|
+
try {
|
|
170
|
+
const data = JSON.parse(body) as {
|
|
171
|
+
hits?: Array<{ title?: string; url?: string; story_url?: string; objectID?: string; points?: number; author?: string }>;
|
|
172
|
+
};
|
|
173
|
+
const hits: SearchHit[] = [];
|
|
174
|
+
for (const h of data.hits ?? []) {
|
|
175
|
+
const title = h.title?.trim();
|
|
176
|
+
if (!title) continue;
|
|
177
|
+
const url =
|
|
178
|
+
h.url ||
|
|
179
|
+
h.story_url ||
|
|
180
|
+
(h.objectID ? `https://news.ycombinator.com/item?id=${h.objectID}` : '');
|
|
181
|
+
if (!url.startsWith('http')) continue;
|
|
182
|
+
const points = typeof h.points === 'number' ? h.points : 0;
|
|
183
|
+
hits.push({
|
|
184
|
+
title,
|
|
185
|
+
url,
|
|
186
|
+
snippet: h.author ? `by ${h.author}${points ? ` · ${points} points` : ''}` : '',
|
|
187
|
+
engine: 'hn_algolia',
|
|
188
|
+
score: Math.min(1.2, 0.35 + Math.log10(points + 1) * 0.25),
|
|
189
|
+
scoreExplain: ['engine=hn_algolia', `points=${points}`],
|
|
190
|
+
host: hostnameOf(url),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return hits;
|
|
194
|
+
} catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function searchHackerNews(query: string): Promise<{ hits: SearchHit[]; warning?: string }> {
|
|
200
|
+
const url = `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query)}&tags=story&hitsPerPage=6`;
|
|
201
|
+
const res = await webFetch(url, { timeoutMs: 12_000 });
|
|
202
|
+
if (!res.ok || !res.body) {
|
|
203
|
+
return { hits: [], warning: res.message ?? res.code ?? 'hn_algolia_failed' };
|
|
204
|
+
}
|
|
205
|
+
const hits = parseHnAlgoliaJson(res.body);
|
|
206
|
+
if (!hits.length) return { hits: [], warning: 'hn_algolia_parse_empty' };
|
|
207
|
+
return { hits };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Free npm registry search — useful for agents resolving packages without API keys. */
|
|
211
|
+
export function parseNpmSearchJson(body: string): SearchHit[] {
|
|
212
|
+
try {
|
|
213
|
+
const data = JSON.parse(body) as {
|
|
214
|
+
objects?: Array<{ package?: { name?: string; description?: string; links?: { npm?: string } }; score?: { final?: number } }>;
|
|
215
|
+
};
|
|
216
|
+
const hits: SearchHit[] = [];
|
|
217
|
+
for (const obj of data.objects ?? []) {
|
|
218
|
+
const pkg = obj.package;
|
|
219
|
+
if (!pkg?.name) continue;
|
|
220
|
+
const url = pkg.links?.npm ?? `https://www.npmjs.com/package/${pkg.name}`;
|
|
221
|
+
const score = typeof obj.score?.final === 'number' ? obj.score.final : 0.5;
|
|
222
|
+
hits.push({
|
|
223
|
+
title: pkg.name,
|
|
224
|
+
url,
|
|
225
|
+
snippet: pkg.description ?? '',
|
|
226
|
+
engine: 'npm_registry',
|
|
227
|
+
score: Math.min(1.2, 0.4 + score),
|
|
228
|
+
scoreExplain: ['engine=npm_registry', `pkg=${pkg.name}`],
|
|
229
|
+
host: hostnameOf(url),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return hits;
|
|
233
|
+
} catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function searchNpmRegistry(query: string): Promise<{ hits: SearchHit[]; warning?: string }> {
|
|
239
|
+
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=6`;
|
|
240
|
+
const res = await webFetch(url, { timeoutMs: 12_000 });
|
|
241
|
+
if (!res.ok || !res.body) {
|
|
242
|
+
return { hits: [], warning: res.message ?? res.code ?? 'npm_registry_failed' };
|
|
243
|
+
}
|
|
244
|
+
const hits = parseNpmSearchJson(res.body);
|
|
245
|
+
if (!hits.length) return { hits: [], warning: 'npm_registry_parse_empty' };
|
|
246
|
+
return { hits };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Fuse multi-engine hits: URL dedupe, multi-engine boost, host diversity soft penalty. */
|
|
250
|
+
export function fuse(hitLists: SearchHit[][], query?: string): SearchHit[] {
|
|
251
|
+
const byUrl = new Map<string, SearchHit>();
|
|
252
|
+
for (const list of hitLists) {
|
|
253
|
+
for (const hit of list) {
|
|
254
|
+
const prev = byUrl.get(hit.url);
|
|
255
|
+
if (!prev || hit.score > prev.score) {
|
|
256
|
+
byUrl.set(hit.url, {
|
|
257
|
+
...hit,
|
|
258
|
+
scoreExplain: [...hit.scoreExplain, prev ? 'replaced_lower_score' : 'first_seen'],
|
|
259
|
+
});
|
|
260
|
+
} else {
|
|
261
|
+
prev.score = Math.min(1, prev.score + 0.05);
|
|
262
|
+
prev.scoreExplain.push(`boost_from_${hit.engine}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const terms = (query ?? '')
|
|
267
|
+
.toLowerCase()
|
|
268
|
+
.split(/\s+/)
|
|
269
|
+
.map((t) => t.trim())
|
|
270
|
+
.filter((t) => t.length >= 2);
|
|
271
|
+
if (terms.length) {
|
|
272
|
+
for (const hit of byUrl.values()) {
|
|
273
|
+
const hay = `${hit.title} ${hit.snippet}`.toLowerCase();
|
|
274
|
+
let hits = 0;
|
|
275
|
+
for (const term of terms) {
|
|
276
|
+
if (hay.includes(term)) hits += 1;
|
|
277
|
+
}
|
|
278
|
+
if (hits > 0) {
|
|
279
|
+
const boost = Math.min(0.25, 0.08 * hits);
|
|
280
|
+
hit.score = Math.min(1.5, hit.score + boost);
|
|
281
|
+
hit.scoreExplain.push(`query_term_boost=${boost.toFixed(2)}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const ranked = [...byUrl.values()].sort((a, b) => b.score - a.score);
|
|
286
|
+
const hostCount = new Map<string, number>();
|
|
287
|
+
for (const hit of ranked) {
|
|
288
|
+
const host = hostnameOf(hit.url);
|
|
289
|
+
const n = hostCount.get(host) ?? 0;
|
|
290
|
+
if (n > 0) {
|
|
291
|
+
// Soft demote repeated hosts so top-N is less mono-site.
|
|
292
|
+
const penalty = Math.min(0.25, 0.08 * n);
|
|
293
|
+
hit.score = Math.max(0.01, hit.score - penalty);
|
|
294
|
+
hit.scoreExplain.push(`host_diversity_penalty=${penalty.toFixed(2)}`);
|
|
295
|
+
}
|
|
296
|
+
hostCount.set(host, n + 1);
|
|
297
|
+
}
|
|
298
|
+
const finalRanked = ranked.sort((a, b) => b.score - a.score).slice(0, 12);
|
|
299
|
+
return finalRanked.map((hit, i) => ({
|
|
300
|
+
...hit,
|
|
301
|
+
scoreExplain: [...hit.scoreExplain, `rank=${i + 1}`],
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Filter ranked hits by include/exclude host substrings (case-insensitive). */
|
|
306
|
+
export function filterHitsByHosts(
|
|
307
|
+
hits: SearchHit[],
|
|
308
|
+
opts: { include?: string[]; exclude?: string[] } = {},
|
|
309
|
+
): SearchHit[] {
|
|
310
|
+
const include = (opts.include ?? []).map((h) => h.toLowerCase()).filter(Boolean);
|
|
311
|
+
const exclude = (opts.exclude ?? []).map((h) => h.toLowerCase()).filter(Boolean);
|
|
312
|
+
return hits.filter((hit) => {
|
|
313
|
+
const host = (hit.host ?? hostnameOf(hit.url)).toLowerCase();
|
|
314
|
+
if (exclude.some((e) => host === e || host.endsWith(`.${e}`) || host.includes(e))) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
if (!include.length) return true;
|
|
318
|
+
return include.some((e) => host === e || host.endsWith(`.${e}`) || host.includes(e));
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function webSearch(query: string): Promise<SearchResult> {
|
|
323
|
+
const warnings: string[] = [];
|
|
324
|
+
const engines: SearchResult['engines'] = [];
|
|
325
|
+
const lists: SearchHit[][] = [];
|
|
326
|
+
|
|
327
|
+
const ddg = await searchDuckDuckGo(query);
|
|
328
|
+
engines.push({ name: 'duckduckgo_html', ok: ddg.hits.length > 0, message: ddg.warning });
|
|
329
|
+
if (ddg.warning) warnings.push(`duckduckgo: ${ddg.warning}`);
|
|
330
|
+
if (ddg.hits.length) lists.push(ddg.hits);
|
|
331
|
+
|
|
332
|
+
const wiki = await searchWikipedia(query);
|
|
333
|
+
engines.push({ name: 'wikipedia_opensearch', ok: wiki.hits.length > 0, message: wiki.warning });
|
|
334
|
+
if (wiki.warning) warnings.push(`wikipedia: ${wiki.warning}`);
|
|
335
|
+
if (wiki.hits.length) lists.push(wiki.hits);
|
|
336
|
+
|
|
337
|
+
const npm = await searchNpmRegistry(query);
|
|
338
|
+
engines.push({ name: 'npm_registry', ok: npm.hits.length > 0, message: npm.warning });
|
|
339
|
+
if (npm.warning) warnings.push(`npm: ${npm.warning}`);
|
|
340
|
+
if (npm.hits.length) lists.push(npm.hits);
|
|
341
|
+
|
|
342
|
+
const hn = await searchHackerNews(query);
|
|
343
|
+
engines.push({ name: 'hn_algolia', ok: hn.hits.length > 0, message: hn.warning });
|
|
344
|
+
if (hn.warning) warnings.push(`hn: ${hn.warning}`);
|
|
345
|
+
if (hn.hits.length) lists.push(hn.hits);
|
|
346
|
+
|
|
347
|
+
const hits = fuse(lists, query);
|
|
348
|
+
return {
|
|
349
|
+
ok: hits.length > 0,
|
|
350
|
+
query,
|
|
351
|
+
hits,
|
|
352
|
+
route: 'local_adapters',
|
|
353
|
+
warnings,
|
|
354
|
+
engines,
|
|
355
|
+
};
|
|
356
|
+
}
|
package/src/sitemap.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Light sitemap.xml URL extraction (local-first, no full sitemap protocol). */
|
|
2
|
+
|
|
3
|
+
export function parseSitemapXml(body: string, limit = 50): string[] {
|
|
4
|
+
const urls: string[] = [];
|
|
5
|
+
const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
|
|
6
|
+
let m: RegExpExecArray | null;
|
|
7
|
+
while ((m = re.exec(body)) !== null) {
|
|
8
|
+
const u = (m[1] ?? '').trim();
|
|
9
|
+
if (u.startsWith('http')) urls.push(u);
|
|
10
|
+
if (urls.length >= limit) break;
|
|
11
|
+
}
|
|
12
|
+
return [...new Set(urls)];
|
|
13
|
+
}
|
package/src/ssrf.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF policy — deny private/link-local/metadata targets. Local-first safety for Lookout.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
6
|
+
'localhost',
|
|
7
|
+
'metadata.google.internal',
|
|
8
|
+
'metadata',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function parseIpv4(host: string): number[] | null {
|
|
12
|
+
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
13
|
+
if (!m) return null;
|
|
14
|
+
const parts = m.slice(1).map((x) => Number(x));
|
|
15
|
+
if (parts.some((n) => n > 255)) return null;
|
|
16
|
+
return parts;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isPrivateIpv4(parts: number[]): boolean {
|
|
20
|
+
const [a, b] = parts;
|
|
21
|
+
if (a === 10) return true;
|
|
22
|
+
if (a === 127) return true;
|
|
23
|
+
if (a === 0) return true;
|
|
24
|
+
if (a === 169 && b === 254) return true; // link-local / cloud metadata
|
|
25
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
26
|
+
if (a === 192 && b === 168) return true;
|
|
27
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPrivateIpv6(host: string): boolean {
|
|
32
|
+
const h = host.toLowerCase();
|
|
33
|
+
if (h === '::1') return true;
|
|
34
|
+
if (h.startsWith('fc') || h.startsWith('fd')) return true; // ULA
|
|
35
|
+
if (h.startsWith('fe80')) return true; // link-local
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type SsrfCheck =
|
|
40
|
+
| { ok: true; url: URL }
|
|
41
|
+
| { ok: false; code: 'BLOCKED_SCHEME' | 'BLOCKED_HOST' | 'BLOCKED_IP' | 'INVALID_URL'; message: string };
|
|
42
|
+
|
|
43
|
+
export function assertSafeUrl(raw: string): SsrfCheck {
|
|
44
|
+
let url: URL;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(raw);
|
|
47
|
+
} catch {
|
|
48
|
+
return { ok: false, code: 'INVALID_URL', message: `Invalid URL: ${raw}` };
|
|
49
|
+
}
|
|
50
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
code: 'BLOCKED_SCHEME',
|
|
54
|
+
message: `Only http/https allowed (got ${url.protocol})`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
58
|
+
if (BLOCKED_HOSTNAMES.has(host) || host.endsWith('.localhost') || host.endsWith('.local')) {
|
|
59
|
+
return { ok: false, code: 'BLOCKED_HOST', message: `Blocked hostname: ${host}` };
|
|
60
|
+
}
|
|
61
|
+
const v4 = parseIpv4(host);
|
|
62
|
+
if (v4 && isPrivateIpv4(v4)) {
|
|
63
|
+
return { ok: false, code: 'BLOCKED_IP', message: `Blocked private IPv4: ${host}` };
|
|
64
|
+
}
|
|
65
|
+
if (host.includes(':') && isPrivateIpv6(host)) {
|
|
66
|
+
return { ok: false, code: 'BLOCKED_IP', message: `Blocked private IPv6: ${host}` };
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, url };
|
|
69
|
+
}
|