@demigodmode/pi-web-agent 1.12.0 → 1.13.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/CHANGELOG.md +15 -0
- package/README.md +2 -0
- package/dist/backends/doctor.js +27 -29
- package/dist/backends/factory.d.ts +3 -7
- package/dist/backends/factory.js +12 -12
- package/dist/backends/fallback-policy.d.ts +2 -4
- package/dist/backends/provider-failure.js +0 -1
- package/dist/extract/bot-check.d.ts +1 -0
- package/dist/extract/bot-check.js +10 -0
- package/dist/extract/readability.d.ts +4 -0
- package/dist/extract/readability.js +67 -3
- package/dist/extract/section-selector.d.ts +14 -0
- package/dist/extract/section-selector.js +233 -0
- package/dist/fetch/firecrawl-fetch.d.ts +1 -1
- package/dist/fetch/firecrawl-fetch.js +17 -8
- package/dist/fetch/headless-fetch.d.ts +2 -1
- package/dist/fetch/headless-fetch.js +11 -6
- package/dist/fetch/http-fetch.d.ts +1 -1
- package/dist/fetch/http-fetch.js +13 -7
- package/dist/orchestration/index.d.ts +3 -7
- package/dist/orchestration/research-orchestrator.d.ts +3 -7
- package/dist/orchestration/research-orchestrator.js +20 -19
- package/dist/orchestration/research-worker.d.ts +2 -4
- package/dist/orchestration/research-worker.js +13 -12
- package/dist/readers/resolver.d.ts +3 -7
- package/dist/search/youcom.d.ts +3 -0
- package/dist/search/youcom.js +39 -4
- package/dist/tools/web-fetch-headless.d.ts +3 -5
- package/dist/tools/web-fetch-headless.js +3 -3
- package/dist/tools/web-fetch.d.ts +3 -5
- package/dist/tools/web-fetch.js +3 -3
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,21 @@ The format is intentionally simple and release-oriented.
|
|
|
18
18
|
### Breaking
|
|
19
19
|
- None.
|
|
20
20
|
|
|
21
|
+
## [1.13.0] - 2026-09-24
|
|
22
|
+
### Added
|
|
23
|
+
- `web_explore` now uses the research question to pick relevant sections from long HTML pages instead of stopping at the first 4,000 characters. It can reach an answer buried later in a page, whether the page came through HTTP, headless browsing, or Firecrawl. Direct `web_fetch` output is unchanged. (#36)
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- You.com search and `/web-agent doctor` now use the documented `/v1/search` endpoint. Web and news results appear in the usual search list, with web results first. Result descriptions provide the snippets when present. (#60)
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Research still excludes recognized bot-check pages when their verification message sits outside the section chosen for the question. That page cannot be used as evidence just because another section looks relevant. (#36)
|
|
30
|
+
- `/web-agent doctor` no longer repeats the default DuckDuckGo and HTTP backends after listing them in the config summary. It still shows checks and warnings for backends that need them. (#64)
|
|
31
|
+
- On pages with several `<article>` sections and no `<main>`, query-based reads now search across the articles. A match in the first article no longer hides an answer in a later one. (#71)
|
|
32
|
+
|
|
33
|
+
### Breaking
|
|
34
|
+
- None.
|
|
35
|
+
|
|
21
36
|
## [1.12.0] - 2026-09-17
|
|
22
37
|
### Added
|
|
23
38
|
- A network allow list, under Settings → Backends → Network allow list. web_explore now refuses private and local addresses when the link came from the model or a page it read, so if you genuinely want it to read something on your own network (an internal docs site, a service on localhost), add that range here as a CIDR like `10.0.0.0/24`. Entries are checked when you save, and ones that would allow everything (`0.0.0.0/0`, `::/0`) are rejected, since that would quietly turn the protection off. It is also the fix if every fetch suddenly fails: some proxy apps run in fake-IP mode and make every site look like it lives in `198.18.0.0/15`, and adding that range gets you going again. (#53)
|
package/README.md
CHANGED
|
@@ -34,6 +34,8 @@ One public tool, `web_explore`, that does bounded web research for Pi: search, f
|
|
|
34
34
|
- **Six search backends.** DuckDuckGo (keyless default), SearXNG, Brave, You.com, Exa, Tavily.
|
|
35
35
|
- **Optional search fanout.** Query several backends at once, dedupe, and rank pages that more than one provider agreed on to the top. Off by default; flip it to `on` or `auto`.
|
|
36
36
|
- **Honest by default.** Weak, narrow, blocked, or cautionary evidence gets flagged instead of dressed up as confidence.
|
|
37
|
+
- **Safe with untrusted pages.** Links the model picks, or finds on a page it read, can't reach localhost, your LAN, or cloud metadata endpoints. That includes redirects and everything a headless page loads. Addresses you configure yourself aren't affected, and an allow list covers the private ranges you do want.
|
|
38
|
+
- **Fallback that knows why.** Rate-limited or misconfigured backends get skipped for a while, flaky ones get one retry, and answers say when some search backends were unavailable.
|
|
37
39
|
- **Bounded output.** `compact` / `preview` / `verbose` transcript modes.
|
|
38
40
|
- **Zero-config to start.** Runs keyless out of the box (DuckDuckGo search, local browser, the built-in readers). Opt into hosted backends, fallback, search fanout, and per-tool output modes through config when you want more control.
|
|
39
41
|
|
package/dist/backends/doctor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeYouComResults, YOUCOM_SEARCH_URL } from '../search/youcom.js';
|
|
1
2
|
function withTimeout(timeoutMs) {
|
|
2
3
|
const controller = new AbortController();
|
|
3
4
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -13,7 +14,7 @@ function braveDoctorUrl() {
|
|
|
13
14
|
return url.toString();
|
|
14
15
|
}
|
|
15
16
|
function youcomDoctorBody() {
|
|
16
|
-
return JSON.stringify({ query: 'pi-web-agent-doctor',
|
|
17
|
+
return JSON.stringify({ query: 'pi-web-agent-doctor', count: 1 });
|
|
17
18
|
}
|
|
18
19
|
function exaDoctorBody() {
|
|
19
20
|
return JSON.stringify({ query: 'pi-web-agent-doctor', numResults: 1 });
|
|
@@ -42,10 +43,7 @@ function firecrawlDoctorBody(options = {}) {
|
|
|
42
43
|
}
|
|
43
44
|
export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs = 3_000 } = {}) {
|
|
44
45
|
const lines = [];
|
|
45
|
-
if (config.search.provider === '
|
|
46
|
-
lines.push('search backend: duckduckgo');
|
|
47
|
-
}
|
|
48
|
-
else if (config.search.provider === 'brave') {
|
|
46
|
+
if (config.search.provider === 'brave') {
|
|
49
47
|
const apiKey = process.env.PI_WEB_AGENT_BRAVE_API_KEY;
|
|
50
48
|
if (!apiKey?.trim()) {
|
|
51
49
|
lines.push('search backend: brave warning (missing PI_WEB_AGENT_BRAVE_API_KEY)');
|
|
@@ -86,7 +84,7 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
|
|
|
86
84
|
else {
|
|
87
85
|
const timeout = withTimeout(timeoutMs);
|
|
88
86
|
try {
|
|
89
|
-
const response = await fetchImpl(
|
|
87
|
+
const response = await fetchImpl(YOUCOM_SEARCH_URL, {
|
|
90
88
|
method: 'POST',
|
|
91
89
|
headers: {
|
|
92
90
|
Accept: 'application/json',
|
|
@@ -100,8 +98,8 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
|
|
|
100
98
|
lines.push(`search backend: youcom warning (HTTP ${response.status})`);
|
|
101
99
|
}
|
|
102
100
|
else {
|
|
103
|
-
const
|
|
104
|
-
lines.push(
|
|
101
|
+
const normalized = normalizeYouComResults(await response.json());
|
|
102
|
+
lines.push(normalized && (normalized.rawCount === 0 || normalized.results.length > 0)
|
|
105
103
|
? 'search backend: youcom ok'
|
|
106
104
|
: 'search backend: youcom warning (unexpected response)');
|
|
107
105
|
}
|
|
@@ -186,23 +184,26 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
|
|
|
186
184
|
}
|
|
187
185
|
}
|
|
188
186
|
}
|
|
189
|
-
else if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const timeout = withTimeout(timeoutMs);
|
|
194
|
-
try {
|
|
195
|
-
const response = await fetchImpl(searxngDoctorUrl(config.search.baseUrl, config.search.options), { signal: timeout.signal });
|
|
196
|
-
const json = (await response.json());
|
|
197
|
-
lines.push(response.ok && Array.isArray(json.results)
|
|
198
|
-
? 'search backend: searxng ok'
|
|
199
|
-
: 'search backend: searxng warning (unexpected response)');
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
lines.push(`search backend: searxng warning (${message(error)})`);
|
|
187
|
+
else if (config.search.provider === 'searxng') {
|
|
188
|
+
const baseUrl = config.search.baseUrl;
|
|
189
|
+
if (!baseUrl) {
|
|
190
|
+
lines.push('search backend: searxng warning (missing baseUrl)');
|
|
203
191
|
}
|
|
204
|
-
|
|
205
|
-
timeout
|
|
192
|
+
else {
|
|
193
|
+
const timeout = withTimeout(timeoutMs);
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetchImpl(searxngDoctorUrl(baseUrl, config.search.options), { signal: timeout.signal });
|
|
196
|
+
const json = (await response.json());
|
|
197
|
+
lines.push(response.ok && Array.isArray(json.results)
|
|
198
|
+
? 'search backend: searxng ok'
|
|
199
|
+
: 'search backend: searxng warning (unexpected response)');
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
lines.push(`search backend: searxng warning (${message(error)})`);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
timeout.done();
|
|
206
|
+
}
|
|
206
207
|
}
|
|
207
208
|
}
|
|
208
209
|
if (config.search.fallback) {
|
|
@@ -241,13 +242,10 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
|
|
|
241
242
|
}
|
|
242
243
|
}
|
|
243
244
|
}
|
|
244
|
-
if (config.fetch.provider
|
|
245
|
-
lines.push('fetch backend: http');
|
|
246
|
-
}
|
|
247
|
-
else if (!config.fetch.baseUrl) {
|
|
245
|
+
if (config.fetch.provider !== 'http' && !config.fetch.baseUrl) {
|
|
248
246
|
lines.push('fetch backend: firecrawl warning (missing baseUrl)');
|
|
249
247
|
}
|
|
250
|
-
else {
|
|
248
|
+
else if (config.fetch.provider !== 'http') {
|
|
251
249
|
const timeout = withTimeout(timeoutMs);
|
|
252
250
|
try {
|
|
253
251
|
const headers = { 'content-type': 'application/json' };
|
|
@@ -11,18 +11,14 @@ import { type ProviderHealth } from './provider-health.js';
|
|
|
11
11
|
import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
|
|
12
12
|
import { createWebFetchTool } from '../tools/web-fetch.js';
|
|
13
13
|
import { createWebSearchTool } from '../tools/web-search.js';
|
|
14
|
-
import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
14
|
+
import type { ResearchFetchInput, WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
15
15
|
import { type BackendConfig, type ProxyConfig } from './config.js';
|
|
16
16
|
export type BackendSet = {
|
|
17
17
|
search: (input: {
|
|
18
18
|
query: string;
|
|
19
19
|
}) => Promise<WebSearchResponse>;
|
|
20
|
-
fetchPage: (input:
|
|
21
|
-
|
|
22
|
-
}) => Promise<WebFetchResponse>;
|
|
23
|
-
headlessFetch: (input: {
|
|
24
|
-
url: string;
|
|
25
|
-
}) => Promise<WebFetchHeadlessResponse>;
|
|
20
|
+
fetchPage: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
|
|
21
|
+
headlessFetch: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
|
|
26
22
|
/** Releases the guard proxy and its agents. Idempotent; never starts the proxy. */
|
|
27
23
|
close: () => Promise<void>;
|
|
28
24
|
};
|
package/dist/backends/factory.js
CHANGED
|
@@ -241,18 +241,18 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
241
241
|
// chainSearch only falls back on non-terminal failures, so a terminal result or bad_request never reaches keyless Tavily.
|
|
242
242
|
search = chainSearch([search, guarded('tavily', createTavilySearch({ keyless: true, fetchImpl }), 'tavily-keyless')], policyDeps);
|
|
243
243
|
}
|
|
244
|
-
const
|
|
244
|
+
const httpFetcher = createHttpFetcher({ fetchImpl: targetFetch });
|
|
245
|
+
const httpFetch = createHttpFetch({ fetchPage: ({ url, query }) => httpFetcher(url, query) });
|
|
246
|
+
const firecrawlFetcher = config.fetch.baseUrl
|
|
247
|
+
? createFirecrawlFetch({
|
|
248
|
+
baseUrl: config.fetch.baseUrl,
|
|
249
|
+
apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
|
|
250
|
+
options: config.fetch.options,
|
|
251
|
+
fetchImpl
|
|
252
|
+
})
|
|
253
|
+
: invalidFirecrawlFetch();
|
|
245
254
|
const fetchPage = config.fetch.provider === 'firecrawl'
|
|
246
|
-
? withFetchPolicy(config.fetch.
|
|
247
|
-
? createHttpFetch({
|
|
248
|
-
fetchPage: createFirecrawlFetch({
|
|
249
|
-
baseUrl: config.fetch.baseUrl,
|
|
250
|
-
apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
|
|
251
|
-
options: config.fetch.options,
|
|
252
|
-
fetchImpl
|
|
253
|
-
})
|
|
254
|
-
})
|
|
255
|
-
: createHttpFetch({ fetchPage: invalidFirecrawlFetch() }), config.fetch.fallback === 'http' ? httpFetch : undefined, policyDeps)
|
|
255
|
+
? withFetchPolicy(createHttpFetch({ fetchPage: ({ url, query }) => firecrawlFetcher(url, query) }), config.fetch.fallback === 'http' ? httpFetch : undefined, policyDeps)
|
|
256
256
|
: httpFetch;
|
|
257
257
|
const fetchPageWithReaders = createSpecialContentResolver({
|
|
258
258
|
readers: [
|
|
@@ -262,7 +262,7 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
262
262
|
],
|
|
263
263
|
fallback: fetchPage
|
|
264
264
|
});
|
|
265
|
-
const headlessPage = (url) => headlessFetch(url, { guard: networkGuard, guardProxy: getGuardProxy });
|
|
265
|
+
const headlessPage = ({ url, query }) => headlessFetch(url, { query, guard: networkGuard, guardProxy: getGuardProxy });
|
|
266
266
|
return {
|
|
267
267
|
search,
|
|
268
268
|
fetchPage: withTargetGuard(fetchPageWithReaders, networkGuard, config.fetch.provider === 'firecrawl' ? 'firecrawl' : 'http'),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SearchProviderName, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
1
|
+
import type { ResearchFetchInput, SearchProviderName, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
2
2
|
import type { ProviderHealth } from './provider-health.js';
|
|
3
3
|
export declare const RETRY_BASE_MS = 500;
|
|
4
4
|
export declare const RETRY_JITTER_MS = 250;
|
|
@@ -11,9 +11,7 @@ export type PolicyDeps = {
|
|
|
11
11
|
type Search = (input: {
|
|
12
12
|
query: string;
|
|
13
13
|
}) => Promise<WebSearchResponse>;
|
|
14
|
-
type FetchPage = (input:
|
|
15
|
-
url: string;
|
|
16
|
-
}) => Promise<WebFetchResponse>;
|
|
14
|
+
type FetchPage = (input: ResearchFetchInput) => Promise<WebFetchResponse>;
|
|
17
15
|
/**
|
|
18
16
|
* One provider under the #55 policy: skip when cooling down or disabled,
|
|
19
17
|
* retry exactly once on transient, record state. Never falls back itself.
|
|
@@ -88,7 +88,6 @@ export function classifyHttpFailure(provider, parts, now = Date.now()) {
|
|
|
88
88
|
}
|
|
89
89
|
else if (provider === 'youcom') {
|
|
90
90
|
// Source: https://you.com/docs/api-reference/search/v1-search (429 UNVERIFIED -> default)
|
|
91
|
-
// Documented for /v1/search; the client currently calls /v1/agents/search (#60).
|
|
92
91
|
if (status === 402)
|
|
93
92
|
kind = 'quota_exhausted';
|
|
94
93
|
else if (status === 403)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function hasBotCheckContent(source: string, format?: 'html' | 'markdown' | 'text'): boolean;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const BOT_CHECK_RE = /performing security verification|security service|verify you are not a bot|just a moment|checking your browser/i;
|
|
2
|
+
export function hasBotCheckContent(source, format = 'text') {
|
|
3
|
+
const visibleSource = format === 'html'
|
|
4
|
+
? source
|
|
5
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
6
|
+
.replace(/<(script|style|noscript|svg|template)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
|
7
|
+
.replace(/<[^>]+>/g, ' ')
|
|
8
|
+
: source;
|
|
9
|
+
return BOT_CHECK_RE.test(visibleSource);
|
|
10
|
+
}
|
|
@@ -6,3 +6,7 @@ export type SafeReadableExtraction = {
|
|
|
6
6
|
};
|
|
7
7
|
export declare function extractReadableContent(html: string, maxLength?: number): ExtractedContent;
|
|
8
8
|
export declare function extractReadableContentSafely(html: string, maxLength?: number): SafeReadableExtraction;
|
|
9
|
+
/** Research-only extraction: choose relevant content before the usual text cap. */
|
|
10
|
+
export declare function extractReadableContentForQuery(html: string, query: string, maxLength?: number): SafeReadableExtraction & {
|
|
11
|
+
omitted: boolean;
|
|
12
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Readability } from '@mozilla/readability';
|
|
2
2
|
import { JSDOM, VirtualConsole } from 'jsdom';
|
|
3
|
+
import { selectRelevantContent } from './section-selector.js';
|
|
3
4
|
export function extractReadableContent(html, maxLength = 4000) {
|
|
4
5
|
let stylesheetError;
|
|
5
6
|
const virtualConsole = new VirtualConsole();
|
|
@@ -57,14 +58,25 @@ function extractPreferredSection(html) {
|
|
|
57
58
|
const mainMatch = html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i);
|
|
58
59
|
if (mainMatch)
|
|
59
60
|
return mainMatch[1];
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
|
|
61
|
+
const articles = html.match(/<article\b[^>]*>/gi) ?? [];
|
|
62
|
+
if (articles.length === 1) {
|
|
63
|
+
const articleMatch = html.match(/<article\b[^>]*>([\s\S]*?)<\/article>/i);
|
|
64
|
+
if (articleMatch)
|
|
65
|
+
return articleMatch[1];
|
|
66
|
+
}
|
|
63
67
|
const bodyMatch = html.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
|
|
64
68
|
if (bodyMatch)
|
|
65
69
|
return bodyMatch[1];
|
|
66
70
|
return html;
|
|
67
71
|
}
|
|
72
|
+
function extractPreferredDomSection(document) {
|
|
73
|
+
const main = document.querySelector('main');
|
|
74
|
+
const articles = document.querySelectorAll('article');
|
|
75
|
+
const region = main ?? (articles.length === 1 ? articles[0] : document.body);
|
|
76
|
+
const cleanedRegion = region.cloneNode(true);
|
|
77
|
+
cleanedRegion.querySelectorAll('script, style, noscript, svg, template').forEach((element) => element.remove());
|
|
78
|
+
return cleanedRegion.outerHTML;
|
|
79
|
+
}
|
|
68
80
|
function extractFallbackText(html, maxLength) {
|
|
69
81
|
const title = extractTitle(html);
|
|
70
82
|
let section = extractPreferredSection(html);
|
|
@@ -97,3 +109,55 @@ export function extractReadableContentSafely(html, maxLength = 4000) {
|
|
|
97
109
|
};
|
|
98
110
|
}
|
|
99
111
|
}
|
|
112
|
+
/** Research-only extraction: choose relevant content before the usual text cap. */
|
|
113
|
+
export function extractReadableContentForQuery(html, query, maxLength = 4000) {
|
|
114
|
+
let stylesheetError;
|
|
115
|
+
const virtualConsole = new VirtualConsole();
|
|
116
|
+
virtualConsole.on('jsdomError', (error) => {
|
|
117
|
+
if (!stylesheetError && error.message.includes('Could not parse CSS stylesheet')) {
|
|
118
|
+
stylesheetError = error;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
try {
|
|
122
|
+
const dom = new JSDOM(html, { url: 'https://example.com', virtualConsole });
|
|
123
|
+
if (stylesheetError)
|
|
124
|
+
throw stylesheetError;
|
|
125
|
+
const preferredRegion = extractPreferredDomSection(dom.window.document);
|
|
126
|
+
const article = new Readability(dom.window.document).parse();
|
|
127
|
+
const selected = selectRelevantContent({
|
|
128
|
+
source: article?.content ?? dom.window.document.body.innerHTML,
|
|
129
|
+
format: 'html', query, maxLength
|
|
130
|
+
});
|
|
131
|
+
const preferredSelection = selectRelevantContent({
|
|
132
|
+
source: preferredRegion,
|
|
133
|
+
format: 'html', query, maxLength
|
|
134
|
+
});
|
|
135
|
+
const querySelection = preferredSelection.matched ? preferredSelection : selected;
|
|
136
|
+
return {
|
|
137
|
+
mode: 'readability',
|
|
138
|
+
omitted: querySelection.omitted,
|
|
139
|
+
content: {
|
|
140
|
+
title: article?.title ?? (dom.window.document.title || undefined),
|
|
141
|
+
byline: article?.byline || undefined,
|
|
142
|
+
text: querySelection.text,
|
|
143
|
+
...(querySelection.anchor ? { sectionAnchor: querySelection.anchor } : {})
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
let region = extractPreferredSection(html);
|
|
149
|
+
for (const tag of ['script', 'style', 'noscript', 'svg', 'template']) {
|
|
150
|
+
region = stripTagContent(region, tag);
|
|
151
|
+
}
|
|
152
|
+
const selected = selectRelevantContent({ source: region, format: 'html', query, maxLength });
|
|
153
|
+
return {
|
|
154
|
+
mode: 'fallback',
|
|
155
|
+
omitted: selected.omitted,
|
|
156
|
+
content: {
|
|
157
|
+
title: extractTitle(html),
|
|
158
|
+
text: selected.text,
|
|
159
|
+
...(selected.anchor ? { sectionAnchor: selected.anchor } : {})
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type SelectionFormat = 'html' | 'markdown' | 'text';
|
|
2
|
+
export type SectionSelection = {
|
|
3
|
+
text: string;
|
|
4
|
+
anchor?: string;
|
|
5
|
+
omitted: boolean;
|
|
6
|
+
matched: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function selectRelevantContent({ source, format, query, maxLength }: {
|
|
9
|
+
source: string;
|
|
10
|
+
format: SelectionFormat;
|
|
11
|
+
query: string;
|
|
12
|
+
maxLength?: number;
|
|
13
|
+
}): SectionSelection;
|
|
14
|
+
export declare function selectRelevantExcerpt(text: string, query: string, maxLength: number): string;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { JSDOM } from 'jsdom';
|
|
2
|
+
const STOPWORDS = new Set([
|
|
3
|
+
'about', 'after', 'from', 'have', 'into', 'that', 'their', 'there', 'these',
|
|
4
|
+
'this', 'what', 'when', 'where', 'which', 'with', 'would', 'your', 'https', 'http'
|
|
5
|
+
]);
|
|
6
|
+
function clean(text) {
|
|
7
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
8
|
+
}
|
|
9
|
+
function queryTerms(query) {
|
|
10
|
+
const withoutUrls = query.replace(/https?:\/\/\S+/gi, ' ');
|
|
11
|
+
return [...new Set((withoutUrls.toLowerCase().match(/[\p{L}\p{N}]{3,}/gu) ?? [])
|
|
12
|
+
.filter((term) => !STOPWORDS.has(term)))];
|
|
13
|
+
}
|
|
14
|
+
function scoreText(text, terms, allowPartialMatch = false) {
|
|
15
|
+
const normalized = text.toLowerCase();
|
|
16
|
+
const words = new Set(normalized.match(/[\p{L}\p{N}]{3,}/gu) ?? []);
|
|
17
|
+
return terms.reduce((score, term) => score + Number(words.has(term) ||
|
|
18
|
+
(/\p{Script=Han}/u.test(term) && normalized.includes(term)) ||
|
|
19
|
+
(allowPartialMatch && normalized.includes(term))), 0);
|
|
20
|
+
}
|
|
21
|
+
function splitLong(text, maxLength) {
|
|
22
|
+
const normalized = clean(text);
|
|
23
|
+
if (normalized.length <= maxLength)
|
|
24
|
+
return normalized ? [normalized] : [];
|
|
25
|
+
const words = normalized.split(' ');
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let chunk = '';
|
|
28
|
+
for (let word of words) {
|
|
29
|
+
if (word.length > maxLength) {
|
|
30
|
+
if (chunk)
|
|
31
|
+
chunks.push(chunk);
|
|
32
|
+
while (word.length > maxLength) {
|
|
33
|
+
chunks.push(word.slice(0, maxLength));
|
|
34
|
+
word = word.slice(maxLength);
|
|
35
|
+
}
|
|
36
|
+
chunk = word;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (chunk && chunk.length + word.length + 1 > maxLength) {
|
|
40
|
+
chunks.push(chunk);
|
|
41
|
+
chunk = word;
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
chunk = chunk ? `${chunk} ${word}` : word;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (chunk)
|
|
48
|
+
chunks.push(chunk);
|
|
49
|
+
return chunks;
|
|
50
|
+
}
|
|
51
|
+
function htmlSections(source) {
|
|
52
|
+
const document = new JSDOM(source).window.document;
|
|
53
|
+
const main = document.querySelector('main');
|
|
54
|
+
const articles = document.querySelectorAll('article');
|
|
55
|
+
const root = main ?? (articles.length === 1 ? articles[0] : document.body);
|
|
56
|
+
root.querySelectorAll('script, style, noscript, svg, template').forEach((element) => element.remove());
|
|
57
|
+
const chromeTags = new Set(['nav', 'aside', 'header', 'footer']);
|
|
58
|
+
const chromeRoles = new Set(['navigation', 'complementary', 'banner', 'contentinfo']);
|
|
59
|
+
root.querySelectorAll('nav, aside, header, footer, [role], [aria-label]').forEach((element) => {
|
|
60
|
+
const roles = (element.getAttribute('role') ?? '').toLowerCase().split(/\s+/);
|
|
61
|
+
const label = element.getAttribute('aria-label') ?? '';
|
|
62
|
+
if (chromeTags.has(element.tagName.toLowerCase()) || roles.some((role) => chromeRoles.has(role)) || /\bbreadcrumbs?\b/i.test(label)) {
|
|
63
|
+
element.remove();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const sections = [{ blocks: [] }];
|
|
67
|
+
let current = sections[0];
|
|
68
|
+
let pendingAnchor;
|
|
69
|
+
const contentSelector = 'h1, h2, h3, h4, h5, h6, p, li, pre, blockquote, dt, dd, div, a[name]';
|
|
70
|
+
for (const element of root.querySelectorAll(contentSelector)) {
|
|
71
|
+
const tag = element.tagName.toLowerCase();
|
|
72
|
+
if (tag === 'a') {
|
|
73
|
+
pendingAnchor = element.getAttribute('name') ?? element.id ?? pendingAnchor;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (/^h[1-6]$/.test(tag)) {
|
|
77
|
+
const heading = clean(element.textContent ?? '');
|
|
78
|
+
if (!heading)
|
|
79
|
+
continue;
|
|
80
|
+
const anchor = element.id || element.querySelector('[id], a[name]')?.getAttribute('id') ||
|
|
81
|
+
element.querySelector('a[name]')?.getAttribute('name') || pendingAnchor || undefined;
|
|
82
|
+
current = { heading, anchor, blocks: [] };
|
|
83
|
+
sections.push(current);
|
|
84
|
+
pendingAnchor = undefined;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (tag === 'div' && element.querySelector(contentSelector))
|
|
88
|
+
continue;
|
|
89
|
+
if (element.closest('li, blockquote') !== element && element.parentElement?.closest('li, blockquote'))
|
|
90
|
+
continue;
|
|
91
|
+
const text = clean(element.textContent ?? '');
|
|
92
|
+
if (text) {
|
|
93
|
+
if (!current.anchor && pendingAnchor)
|
|
94
|
+
current.anchor = pendingAnchor;
|
|
95
|
+
current.blocks.push(text);
|
|
96
|
+
pendingAnchor = undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (sections.every((section) => section.blocks.length === 0)) {
|
|
100
|
+
const text = clean(root.textContent ?? '');
|
|
101
|
+
if (text)
|
|
102
|
+
return [{ blocks: [text] }];
|
|
103
|
+
}
|
|
104
|
+
return sections.filter((section) => section.heading || section.blocks.length);
|
|
105
|
+
}
|
|
106
|
+
function markdownSections(source) {
|
|
107
|
+
const sections = [{ blocks: [] }];
|
|
108
|
+
let current = sections[0];
|
|
109
|
+
let paragraph = [];
|
|
110
|
+
const flush = () => {
|
|
111
|
+
if (paragraph.length)
|
|
112
|
+
current.blocks.push(clean(paragraph.join(' ')));
|
|
113
|
+
paragraph = [];
|
|
114
|
+
};
|
|
115
|
+
for (const line of source.split(/\r?\n/)) {
|
|
116
|
+
const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/);
|
|
117
|
+
if (heading) {
|
|
118
|
+
flush();
|
|
119
|
+
current = { heading: clean(heading[1]), blocks: [] };
|
|
120
|
+
sections.push(current);
|
|
121
|
+
}
|
|
122
|
+
else if (!line.trim()) {
|
|
123
|
+
flush();
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
paragraph.push(line.trim());
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
flush();
|
|
130
|
+
return sections.filter((section) => section.heading || section.blocks.length);
|
|
131
|
+
}
|
|
132
|
+
function plainSections(source) {
|
|
133
|
+
return source.split(/\n\s*\n/).map((text) => ({ blocks: [clean(text)] }))
|
|
134
|
+
.filter((section) => section.blocks[0]);
|
|
135
|
+
}
|
|
136
|
+
function renderSections(sections) {
|
|
137
|
+
return sections.flatMap((section) => [section.heading, ...section.blocks].filter(Boolean)).join('\n\n');
|
|
138
|
+
}
|
|
139
|
+
function makeWindows(sections, terms, maxLength) {
|
|
140
|
+
const windows = [];
|
|
141
|
+
const windowLimit = Math.max(1, Math.min(1200, maxLength));
|
|
142
|
+
for (const section of sections) {
|
|
143
|
+
const fullHeading = section.heading ?? '';
|
|
144
|
+
if (fullHeading.length + 2 > windowLimit) {
|
|
145
|
+
const heading = fullHeading.slice(0, windowLimit);
|
|
146
|
+
windows.push({
|
|
147
|
+
text: heading,
|
|
148
|
+
anchor: section.anchor,
|
|
149
|
+
score: scoreText(heading, terms) * 3,
|
|
150
|
+
index: windows.length
|
|
151
|
+
});
|
|
152
|
+
for (const block of section.blocks) {
|
|
153
|
+
const allowPartialMatch = /^\p{L}[\p{L}\p{N}]*$/u.test(block) && block.length > windowLimit;
|
|
154
|
+
for (const chunk of splitLong(block, windowLimit)) {
|
|
155
|
+
windows.push({
|
|
156
|
+
text: chunk,
|
|
157
|
+
anchor: section.anchor,
|
|
158
|
+
score: scoreText(chunk, terms, allowPartialMatch),
|
|
159
|
+
index: windows.length
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const heading = fullHeading;
|
|
166
|
+
const headingLength = heading ? heading.length + 2 : 0;
|
|
167
|
+
const bodyLimit = Math.max(0, windowLimit - headingLength);
|
|
168
|
+
const chunks = bodyLimit > 0 ? section.blocks.flatMap((block) => {
|
|
169
|
+
const allowPartialMatch = /^\p{L}[\p{L}\p{N}]*$/u.test(block) && block.length > bodyLimit;
|
|
170
|
+
return splitLong(block, bodyLimit).map((text) => ({ text, allowPartialMatch }));
|
|
171
|
+
}) : [];
|
|
172
|
+
if (chunks.length === 0 && heading)
|
|
173
|
+
chunks.push({ text: '', allowPartialMatch: false });
|
|
174
|
+
let body = '';
|
|
175
|
+
let bodyAllowsPartialMatch = false;
|
|
176
|
+
const add = () => {
|
|
177
|
+
const text = clean([heading, body].filter(Boolean).join('\n\n'));
|
|
178
|
+
windows.push({ text, anchor: section.anchor, score: scoreText(heading, terms) * 3 + scoreText(body, terms, bodyAllowsPartialMatch), index: windows.length });
|
|
179
|
+
body = '';
|
|
180
|
+
bodyAllowsPartialMatch = false;
|
|
181
|
+
};
|
|
182
|
+
for (const chunk of chunks) {
|
|
183
|
+
if (body && body.length + chunk.text.length + 2 > bodyLimit)
|
|
184
|
+
add();
|
|
185
|
+
body = body ? `${body}\n\n${chunk.text}` : chunk.text;
|
|
186
|
+
bodyAllowsPartialMatch = !body.includes('\n\n') && chunk.allowPartialMatch;
|
|
187
|
+
}
|
|
188
|
+
if (body || heading)
|
|
189
|
+
add();
|
|
190
|
+
}
|
|
191
|
+
return windows;
|
|
192
|
+
}
|
|
193
|
+
export function selectRelevantContent({ source, format, query, maxLength = 4000 }) {
|
|
194
|
+
const sections = format === 'html' ? htmlSections(source) : format === 'markdown'
|
|
195
|
+
? markdownSections(source) : plainSections(source);
|
|
196
|
+
const fullText = renderSections(sections);
|
|
197
|
+
if (maxLength <= 0) {
|
|
198
|
+
return { text: '', omitted: fullText.length > 0, matched: false };
|
|
199
|
+
}
|
|
200
|
+
const terms = queryTerms(query);
|
|
201
|
+
const windows = makeWindows(sections, terms, maxLength);
|
|
202
|
+
const ranked = windows.filter((window) => window.score > 0)
|
|
203
|
+
.sort((a, b) => b.score - a.score || a.index - b.index);
|
|
204
|
+
if (ranked.length === 0) {
|
|
205
|
+
return { text: fullText.slice(0, maxLength), omitted: fullText.length > maxLength, matched: false };
|
|
206
|
+
}
|
|
207
|
+
const selected = [];
|
|
208
|
+
let remaining = maxLength;
|
|
209
|
+
for (const window of ranked) {
|
|
210
|
+
const separatorLength = selected.length ? 2 : 0;
|
|
211
|
+
if (window.text.length + separatorLength > remaining)
|
|
212
|
+
continue;
|
|
213
|
+
selected.push(window);
|
|
214
|
+
remaining -= window.text.length + separatorLength;
|
|
215
|
+
}
|
|
216
|
+
selected.sort((a, b) => a.index - b.index);
|
|
217
|
+
const text = selected.map((window) => window.text).join('\n\n');
|
|
218
|
+
return {
|
|
219
|
+
text,
|
|
220
|
+
anchor: ranked.find((window) => selected.includes(window))?.anchor,
|
|
221
|
+
omitted: selected.length < windows.length,
|
|
222
|
+
matched: true
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
export function selectRelevantExcerpt(text, query, maxLength) {
|
|
226
|
+
const terms = queryTerms(query);
|
|
227
|
+
const chunks = text.split(/\n\s*\n/).flatMap((paragraph) => splitLong(paragraph, maxLength));
|
|
228
|
+
if (chunks.length === 0)
|
|
229
|
+
return '';
|
|
230
|
+
const best = chunks.map((chunk, index) => ({ chunk, index, score: scoreText(chunk, terms) }))
|
|
231
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)[0];
|
|
232
|
+
return best.chunk.slice(0, maxLength);
|
|
233
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { classifyHttpFailure, readResponseParts } from '../backends/provider-failure.js';
|
|
2
|
+
import { selectRelevantContent } from '../extract/section-selector.js';
|
|
3
|
+
import { hasBotCheckContent } from '../extract/bot-check.js';
|
|
2
4
|
function buildScrapeUrl(baseUrl) {
|
|
3
5
|
return new URL('/v1/scrape', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
|
|
4
6
|
}
|
|
@@ -6,7 +8,7 @@ function errorMessage(error) {
|
|
|
6
8
|
return error instanceof Error ? error.message : String(error);
|
|
7
9
|
}
|
|
8
10
|
export function createFirecrawlFetcher({ baseUrl, apiKey, options, fetchImpl = fetch }) {
|
|
9
|
-
return async function firecrawlFetch(url) {
|
|
11
|
+
return async function firecrawlFetch(url, query) {
|
|
10
12
|
const failed = (message, failure) => ({
|
|
11
13
|
status: 'error',
|
|
12
14
|
url,
|
|
@@ -56,11 +58,9 @@ export function createFirecrawlFetcher({ baseUrl, apiKey, options, fetchImpl = f
|
|
|
56
58
|
httpStatus: response.status
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
? parsed.data.html
|
|
63
|
-
: '';
|
|
61
|
+
const markdown = typeof parsed.data?.markdown === 'string' ? parsed.data.markdown : undefined;
|
|
62
|
+
const html = typeof parsed.data?.html === 'string' ? parsed.data.html : undefined;
|
|
63
|
+
const text = markdown ?? html ?? '';
|
|
64
64
|
const resolvedUrl = typeof parsed.data?.metadata?.sourceURL === 'string'
|
|
65
65
|
? parsed.data.metadata.sourceURL
|
|
66
66
|
: url;
|
|
@@ -75,11 +75,20 @@ export function createFirecrawlFetcher({ baseUrl, apiKey, options, fetchImpl = f
|
|
|
75
75
|
error: { code: 'WEAK_EXTRACTION', message: 'Firecrawl did not return useful page text.' }
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
+
const selection = query
|
|
79
|
+
? selectRelevantContent({ source: text, format: markdown !== undefined ? 'markdown' : 'html', query })
|
|
80
|
+
: undefined;
|
|
81
|
+
const selectedText = selection?.text ?? text;
|
|
78
82
|
return {
|
|
79
83
|
status: 'ok',
|
|
80
84
|
url: resolvedUrl,
|
|
81
|
-
content: {
|
|
82
|
-
|
|
85
|
+
content: {
|
|
86
|
+
title,
|
|
87
|
+
text: selectedText,
|
|
88
|
+
...(hasBotCheckContent(text, markdown !== undefined ? 'markdown' : 'html') ? { botCheck: true } : {}),
|
|
89
|
+
...(selection?.anchor ? { sectionAnchor: selection.anchor } : {})
|
|
90
|
+
},
|
|
91
|
+
metadata: { method: 'firecrawl', cacheHit: false, truncated: selection?.omitted ?? text.length >= 4000 }
|
|
83
92
|
};
|
|
84
93
|
};
|
|
85
94
|
}
|
|
@@ -8,8 +8,9 @@ export type BrowserProxyOptions = {
|
|
|
8
8
|
password?: string;
|
|
9
9
|
bypass?: string;
|
|
10
10
|
};
|
|
11
|
-
export declare function headlessFetch(url: string, { configuredPath, proxy, guard, guardProxy, resolveBrowser, launchBrowser, now }?: {
|
|
11
|
+
export declare function headlessFetch(url: string, { configuredPath, query, proxy, guard, guardProxy, resolveBrowser, launchBrowser, now }?: {
|
|
12
12
|
configuredPath?: string;
|
|
13
|
+
query?: string;
|
|
13
14
|
/** Only used without a guard. With a guard, Chromium always goes through the guard proxy, which chains upstream itself. */
|
|
14
15
|
proxy?: BrowserProxyOptions;
|
|
15
16
|
guard?: NetworkGuard;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
|
-
import { extractReadableContentSafely } from '../extract/readability.js';
|
|
2
|
+
import { extractReadableContentForQuery, extractReadableContentSafely } from '../extract/readability.js';
|
|
3
|
+
import { hasBotCheckContent } from '../extract/bot-check.js';
|
|
3
4
|
import { resolveBrowserExecutable } from './browser-resolution.js';
|
|
4
5
|
import { BLOCKED_HEADER } from './guard-proxy.js';
|
|
5
6
|
import { BLOCKED_PRIVATE_ADDRESS, BlockedAddressError, UPSTREAM_PROXY_REFUSED } from './network-guard.js';
|
|
@@ -30,7 +31,7 @@ function hostnameOf(url) {
|
|
|
30
31
|
function normalizeHost(host) {
|
|
31
32
|
return host.replace(/^\[|\]$/g, '').toLowerCase().replace(/\.+$/, '');
|
|
32
33
|
}
|
|
33
|
-
export async function headlessFetch(url, { configuredPath, proxy, guard, guardProxy, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless, proxy }) => chromium.launch(executablePath ? { executablePath, headless, ...(proxy ? { proxy } : {}) } : { headless, ...(proxy ? { proxy } : {}) }), now = () => Date.now() } = {}) {
|
|
34
|
+
export async function headlessFetch(url, { configuredPath, query, proxy, guard, guardProxy, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless, proxy }) => chromium.launch(executablePath ? { executablePath, headless, ...(proxy ? { proxy } : {}) } : { headless, ...(proxy ? { proxy } : {}) }), now = () => Date.now() } = {}) {
|
|
34
35
|
if (guard) {
|
|
35
36
|
const hostname = hostnameOf(url);
|
|
36
37
|
if (!guardProxy) {
|
|
@@ -199,12 +200,16 @@ export async function headlessFetch(url, { configuredPath, proxy, guard, guardPr
|
|
|
199
200
|
const html = await page.content();
|
|
200
201
|
const finishedAt = now();
|
|
201
202
|
const blockedSubresources = subresourceRefusals();
|
|
202
|
-
const
|
|
203
|
+
const baselineExtraction = extractReadableContentSafely(html);
|
|
204
|
+
const queryExtraction = query ? extractReadableContentForQuery(html, query) : undefined;
|
|
205
|
+
const extraction = queryExtraction ?? baselineExtraction;
|
|
206
|
+
const cleanedBaselineText = cleanupRenderedText(baselineExtraction.content.text);
|
|
203
207
|
const cleanedContent = {
|
|
204
208
|
...extraction.content,
|
|
205
|
-
text: cleanupRenderedText(extraction.content.text)
|
|
209
|
+
text: cleanupRenderedText(extraction.content.text),
|
|
210
|
+
...(hasBotCheckContent(html, 'html') ? { botCheck: true } : {})
|
|
206
211
|
};
|
|
207
|
-
if (!
|
|
212
|
+
if (!cleanedBaselineText || cleanedBaselineText.length < 40) {
|
|
208
213
|
return {
|
|
209
214
|
status: 'blocked',
|
|
210
215
|
url,
|
|
@@ -230,7 +235,7 @@ export async function headlessFetch(url, { configuredPath, proxy, guard, guardPr
|
|
|
230
235
|
cacheHit: false,
|
|
231
236
|
browser: browserName,
|
|
232
237
|
navigationMs: finishedAt - startedAt,
|
|
233
|
-
truncated: cleanedContent.text.length >= 4000,
|
|
238
|
+
truncated: queryExtraction?.omitted ?? cleanedContent.text.length >= 4000,
|
|
234
239
|
...(blockedSubresources > 0 ? { blockedSubresources } : {})
|
|
235
240
|
}
|
|
236
241
|
};
|
package/dist/fetch/http-fetch.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { extractReadableContentSafely } from '../extract/readability.js';
|
|
1
|
+
import { extractReadableContentForQuery, extractReadableContentSafely } from '../extract/readability.js';
|
|
2
|
+
import { hasBotCheckContent } from '../extract/bot-check.js';
|
|
2
3
|
import { findGuardError } from './network-guard.js';
|
|
3
4
|
function looksLikeScriptShell(html) {
|
|
4
5
|
const lower = html.toLowerCase();
|
|
@@ -15,7 +16,7 @@ function isWeakHttpContent(options) {
|
|
|
15
16
|
return veryShortBody && (lowDensity || hasGenericShellMarker);
|
|
16
17
|
}
|
|
17
18
|
export function createHttpFetcher({ fetchImpl = fetch } = {}) {
|
|
18
|
-
return async function httpFetch(url) {
|
|
19
|
+
return async function httpFetch(url, query) {
|
|
19
20
|
let response;
|
|
20
21
|
try {
|
|
21
22
|
response = await fetchImpl(url);
|
|
@@ -40,11 +41,16 @@ export function createHttpFetcher({ fetchImpl = fetch } = {}) {
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
const html = await response.text();
|
|
43
|
-
const
|
|
44
|
-
const
|
|
44
|
+
const baselineExtraction = extractReadableContentSafely(html);
|
|
45
|
+
const queryExtraction = query ? extractReadableContentForQuery(html, query) : undefined;
|
|
46
|
+
const extraction = queryExtraction ?? baselineExtraction;
|
|
47
|
+
const content = {
|
|
48
|
+
...extraction.content,
|
|
49
|
+
...(hasBotCheckContent(html, 'html') ? { botCheck: true } : {})
|
|
50
|
+
};
|
|
45
51
|
if (looksLikeScriptShell(html) ||
|
|
46
|
-
content.text.length < 40 ||
|
|
47
|
-
isWeakHttpContent({ html, title: content.title, text: content.text })) {
|
|
52
|
+
baselineExtraction.content.text.length < 40 ||
|
|
53
|
+
isWeakHttpContent({ html, title: baselineExtraction.content.title, text: baselineExtraction.content.text })) {
|
|
48
54
|
return {
|
|
49
55
|
status: 'needs_headless',
|
|
50
56
|
url: response.url,
|
|
@@ -59,7 +65,7 @@ export function createHttpFetcher({ fetchImpl = fetch } = {}) {
|
|
|
59
65
|
status: 'ok',
|
|
60
66
|
url: response.url,
|
|
61
67
|
content,
|
|
62
|
-
metadata: { method: 'http', cacheHit: false, contentType, truncated: content.text.length >= 4000 }
|
|
68
|
+
metadata: { method: 'http', cacheHit: false, contentType, truncated: queryExtraction?.omitted ?? content.text.length >= 4000 }
|
|
63
69
|
};
|
|
64
70
|
};
|
|
65
71
|
}
|
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import type { BackendConfig } from '../backends/config.js';
|
|
2
|
-
import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
2
|
+
import type { ResearchFetchInput, WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
3
3
|
export declare function createResearchWorkflow({ backendConfig, search, fetchPage, headlessFetch }?: {
|
|
4
4
|
backendConfig?: BackendConfig;
|
|
5
5
|
search?: (input: {
|
|
6
6
|
query: string;
|
|
7
7
|
}) => Promise<WebSearchResponse>;
|
|
8
|
-
fetchPage?: (input:
|
|
9
|
-
|
|
10
|
-
}) => Promise<WebFetchResponse>;
|
|
11
|
-
headlessFetch?: (input: {
|
|
12
|
-
url: string;
|
|
13
|
-
}) => Promise<WebFetchHeadlessResponse>;
|
|
8
|
+
fetchPage?: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
|
|
9
|
+
headlessFetch?: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
|
|
14
10
|
}): {
|
|
15
11
|
run({ query }: {
|
|
16
12
|
query: string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Attempt, SearchProviderName, WebFetchHeadlessResponse, WebFetchResponse } from '../types.js';
|
|
1
|
+
import type { Attempt, ResearchFetchInput, SearchProviderName, WebFetchHeadlessResponse, WebFetchResponse } from '../types.js';
|
|
2
2
|
import type { ResearchEvidence, ResearchOrchestratorDecision, ResearchWorkerResult } from './research-types.js';
|
|
3
3
|
import { type EvidenceCaveatReason } from './evidence-quality.js';
|
|
4
4
|
export declare function createResearchOrchestrator({ worker, fetchDirect, headlessFetch }: {
|
|
@@ -9,12 +9,8 @@ export declare function createResearchOrchestrator({ worker, fetchDirect, headle
|
|
|
9
9
|
maxFetches: number;
|
|
10
10
|
}) => Promise<ResearchWorkerResult>;
|
|
11
11
|
};
|
|
12
|
-
fetchDirect?: (input:
|
|
13
|
-
|
|
14
|
-
}) => Promise<WebFetchResponse>;
|
|
15
|
-
headlessFetch: (input: {
|
|
16
|
-
url: string;
|
|
17
|
-
}) => Promise<WebFetchHeadlessResponse>;
|
|
12
|
+
fetchDirect?: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
|
|
13
|
+
headlessFetch: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
|
|
18
14
|
}): {
|
|
19
15
|
run({ query }: {
|
|
20
16
|
query: string;
|
|
@@ -5,25 +5,26 @@ import { classifySourceProfile } from './source-profile.js';
|
|
|
5
5
|
import { extractDirectUrls } from './direct-url.js';
|
|
6
6
|
import { decideNextResearchStep } from './stop-decider.js';
|
|
7
7
|
import { analyzeEvidenceQuality } from './evidence-quality.js';
|
|
8
|
+
import { selectRelevantExcerpt } from '../extract/section-selector.js';
|
|
9
|
+
import { hasBotCheckContent } from '../extract/bot-check.js';
|
|
8
10
|
const DEFAULT_MAX_PASSES = 3;
|
|
9
11
|
const DEFAULT_MAX_FETCHES_PER_PASS = 4;
|
|
10
12
|
const DEFAULT_MAX_HEADLESS_ATTEMPTS = 2;
|
|
11
13
|
function classifyEvidenceUrl(url) {
|
|
12
14
|
return classifySourceProfile(url).sourceKind;
|
|
13
15
|
}
|
|
14
|
-
function summarizeText(text, maxLength = 180) {
|
|
15
|
-
return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
|
16
|
-
}
|
|
17
16
|
function isReaderMethod(method) {
|
|
18
17
|
return method === 'github' || method === 'pdf' || method === 'youtube';
|
|
19
18
|
}
|
|
20
|
-
function isBotCheckContent({ title = '', text }) {
|
|
21
|
-
|
|
19
|
+
function isBotCheckContent({ title = '', text, botCheck }) {
|
|
20
|
+
if (botCheck)
|
|
21
|
+
return true;
|
|
22
|
+
return hasBotCheckContent(`${title}\n${text}`);
|
|
22
23
|
}
|
|
23
|
-
function evidenceFromFetch(result) {
|
|
24
|
+
function evidenceFromFetch(result, query) {
|
|
24
25
|
if (result.status !== 'ok' || !result.content?.text.trim())
|
|
25
26
|
return null;
|
|
26
|
-
if (isBotCheckContent({ title: result.content.title, text: result.content.text }))
|
|
27
|
+
if (isBotCheckContent({ title: result.content.title, text: result.content.text, botCheck: result.content.botCheck }))
|
|
27
28
|
return null;
|
|
28
29
|
if (isReaderMethod(result.metadata.method)) {
|
|
29
30
|
return {
|
|
@@ -40,22 +41,22 @@ function evidenceFromFetch(result) {
|
|
|
40
41
|
url: result.url,
|
|
41
42
|
sourceKind: classifyEvidenceUrl(result.url),
|
|
42
43
|
method: result.metadata.method,
|
|
43
|
-
summary:
|
|
44
|
-
supports: [
|
|
44
|
+
summary: selectRelevantExcerpt(result.content.text, query, 180),
|
|
45
|
+
supports: [selectRelevantExcerpt(result.content.text, query, 120)]
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
|
-
function evidenceFromHeadless(result) {
|
|
48
|
+
function evidenceFromHeadless(result, query) {
|
|
48
49
|
if (result.status !== 'ok' || !result.content?.text.trim())
|
|
49
50
|
return null;
|
|
50
|
-
if (isBotCheckContent({ title: result.content.title, text: result.content.text }))
|
|
51
|
+
if (isBotCheckContent({ title: result.content.title, text: result.content.text, botCheck: result.content.botCheck }))
|
|
51
52
|
return null;
|
|
52
53
|
return {
|
|
53
54
|
title: result.content.title ?? result.url,
|
|
54
55
|
url: result.url,
|
|
55
56
|
sourceKind: classifyEvidenceUrl(result.url),
|
|
56
57
|
method: 'headless',
|
|
57
|
-
summary:
|
|
58
|
-
supports: [
|
|
58
|
+
summary: selectRelevantExcerpt(result.content.text, query, 180),
|
|
59
|
+
supports: [selectRelevantExcerpt(result.content.text, query, 120)]
|
|
59
60
|
};
|
|
60
61
|
}
|
|
61
62
|
function combinedWorkerPass({ lastPass, previousQueries, allGaps, allLowValueOutcomes, exhaustedBudget }) {
|
|
@@ -128,10 +129,10 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
128
129
|
}
|
|
129
130
|
if (fetchDirect) {
|
|
130
131
|
for (const url of extractDirectUrls(query).slice(0, 3)) {
|
|
131
|
-
const directResult = await fetchDirect({ url });
|
|
132
|
+
const directResult = await fetchDirect({ url, query });
|
|
132
133
|
if (directResult.metadata.attempts)
|
|
133
134
|
runAttempts.push(...directResult.metadata.attempts);
|
|
134
|
-
const directEvidence = evidenceFromFetch(directResult);
|
|
135
|
+
const directEvidence = evidenceFromFetch(directResult, query);
|
|
135
136
|
if (directEvidence) {
|
|
136
137
|
allEvidence.push(directEvidence);
|
|
137
138
|
continue;
|
|
@@ -146,8 +147,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
146
147
|
if (shouldRetryDirectWithHeadless(directResult, directEvidence)) {
|
|
147
148
|
if (headlessAttempts < DEFAULT_MAX_HEADLESS_ATTEMPTS) {
|
|
148
149
|
headlessAttempts++;
|
|
149
|
-
const headlessResult = await headlessFetch({ url: directResult.url });
|
|
150
|
-
const headlessEvidence = evidenceFromHeadless(headlessResult);
|
|
150
|
+
const headlessResult = await headlessFetch({ url: directResult.url, query });
|
|
151
|
+
const headlessEvidence = evidenceFromHeadless(headlessResult, query);
|
|
151
152
|
if (headlessEvidence) {
|
|
152
153
|
allEvidence.push(headlessEvidence);
|
|
153
154
|
}
|
|
@@ -255,8 +256,8 @@ export function createResearchOrchestrator({ worker, fetchDirect, headlessFetch
|
|
|
255
256
|
});
|
|
256
257
|
if (decision.action === 'headless') {
|
|
257
258
|
headlessAttempts++;
|
|
258
|
-
const headlessResult = await headlessFetch({ url: decision.url });
|
|
259
|
-
const headlessEvidence = evidenceFromHeadless(headlessResult);
|
|
259
|
+
const headlessResult = await headlessFetch({ url: decision.url, query });
|
|
260
|
+
const headlessEvidence = evidenceFromHeadless(headlessResult, query);
|
|
260
261
|
if (headlessEvidence) {
|
|
261
262
|
allEvidence.push(headlessEvidence);
|
|
262
263
|
const updatedRanked = rankEvidence(allEvidence.filter((item) => item.sourceKind !== 'package-page'));
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
import type { WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
1
|
+
import type { ResearchFetchInput, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
2
2
|
import type { ResearchWorkerResult } from './research-types.js';
|
|
3
3
|
export declare function createResearchWorker({ search, fetchPage }: {
|
|
4
4
|
search: (input: {
|
|
5
5
|
query: string;
|
|
6
6
|
}) => Promise<WebSearchResponse>;
|
|
7
|
-
fetchPage: (input:
|
|
8
|
-
url: string;
|
|
9
|
-
}) => Promise<WebFetchResponse>;
|
|
7
|
+
fetchPage: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
|
|
10
8
|
}): {
|
|
11
9
|
run({ query, maxSearchRounds, maxFetches }: {
|
|
12
10
|
query: string;
|
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
import { failureOf, isTerminalFailure } from '../backends/failure.js';
|
|
2
2
|
import { selectCandidates } from './candidate-selector.js';
|
|
3
3
|
import { classifySourceProfile } from './source-profile.js';
|
|
4
|
+
import { selectRelevantExcerpt } from '../extract/section-selector.js';
|
|
5
|
+
import { hasBotCheckContent } from '../extract/bot-check.js';
|
|
4
6
|
function classifySource(url) {
|
|
5
7
|
return classifySourceProfile(url).sourceKind;
|
|
6
8
|
}
|
|
7
|
-
function summarizeText(text, maxLength = 180) {
|
|
8
|
-
return text.replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
|
9
|
-
}
|
|
10
9
|
function isReaderMethod(method) {
|
|
11
10
|
return method === 'github' || method === 'pdf' || method === 'youtube';
|
|
12
11
|
}
|
|
13
|
-
function isBotCheckContent({ title = '', text }) {
|
|
14
|
-
|
|
12
|
+
function isBotCheckContent({ title = '', text, botCheck }) {
|
|
13
|
+
if (botCheck)
|
|
14
|
+
return true;
|
|
15
|
+
return hasBotCheckContent(`${title}\n${text}`);
|
|
15
16
|
}
|
|
16
|
-
function evidenceFromFetch(fetched, fallbackTitle) {
|
|
17
|
+
function evidenceFromFetch(fetched, fallbackTitle, query) {
|
|
17
18
|
const content = fetched.content;
|
|
18
19
|
if (fetched.status !== 'ok' || !content)
|
|
19
20
|
return null;
|
|
20
|
-
if (isBotCheckContent({ title: content.title, text: content.text }))
|
|
21
|
+
if (isBotCheckContent({ title: content.title, text: content.text, botCheck: content.botCheck }))
|
|
21
22
|
return null;
|
|
22
23
|
// A successful reader read with usable text is primary content, exempt from the
|
|
23
24
|
// package-page filter below.
|
|
@@ -40,14 +41,14 @@ function evidenceFromFetch(fetched, fallbackTitle) {
|
|
|
40
41
|
url: fetched.url,
|
|
41
42
|
sourceKind,
|
|
42
43
|
method: fetched.metadata.method,
|
|
43
|
-
summary:
|
|
44
|
-
supports: [
|
|
44
|
+
summary: selectRelevantExcerpt(content.text, query, 180),
|
|
45
|
+
supports: [selectRelevantExcerpt(content.text, query, 120)]
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
function lowValueOutcomeFromFetch(fetched) {
|
|
48
49
|
if (fetched.status !== 'ok' || !fetched.content)
|
|
49
50
|
return null;
|
|
50
|
-
if (isBotCheckContent({ title: fetched.content.title, text: fetched.content.text })) {
|
|
51
|
+
if (isBotCheckContent({ title: fetched.content.title, text: fetched.content.text, botCheck: fetched.content.botCheck })) {
|
|
51
52
|
return {
|
|
52
53
|
kind: 'bot-check',
|
|
53
54
|
url: fetched.url,
|
|
@@ -145,11 +146,11 @@ export function createResearchWorker({ search, fetchPage }) {
|
|
|
145
146
|
});
|
|
146
147
|
const fetchAttempts = [];
|
|
147
148
|
for (const candidate of candidates) {
|
|
148
|
-
const fetched = await fetchPage({ url: candidate.url });
|
|
149
|
+
const fetched = await fetchPage({ url: candidate.url, query });
|
|
149
150
|
if (fetched.metadata.attempts)
|
|
150
151
|
fetchAttempts.push(...fetched.metadata.attempts);
|
|
151
152
|
if (fetched.status === 'ok') {
|
|
152
|
-
const parsedEvidence = evidenceFromFetch(fetched, candidate.title);
|
|
153
|
+
const parsedEvidence = evidenceFromFetch(fetched, candidate.title, query);
|
|
153
154
|
if (parsedEvidence) {
|
|
154
155
|
evidence.push(parsedEvidence);
|
|
155
156
|
continue;
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import type { WebFetchResponse } from '../types.js';
|
|
1
|
+
import type { ResearchFetchInput, WebFetchResponse } from '../types.js';
|
|
2
2
|
import type { SpecialContentReader } from './types.js';
|
|
3
3
|
type ResolverDeps = {
|
|
4
4
|
readers: SpecialContentReader[];
|
|
5
|
-
fallback: (input:
|
|
6
|
-
url: string;
|
|
7
|
-
}) => Promise<WebFetchResponse>;
|
|
5
|
+
fallback: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
|
|
8
6
|
};
|
|
9
|
-
export declare function createSpecialContentResolver({ readers, fallback }: ResolverDeps): (input:
|
|
10
|
-
url: string;
|
|
11
|
-
}) => Promise<WebFetchResponse>;
|
|
7
|
+
export declare function createSpecialContentResolver({ readers, fallback }: ResolverDeps): (input: ResearchFetchInput) => Promise<WebFetchResponse>;
|
|
12
8
|
export {};
|
package/dist/search/youcom.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { type Normalized } from './json-provider.js';
|
|
2
|
+
export declare const YOUCOM_SEARCH_URL = "https://ydc-index.io/v1/search";
|
|
3
|
+
export declare function normalizeYouComResults(json: unknown): Normalized | undefined;
|
|
1
4
|
export declare function createYouComSearchTool({ apiKey, fetchImpl }: {
|
|
2
5
|
apiKey?: string;
|
|
3
6
|
fetchImpl?: typeof fetch;
|
package/dist/search/youcom.js
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
|
-
import { createJsonSearchProvider
|
|
2
|
-
const YOUCOM_SEARCH_URL = 'https://
|
|
1
|
+
import { createJsonSearchProvider } from './json-provider.js';
|
|
2
|
+
export const YOUCOM_SEARCH_URL = 'https://ydc-index.io/v1/search';
|
|
3
|
+
function normalizeResult(item, snippet) {
|
|
4
|
+
if (!item || typeof item !== 'object')
|
|
5
|
+
return [];
|
|
6
|
+
const result = item;
|
|
7
|
+
return typeof result.title === 'string' && typeof result.url === 'string'
|
|
8
|
+
? [{ title: result.title, url: result.url, snippet: typeof result.description === 'string' ? result.description : snippet }]
|
|
9
|
+
: [];
|
|
10
|
+
}
|
|
11
|
+
export function normalizeYouComResults(json) {
|
|
12
|
+
if (!json || typeof json !== 'object')
|
|
13
|
+
return undefined;
|
|
14
|
+
const response = json;
|
|
15
|
+
if (!response.results || typeof response.results !== 'object' || Array.isArray(response.results))
|
|
16
|
+
return undefined;
|
|
17
|
+
const sections = response.results;
|
|
18
|
+
if (sections.web === undefined && sections.news === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (sections.web !== undefined && !Array.isArray(sections.web))
|
|
21
|
+
return undefined;
|
|
22
|
+
if (sections.news !== undefined && !Array.isArray(sections.news))
|
|
23
|
+
return undefined;
|
|
24
|
+
const web = sections.web ?? [];
|
|
25
|
+
const news = sections.news ?? [];
|
|
26
|
+
return {
|
|
27
|
+
rawCount: web.length + news.length,
|
|
28
|
+
results: [
|
|
29
|
+
...web.flatMap((item) => {
|
|
30
|
+
const snippets = item && typeof item === 'object' ? item.snippets : undefined;
|
|
31
|
+
const snippet = Array.isArray(snippets) ? snippets.find((value) => typeof value === 'string') ?? '' : '';
|
|
32
|
+
return normalizeResult(item, snippet);
|
|
33
|
+
}),
|
|
34
|
+
...news.flatMap((item) => normalizeResult(item, ''))
|
|
35
|
+
]
|
|
36
|
+
};
|
|
37
|
+
}
|
|
3
38
|
export function createYouComSearchTool({ apiKey, fetchImpl = fetch }) {
|
|
4
39
|
return createJsonSearchProvider({
|
|
5
40
|
name: 'youcom',
|
|
@@ -12,9 +47,9 @@ export function createYouComSearchTool({ apiKey, fetchImpl = fetch }) {
|
|
|
12
47
|
init: {
|
|
13
48
|
method: 'POST',
|
|
14
49
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-API-Key': apiKey ?? '' },
|
|
15
|
-
body: JSON.stringify({ query,
|
|
50
|
+
body: JSON.stringify({ query, count: 10 })
|
|
16
51
|
}
|
|
17
52
|
}),
|
|
18
|
-
normalize:
|
|
53
|
+
normalize: normalizeYouComResults
|
|
19
54
|
});
|
|
20
55
|
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import type { WebFetchHeadlessResponse } from '../types.js';
|
|
1
|
+
import type { ResearchFetchInput, WebFetchHeadlessResponse } from '../types.js';
|
|
2
2
|
export declare function createWebFetchHeadlessTool({ fetchPage }?: {
|
|
3
|
-
fetchPage?: (
|
|
4
|
-
}): ({ url }:
|
|
5
|
-
url: string;
|
|
6
|
-
}) => Promise<WebFetchHeadlessResponse>;
|
|
3
|
+
fetchPage?: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
|
|
4
|
+
}): ({ url, query }: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { headlessFetch } from '../fetch/headless-fetch.js';
|
|
2
2
|
import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
|
|
3
|
-
export function createWebFetchHeadlessTool({ fetchPage = headlessFetch } = {}) {
|
|
4
|
-
return async function webFetchHeadless({ url }) {
|
|
3
|
+
export function createWebFetchHeadlessTool({ fetchPage = ({ url, query }) => headlessFetch(url, { query }) } = {}) {
|
|
4
|
+
return async function webFetchHeadless({ url, query }) {
|
|
5
5
|
if (!/^https?:\/\//.test(url)) {
|
|
6
6
|
const result = {
|
|
7
7
|
status: 'unsupported',
|
|
@@ -14,7 +14,7 @@ export function createWebFetchHeadlessTool({ fetchPage = headlessFetch } = {}) {
|
|
|
14
14
|
presentation: buildFetchPresentation(result)
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
-
const result = await fetchPage(url);
|
|
17
|
+
const result = await fetchPage({ url, ...(query ? { query } : {}) });
|
|
18
18
|
return {
|
|
19
19
|
...result,
|
|
20
20
|
presentation: buildFetchPresentation(result)
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import type { WebFetchResponse } from '../types.js';
|
|
1
|
+
import type { ResearchFetchInput, WebFetchResponse } from '../types.js';
|
|
2
2
|
export declare function createWebFetchTool({ fetchPage }?: {
|
|
3
|
-
fetchPage?: (
|
|
4
|
-
}): ({ url }:
|
|
5
|
-
url: string;
|
|
6
|
-
}) => Promise<WebFetchResponse>;
|
|
3
|
+
fetchPage?: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
|
|
4
|
+
}): ({ url, query }: ResearchFetchInput) => Promise<WebFetchResponse>;
|
package/dist/tools/web-fetch.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHttpFetcher } from '../fetch/http-fetch.js';
|
|
2
2
|
import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
|
|
3
|
-
export function createWebFetchTool({ fetchPage = createHttpFetcher() } = {}) {
|
|
4
|
-
return async function webFetch({ url }) {
|
|
3
|
+
export function createWebFetchTool({ fetchPage = ({ url, query }) => createHttpFetcher()(url, query) } = {}) {
|
|
4
|
+
return async function webFetch({ url, query }) {
|
|
5
5
|
if (!/^https?:\/\//.test(url)) {
|
|
6
6
|
const result = {
|
|
7
7
|
status: 'unsupported',
|
|
@@ -14,7 +14,7 @@ export function createWebFetchTool({ fetchPage = createHttpFetcher() } = {}) {
|
|
|
14
14
|
presentation: buildFetchPresentation(result)
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
-
const result = await fetchPage(url);
|
|
17
|
+
const result = await fetchPage({ url, ...(query ? { query } : {}) });
|
|
18
18
|
return {
|
|
19
19
|
...result,
|
|
20
20
|
presentation: buildFetchPresentation(result)
|
package/dist/types.d.ts
CHANGED
|
@@ -61,6 +61,10 @@ export type SearchMetadata = {
|
|
|
61
61
|
coverage?: SearchCoverage;
|
|
62
62
|
};
|
|
63
63
|
export type FetchMethod = 'http' | 'headless' | 'firecrawl' | 'github' | 'pdf' | 'youtube';
|
|
64
|
+
export type ResearchFetchInput = {
|
|
65
|
+
url: string;
|
|
66
|
+
query?: string;
|
|
67
|
+
};
|
|
64
68
|
export type FetchMetadata = {
|
|
65
69
|
method: FetchMethod;
|
|
66
70
|
cacheHit: boolean;
|
|
@@ -78,6 +82,10 @@ export type ExtractedContent = {
|
|
|
78
82
|
title?: string;
|
|
79
83
|
byline?: string;
|
|
80
84
|
text: string;
|
|
85
|
+
/** A bot or security verification marker appeared in the source before query selection. */
|
|
86
|
+
botCheck?: boolean;
|
|
87
|
+
/** Anchor of the section chosen for research; direct fetches do not set this. */
|
|
88
|
+
sectionAnchor?: string;
|
|
81
89
|
};
|
|
82
90
|
export type WebSearchResponse = {
|
|
83
91
|
status: 'ok' | 'error';
|
package/package.json
CHANGED