# 5. Инструменты — сбор и поиск данных

🔵 Движки для получения данных извне: HTTP-скрап, локальный архив, JSON, RSS,
PDF, геокодинг. Сводную таблицу всех движков см. в
[«Справочник инструментов»](05-tools-reference.md).

## 5.4 site_tool

Универсальный скрапер: HTTP-запрос → извлечение полей → формат вывода.

```yaml
kind: site_tool
name: my_scraper
description: Парсит страницу товара.
parameters:
  type: object
  properties:
    url: { type: string }
  required: [url]

fetch:
  url: "{{ input.url }}"          # шаблон MiniJinja
  method: POST                    # GET (по умолч.) | POST | PUT | PATCH | DELETE
  json: { query: "{{ input.q }}" }  # JSON-тело (листья — шаблоны)
  # body: "q={{ input.q | urlencode }}"   # сырое тело (шаблон)
  # form: { search: "{{ input.q }}" }      # x-www-form-urlencoded
  # multipart: { fields: {...}, files: {...} }
  timeout_secs: 15
  cache_ttl_secs: 300
  follow_redirects: true
  cookies: true
  headers:
    User-Agent: "MyBot/1.0"
    Authorization: "Bearer {{ env_var('API_KEY') }}"

source:
  type: script_json               # JSON внутри <script>
  contains: productData
  # type: json                    # или тело само — JSON

extract: { ... }
output: { ... }
```

### `fetch` — URL

Два стиля:

1. **Прямой URL** — `url: "{{ input.url }}"`.
2. **Построитель пути** — `base` + `path_opt` + `path` + `query` (пример —
   `drom_search.yaml`):

```yaml
fetch:
  base: "https://auto.drom.ru/"
  path_opt:
    - when: { has: region }
      value: "region{{ region | region_code }}"
    - when: { equals: [condition, "new"] }
      value: "new"
  path:
    - "{{ brand }}"
    - "{{ model | default('') | slugify }}"
    - "{% if page is defined and page > 1 %}page{{ page }}{% endif %}"
  query:
    unsold: "1"
    minprice: "{{ price_from }}"
  filters:
    slugify: _builtin_
    region_code:
      moscow: "77"
      nsk: "54"
    region_code_ci:
      case_insensitive: true
      default: "77"
      "Москва": "77"
```

`filters` сопоставляет имена фильтров с `_builtin_` (например `slugify`) или
таблицей ключ→значение. Lookup-таблица — обычная карта, либо объект с
`case_insensitive: true` и `default` (резерв при отсутствии значения).

`env_var('NAME')` читает переменную окружения — используй для ключей в заголовках.

`query` применяется и при прямом `url` (добавляется с URL-кодированием).

### `fetch` — общий профиль (`extends`)

Чтобы не повторять `base_url` и `Authorization`, вынеси их в профиль и укажи
`extends` (путь относительно YAML инструмента):

```yaml
# tools/_shared.yaml
base_url: "https://api.example.com"
headers:
  Authorization: "Bearer {{ env_var('API_KEY') }}"
```

```yaml
# tools/list.yaml
fetch:
  extends: _shared.yaml
  path: ["issues"]
  headers:
    Accept: "text/plain"      # перекрывает Accept из профиля
```

Собственные `url`/`base`/`headers` инструмента выигрывают у профиля.

### `fetch` — тело запроса

Ровно **одно** из `body` / `json` / `form` / `multipart` (неоднозначный конфиг
отвергается при загрузке). `json:` — JSON-тело, каждый строковый лист — шаблон.
`form:` — urlencoded. `multipart: { fields, files }` — `files` это локальные пути.

Булевы/null в MiniJinja рендерятся Python-стилем (`True`/`False`/`None`), что
невалидный JSON — в сыром `body` прогоняй значения через `tojson`:

```yaml
body: '{"completed": {{ input.completed | tojson }}}'
```

Для `json`-тела `omit_empty: true` рекурсивно выбрасывает ключи с пустым
значением (`""`, `null`, `[]`, `{}`) — удобно для PATCH.

### `fetch` — ретраи / backoff

Временные сбои ретраятся автоматически: HTTP 429, 5xx, сетевые/таймаут-ошибки.
`retries` — число попыток (по умолчанию 3, `0` отключает); `backoff_ms` — базовая
задержка с удвоением (по умолчанию 3000 → 3с, 6с, 12с).

### `fetch` — пагинация (token-стиль)

Для списков с токеном следующей страницы добавь `pagination`:

```yaml
pagination:
  token_field: /next_page_token
  token_param: page_token
  items_field: /issues
  max_items: 100
  max_pages: 10
```

Инструмент сам следует по страницам (агрегируя элементы) и сообщает, где
продолжить. Результат: `{ items, page_info: { returned, total, has_more, next_token } }`.
LLM продолжает вызовом с `page_token: <next_token>`.

### `source` — где лежат структурированные данные

| Поле | Значения |
|---|---|
| `type` | `script_json` (JSON-блоб в `<script>`) или `json` (тело — JSON) |
| `contains` | подстрока, которую должен содержать скрипт (для `script_json`) |
| `min_length` | мин. длина скрипта (по умолчанию 5000) |

Другие типы `source`:

- **`json_ld`** — schema.org JSON-LD; выбор блока по `@type`:
  ```yaml
  source:
    type: json_ld
    type_selector: Product     # совпадает с "Product" или "https://schema.org/Product"
    graph: true                # искать и во вложенных @graph (по умолчанию true)
  ```
- **`file_json`** — JSON из локального файла (без HTTP):
  ```yaml
  source: { type: file_json, file: data/input.json }
  ```
- **`devalue`** — payload Nuxt 3 (`__NUXT_DATA__`): резолвит целочисленные ссылки
  и маркеры.
  ```yaml
  source: { type: devalue, contains: __NUXT_DATA__, min_length: 1000 }
  ```
- **`html`** — извлечение CSS-селекторами (JSON не нужен):
  ```yaml
  source:
    type: html
    selector: ".product-card"              # повторяющийся элемент-запись
    fields:
      title: { selector: ".product-card__name", attr: text }
      price: { selector: ".product-card__price-current", attr: text }
      url:   { selector: "a.product-card__name", attr: href }
  ```
  Возвращает `{ "items": [ {field: value, …}, …] }`.

### `fetch` — двухшаговый токен (`auth`)

Когда сначала нужен токен (анонимная авторизация), добавь `auth`:

```yaml
fetch:
  auth:
    token_url: "https://…/api/v1/auth/anonymous"
    method: POST
    json: { app: "mobile" }        # или body / form / headers
    field: token.accessToken       # JSON-указатель /a/b или точка a.b
    header: Authorization
    prefix: "Bearer "
```

### `extract` — типы полей

Три вида, различаются по ключам.

**Простое поле** — навигация по JSON-указателю:

```yaml
extract:
  location:
    pointer: /geoInfo/0/text
    value: /sub/path           # доп. навигация вглубь
    fallback: "unknown"
    transform: int
```

Указатели могут быть MiniJinja-шаблонами от `input`; сегмент `*` (или `~first`)
перешагивает динамический ключ.

**Поле-массив** — фильтр массива по дискриминатору:

```yaml
  year:
    pointer: /fields
    array_match: { type: year }   # первый элемент с .type == "year"
    value: /payload
    transform: int
  photos:
    pointer: /gallery/images
    collect_all: true             # все элементы → массив
    value: /image/src
    transform: [ { skip: 5 }, { take: 20 } ]
```

**Вычисляемое поле** — условная логика:

```yaml
  status:
    compute:
      - when: { op: not_null, pointer: /soldNotification }   then: sold
      - when: { op: equals, pointer: /shouldShowDeleted, value: true }  then: deleted
    default: active
```

`op` в поставках: `not_null`, `equals`.

### Трансформации

Применяются по порядку (можно цепочкой списком):

| Трансформация | YAML | Описание |
|---|---|---|
| Lookup | `{ lookup: { "1": foo, "2": bar } }` | значение по таблице (неизвестно → `"?"`) |
| Truncate | `{ truncate: 2000 }` | обрезать до N символов |
| Take | `{ take: 20 }` | взять первые N |
| Skip | `{ skip: 5 }` | отбросить первые N |
| Len | `len` | длина массива |
| NotNull | `not_null` | `true`, если не null |
| Bool | `bool` | к булевому |
| Int | `int` | к целому |
| Filter | `{ filter: "not {{ item._sold }}" }` | отбросить элементы (массивы) |
| Transliterate | `transliterate` | транслитерация в slug |
| StripHtml | `strip_html` | убрать HTML/XML-теги (`<noindex>…</noindex>` → текст) |

### `output` — формат

Опциональный MiniJinja-шаблон. Листья рендерятся от `input` и `extracted`.
`{{ extracted._warnings }}` доступен, когда поля не разрешились. Без `output`
возвращается извлечённая карта как есть.

### Обработка ошибок

