# Задания: два режима и жизненный цикл

> Когда ответ приходит целиком, когда идентификатором, как опрашивать, забирать страницами и отменять

Источник: https://docs.parreq.com/crawl-jobs/

Обход из пяти страниц занимает секунды, обход из двухсот — минуты. Держать
соединение открытым всё это время бессмысленно: посредники рвут долгие
запросы, а клиентские библиотеки закрывают их по своему таймауту. Поэтому
режима два — и переключается он **сам**, по величине заказа.

| условие | ответ |
|---|---|
| `pages` ≤ 10 (`PARREQ_CRAWL_SYNC_PAGES`) и без `async` | `200` — результат целиком |
| `pages` > 10 либо `async: true` | `202` — `crawl_metadata` с идентификатором |

> **К сведению.**
>   Исполнитель в обоих случаях один и тот же. Синхронный обход — не «облегчённый
>   режим»: то же задание, та же очередь, тот же счёт. Разница только в том,
>   дожидаемся мы его в соединении или нет.

`async: true` просит идентификатор даже на маленьком обходе. Это удобно, когда
обходы ставятся пачкой и ждать каждый по очереди незачем.

## Что приходит

```json
{
  "crawl_metadata": {
    "id": "cr_8f31a0c94e2b",
    "status": "running",
    "code": "",
    "note": "",
    "pages_planned": 34,
    "pages_done": 12,
    "pages_failed": 1,
    "credits": 24,
    "credits_max": 68,
    "created_at": 1787133737,
    "started_at": 1787133739,
    "finished_at": null
  },
  "crawl_parameters": { "…": "…" },
  "pages": [
    { "fetch_metadata": { "…": "…" }, "parsed": null }
  ],
  "next_offset": 12
}
```

- **`id`** (`string`):
  Идентификатор задания. По нему смотрят ход, забирают страницы и отменяют.

- **`status`** (`string`):
  Где задание сейчас. Значения — в таблице ниже.

- **`code`** (`string`):
  Машиночитаемая причина, когда задание закончилось не по-хорошему. У
  благополучного обхода — пустая строка.

- **`note`** (`string`):
  То же самое человеческим языком: чего не хватило, где остановились, почему
  часть страниц не взялась.

- **`pages_planned`** (`integer`):
  Заказанный потолок — то же число, что вы передали в `pages`. Не обещание:
  ссылки могут кончиться раньше.

- **`pages_done`** (`integer`):
  Сколько страниц вправду пройдено. Растёт по ходу и по нему же считается
  счёт.

- **`pages_failed`** (`integer`):
  Сколько страниц не взялись. Обход из-за них не останавливается: чужой сайт
  время от времени отдаёт `502`, и терять из-за одной страницы остальные
  девяносто девять было бы глупо.

- **`credits`** (`integer`):
  Списано на данный момент. По ходу задания растёт.

