Готовые примеры
Клиенты на Python и TypeScript, справочник всех запросов и машиночитаемое описание для программы проверки запросов.
Страница содержит справочник запросов, готовые клиенты на Python и TypeScript и машиночитаемое описание запросов.
Интерактивной песочницы нет. Сохраните машиночитаемое описание в файл и откройте его в программе для проверки запросов или используйте для генерации клиента.
Справочник запросов
Всё, что доступно по ключу. Адрес папки везде — тот id, который вернул /mine.
| Запрос | Что делает | Подробно |
|---|---|---|
GET /api/admin/library-folders/mine | Папки, разрешённые этому ключу | С чего начать |
GET /api/admin/library-folders/{id} | Карточка папки: имя, описание, число файлов | Остальные запросы |
GET /api/admin/library-folders/{id}/contents | Дерево подпапок | Остальные запросы |
GET /api/admin/library-folders/{id}/files | Файлы папки: страницы, отбор по пути, сортировка | Остальные запросы |
POST /api/admin/library-folders/{id}/files | Загрузка, до 50 файлов за раз | Загрузка файлов |
DELETE /api/admin/library-folders/{id}/files/{fileId} | Убрать файл из папки | Остальные запросы |
POST /api/admin/library-folders/{id}/reindex-failed | Повторить обработку файлов с ошибкой | Остальные запросы |
POST /api/admin/library-folders/{id}/reindex-all | Обработать заново всю папку | Остальные запросы |
Ничего, кроме перечисленного, ключу не отвечает: остальные запросы администраторской части
портал отклоняет с кодом 403, даже когда ключ действителен.
Python
Клиент использует только библиотеку requests. Он получает список папок, загружает папку
с диска, ждёт окончания обработки и удаляет ненужные файлы.
import json
import os
import time
from pathlib import Path
import requests
class VortholmLibrary:
"""Клиент базы знаний портала. Работает только с папками своего ключа."""
UPLOAD_BATCH = 50 # предел одного запроса на стороне портала
def __init__(self, base_url: str, key: str, timeout: int = 300):
self.base = base_url.rstrip("/") + "/api/admin/library-folders"
self.session = requests.Session()
self.session.headers["Authorization"] = f"Bearer {key}"
self.timeout = timeout
# --- служебное -------------------------------------------------------
def _call(self, method: str, path: str = "", **kwargs):
response = self.session.request(
method, f"{self.base}{path}", timeout=self.timeout, **kwargs
)
if not response.ok:
# Портал всегда отвечает одним и тем же конвертом: {"error": {...}}
try:
error = response.json()["error"]
except Exception:
raise RuntimeError(f"HTTP {response.status_code}: {response.text[:200]}")
raise RuntimeError(
f"HTTP {response.status_code} {error.get('code')}: {error.get('message')} "
f"{json.dumps(error.get('details', {}), ensure_ascii=False)}"
)
return response.json()
# --- чтение ----------------------------------------------------------
def folders(self):
return self._call("GET", "/mine")["data"]
def files(self, folder_id: str, path: str | None = None, page_size: int = 100):
"""Все файлы папки, страница за страницей. Больше 100 за раз портал не отдаст."""
page = 1
while True:
params = {"page": page, "pageSize": page_size}
if path is not None:
params["path"] = path
body = self._call("GET", f"/{folder_id}/files", params=params)
yield from body["data"]
if page >= body["pagination"]["totalPages"]:
return
page += 1
# --- запись ----------------------------------------------------------
@staticmethod
def _relative(path: Path, root: Path | None) -> str:
if root is None:
return ""
inside = path.parent.relative_to(root)
return "" if inside == Path(".") else inside.as_posix()
def upload(self, folder_id: str, paths: list[Path], root: Path | None = None):
"""Заливает файлы пачками по 50, сохраняя структуру каталогов."""
totals = {"uploaded": 0, "deduplicated": 0, "replaced": 0, "alreadyInFolder": 0}
skipped = []
for start in range(0, len(paths), self.UPLOAD_BATCH):
batch = paths[start : start + self.UPLOAD_BATCH]
# Порядок relativePaths обязан совпадать с порядком files.
# Пустая строка означает корень папки.
relative = [self._relative(p, root) for p in batch]
files = [("files", (p.name, p.open("rb"))) for p in batch]
try:
body = self._call(
"POST",
f"/{folder_id}/files",
files=files,
data={"relativePaths": json.dumps(relative)},
)["data"]
finally:
for _, (_, handle) in files:
handle.close()
for counter in totals:
totals[counter] += body.get(counter, 0)
skipped.extend(body.get("skippedFiles", []))
return totals, skipped
def delete(self, folder_id: str, file_id: str):
self._call("DELETE", f"/{folder_id}/files/{file_id}")
def reindex_failed(self, folder_id: str):
"""Ставит в очередь заново те файлы, на которых обработка сорвалась."""
return self._call("POST", f"/{folder_id}/reindex-failed")["data"]["updated"]
# --- ожидание --------------------------------------------------------
def wait_until_ready(self, folder_id: str, poll_seconds: int = 30, limit: int = 120):
"""Ждёт, пока в папке не останется файлов в работе или в очереди."""
for _ in range(limit):
waiting = [
f for f in self.files(folder_id)
if f["indexStatus"] in ("pending", "processing")
]
if not waiting:
return True
time.sleep(poll_seconds)
return False
if __name__ == "__main__":
library = VortholmLibrary(os.environ["PORTAL_URL"], os.environ["PORTAL_KEY"])
for folder in library.folders():
print(folder["id"], folder["name"], folder["fileCount"])
folder_id = library.folders()[0]["id"]
root = Path("./выгрузка")
documents = sorted(p for p in root.rglob("*") if p.is_file())
totals, skipped = library.upload(folder_id, documents, root=root)
print("залито:", totals)
for item in skipped:
print("пропущен:", item["filename"], item["reason"])
library.wait_until_ready(folder_id)TypeScript
Клиент работает в Node 20 и новее без сторонних библиотек: fetch, FormData и Blob
входят в Node.
type Folder = {
id: string;
name: string;
description: string | null;
type: string;
fileCount: number;
};
type UploadResult = {
uploaded: number;
deduplicated: number;
replaced: number;
alreadyInFolder: number;
files: Array<{ id: string; filename: string; relativePath: string }>;
skippedFiles: Array<{ filename: string; reason: string }>;
};
const UPLOAD_BATCH = 50; // предел одного запроса на стороне портала
export class VortholmLibrary {
private readonly base: string;
constructor(baseUrl: string, private readonly key: string) {
this.base = `${baseUrl.replace(/\/$/, '')}/api/admin/library-folders`;
}
private async call<T>(method: string, path = '', body?: BodyInit): Promise<T> {
const response = await fetch(`${this.base}${path}`, {
method,
headers: { Authorization: `Bearer ${this.key}` },
body,
});
if (!response.ok) {
// Единый конверт ошибки: { error: { code, message, details } }
const text = await response.text();
let detail = text.slice(0, 200);
try {
const { error } = JSON.parse(text) as { error: { code: string; message: string } };
detail = `${error.code}: ${error.message}`;
} catch {
/* тело не разобралось — покажем как есть */
}
throw new Error(`HTTP ${response.status} ${detail}`);
}
return (await response.json()) as T;
}
async folders(): Promise<Folder[]> {
const { data } = await this.call<{ data: Folder[] }>('GET', '/mine');
return data;
}
/** Заливает файлы пачками по 50 и складывает счётчики всех пачек. */
async upload(
folderId: string,
files: Array<{ name: string; relativePath?: string; content: Blob }>,
): Promise<UploadResult> {
const total: UploadResult = {
uploaded: 0,
deduplicated: 0,
replaced: 0,
alreadyInFolder: 0,
files: [],
skippedFiles: [],
};
for (let start = 0; start < files.length; start += UPLOAD_BATCH) {
const batch = files.slice(start, start + UPLOAD_BATCH);
const form = new FormData();
// Порядок relativePaths обязан совпадать с порядком files.
form.append('relativePaths', JSON.stringify(batch.map((f) => f.relativePath ?? '')));
for (const file of batch) form.append('files', file.content, file.name);
const { data } = await this.call<{ data: UploadResult }>(
'POST',
`/${folderId}/files`,
form,
);
total.uploaded += data.uploaded;
total.deduplicated += data.deduplicated;
total.replaced += data.replaced;
total.alreadyInFolder += data.alreadyInFolder;
total.files.push(...data.files);
total.skippedFiles.push(...data.skippedFiles);
}
return total;
}
async remove(folderId: string, fileId: string): Promise<void> {
await this.call('DELETE', `/${folderId}/files/${fileId}`);
}
}Вызов:
import { readFile } from 'node:fs/promises';
const library = new VortholmLibrary(process.env.PORTAL_URL!, process.env.PORTAL_KEY!);
const [folder] = await library.folders();
const result = await library.upload(folder.id, [
{
name: 'договор-2026-014.pdf',
relativePath: '2026/01',
content: new Blob([await readFile('./договор-2026-014.pdf')]),
},
]);
console.log(result.uploaded, result.deduplicated, result.skippedFiles);Повторная отправка того же файла не создаёт копию
Если отправить один и тот же файл дважды, портал узнает содержимое и не создаст вторую
копию: файл попадёт в счётчик deduplicated, а не uploaded. После обрыва связи запрос
можно повторить целиком. Проверяйте результат повторного запроса отдельно, не складывая
его счётчики с первым.
Единственное, что повтор действительно меняет, — файл с тем же именем, но другим
содержимым: он заменяет прежний, и это видно в счётчике replaced. Прежняя версия из папки
исчезает.
Машиночитаемое описание
Сохраните текст ниже в vortholm-library.yaml и откройте своим инструментом — тем, который
рисует справочник по такому файлу, собирает коллекцию запросов или генерирует клиент.
openapi: 3.1.0
info:
title: Vortholm — база знаний
version: "1"
description: >-
Загрузка и разбор файлов базы знаний портала. Доступ по ключу вида sk_forge_...,
только к папкам, разрешённым этому ключу.
servers:
- url: https://portal.example.com/api/admin
description: адрес администраторской части портала
security:
- bearerAuth: []
paths:
/library-folders/mine:
get:
summary: Папки, разрешённые ключу
responses:
"200":
description: Список папок
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Folder" }
meta:
type: object
properties:
total: { type: integer }
"401": { $ref: "#/components/responses/Error" }
/library-folders/{folderId}:
get:
summary: Карточка папки
parameters:
- $ref: "#/components/parameters/FolderId"
responses:
"200":
description: Папка
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Folder" }
"403": { $ref: "#/components/responses/Error" }
"404": { $ref: "#/components/responses/Error" }
/library-folders/{folderId}/contents:
get:
summary: Содержимое папки по указанному пути
parameters:
- $ref: "#/components/parameters/FolderId"
- { name: path, in: query, schema: { type: string }, description: подпапка; без него корень }
- { name: page, in: query, schema: { type: integer, default: 1 } }
- { name: pageSize, in: query, schema: { type: integer, default: 50, maximum: 200 } }
responses:
"200": { description: Подпапки и файлы указанного пути }
/library-folders/{folderId}/files:
get:
summary: Файлы папки
parameters:
- $ref: "#/components/parameters/FolderId"
- { name: page, in: query, schema: { type: integer, default: 1 } }
- { name: pageSize, in: query, schema: { type: integer, default: 20, maximum: 100 } }
- { name: path, in: query, schema: { type: string }, description: подпапка }
- { name: search, in: query, schema: { type: string }, description: по имени и описанию }
- name: sortBy
in: query
schema: { type: string, enum: [filename, indexStatus], default: filename }
- name: sortOrder
in: query
schema: { type: string, enum: [asc, desc] }
responses:
"200":
description: Страница списка файлов
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/File" }
pagination:
type: object
properties:
page: { type: integer }
pageSize: { type: integer }
total: { type: integer }
totalPages: { type: integer }
post:
summary: Загрузка файлов (до 50 за запрос)
parameters:
- $ref: "#/components/parameters/FolderId"
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
files:
type: array
maxItems: 50
items: { type: string, format: binary }
relativePaths:
type: string
description: >-
JSON-массив строк той же длины и в том же порядке, что files.
Пустая строка — корень папки.
required: [files]
responses:
"201":
description: Результат загрузки
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/UploadResult" }
"400": { $ref: "#/components/responses/Error" }
"413": { $ref: "#/components/responses/Error" }
/library-folders/{folderId}/files/{fileId}:
delete:
summary: Убрать файл из папки
parameters:
- $ref: "#/components/parameters/FolderId"
- { name: fileId, in: path, required: true, schema: { type: string, format: uuid } }
responses:
"200":
description: Файл убран из папки
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
success: { type: boolean }
"404": { $ref: "#/components/responses/Error" }
/library-folders/{folderId}/reindex-failed:
post:
summary: Повторить обработку файлов с ошибкой
parameters:
- $ref: "#/components/parameters/FolderId"
responses:
"200": { $ref: "#/components/responses/Updated" }
/library-folders/{folderId}/reindex-all:
post:
summary: Обработать заново всю папку
parameters:
- $ref: "#/components/parameters/FolderId"
responses:
"200": { $ref: "#/components/responses/Updated" }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: "Authorization: Bearer sk_forge_..."
parameters:
FolderId:
name: folderId
in: path
required: true
schema: { type: string, format: uuid }
responses:
Updated:
description: Сколько файлов поставлено в очередь
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
updated: { type: integer }
Error:
description: Ошибка
content:
application/json:
schema:
type: object
properties:
error:
type: object
properties:
code: { type: string }
message: { type: string }
details: { type: object }
schemas:
Folder:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
description: { type: string, nullable: true }
type: { type: string }
fileCount: { type: integer }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
File:
type: object
description: строка списка файлов; linkId — связь «файл в этой папке», id — сам файл
properties:
linkId: { type: string, format: uuid }
id: { type: string, format: uuid }
filename: { type: string }
fileSize: { type: integer }
mimeType: { type: string }
indexStatus:
type: string
enum: [pending, processing, done, failed, flagged]
indexError: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
UploadResult:
type: object
properties:
uploaded: { type: integer }
deduplicated: { type: integer }
replaced: { type: integer }
alreadyInFolder: { type: integer }
files:
type: array
items:
type: object
properties:
id: { type: string, format: uuid }
filename: { type: string }
relativePath: { type: string }
deduplicated: { type: boolean }
alreadyInFolder: { type: boolean }
replaced: { type: boolean }
skippedFiles:
type: array
items:
type: object
properties:
filename: { type: string }
reason: { type: string }Описание собрано по этому разделу документации и обновляется вместе с ним. Портал его не отдаёт и не проверяет. При расхождении сверяйтесь со страницами «Загрузка файлов» и «Остальные запросы».