Движок не паникует: ошибки возвращают `success: false` с сообщением (HTTP
403/404/429/5xx, сетевые ошибки, «структурированные данные не найдены»).

---

## 5.5 archive_tool

Поиск по локальному архиву.

```yaml
kind: archive_tool
name: ngu_search
description: Поиск по архиву НГУ.
parameters:
  type: object
  properties:
    query: { type: string }
  required: [query]

mode: search_grouped
archive:
  path: data/ngu
  default_limit: 10

filters:
  - { field: players, op: players_min, param: players_min }
  - { field: price,   op: gte,        param: price_min }

sort:
  field: price
  order: asc
```

### `mode`

| Режим | Назначение |
|---|---|
| `search` | поиск по ключевым словам |
| `search_grouped` | результаты, сгруппированные по странице (`matched_chunks`) |
| `lookup` | полный документ по URL |
| `read_chunks` | диапазон чанков (`start_chunk`, `end_chunk`) |
| `outline` | структура страницы: заголовок, число символов/чанков, превью чанков |

Архив — каталог с `meta.json`, `docs.bin` (документы по чанкам) и `terms.bin`
(индекс терминов); собирается `scraper`-ом.

---

## 5.6 json_tool

Поиск по предзагруженному JSON.

```yaml
kind: json_tool
name: find_brands
description: Поиск брендов по имени.
parameters:
  type: object
  properties:
    query: { type: string }
    section: { type: string, enum: [auto, moto, truck] }
  required: [query]

data_file: data/drom_brands.json

search:
  paths: ["{{ input.section | default('*') }}.top", "*.all"]
  field: name
  query_param: query
  mode: contains
  limit: 20
  dedupe_by: id
  source_field: section

computed:
  slug: "{{ item.url | path_segment(0) }}"
```

| Поле | Описание |
|---|---|
| `data_file` | загружаемый JSON-файл |
| `search.paths` | JSON-пути поиска; `*` = wildcard-ключ |
| `search.field` | поле для сопоставления |
| `search.query_param` | входной параметр с запросом |
| `search.mode` | `contains` (частичное совпадение) |
| `search.limit` | максимум результатов |
| `search.dedupe_by` | дедупликация по полю |
| `computed` | MiniJinja-шаблоны на каждый результат (`item` в скоупе) |

---

## 5.7 rss_tool

Поиск по RSS-лентам.

```yaml
kind: rss_tool
name: rss_search
description: Поиск новостей по ключевому слову.
parameters:
  type: object
  properties:
    site: { type: string }
    query: { type: string }
  required: [site, query]

feeds:
  lenta: "https://lenta.ru/rss"
  ria: "https://ria.ru/export/rss2/archive/index.xml"
  rbc: "https://rssexport.rbc.ru/rbcnews/news/30/full.rss"
```

`site` — ключ в `feeds`. Результат несёт `count`, заголовки, сниппеты, URL.

---

## 5.8 pdf_tool

Чтение PDF постранично.

```yaml
kind: pdf_tool
name: pdf_read
description: Скачать и прочитать PDF.
parameters:
  type: object
  properties:
    url: { type: string }
    page: { type: integer, default: 1 }
  required: [url]

fetch:
  url: "{{ input.url }}"
  timeout_secs: 30
  cache_ttl_secs: 3600

pdf:
  chars_per_page: 3000

output:
  page: "{{ extracted.page }}"
  total_pages: "{{ extracted.total_pages }}"
  text: "{{ extracted.text }}"
  next_page: "{{ extracted.next_page }}"
```

---

## 5.15 geo_tool

Геокодинг и места (OpenStreetMap). Бесплатный, без ключей, геокодинг и поиск POI
(Nominatim + Overpass API) — агент не выдумывает адреса.

```yaml
kind: geo_tool
name: geo_places
description: Найти места категории рядом.
operation: places
```

| operation | Назначение | Параметры |
|---|---|---|
| `locate` | геокодинг места → координаты + bbox | `query` |
| `places` | POI категории рядом / внутри bbox | `category`, `place`/`bbox`, `limit` |
| `distance` | сортировка адресов по расстоянию | `place`, `addresses` |

Категории (RU/EN синонимы): `сто`/`автосервис`/`car_repair`, `шиномонтаж`,
`аптека`/`pharmacy`, `банк`/`атм`, `кафе`/`ресторан`, `школа`, `магазин`/
`продукты`/`супермаркет`, `заправка`/`азс`, `парковка`. Неизвестная категория →
поиск Overpass `shop=<value>`.

Опционально: `nominatim_url` / `overpass_url`, `user_agent`, `ui`.