- **`credits_max`** (`integer`):
  Во сколько обойдётся обход, если пройдёт все `pages_planned` страниц. Именно
  эту сумму проверяют на балансе **до старта** — см.
  [Цена обхода](https://docs.parreq.com/crawl-pricing).

- **`pages`** (`array`):
  Срез результата. Каждый элемент — **ровно ответ Fetch**:
  `{"fetch_metadata": {…}, "parsed": {…}}`.

- **`next_offset`** (`integer | null`):
  Смещение следующего среза. `null` означает, что дальше ничего нет — на этот
  момент.

## Статусы

| статус | что значит |
|---|---|
| `queued` | принято, ждёт свободных вкладок в пуле |
| `running` | идёт; `pages_done` растёт |
| `done` | закончено благополучно: набрали `pages` или кончились ссылки |
| `partial` | закончено, но часть страниц не взялась — смотрите `pages_failed` и `note` |
| `failed` | не получилось; причина в `code` и `note` |
| `canceled` | остановлено по вашей просьбе |

Первые два — незавершённые, остальные четыре — окончательные: после них
`finished_at` заполнен и `pages_done` больше не меняется.

> **Примечание.**
>   Список статусов расширяется только добавлением. Ветвитесь на «закончено —
>   это не `queued` и не `running`», а не перечислением четырёх окончательных:
>   тогда новое значение не сломает ваш код.

## Опрос

```python Python

job = client.crawl(
    url="https://books.toscrape.com/catalogue/category/books/mystery_3/index.html",
    pages=34,
    next="li.next a",
    follow=".product_pod h3 a",
)

job_id = job.metadata["id"]

while True:
    state = client.crawl_status(job_id)
    print(state.metadata["status"], state.metadata["pages_done"], "из",
          state.metadata["pages_planned"])
    if state.metadata["status"] not in ("queued", "running"):
        break
    time.sleep(5)
```

```javascript Node
const job = await client.crawl({
  url: "https://books.toscrape.com/catalogue/category/books/mystery_3/index.html",
  pages: 34,
  next: "li.next a",
  follow: ".product_pod h3 a",
});

const jobId = job.metadata.id;

for (;;) {
  const state = await client.crawlStatus(jobId);
  console.log(state.metadata.status, state.metadata.pages_done, "из",
              state.metadata.pages_planned);
  if (!["queued", "running"].includes(state.metadata.status)) break;
  await new Promise((r) => setTimeout(r, 5000));
}
```

```bash cURL
curl "https://api.parreq.com/v1/crawl/cr_8f31a0c94e2b" \
  -H "Authorization: Bearer pr_ВАШКЛЮЧ"
```

Тот же цикл, написанный за вас, — `crawl_wait`: он опрашивает задание сам и
возвращается, когда статус стал окончательным.

```python Python
res = client.crawl_wait(job_id)
print(res.metadata["status"], res.metadata["pages_done"])
```

```javascript Node
const res = await client.crawlWait(jobId);
console.log(res.metadata.status, res.metadata.pages_done);
```

> **Внимание.**
>   Опрашивайте раз в несколько секунд, а не в цикле без паузы. Опрос — обычный
>   запрос к API и считается в `rpm`; сотня опросов в минуту исчерпает частотный
>   предел раньше, чем обход дойдёт до середины.

## Забрать страницы

Двести страниц с разметкой — это десятки мегабайт, и отдавать их одним куском
неразумно. Поэтому страницы приходят срезами: `offset` — с какой начинать,
`limit` — сколько взять, до 200 за раз.

`GET /v1/crawl/{job_id}` отдаёт метаданные вместе со срезом,
`GET /v1/crawl/{job_id}/pages` — только страницы, без метаданных и эха
правила. Второе удобнее в цикле выкачивания: ответ меньше и в нём нет
повторяющегося.

```python Python
offset = 0
while offset is not None:
    chunk = client.crawl_pages(job_id, offset=offset, limit=50)
    for page in chunk.pages:
        handle(page["fetch_metadata"], page.get("parsed"))
    offset = chunk.next_offset
```

```javascript Node
let offset = 0;
while (offset !== null) {
  const chunk = await client.crawlPages(jobId, { offset, limit: 50 });
  for (const page of chunk.pages) {
    handle(page.fetch_metadata, page.parsed);
  }
  offset = chunk.next_offset;
}
```

```bash cURL
curl "https://api.parreq.com/v1/crawl/cr_8f31a0c94e2b/pages?offset=0&limit=50" \
  -H "Authorization: Bearer pr_ВАШКЛЮЧ"
```

Страницы доступны **по мере готовности**, а не только в конце: у идущего
задания уже пройденное можно забирать и обрабатывать, не дожидаясь остальных.
`next_offset: null` у `running` означает «на сейчас всё», а не «больше не
будет».

## Отмена

`POST /v1/crawl/{job_id}/cancel` останавливает задание.

```python Python
state = client.crawl_cancel(job_id)
print(state.metadata["status"], state.metadata["pages_done"])
```

```javascript Node
const state = await client.crawlCancel(jobId);
console.log(state.metadata.status, state.metadata.pages_done);
```

```bash cURL
curl -X POST "https://api.parreq.com/v1/crawl/cr_8f31a0c94e2b/cancel" \
  -H "Authorization: Bearer pr_ВАШКЛЮЧ"
```

Отмена срабатывает **на границе страницы**, а не мгновенно: начатую страницу
не выбрасывают. Браузер за неё уже сходил, кредиты уже потрачены, и выкидывать
готовый результат ради красивой мгновенности значило бы выкидывать ваши
деньги. Поэтому между просьбой и статусом `canceled` может пройти несколько
секунд, и `pages_done` за это время подрастёт на единицу.

Всё, что успело пройти, остаётся доступным: отменённое задание читается и
отдаёт страницы так же, как завершённое.

Отмена уже законченного — `409 already_finished`. Это не ошибка вашего кода, а
обычная гонка: задание закончилось, пока просьба шла. Достаточно перечитать
статус.

## Список заданий

`GET /v1/crawl?limit=` показывает ваши задания, свежие сверху.

```python Python
# отдельного метода в библиотеке нет — берём обычным HTTP

jobs = requests.get(
    "https://api.parreq.com/v1/crawl",
    params={"limit": 20},
    headers={"Authorization": "Bearer pr_ВАШКЛЮЧ"},
    timeout=30,
).json()["jobs"]

for job in jobs:
    print(job["id"], job["status"], job["pages_done"], job["credits"])
```

```javascript Node
const { jobs } = await fetch("https://api.parreq.com/v1/crawl?limit=20", {
  headers: { "Authorization": "Bearer pr_ВАШКЛЮЧ" },
}).then((r) => r.json());

for (const job of jobs) {
  console.log(job.id, job.status, job.pages_done, job.credits);
}
```

```bash cURL
curl "https://api.parreq.com/v1/crawl?limit=20" \
  -H "Authorization: Bearer pr_ВАШКЛЮЧ"
```

Нужно это чаще всего в одном случае: код упал, идентификатор потерялся, а
задание идёт и тратит кредиты. Здесь его видно и отсюда его можно отменить.

## Задание переживает перезапуск

Задания живут в хранилище, а не в памяти процесса. Перезапуск сервиса их не
теряет: `queued` дождётся своей очереди, `running` продолжится с того места,
где остановился. Брошенное задание — то, чей исполнитель не вернулся, —
возвращается в очередь автоматически, без вашего участия.

Обратная сторона: если хранилище заданий недоступно, обход не принимается
вовсе — `503 service_unavailable`. Принять задание и потерять его было бы
хуже, чем честно отказать.

## Дальше

**[Правило обхода](https://docs.parreq.com/crawl-rules)**
    Из чего складывается маршрут и как его сузить.

**[Цена обхода](https://docs.parreq.com/crawl-pricing)**
    Откуда берётся `credits_max` и за что списывают по факту.

**[Ошибки](https://docs.parreq.com/crawl-errors)**
    `already_finished`, `not_found`, `service_unavailable` и остальные.

**[Справочник](https://docs.parreq.com/api-reference/crawl-get)**
    Ручки состояния, страниц и отмены.
