@ykurilov/agent-chat-web-tools 0.0.0-stage → 0.55.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 +691 -0
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/dist/chunk-66W2PQRQ.js +133 -0
- package/dist/chunk-66W2PQRQ.js.map +1 -0
- package/dist/firecrawl.js +135 -0
- package/dist/firecrawl.js.map +1 -0
- package/dist/index.js +474 -0
- package/dist/index.js.map +1 -0
- package/dist/labels.js +40 -0
- package/dist/labels.js.map +1 -0
- package/dist/types/create-web-tools.d.ts +55 -0
- package/dist/types/dns-host-resolver.d.ts +3 -0
- package/dist/types/firecrawl/firecrawl-services.d.ts +17 -0
- package/dist/types/image-cache.d.ts +13 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/instructions.d.ts +2 -0
- package/dist/types/labels.d.ts +22 -0
- package/dist/types/page-address.d.ts +14 -0
- package/dist/types/result-text.d.ts +5 -0
- package/dist/types/screenshot-download.d.ts +19 -0
- package/dist/types/service-call.d.ts +5 -0
- package/dist/types/services.d.ts +29 -0
- package/dist/types/tools/page-screenshot.d.ts +15 -0
- package/dist/types/tools/person-link-view.d.ts +41 -0
- package/dist/types/tools/read-page.d.ts +6 -0
- package/dist/types/tools/web-search.d.ts +6 -0
- package/dist/types/web-tool-error.d.ts +18 -0
- package/package.json +47 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yury Kurilov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ykurilov/agent-chat-web-tools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Веб-инструменты для агента: поиск в интернете, чтение страницы (в Markdown) и скриншот страницы.
|
|
4
|
+
Отдельное расширение: приложение, которому они не нужны, не получает ни кода, ни зависимостей.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
import { createWebTools, webToolsInstructions } from "@ykurilov/agent-chat-web-tools";
|
|
8
|
+
import { createFirecrawlServices } from "@ykurilov/agent-chat-web-tools/firecrawl";
|
|
9
|
+
|
|
10
|
+
const firecrawl = createFirecrawlServices({
|
|
11
|
+
apiKey: process.env.FIRECRAWL_API_KEY!,
|
|
12
|
+
baseUrl: process.env.FIRECRAWL_BASE_URL, // прокси; по умолчанию https://api.firecrawl.dev
|
|
13
|
+
headers: process.env.PROXY_TOKEN ? { "X-Proxy-Token": process.env.PROXY_TOKEN } : undefined,
|
|
14
|
+
});
|
|
15
|
+
const webTools = createWebTools({
|
|
16
|
+
...firecrawl,
|
|
17
|
+
// по желанию: траты Firecrawl в лимит расходов; поиск — 2 кредита за 10 результатов, страница и снимок — по 1.
|
|
18
|
+
// Пишется удачный вызов и вызов, оборванный «Стопом» или сбоем после отправки запроса.
|
|
19
|
+
spending: { storage, pricePerCallUsd: { web_search: 2 * creditUsd, read_page: creditUsd, page_screenshot: creditUsd } },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
createChatHandler({
|
|
23
|
+
…,
|
|
24
|
+
agent: {
|
|
25
|
+
instructions: `${appInstructions}\n${webToolsInstructions}`,
|
|
26
|
+
tools: (model) => ({ ...appTools, ...webTools(model) }),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
В браузерной части подписи «Поиск в интернете», «Чтение страницы», «Скриншот страницы» подключаются отдельно и
|
|
32
|
+
не тянут код сервисов:
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { withWebToolTitles } from "@ykurilov/agent-chat-web-tools/labels";
|
|
36
|
+
<ChatLabelsProvider labels={withWebToolTitles(russianLabels)}>…</ChatLabelsProvider>;
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
По-английски — `withWebToolTitles(englishLabels, englishWebToolTitles)`.
|
|
40
|
+
|
|
41
|
+
- `web_search` — список найденных страниц (заголовок-ссылка и выдержка); `read_page` — текст страницы с адресом-источником.
|
|
42
|
+
В результаты попадают только адреса `http(s)`; `javascript:`, `data:`, `file:` и прочее отбрасывается.
|
|
43
|
+
- `page_screenshot` есть в наборе, только если задана служба снимков, у модели в профиле `acceptsImages: true` и её провайдер
|
|
44
|
+
указан в `toolResultImageProviders` профиля модели («через этого провайдера модель видит картинку в результате инструмента»).
|
|
45
|
+
Картинка доходит не всегда (через Fireworks и Together — нет; через OpenRouter — у всех четырёх моделей стандартного набора,
|
|
46
|
+
слепые хозяева запрещены в профиле модели; у самого DeepSeek — у DeepSeek 4.1 Flash), поэтому отметка стоит только там,
|
|
47
|
+
где это проверено вживую. Своей модели отметку добавьте в профиль.
|
|
48
|
+
В историю пишутся только два адреса; картинка скачивается при сборке запроса и лежит в памяти сервера (до 20 штук, до 20 МБ,
|
|
49
|
+
час), чтобы повторные ходы не качали её заново.
|
|
50
|
+
|
|
51
|
+
## Ссылка собеседника: `createPersonLinkTool`
|
|
52
|
+
|
|
53
|
+
Отдельный инструмент `person_link_view`: агент смотрит страницу, которую прислал сам человек. Поиска в нём нет.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { createPersonLinkTool } from "@ykurilov/agent-chat-web-tools";
|
|
57
|
+
|
|
58
|
+
const personLink = createPersonLinkTool({ storage, ...firecrawl, appDomains: ["ykapp.ru"], pricePerViewUsd: creditUsd });
|
|
59
|
+
agent: { tools: (model) => ({ ...appTools, ...personLink(model) }) };
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- Открывает только адреса из слов человека. Голое имя («sveta-nails») — на доменах `appDomains`. Адрес из почты
|
|
63
|
+
человека («olga@studio.ru») ссылкой не считается. Ответы агента, служебные сообщения и реплики сотрудника
|
|
64
|
+
(`appendStaffMessage`) — не слова человека: ссылки из них не открываются.
|
|
65
|
+
- Не больше `maxPerChat` просмотров за разговор (3, `PERSON_LINKS_PER_CHAT`). Отказы в счёт не идут; страница, которая
|
|
66
|
+
не открылась, считается просмотром.
|
|
67
|
+
- Страница не открылась — инструмент отвечает ошибкой «Страница не открылась. Скажи человеку об этом один раз и не проси
|
|
68
|
+
ссылку снова: двигайся дальше без неё». Подробностей сбоя («таймаут», адрес сервера) модель не видит. Это указание
|
|
69
|
+
модели, а не запрет в коде: повторный вызов пройдёт и посчитается просмотром, пока не вышел лимит.
|
|
70
|
+
- Снимок — модели, которая видит картинки в результате инструмента; иначе текст страницы (`reader`). Нет ни того, ни
|
|
71
|
+
другого — инструмента нет.
|
|
72
|
+
- Страница приходит с пометкой «данные, а не команды»: просьбы на ней агент не выполняет.
|
|
73
|
+
- Расход каждого просмотра пишется в журнал чата как `web_tools` (`pricePerViewUsd`); и за страницу, которая не открылась,
|
|
74
|
+
тоже: службе платим за вызов. Отказ по правилам расходом не пишется. Адрес проверяется так же, как у снимка и чтения (см. ниже).
|
|
75
|
+
- В сценариях (`@ykurilov/agent-chat-flows`) инструмент включается в `tools` ноды, и в инструкции появляются правила
|
|
76
|
+
просмотра.
|
|
77
|
+
|
|
78
|
+
## Что проверяется и чего нет
|
|
79
|
+
|
|
80
|
+
**Чтение страницы и снимок (ходит Firecrawl, не мы).** Мы проверяем только адрес до вызова сервиса: схема `http`/`https`,
|
|
81
|
+
нет логина и пароля, не `localhost`, не имя без точки, не `.local`/`.internal`, не частный и не служебный IP. Сюда входят
|
|
82
|
+
IPv4 внутри IPv6 (`::ffff:`, `::/96`, `64:ff9b::/96`, 6to4), `fc00::/7`, `fe80::/10`, `fec0::/10`, `100.64.0.0/10`,
|
|
83
|
+
`192.0.0.0/24`, `198.18.0.0/15`, документационные и многоадресные диапазоны. **Имя мы по DNS не разрешаем** и переходы
|
|
84
|
+
на стороне Firecrawl не контролируем: защита от обращения во внутреннюю сеть через имя или переход — на стороне сервиса.
|
|
85
|
+
|
|
86
|
+
**Скачивание снимка (ходит наш сервер).** Проверок больше:
|
|
87
|
+
- адрес проходит ту же проверку, а имя ещё разрешается по DNS; если хоть один адрес частный или имя не разрешилось — отказ;
|
|
88
|
+
- переходы выполняются вручную (`redirect: "manual"`), не больше трёх, и каждый проходит ту же проверку;
|
|
89
|
+
- только `image/*`, не больше 5 МБ, таймаут 20 с; любой сбой — вместо картинки текст «снимок больше недоступен».
|
|
90
|
+
|
|
91
|
+
Остаётся короткое окно: между проверкой по DNS и запросом `fetch` разрешает имя ещё раз, и злонамеренный DNS-сервер
|
|
92
|
+
может ответить иначе (DNS rebinding). Полностью закрывается сетевым правилом на сервере: исходящие соединения во внутреннюю
|
|
93
|
+
сеть запрещены. Для сервера, у которого во внутренней сети есть что-то ценное, добавьте такое правило.
|
|
94
|
+
|
|
95
|
+
- Описания инструментов и схемы входа постоянны и не зависят от настроек и от службы: они идут в начало запроса к модели и
|
|
96
|
+
попадают в кэш провайдера. Результаты идут в конец.
|
|
97
|
+
- Служба заменяется: `WebSearchService`, `PageReaderService`, `PageScreenshotService` — небольшие интерфейсы. Поиск на Exa —
|
|
98
|
+
своя реализация `WebSearchService`, инструменты не меняются. Ключ и адрес сервиса живут только на сервере.
|
|
99
|
+
- Ошибки — `WebToolError` с понятным русским текстом без ключа и адреса; текст виден в строке вызова в ленте.
|
|
100
|
+
- Пределы: `searchResultLimit` (5 результатов), `pageCharacterLimit` (30 000 знаков; длинный текст режется на границе абзаца с пометкой).
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// src/web-tool-error.ts
|
|
2
|
+
var WebToolError = class extends Error {
|
|
3
|
+
kind;
|
|
4
|
+
constructor(kind, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "WebToolError";
|
|
7
|
+
this.kind = kind;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var serviceFailureMessages = {
|
|
11
|
+
invalid_key: "\u0421\u0435\u0440\u0432\u0438\u0441 \u0432\u0435\u0431-\u0438\u043D\u0441\u0442\u0440\u0443\u043C\u0435\u043D\u0442\u043E\u0432 \u043E\u0442\u043A\u043B\u043E\u043D\u0438\u043B \u043A\u043B\u044E\u0447 \u0434\u043E\u0441\u0442\u0443\u043F\u0430.",
|
|
12
|
+
insufficient_funds: "\u041D\u0430 \u0441\u0447\u0451\u0442\u0435 \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \u0432\u0435\u0431-\u0438\u043D\u0441\u0442\u0440\u0443\u043C\u0435\u043D\u0442\u043E\u0432 \u0437\u0430\u043A\u043E\u043D\u0447\u0438\u043B\u0438\u0441\u044C \u0441\u0440\u0435\u0434\u0441\u0442\u0432\u0430.",
|
|
13
|
+
rate_limited: "\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u043B\u0438\u043C\u0438\u0442 \u0437\u0430\u043F\u0440\u043E\u0441\u043E\u0432 \u0441\u0435\u0440\u0432\u0438\u0441\u0430 \u0432\u0435\u0431-\u0438\u043D\u0441\u0442\u0440\u0443\u043C\u0435\u043D\u0442\u043E\u0432, \u043F\u043E\u043F\u0440\u043E\u0431\u0443\u0439\u0442\u0435 \u043F\u043E\u0437\u0436\u0435.",
|
|
14
|
+
unavailable: "\u0421\u0435\u0440\u0432\u0438\u0441 \u0432\u0435\u0431-\u0438\u043D\u0441\u0442\u0440\u0443\u043C\u0435\u043D\u0442\u043E\u0432 \u0441\u0435\u0439\u0447\u0430\u0441 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D, \u043F\u043E\u043F\u0440\u043E\u0431\u0443\u0439\u0442\u0435 \u043F\u043E\u0437\u0436\u0435.",
|
|
15
|
+
timeout: "\u0421\u0435\u0440\u0432\u0438\u0441 \u0432\u0435\u0431-\u0438\u043D\u0441\u0442\u0440\u0443\u043C\u0435\u043D\u0442\u043E\u0432 \u043D\u0435 \u043E\u0442\u0432\u0435\u0442\u0438\u043B \u0432\u043E\u0432\u0440\u0435\u043C\u044F.",
|
|
16
|
+
bad_response: "\u0421\u0435\u0440\u0432\u0438\u0441 \u0432\u0435\u0431-\u0438\u043D\u0441\u0442\u0440\u0443\u043C\u0435\u043D\u0442\u043E\u0432 \u0432\u0435\u0440\u043D\u0443\u043B \u043D\u0435\u043E\u0436\u0438\u0434\u0430\u043D\u043D\u044B\u0439 \u043E\u0442\u0432\u0435\u0442."
|
|
17
|
+
};
|
|
18
|
+
function serviceFailure(kind) {
|
|
19
|
+
return new WebToolError(kind, serviceFailureMessages[kind]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// src/page-address.ts
|
|
23
|
+
var notPublicMessage = "\u0410\u0434\u0440\u0435\u0441 \u043B\u043E\u043A\u0430\u043B\u044C\u043D\u044B\u0439 \u0438\u043B\u0438 \u0447\u0430\u0441\u0442\u043D\u044B\u0439: \u0442\u0430\u043A\u0438\u0435 \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u044B \u0447\u0438\u0442\u0430\u0442\u044C \u043D\u0435\u043B\u044C\u0437\u044F.";
|
|
24
|
+
function isHttpAddress(address) {
|
|
25
|
+
try {
|
|
26
|
+
const { protocol } = new URL(address);
|
|
27
|
+
return protocol === "http:" || protocol === "https:";
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function assertPublicWebAddress(address) {
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(address);
|
|
36
|
+
} catch {
|
|
37
|
+
throw new WebToolError("bad_address", "\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u0430\u0434\u0440\u0435\u0441 \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u044B: \u043D\u0443\u0436\u0435\u043D \u043F\u043E\u043B\u043D\u044B\u0439 \u0430\u0434\u0440\u0435\u0441 \u0441 http:// \u0438\u043B\u0438 https://.");
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
40
|
+
throw new WebToolError("bad_address", "\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u0430\u0434\u0440\u0435\u0441 \u0441\u0442\u0440\u0430\u043D\u0438\u0446\u044B: \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u044E\u0442\u0441\u044F \u0442\u043E\u043B\u044C\u043A\u043E http:// \u0438 https://.");
|
|
41
|
+
}
|
|
42
|
+
if (url.username || url.password) {
|
|
43
|
+
throw new WebToolError("bad_address", "\u0410\u0434\u0440\u0435\u0441 \u0441 \u043B\u043E\u0433\u0438\u043D\u043E\u043C \u0438 \u043F\u0430\u0440\u043E\u043B\u0435\u043C \u043D\u0435 \u043F\u043E\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044F.");
|
|
44
|
+
}
|
|
45
|
+
if (isLocalHost(url.hostname)) throw new WebToolError("bad_address", notPublicMessage);
|
|
46
|
+
return url;
|
|
47
|
+
}
|
|
48
|
+
async function assertResolvesPublic(url, resolveHost) {
|
|
49
|
+
const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
|
|
50
|
+
if (hostname.startsWith("[") || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) return;
|
|
51
|
+
let addresses;
|
|
52
|
+
try {
|
|
53
|
+
addresses = await resolveHost(hostname);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new WebToolError("bad_address", "\u0410\u0434\u0440\u0435\u0441 \u043D\u0435 \u043E\u0442\u043A\u0440\u044B\u043B\u0441\u044F: \u0438\u043C\u044F \u043D\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0438\u043B\u043E\u0441\u044C.");
|
|
56
|
+
}
|
|
57
|
+
if (addresses.length === 0) throw new WebToolError("bad_address", "\u0410\u0434\u0440\u0435\u0441 \u043D\u0435 \u043E\u0442\u043A\u0440\u044B\u043B\u0441\u044F: \u0438\u043C\u044F \u043D\u0435 \u0440\u0430\u0437\u0440\u0435\u0448\u0438\u043B\u043E\u0441\u044C.");
|
|
58
|
+
if (addresses.some(isPrivateAddress)) throw new WebToolError("bad_address", notPublicMessage);
|
|
59
|
+
}
|
|
60
|
+
function isPrivateAddress(address) {
|
|
61
|
+
const bare = address.trim().replace(/^\[|\]$/g, "");
|
|
62
|
+
const ipv4 = parseIpv4(bare);
|
|
63
|
+
if (ipv4) return isPrivateIpv4(ipv4);
|
|
64
|
+
const groups = parseIpv6(bare);
|
|
65
|
+
return groups ? isPrivateIpv6(groups) : true;
|
|
66
|
+
}
|
|
67
|
+
function isLocalHost(rawHostname) {
|
|
68
|
+
const hostname = rawHostname.toLowerCase().replace(/\.$/, "");
|
|
69
|
+
if (hostname.startsWith("[")) return isPrivateAddress(hostname.slice(1, -1));
|
|
70
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) return isPrivateAddress(hostname);
|
|
71
|
+
if (!hostname.includes(".")) return true;
|
|
72
|
+
return hostname.endsWith(".localhost") || hostname.endsWith(".local") || hostname.endsWith(".internal");
|
|
73
|
+
}
|
|
74
|
+
function parseIpv4(text) {
|
|
75
|
+
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text);
|
|
76
|
+
if (!match) return void 0;
|
|
77
|
+
const octets = match.slice(1).map(Number);
|
|
78
|
+
return octets.every((octet) => octet <= 255) ? octets : void 0;
|
|
79
|
+
}
|
|
80
|
+
function parseIpv6(text) {
|
|
81
|
+
const withoutZone = text.split("%")[0] ?? "";
|
|
82
|
+
if (!/^[0-9a-f:.]+$/i.test(withoutZone) || !withoutZone.includes(":")) return void 0;
|
|
83
|
+
let value = withoutZone;
|
|
84
|
+
const tail = value.slice(value.lastIndexOf(":") + 1);
|
|
85
|
+
if (tail.includes(".")) {
|
|
86
|
+
const ipv4 = parseIpv4(tail);
|
|
87
|
+
if (!ipv4) return void 0;
|
|
88
|
+
const [a = 0, b = 0, c = 0, d = 0] = ipv4;
|
|
89
|
+
value = `${value.slice(0, value.lastIndexOf(":") + 1)}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
|
|
90
|
+
}
|
|
91
|
+
const halves = value.split("::");
|
|
92
|
+
if (halves.length > 2) return void 0;
|
|
93
|
+
const toGroups = (part) => {
|
|
94
|
+
if (part === "") return [];
|
|
95
|
+
const texts = part.split(":");
|
|
96
|
+
if (!texts.every((group) => /^[0-9a-f]{1,4}$/i.test(group))) return void 0;
|
|
97
|
+
return texts.map((group) => parseInt(group, 16));
|
|
98
|
+
};
|
|
99
|
+
const head = toGroups(halves[0] ?? "");
|
|
100
|
+
const rest = halves.length === 2 ? toGroups(halves[1] ?? "") : [];
|
|
101
|
+
if (!head || !rest) return void 0;
|
|
102
|
+
if (halves.length === 1) return head.length === 8 ? head : void 0;
|
|
103
|
+
const missing = 8 - head.length - rest.length;
|
|
104
|
+
return missing >= 1 ? [...head, ...new Array(missing).fill(0), ...rest] : void 0;
|
|
105
|
+
}
|
|
106
|
+
function isPrivateIpv4([first = 0, second = 0, third = 0]) {
|
|
107
|
+
return first === 0 || first === 10 || first === 100 && second >= 64 && second <= 127 || first === 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 0 && (third === 0 || third === 2) || first === 192 && second === 168 || first === 198 && (second === 18 || second === 19) || first === 198 && second === 51 && third === 100 || first === 203 && second === 0 && third === 113 || first >= 224;
|
|
108
|
+
}
|
|
109
|
+
function isPrivateIpv6(groups) {
|
|
110
|
+
const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0] = groups;
|
|
111
|
+
const embedded = (high, low) => isPrivateIpv4([high >> 8, high & 255, low >> 8, low & 255]);
|
|
112
|
+
const zeroUpTo = (count) => groups.slice(0, count).every((group) => group === 0);
|
|
113
|
+
if (zeroUpTo(6)) return embedded(g6, g7);
|
|
114
|
+
if (zeroUpTo(5) && g5 === 65535) return embedded(g6, g7);
|
|
115
|
+
if (g0 === 100 && g1 === 65435) {
|
|
116
|
+
if (g2 === 1) return true;
|
|
117
|
+
if (g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) return embedded(g6, g7);
|
|
118
|
+
}
|
|
119
|
+
if (g0 === 8194) return embedded(g1, g2);
|
|
120
|
+
if (g0 === 8193 && g1 === 0) return true;
|
|
121
|
+
if (g0 === 8193 && g1 === 3512) return true;
|
|
122
|
+
return (g0 & 65024) === 64512 || (g0 & 65472) === 65152 || (g0 & 65472) === 65216 || (g0 & 65280) === 65280;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export {
|
|
126
|
+
WebToolError,
|
|
127
|
+
serviceFailure,
|
|
128
|
+
isHttpAddress,
|
|
129
|
+
assertPublicWebAddress,
|
|
130
|
+
assertResolvesPublic,
|
|
131
|
+
isPrivateAddress
|
|
132
|
+
};
|
|
133
|
+
//# sourceMappingURL=chunk-66W2PQRQ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/web-tool-error.ts","../src/page-address.ts"],"sourcesContent":["export type WebToolErrorKind =\n | \"bad_address\"\n | \"bad_query\"\n | \"invalid_key\"\n | \"insufficient_funds\"\n | \"rate_limited\"\n | \"unavailable\"\n | \"timeout\"\n | \"bad_response\";\n\n/**\n * Ошибка веб-инструмента. Текст по-русски показывается и модели, и человеку в строке вызова,\n * поэтому в нём нет ключа, адреса сервиса и заголовков.\n */\nexport class WebToolError extends Error {\n readonly kind: WebToolErrorKind;\n\n constructor(kind: WebToolErrorKind, message: string) {\n super(message);\n this.name = \"WebToolError\";\n this.kind = kind;\n }\n}\n\nexport const serviceFailureMessages = {\n invalid_key: \"Сервис веб-инструментов отклонил ключ доступа.\",\n insufficient_funds: \"На счёте сервиса веб-инструментов закончились средства.\",\n rate_limited: \"Превышен лимит запросов сервиса веб-инструментов, попробуйте позже.\",\n unavailable: \"Сервис веб-инструментов сейчас недоступен, попробуйте позже.\",\n timeout: \"Сервис веб-инструментов не ответил вовремя.\",\n bad_response: \"Сервис веб-инструментов вернул неожиданный ответ.\",\n} as const;\n\nexport function serviceFailure(kind: keyof typeof serviceFailureMessages): WebToolError {\n return new WebToolError(kind, serviceFailureMessages[kind]);\n}\n","import { WebToolError } from \"./web-tool-error\";\n\nconst notPublicMessage = \"Адрес локальный или частный: такие страницы читать нельзя.\";\n\n/** Определяет адреса имени. В приложении это DNS; в тестах подменяется. */\nexport type HostResolver = (hostname: string) => Promise<string[]>;\n\n/** Адрес http или https: единственные схемы, которые пускают в результаты и в скачивание. */\nexport function isHttpAddress(address: string): boolean {\n try {\n const { protocol } = new URL(address);\n return protocol === \"http:\" || protocol === \"https:\";\n } catch {\n return false;\n }\n}\n\n/** Пропускает только публичные адреса http и https: сервис чтения не должен ходить во внутреннюю сеть. */\nexport function assertPublicWebAddress(address: string): URL {\n let url: URL;\n try {\n url = new URL(address);\n } catch {\n throw new WebToolError(\"bad_address\", \"Неверный адрес страницы: нужен полный адрес с http:// или https://.\");\n }\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n throw new WebToolError(\"bad_address\", \"Неверный адрес страницы: поддерживаются только http:// и https://.\");\n }\n if (url.username || url.password) {\n throw new WebToolError(\"bad_address\", \"Адрес с логином и паролем не поддерживается.\");\n }\n if (isLocalHost(url.hostname)) throw new WebToolError(\"bad_address\", notPublicMessage);\n return url;\n}\n\n/**\n * Проверка по DNS для тех, кто ходит по адресу сам: имя разрешается, и если хоть один адрес частный\n * (или имя не разрешилось), оно отклоняется. Адрес-число уже проверен `assertPublicWebAddress`.\n * Между проверкой и запросом остаётся короткое окно: fetch разрешает имя ещё раз.\n */\nexport async function assertResolvesPublic(url: URL, resolveHost: HostResolver): Promise<void> {\n const hostname = url.hostname.toLowerCase().replace(/\\.$/, \"\");\n if (hostname.startsWith(\"[\") || /^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(hostname)) return;\n let addresses: string[];\n try {\n addresses = await resolveHost(hostname);\n } catch {\n throw new WebToolError(\"bad_address\", \"Адрес не открылся: имя не разрешилось.\");\n }\n if (addresses.length === 0) throw new WebToolError(\"bad_address\", \"Адрес не открылся: имя не разрешилось.\");\n if (addresses.some(isPrivateAddress)) throw new WebToolError(\"bad_address\", notPublicMessage);\n}\n\n/** Адрес из ответа DNS (без скобок). Всё, что не удалось разобрать, считается частным. */\nexport function isPrivateAddress(address: string): boolean {\n const bare = address.trim().replace(/^\\[|\\]$/g, \"\");\n const ipv4 = parseIpv4(bare);\n if (ipv4) return isPrivateIpv4(ipv4);\n const groups = parseIpv6(bare);\n return groups ? isPrivateIpv6(groups) : true;\n}\n\nfunction isLocalHost(rawHostname: string): boolean {\n const hostname = rawHostname.toLowerCase().replace(/\\.$/, \"\");\n if (hostname.startsWith(\"[\")) return isPrivateAddress(hostname.slice(1, -1));\n if (/^\\d+\\.\\d+\\.\\d+\\.\\d+$/.test(hostname)) return isPrivateAddress(hostname);\n if (!hostname.includes(\".\")) return true;\n return hostname.endsWith(\".localhost\") || hostname.endsWith(\".local\") || hostname.endsWith(\".internal\");\n}\n\nfunction parseIpv4(text: string): number[] | undefined {\n const match = /^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/.exec(text);\n if (!match) return undefined;\n const octets = match.slice(1).map(Number);\n return octets.every((octet) => octet <= 255) ? octets : undefined;\n}\n\n/** Восемь групп по 16 бит или `undefined`. Понимает `::` и IPv4 в конце. */\nfunction parseIpv6(text: string): number[] | undefined {\n const withoutZone = text.split(\"%\")[0] ?? \"\";\n if (!/^[0-9a-f:.]+$/i.test(withoutZone) || !withoutZone.includes(\":\")) return undefined;\n let value = withoutZone;\n const tail = value.slice(value.lastIndexOf(\":\") + 1);\n if (tail.includes(\".\")) {\n const ipv4 = parseIpv4(tail);\n if (!ipv4) return undefined;\n const [a = 0, b = 0, c = 0, d = 0] = ipv4;\n value = `${value.slice(0, value.lastIndexOf(\":\") + 1)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;\n }\n const halves = value.split(\"::\");\n if (halves.length > 2) return undefined;\n const toGroups = (part: string): number[] | undefined => {\n if (part === \"\") return [];\n const texts = part.split(\":\");\n if (!texts.every((group) => /^[0-9a-f]{1,4}$/i.test(group))) return undefined;\n return texts.map((group) => parseInt(group, 16));\n };\n const head = toGroups(halves[0] ?? \"\");\n const rest = halves.length === 2 ? toGroups(halves[1] ?? \"\") : [];\n if (!head || !rest) return undefined;\n if (halves.length === 1) return head.length === 8 ? head : undefined;\n const missing = 8 - head.length - rest.length;\n return missing >= 1 ? [...head, ...new Array<number>(missing).fill(0), ...rest] : undefined;\n}\n\nfunction isPrivateIpv4([first = 0, second = 0, third = 0]: number[]): boolean {\n return (\n first === 0 ||\n first === 10 ||\n (first === 100 && second >= 64 && second <= 127) ||\n first === 127 ||\n (first === 169 && second === 254) ||\n (first === 172 && second >= 16 && second <= 31) ||\n (first === 192 && second === 0 && (third === 0 || third === 2)) ||\n (first === 192 && second === 168) ||\n (first === 198 && (second === 18 || second === 19)) ||\n (first === 198 && second === 51 && third === 100) ||\n (first === 203 && second === 0 && third === 113) ||\n first >= 224\n );\n}\n\nfunction isPrivateIpv6(groups: number[]): boolean {\n const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0, g6 = 0, g7 = 0] = groups;\n const embedded = (high: number, low: number) => isPrivateIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]);\n const zeroUpTo = (count: number) => groups.slice(0, count).every((group) => group === 0);\n\n if (zeroUpTo(6)) return embedded(g6, g7); // ::/96, в том числе :: и ::1\n if (zeroUpTo(5) && g5 === 0xffff) return embedded(g6, g7); // ::ffff:0:0/96\n if (g0 === 0x64 && g1 === 0xff9b) {\n if (g2 === 1) return true; // 64:ff9b:1::/48, локальное использование\n if (g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) return embedded(g6, g7); // 64:ff9b::/96\n }\n if (g0 === 0x2002) return embedded(g1, g2); // 6to4 несёт IPv4 в адресе\n if (g0 === 0x2001 && g1 === 0) return true; // Teredo\n if (g0 === 0x2001 && g1 === 0xdb8) return true; // для документации\n return (g0 & 0xfe00) === 0xfc00 || (g0 & 0xffc0) === 0xfe80 || (g0 & 0xffc0) === 0xfec0 || (g0 & 0xff00) === 0xff00;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isHttpAddress,
|
|
3
|
+
serviceFailure
|
|
4
|
+
} from "./chunk-66W2PQRQ.js";
|
|
5
|
+
|
|
6
|
+
// src/firecrawl/firecrawl-services.ts
|
|
7
|
+
import { cutAtCharBoundary } from "@ykurilov/agent-chat-core";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
var defaultBaseUrl = "https://api.firecrawl.dev";
|
|
10
|
+
var searchTimeoutMs = 3e4;
|
|
11
|
+
var scrapeTimeoutMs = 6e4;
|
|
12
|
+
var snippetLimit = 300;
|
|
13
|
+
var searchResponse = z.object({
|
|
14
|
+
data: z.object({
|
|
15
|
+
web: z.array(z.object({ url: z.string().nullish(), title: z.string().nullish(), description: z.string().nullish() })).optional()
|
|
16
|
+
})
|
|
17
|
+
});
|
|
18
|
+
var scrapeResponse = z.object({
|
|
19
|
+
data: z.object({
|
|
20
|
+
markdown: z.string().nullish(),
|
|
21
|
+
screenshot: z.string().nullish(),
|
|
22
|
+
metadata: z.object({ title: z.string().nullish(), sourceURL: z.string().nullish(), url: z.string().nullish() }).nullish()
|
|
23
|
+
})
|
|
24
|
+
});
|
|
25
|
+
function createFirecrawlServices(options) {
|
|
26
|
+
const baseUrl = (options.baseUrl ?? defaultBaseUrl).replace(/\/+$/, "");
|
|
27
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
28
|
+
async function post(path, body, schema, { signal, timeoutMs }) {
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const forwardAbort = () => controller.abort(signal?.reason);
|
|
31
|
+
if (signal?.aborted) forwardAbort();
|
|
32
|
+
signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
33
|
+
let timedOut = false;
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
timedOut = true;
|
|
36
|
+
controller.abort();
|
|
37
|
+
}, timeoutMs);
|
|
38
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
39
|
+
controller.signal.addEventListener("abort", () => reject(controller.signal.reason ?? new DOMException("Aborted", "AbortError")));
|
|
40
|
+
});
|
|
41
|
+
aborted.catch(() => {
|
|
42
|
+
});
|
|
43
|
+
let status;
|
|
44
|
+
let text;
|
|
45
|
+
try {
|
|
46
|
+
const response = await Promise.race([
|
|
47
|
+
fetchImplementation(`${baseUrl}${path}`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { ...options.headers, "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
signal: controller.signal
|
|
52
|
+
}),
|
|
53
|
+
aborted
|
|
54
|
+
]);
|
|
55
|
+
status = response.status;
|
|
56
|
+
text = await Promise.race([response.text(), aborted]);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
59
|
+
throw serviceFailure(timedOut ? "timeout" : "unavailable");
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
signal?.removeEventListener("abort", forwardAbort);
|
|
63
|
+
}
|
|
64
|
+
const failure = failureKindOf(status);
|
|
65
|
+
if (failure) throw serviceFailure(failure);
|
|
66
|
+
let json;
|
|
67
|
+
try {
|
|
68
|
+
json = JSON.parse(text);
|
|
69
|
+
} catch {
|
|
70
|
+
throw serviceFailure("bad_response");
|
|
71
|
+
}
|
|
72
|
+
if (json?.success === false) throw serviceFailure("bad_response");
|
|
73
|
+
const parsed = schema.safeParse(json);
|
|
74
|
+
if (!parsed.success) throw serviceFailure("bad_response");
|
|
75
|
+
return parsed.data;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
search: {
|
|
79
|
+
async search(query, { limit, signal }) {
|
|
80
|
+
const response = await post("/v2/search", { query, limit }, searchResponse, {
|
|
81
|
+
signal,
|
|
82
|
+
timeoutMs: options.timeoutMs ?? searchTimeoutMs
|
|
83
|
+
});
|
|
84
|
+
const hits = [];
|
|
85
|
+
for (const item of response.data.web ?? []) {
|
|
86
|
+
if (!item.url || !isHttpAddress(item.url)) continue;
|
|
87
|
+
hits.push({ title: (item.title ?? "").trim(), url: item.url, snippet: cleanSnippet(item.description ?? "") });
|
|
88
|
+
}
|
|
89
|
+
return hits;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
reader: {
|
|
93
|
+
async readPage(url, { signal }) {
|
|
94
|
+
const response = await post("/v2/scrape", { url, formats: ["markdown"], onlyMainContent: true }, scrapeResponse, {
|
|
95
|
+
signal,
|
|
96
|
+
timeoutMs: options.timeoutMs ?? scrapeTimeoutMs
|
|
97
|
+
});
|
|
98
|
+
if (typeof response.data.markdown !== "string") throw serviceFailure("bad_response");
|
|
99
|
+
const metadata = response.data.metadata;
|
|
100
|
+
const page = {
|
|
101
|
+
url: [metadata?.url, metadata?.sourceURL].find((candidate) => candidate && isHttpAddress(candidate)) || url,
|
|
102
|
+
title: (metadata?.title ?? "").trim(),
|
|
103
|
+
markdown: response.data.markdown
|
|
104
|
+
};
|
|
105
|
+
return page;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
screenshots: {
|
|
109
|
+
async takeScreenshot(url, { signal }) {
|
|
110
|
+
const response = await post("/v2/scrape", { url, formats: ["screenshot"] }, scrapeResponse, {
|
|
111
|
+
signal,
|
|
112
|
+
timeoutMs: options.timeoutMs ?? scrapeTimeoutMs
|
|
113
|
+
});
|
|
114
|
+
if (!response.data.screenshot || !isHttpAddress(response.data.screenshot)) throw serviceFailure("bad_response");
|
|
115
|
+
return { imageUrl: response.data.screenshot };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function failureKindOf(status) {
|
|
121
|
+
if (status >= 200 && status < 300) return void 0;
|
|
122
|
+
if (status === 401 || status === 403) return "invalid_key";
|
|
123
|
+
if (status === 402) return "insufficient_funds";
|
|
124
|
+
if (status === 429) return "rate_limited";
|
|
125
|
+
if (status >= 500) return "unavailable";
|
|
126
|
+
return "bad_response";
|
|
127
|
+
}
|
|
128
|
+
function cleanSnippet(description) {
|
|
129
|
+
const plain = description.split("\n").filter((line) => !/^[\s|:-]+$/.test(line)).join(" ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[#*`|>[\]]/g, " ").replace(/\s+/g, " ").trim();
|
|
130
|
+
return plain.length > snippetLimit ? `${cutAtCharBoundary(plain, snippetLimit).trimEnd()}\u2026` : plain;
|
|
131
|
+
}
|
|
132
|
+
export {
|
|
133
|
+
createFirecrawlServices
|
|
134
|
+
};
|
|
135
|
+
//# sourceMappingURL=firecrawl.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/firecrawl/firecrawl-services.ts"],"sourcesContent":["import { cutAtCharBoundary } from \"@ykurilov/agent-chat-core\";\nimport { z } from \"zod\";\nimport { isHttpAddress } from \"../page-address\";\nimport type { PageContent, PageReaderService, PageScreenshotService, SearchHit, WebSearchService } from \"../services\";\nimport { serviceFailure } from \"../web-tool-error\";\nimport type { WebToolErrorKind } from \"../web-tool-error\";\n\nexport interface FirecrawlOptions {\n apiKey: string;\n /** Адрес сервиса или прокси перед ним; по умолчанию https://api.firecrawl.dev. */\n baseUrl?: string;\n /** Дополнительные заголовки каждого запроса, например токен прокси. */\n headers?: Record<string, string>;\n fetch?: typeof globalThis.fetch;\n /** Предел ожидания ответа, мс. По умолчанию 30 000 для поиска и 60 000 для чтения и снимка. */\n timeoutMs?: number;\n}\n\nexport interface FirecrawlServices {\n search: WebSearchService;\n reader: PageReaderService;\n screenshots: PageScreenshotService;\n}\n\nconst defaultBaseUrl = \"https://api.firecrawl.dev\";\nconst searchTimeoutMs = 30_000;\nconst scrapeTimeoutMs = 60_000;\nconst snippetLimit = 300;\n\nconst searchResponse = z.object({\n data: z.object({\n web: z\n .array(z.object({ url: z.string().nullish(), title: z.string().nullish(), description: z.string().nullish() }))\n .optional(),\n }),\n});\n\nconst scrapeResponse = z.object({\n data: z.object({\n markdown: z.string().nullish(),\n screenshot: z.string().nullish(),\n metadata: z.object({ title: z.string().nullish(), sourceURL: z.string().nullish(), url: z.string().nullish() }).nullish(),\n }),\n});\n\nexport function createFirecrawlServices(options: FirecrawlOptions): FirecrawlServices {\n const baseUrl = (options.baseUrl ?? defaultBaseUrl).replace(/\\/+$/, \"\");\n const fetchImplementation = options.fetch ?? globalThis.fetch;\n\n async function post<Schema extends z.ZodType>(\n path: string,\n body: unknown,\n schema: Schema,\n { signal, timeoutMs }: { signal: AbortSignal | undefined; timeoutMs: number },\n ): Promise<z.infer<Schema>> {\n const controller = new AbortController();\n const forwardAbort = () => controller.abort(signal?.reason);\n if (signal?.aborted) forwardAbort();\n signal?.addEventListener(\"abort\", forwardAbort, { once: true });\n let timedOut = false;\n const timer = setTimeout(() => {\n timedOut = true;\n controller.abort();\n }, timeoutMs);\n // Оборванный запрос не всегда сам отвечает на сигнал: ждём и сигнал, и ответ.\n const aborted = new Promise<never>((_resolve, reject) => {\n controller.signal.addEventListener(\"abort\", () => reject(controller.signal.reason ?? new DOMException(\"Aborted\", \"AbortError\")));\n });\n aborted.catch(() => {});\n\n let status: number;\n let text: string;\n try {\n const response = await Promise.race([\n fetchImplementation(`${baseUrl}${path}`, {\n method: \"POST\",\n headers: { ...options.headers, \"Content-Type\": \"application/json\", Authorization: `Bearer ${options.apiKey}` },\n body: JSON.stringify(body),\n signal: controller.signal,\n }),\n aborted,\n ]);\n status = response.status;\n text = await Promise.race([response.text(), aborted]);\n } catch (error) {\n if (signal?.aborted) throw signal.reason ?? error;\n throw serviceFailure(timedOut ? \"timeout\" : \"unavailable\");\n } finally {\n clearTimeout(timer);\n signal?.removeEventListener(\"abort\", forwardAbort);\n }\n\n const failure = failureKindOf(status);\n if (failure) throw serviceFailure(failure);\n let json: unknown;\n try {\n json = JSON.parse(text);\n } catch {\n throw serviceFailure(\"bad_response\");\n }\n if ((json as { success?: unknown } | null)?.success === false) throw serviceFailure(\"bad_response\");\n const parsed = schema.safeParse(json);\n if (!parsed.success) throw serviceFailure(\"bad_response\");\n return parsed.data;\n }\n\n return {\n search: {\n async search(query, { limit, signal }) {\n const response = await post(\"/v2/search\", { query, limit }, searchResponse, {\n signal,\n timeoutMs: options.timeoutMs ?? searchTimeoutMs,\n });\n const hits: SearchHit[] = [];\n for (const item of response.data.web ?? []) {\n if (!item.url || !isHttpAddress(item.url)) continue;\n hits.push({ title: (item.title ?? \"\").trim(), url: item.url, snippet: cleanSnippet(item.description ?? \"\") });\n }\n return hits;\n },\n },\n reader: {\n async readPage(url, { signal }) {\n const response = await post(\"/v2/scrape\", { url, formats: [\"markdown\"], onlyMainContent: true }, scrapeResponse, {\n signal,\n timeoutMs: options.timeoutMs ?? scrapeTimeoutMs,\n });\n if (typeof response.data.markdown !== \"string\") throw serviceFailure(\"bad_response\");\n const metadata = response.data.metadata;\n const page: PageContent = {\n url: [metadata?.url, metadata?.sourceURL].find((candidate) => candidate && isHttpAddress(candidate)) || url,\n title: (metadata?.title ?? \"\").trim(),\n markdown: response.data.markdown,\n };\n return page;\n },\n },\n screenshots: {\n async takeScreenshot(url, { signal }) {\n const response = await post(\"/v2/scrape\", { url, formats: [\"screenshot\"] }, scrapeResponse, {\n signal,\n timeoutMs: options.timeoutMs ?? scrapeTimeoutMs,\n });\n if (!response.data.screenshot || !isHttpAddress(response.data.screenshot)) throw serviceFailure(\"bad_response\");\n return { imageUrl: response.data.screenshot };\n },\n },\n };\n}\n\nfunction failureKindOf(status: number): Exclude<WebToolErrorKind, \"bad_address\" | \"bad_query\" | \"timeout\"> | undefined {\n if (status >= 200 && status < 300) return undefined;\n if (status === 401 || status === 403) return \"invalid_key\";\n if (status === 402) return \"insufficient_funds\";\n if (status === 429) return \"rate_limited\";\n if (status >= 500) return \"unavailable\";\n return \"bad_response\";\n}\n\n/** Выдержка поиска приходит с разметкой: оставляем чистый текст в одну строку не длиннее предела. */\nfunction cleanSnippet(description: string): string {\n const plain = description\n .split(\"\\n\")\n .filter((line) => !/^[\\s|:-]+$/.test(line))\n .join(\" \")\n .replace(/\\[([^\\]]*)\\]\\([^)]*\\)/g, \"$1\")\n .replace(/[#*`|>[\\]]/g, \" \")\n .replace(/\\s+/g, \" \")\n .trim();\n return plain.length > snippetLimit ? `${cutAtCharBoundary(plain, snippetLimit).trimEnd()}…` : plain;\n}\n"],"mappings":"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","names":[]}
|