mcgl-mcp 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 exi66
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,83 @@
1
- # Temporary Holding Version
1
+ # mcgl-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/mcgl-mcp)](https://www.npmjs.com/package/mcgl-mcp)
4
+ [![CI](https://github.com/exi66/mcgl-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/exi66/mcgl-mcp/actions/workflows/ci.yml)
5
+
6
+ MCP-сервер для [форума Minecraft Galaxy](https://forum.minecraft-galaxy.ru/main/). Подключается к Claude, Cursor и другим AI-ассистентам и даёт им читать форум: разделы, темы, посты, опросы.
7
+
8
+ Что можно попросить у ассистента:
9
+
10
+ - «Что нового на форуме MCGL?»
11
+ - «Перескажи последние сообщения в теме про Гран-при амфибий»
12
+ - «О чём спорят в разделе Предложения?»
13
+ - «Найди в теме 133931 рецепт крафта и объясни его»
14
+
15
+ ## Установка
16
+
17
+ Нужен [Node.js](https://nodejs.org) 22 или новее. Отдельно ставить ничего не надо: клиент сам скачает и запустит сервер через `npx`.
18
+
19
+ **Claude Code:**
20
+
21
+ ```bash
22
+ claude mcp add mcgl -- npx -y mcgl-mcp
23
+ ```
24
+
25
+ **Claude Desktop, Cursor и другие:** добавьте в конфиг MCP-серверов (`claude_desktop_config.json`, `.cursor/mcp.json` и т.п.):
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "mcgl": {
31
+ "command": "npx",
32
+ "args": ["-y", "mcgl-mcp"]
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ На Windows, если клиент не находит `npx`: `"command": "cmd"`, `"args": ["/c", "npx", "-y", "mcgl-mcp"]`.
39
+
40
+ ### Вход под своим аккаунтом (необязательно)
41
+
42
+ Сейчас все инструменты работают и без входа. Вход понадобится для действий от вашего имени, например чтобы писать посты: это в планах. Настроить его можно уже сейчас.
43
+
44
+ ```bash
45
+ claude mcp add mcgl -e FORUM_LOGIN=ник -e FORUM_PASSWORD=пароль -- npx -y mcgl-mcp
46
+ ```
47
+
48
+ В JSON-конфиге то же самое задаётся через `"env": { "FORUM_LOGIN": "ник", "FORUM_PASSWORD": "пароль" }`.
49
+
50
+ Пароль хранится только в конфиге клиента на вашем компьютере и отправляется только на форум. Если войти не получилось, текст ошибки форума виден в логах MCP-сервера в клиенте. У форума есть лимит попыток входа, поэтому после неудачи сервер больше не пытается войти: исправьте пароль и перезапустите клиент.
51
+
52
+ ## Инструменты
53
+
54
+ | Инструмент | Что делает |
55
+ | --------------------- | ------------------------------------------------------------------------------------ |
56
+ | `forum_list_sections` | разделы форума по категориям |
57
+ | `forum_latest_topics` | темы со свежими сообщениями во всех разделах |
58
+ | `forum_list_topics` | темы раздела: закреплённые, закрытые, кто может отвечать |
59
+ | `forum_get_topic` | посты темы по 15 штук; по умолчанию — последние, плюс опрос и кто читает тему сейчас |
60
+ | `forum_get_post` | один пост по ссылке `/post/N` и где он находится в теме |
61
+
62
+ ## Настройки
63
+
64
+ Переменные окружения задаются в конфиге клиента, все необязательные.
65
+
66
+ | Переменная | По умолчанию | Что это |
67
+ | ------------------------------- | ----------------------------------- | ------------------------------------------ |
68
+ | `FORUM_LOGIN`, `FORUM_PASSWORD` | — | вход под своим аккаунтом |
69
+ | `LOG_LEVEL` | `info` | `debug` — подробные логи для отладки |
70
+ | `FORUM_MIN_INTERVAL_MS` | `300` | минимальная пауза между запросами к форуму |
71
+ | `FORUM_TIMEOUT_MS` | `15000` | таймаут одного запроса к форуму |
72
+ | `FORUM_BASE_URL` | `https://forum.minecraft-galaxy.ru` | адрес форума |
73
+
74
+ ## Документация
75
+
76
+ - [Как сервер работает с форумом](docs/forum.md): вход, пагинация тем, bbCode, предметы, недоступность форума.
77
+ - [Разработка](docs/development.md): запуск из исходников, тесты, релизы, свой HTTP-сервер.
78
+
79
+ Нашли ошибку или форум что-то поменял — [заведите issue](https://github.com/exi66/mcgl-mcp/issues/new/choose).
80
+
81
+ ## Лицензия
82
+
83
+ [MIT](LICENSE)
@@ -0,0 +1,49 @@
1
+ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { loadForumEnv, loadHttpEnv } from "../env.js";
4
+ import { ForumHttp } from "../forum/http.js";
5
+ import { ForumReader } from "../forum/reader.js";
6
+ import { RateLimiter } from "../forum/rate-limiter.js";
7
+ import { createLogger } from "../logger.js";
8
+ import { createMcpServer, SERVER_INFO } from "../mcp/server.js";
9
+ const logger = createLogger("http");
10
+ const forumEnv = loadForumEnv();
11
+ const httpEnv = loadHttpEnv();
12
+ // Общий анонимный клиент и общий лимитер: все запросы к форуму идут с IP хоста.
13
+ const limiter = new RateLimiter(forumEnv.minIntervalMs);
14
+ const forumHttp = new ForumHttp(forumEnv, limiter);
15
+ const reader = new ForumReader(forumHttp);
16
+ const app = createMcpExpressApp({ host: httpEnv.host, allowedHosts: httpEnv.allowedHosts });
17
+ // Сам сервер жив (ok), форум — по данным circuit breaker (без лишних запросов к нему).
18
+ app.get("/health", (_req, res) => {
19
+ res.json({ ok: true, ...SERVER_INFO, forum: forumHttp.health.status });
20
+ });
21
+ const MCP_PATH = "/public/mcp";
22
+ app.post(MCP_PATH, async (req, res) => {
23
+ const server = createMcpServer({ reader });
24
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
25
+ res.on("close", () => {
26
+ void transport.close();
27
+ void server.close();
28
+ });
29
+ try {
30
+ await server.connect(transport);
31
+ await transport.handleRequest(req, res, req.body);
32
+ }
33
+ catch (error) {
34
+ logger.error("Error handling MCP request", error);
35
+ if (!res.headersSent) {
36
+ res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null });
37
+ }
38
+ }
39
+ });
40
+ // Stateless-режим: ни GET (SSE-стрим), ни DELETE (закрытие сессии) не нужны.
41
+ const methodNotAllowed = (_req, res) => {
42
+ res.status(405).json({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null });
43
+ };
44
+ app.get(MCP_PATH, methodNotAllowed);
45
+ app.delete(MCP_PATH, methodNotAllowed);
46
+ app.listen(httpEnv.port, httpEnv.host, () => {
47
+ logger.info(`Listening on http://${httpEnv.host}:${httpEnv.port}${MCP_PATH} (forum: ${forumEnv.baseUrl})`);
48
+ });
49
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Локальный режим: MCP-клиент запускает процесс и общается через stdin/stdout.
4
+ * Креды игрока берутся из env (FORUM_LOGIN / FORUM_PASSWORD), которые пользователь
5
+ * сам прописывает в конфиге клиента. Без них доступны только публичные инструменты.
6
+ */
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { loadForumEnv, loadPlayerCredentials } from "../env.js";
9
+ import { ForumHttp } from "../forum/http.js";
10
+ import { PlayerSession } from "../forum/player-session.js";
11
+ import { ForumReader } from "../forum/reader.js";
12
+ import { RateLimiter } from "../forum/rate-limiter.js";
13
+ import { createLogger } from "../logger.js";
14
+ import { createMcpServer } from "../mcp/server.js";
15
+ const logger = createLogger("stdio");
16
+ async function main() {
17
+ const forumEnv = loadForumEnv();
18
+ const credentials = loadPlayerCredentials();
19
+ const http = new ForumHttp(forumEnv, new RateLimiter(forumEnv.minIntervalMs));
20
+ if (!credentials) {
21
+ await createMcpServer({ reader: new ForumReader(http) }).connect(new StdioServerTransport());
22
+ logger.info("Started with public tools only (FORUM_LOGIN/FORUM_PASSWORD not set)");
23
+ return;
24
+ }
25
+ const session = new PlayerSession(http, credentials);
26
+ await createMcpServer({ reader: new ForumReader(http, session), session }).connect(new StdioServerTransport());
27
+ logger.info(`Started with player tools for login "${credentials.login}"`);
28
+ // Логинимся сразу, чтобы неверный пароль был виден в логах клиента при старте,
29
+ // а не при первом вызове инструмента. Процесс не падает: ошибку получит и инструмент.
30
+ session.whoami().then((username) => logger.info(`Authorized as ${username}`), (error) => logger.error(`Authorization failed: ${error instanceof Error ? error.message : String(error)}`));
31
+ }
32
+ main().catch((error) => {
33
+ logger.error("Fatal", error);
34
+ process.exit(1);
35
+ });
36
+ //# sourceMappingURL=stdio.js.map
package/dist/env.js ADDED
@@ -0,0 +1,57 @@
1
+ import { DEFAULT_FORUM_URL } from "./forum/site.js";
2
+ function str(name, fallback) {
3
+ const value = process.env[name]?.trim() || fallback;
4
+ if (value === undefined) {
5
+ throw new Error(`Missing required environment variable: ${name}`);
6
+ }
7
+ return value;
8
+ }
9
+ function optional(name) {
10
+ const value = process.env[name]?.trim();
11
+ return value ? value : undefined;
12
+ }
13
+ function int(name, fallback) {
14
+ const raw = optional(name);
15
+ if (raw === undefined)
16
+ return fallback;
17
+ const value = Number(raw);
18
+ if (!Number.isInteger(value) || value < 0) {
19
+ throw new Error(`Environment variable ${name} must be a non-negative integer, got "${raw}"`);
20
+ }
21
+ return value;
22
+ }
23
+ /** Настройки доступа к форуму, общие для обоих режимов. */
24
+ export function loadForumEnv() {
25
+ return {
26
+ baseUrl: str("FORUM_BASE_URL", DEFAULT_FORUM_URL).replace(/\/+$/, ""),
27
+ minIntervalMs: int("FORUM_MIN_INTERVAL_MS", 300),
28
+ timeoutMs: int("FORUM_TIMEOUT_MS", 15000),
29
+ };
30
+ }
31
+ /**
32
+ * Креды игрока для локального режима. Пользователь сам кладёт их в env
33
+ * в конфиге своего MCP-клиента. Если они не заданы, сервер стартует только
34
+ * с публичными инструментами.
35
+ */
36
+ export function loadPlayerCredentials() {
37
+ const login = optional("FORUM_LOGIN");
38
+ const password = optional("FORUM_PASSWORD");
39
+ if (!login && !password)
40
+ return undefined;
41
+ if (!login || !password) {
42
+ throw new Error("FORUM_LOGIN and FORUM_PASSWORD must be set together");
43
+ }
44
+ return { login, password };
45
+ }
46
+ export function loadHttpEnv() {
47
+ const allowedHosts = optional("ALLOWED_HOSTS")
48
+ ?.split(",")
49
+ .map((h) => h.trim())
50
+ .filter(Boolean);
51
+ return {
52
+ host: str("HOST", "0.0.0.0"),
53
+ port: int("PORT", 3000),
54
+ allowedHosts: allowedHosts?.length ? allowedHosts : undefined,
55
+ };
56
+ }
57
+ //# sourceMappingURL=env.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Простейшее хранилище кук на один хост форума. Path, domain и secure
3
+ * не учитываются: мы всегда ходим на один и тот же origin.
4
+ */
5
+ export class CookieJar {
6
+ cookies = new Map();
7
+ /** Применяет заголовки Set-Cookie из ответа. */
8
+ absorb(headers) {
9
+ for (const raw of headers.getSetCookie()) {
10
+ const [pair, ...attrs] = raw.split(";");
11
+ const eq = pair.indexOf("=");
12
+ if (eq <= 0)
13
+ continue;
14
+ const name = pair.slice(0, eq).trim();
15
+ const value = pair.slice(eq + 1).trim();
16
+ const expired = attrs.some((a) => {
17
+ const [k, v = ""] = a.split("=").map((s) => s.trim());
18
+ const key = k.toLowerCase();
19
+ if (key === "max-age")
20
+ return Number(v) <= 0;
21
+ if (key === "expires")
22
+ return Date.parse(v) <= Date.now();
23
+ return false;
24
+ });
25
+ if (expired || value === "" || value === "deleted") {
26
+ this.cookies.delete(name);
27
+ }
28
+ else {
29
+ this.cookies.set(name, value);
30
+ }
31
+ }
32
+ }
33
+ header() {
34
+ if (this.cookies.size === 0)
35
+ return undefined;
36
+ return [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; ");
37
+ }
38
+ has(name) {
39
+ return this.cookies.has(name);
40
+ }
41
+ clear() {
42
+ this.cookies.clear();
43
+ }
44
+ }
45
+ //# sourceMappingURL=cookie-jar.js.map
@@ -0,0 +1,38 @@
1
+ /** Ошибка, текст которой можно показать агенту как результат инструмента. */
2
+ export class ForumError extends Error {
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ this.name = "ForumError";
6
+ }
7
+ }
8
+ /** Форум ответил страницей «войдите». Сессия будет переустановлена один раз. */
9
+ export class SessionExpiredError extends ForumError {
10
+ constructor() {
11
+ super("Forum session expired");
12
+ this.name = "SessionExpiredError";
13
+ }
14
+ }
15
+ /** Логин не удался: неверные креды, бан, капча и т.п. */
16
+ export class LoginFailedError extends ForumError {
17
+ constructor(reason) {
18
+ super(`Forum login failed: ${reason}`);
19
+ this.name = "LoginFailedError";
20
+ }
21
+ }
22
+ /** Разметка страницы не совпала с ожиданиями парсера. */
23
+ export class ParseError extends ForumError {
24
+ constructor(what, url) {
25
+ super(`Could not parse ${what} at ${url}. The forum markup may have changed.`);
26
+ this.name = "ParseError";
27
+ }
28
+ }
29
+ /** Форум не отвечает (сработал ForumHealth): запросы временно не отправляются. */
30
+ export class ForumUnavailableError extends ForumError {
31
+ retryInSec;
32
+ constructor(retryInSec) {
33
+ super(`The forum is not responding right now. Try again in ${retryInSec}s.`);
34
+ this.retryInSec = retryInSec;
35
+ this.name = "ForumUnavailableError";
36
+ }
37
+ }
38
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,60 @@
1
+ import { createLogger } from "../logger.js";
2
+ import { ForumUnavailableError } from "./errors.js";
3
+ const logger = createLogger("forum-health");
4
+ /**
5
+ * Circuit breaker для форума. Форум периодически лежит; без этого каждый вызов
6
+ * инструмента ждал бы таймауты всех повторов.
7
+ *
8
+ * - closed: запросы идут как обычно;
9
+ * - open: после `failureThreshold` неудачных запросов подряд форум считается
10
+ * лежащим, запросы сразу получают ForumUnavailableError;
11
+ * - через `cooldownMs` пропускается один пробный запрос (half-open): успех
12
+ * закрывает breaker, неудача снова открывает его на cooldown.
13
+ *
14
+ * Один экземпляр на процесс, общий для всех ForumHttp.
15
+ */
16
+ export class ForumHealth {
17
+ failures = 0;
18
+ openUntil = 0;
19
+ probing = false;
20
+ threshold;
21
+ cooldownMs;
22
+ now;
23
+ constructor(options = {}) {
24
+ this.threshold = options.failureThreshold ?? 2;
25
+ this.cooldownMs = options.cooldownMs ?? 30_000;
26
+ this.now = options.now ?? Date.now;
27
+ }
28
+ /** Бросает ForumUnavailableError, если форум сейчас считается лежащим. */
29
+ check() {
30
+ if (this.failures < this.threshold)
31
+ return;
32
+ const wait = this.openUntil - this.now();
33
+ if (wait > 0 || this.probing) {
34
+ throw new ForumUnavailableError(Math.max(1, Math.ceil(Math.max(wait, 1000) / 1000)));
35
+ }
36
+ // Cooldown прошёл: пропускаем один пробный запрос.
37
+ this.probing = true;
38
+ }
39
+ success() {
40
+ if (this.failures >= this.threshold)
41
+ logger.info("Forum is back up");
42
+ this.failures = 0;
43
+ this.probing = false;
44
+ }
45
+ failure() {
46
+ this.failures++;
47
+ this.probing = false;
48
+ if (this.failures >= this.threshold) {
49
+ if (this.failures === this.threshold)
50
+ logger.warn(`Forum looks down, pausing requests for ${this.cooldownMs / 1000}s`);
51
+ this.openUntil = this.now() + this.cooldownMs;
52
+ }
53
+ }
54
+ get status() {
55
+ if (this.failures < this.threshold)
56
+ return { up: true };
57
+ return { up: false, retryInSec: Math.max(0, Math.ceil((this.openUntil - this.now()) / 1000)) };
58
+ }
59
+ }
60
+ //# sourceMappingURL=health.js.map
@@ -0,0 +1,138 @@
1
+ import { createLogger } from "../logger.js";
2
+ import { CookieJar } from "./cookie-jar.js";
3
+ import { ForumError } from "./errors.js";
4
+ import { ForumHealth } from "./health.js";
5
+ const logger = createLogger("forum-http");
6
+ const USER_AGENT = "mcgl-mcp/0.1 (+MCP forum tools)";
7
+ const MAX_REDIRECTS = 5;
8
+ /** Ответ, после которого GET имеет смысл повторить. */
9
+ class RetryableError extends ForumError {
10
+ }
11
+ /**
12
+ * HTTP-клиент к форуму: свой CookieJar, общий RateLimiter, ручные редиректы
13
+ * (чтобы не терять Set-Cookie из 302 после логина), повторы и circuit breaker.
14
+ *
15
+ * - GET при сетевой ошибке, таймауте или 5xx повторяется (по умолчанию 3 попытки).
16
+ * - POST не повторяется: при обрыве неизвестно, дошёл ли запрос, а повтор
17
+ * отправки поста создал бы дубль.
18
+ * - Если форум лежит (ForumHealth), запрос сразу падает с ForumUnavailableError.
19
+ *
20
+ * Один экземпляр = одна «браузерная сессия». Для анонимного чтения хватает
21
+ * одного общего, для каждого игрока нужен свой.
22
+ */
23
+ export class ForumHttp {
24
+ env;
25
+ limiter;
26
+ jar = new CookieJar();
27
+ health;
28
+ retryDelaysMs;
29
+ constructor(env, limiter, options = {}) {
30
+ this.env = env;
31
+ this.limiter = limiter;
32
+ this.health = options.health ?? new ForumHealth();
33
+ this.retryDelaysMs = options.retryDelaysMs ?? [1000, 2000];
34
+ }
35
+ get(path) {
36
+ return this.withHealth(() => this.withRetries(path, () => this.request(path, { method: "GET" })));
37
+ }
38
+ postForm(path, fields, extraHeaders) {
39
+ const body = new URLSearchParams();
40
+ for (const [k, v] of Object.entries(fields))
41
+ if (v !== undefined)
42
+ body.append(k, String(v));
43
+ return this.withHealth(() => this.request(path, {
44
+ method: "POST",
45
+ headers: { ...extraHeaders, "content-type": "application/x-www-form-urlencoded; charset=utf-8" },
46
+ body: body.toString(),
47
+ }));
48
+ }
49
+ absoluteUrl(path) {
50
+ return new URL(path, this.env.baseUrl + "/").toString();
51
+ }
52
+ async withHealth(run) {
53
+ this.health.check();
54
+ try {
55
+ const page = await run();
56
+ this.health.success();
57
+ return page;
58
+ }
59
+ catch (error) {
60
+ // Форум ответил, но что-то не так (редиректы по кругу и т.п.) — он жив.
61
+ if (error instanceof RetryableError)
62
+ this.health.failure();
63
+ else
64
+ this.health.success();
65
+ throw error;
66
+ }
67
+ }
68
+ async withRetries(path, run) {
69
+ for (let attempt = 0;; attempt++) {
70
+ try {
71
+ return await run();
72
+ }
73
+ catch (error) {
74
+ const delay = this.retryDelaysMs[attempt];
75
+ if (!(error instanceof RetryableError) || delay === undefined)
76
+ throw error;
77
+ logger.warn(`GET ${path} failed (${error.message}), retry ${attempt + 1}/${this.retryDelaysMs.length} in ${delay}ms`);
78
+ await new Promise((r) => setTimeout(r, delay));
79
+ }
80
+ }
81
+ }
82
+ async request(path, init) {
83
+ let url = this.absoluteUrl(path);
84
+ let current = init;
85
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
86
+ const res = await this.limiter.schedule(() => this.fetchOnce(url, current));
87
+ this.jar.absorb(res.headers);
88
+ const location = res.headers.get("location");
89
+ if (res.status >= 300 && res.status < 400 && location) {
90
+ await res.body?.cancel();
91
+ url = new URL(location, url).toString();
92
+ // 301/302/303 после POST браузеры превращают в GET, повторяем это поведение.
93
+ current = res.status === 307 || res.status === 308 ? current : { method: "GET" };
94
+ continue;
95
+ }
96
+ const html = decode(await res.arrayBuffer(), res.headers.get("content-type"));
97
+ logger.debug(`${current.method} ${url} -> ${res.status}${hop ? ` (after ${hop} redirect(s) from ${init.method} ${path})` : ""}`);
98
+ if (res.status >= 500) {
99
+ throw new RetryableError(`Forum returned HTTP ${res.status} for ${url}`);
100
+ }
101
+ return { url, status: res.status, html };
102
+ }
103
+ throw new ForumError(`Too many redirects starting from ${path}`);
104
+ }
105
+ async fetchOnce(url, init) {
106
+ const headers = new Headers(init.headers);
107
+ headers.set("user-agent", USER_AGENT);
108
+ headers.set("accept", "text/html,application/xhtml+xml");
109
+ const cookie = this.jar.header();
110
+ if (cookie)
111
+ headers.set("cookie", cookie);
112
+ try {
113
+ return await fetch(url, {
114
+ ...init,
115
+ headers,
116
+ redirect: "manual",
117
+ signal: AbortSignal.timeout(this.env.timeoutMs),
118
+ });
119
+ }
120
+ catch (error) {
121
+ const timeout = error instanceof DOMException && error.name === "TimeoutError";
122
+ throw new RetryableError(timeout ? `Forum timed out: ${url}` : `Request to forum failed: ${url}`, {
123
+ cause: error,
124
+ });
125
+ }
126
+ }
127
+ }
128
+ /** Форум отдаёт UTF-8 и пишет charset в заголовке; на всякий случай уважаем его. */
129
+ function decode(body, contentType) {
130
+ const charset = /charset=([^;]+)/i.exec(contentType ?? "")?.[1]?.trim() || "utf-8";
131
+ try {
132
+ return new TextDecoder(charset).decode(body);
133
+ }
134
+ catch {
135
+ return new TextDecoder("utf-8").decode(body);
136
+ }
137
+ }
138
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,87 @@
1
+ import { createLogger } from "../logger.js";
2
+ const logger = createLogger("objects");
3
+ /**
4
+ * Эндпоинт всплывающих подсказок форума: `/object/{id}/{type}`.
5
+ * Тип 1 — игрок, 2 — предмет (`?d=` — метадата), 3 — сообщество.
6
+ * Ответ — JS-объект в одинарных кавычках (форум делает ему eval), пустой ответ — нет такого объекта:
7
+ * {'_type':'2','id':'41','title':'Золотой блок','info':'Используется для декора…'}
8
+ */
9
+ export function parseObjectLiteral(raw) {
10
+ const body = raw.trim();
11
+ if (!body.startsWith("{"))
12
+ return undefined;
13
+ const result = {};
14
+ for (const [, key, value] of body.matchAll(/'([^']+)'\s*:\s*'((?:\\.|[^'\\])*)'/g)) {
15
+ result[key] = value.replace(/\\(.)/g, "$1");
16
+ }
17
+ return Object.keys(result).length ? result : undefined;
18
+ }
19
+ /** Маркер предмета в тексте поста, который ставит content.ts и раскрывает ItemResolver. */
20
+ export const ITEM_TOKEN = /⟦item:(\d+):(\d+)⟧/g;
21
+ export const itemToken = (id, meta) => `⟦item:${id}:${meta}⟧`;
22
+ /** Легенда к окну крафта: список предметов через ";". */
23
+ export const LEGEND_TOKEN = /⟦legend:([\d:;]+)⟧/g;
24
+ export const legendToken = (items) => `⟦legend:${items.map(([i, m]) => `${i}:${m}`).join(";")}⟧`;
25
+ /**
26
+ * Названия предметов по id и метадате. Предметы не меняются, поэтому кеш
27
+ * живёт весь процесс и общий для всех запросов. За один вызов resolve
28
+ * делается не больше `maxLookups` новых запросов к форуму; остальные
29
+ * предметы остаются в виде «item #id».
30
+ */
31
+ export class ItemResolver {
32
+ http;
33
+ maxLookups;
34
+ cache = new Map();
35
+ constructor(http, maxLookups = 25) {
36
+ this.http = http;
37
+ this.maxLookups = maxLookups;
38
+ }
39
+ /** Подставляет названия вместо маркеров во всех переданных текстах. */
40
+ async resolve(texts) {
41
+ const wanted = new Set();
42
+ for (const t of texts) {
43
+ for (const [, id, meta] of t.matchAll(ITEM_TOKEN))
44
+ wanted.add(`${id}:${meta}`);
45
+ for (const [, list] of t.matchAll(LEGEND_TOKEN))
46
+ list.split(";").forEach((k) => wanted.add(k));
47
+ }
48
+ let budget = this.maxLookups;
49
+ const known = new Map();
50
+ for (const key of wanted) {
51
+ if (!this.cache.has(key)) {
52
+ if (budget-- <= 0)
53
+ continue;
54
+ const [id, meta] = key.split(":").map(Number);
55
+ this.cache.set(key, this.fetchItem(id, meta));
56
+ }
57
+ known.set(key, await this.cache.get(key));
58
+ }
59
+ const shortKey = (key) => (key.endsWith(":0") ? key.slice(0, -2) : key);
60
+ const name = (key) => known.get(key)?.title ?? "item";
61
+ // "Золотой блок #41", "Красная шерсть #35:14", без названия — "item #41".
62
+ const label = (key) => `${name(key)} #${shortKey(key)}`;
63
+ return texts.map((t) => t
64
+ .replace(ITEM_TOKEN, (_, id, meta) => `[${label(`${id}:${meta}`)}]`)
65
+ .replace(LEGEND_TOKEN, (_, list) => list
66
+ .split(";")
67
+ .filter((k, i, all) => all.indexOf(k) === i)
68
+ .map((k) => `${shortKey(k)} = ${name(k)}`)
69
+ .join("\n")));
70
+ }
71
+ async fetchItem(id, meta) {
72
+ try {
73
+ const page = await this.http.get(`/object/${id}/2${meta ? `?d=${meta}` : ""}`);
74
+ const obj = parseObjectLiteral(page.html);
75
+ if (!obj?.title)
76
+ return undefined;
77
+ return { id, meta, title: obj.title, info: obj.info || undefined };
78
+ }
79
+ catch (error) {
80
+ // Не кешируем сбой сети: в следующий раз попробуем снова.
81
+ this.cache.delete(`${id}:${meta}`);
82
+ logger.warn(`Item ${id}:${meta} lookup failed`, error);
83
+ return undefined;
84
+ }
85
+ }
86
+ }
87
+ //# sourceMappingURL=objects.js.map