// Матрица Судьбы · эндпоинт платного разбора (AI).
// Бесплатный тизер генерится на странице без AI — этот сервер вызывается
// ТОЛЬКО после оплаты, для полного текста.
//
// Запуск: ANTHROPIC_API_KEY=sk-... node razbor-server.js
// Env:
// ANTHROPIC_API_KEY — обязательно
// PORT — по умолчанию 8787
// MODEL — по умолчанию claude-haiku-4-5 (для полного качества — claude-sonnet-5)
// SKILL_PATH — путь к скиллу-интерпретатору, по умолчанию ./matrix-interpreter_v1.md
// ALLOW_ORIGIN — CORS, по умолчанию * (сузить до домена сайта в проде)
//
// API: POST /razbor { focus: "general"|"money"|"love"|"purpose", kind: "full"|"teaser", calc: {…JSON калькулятора} }
// → { text: "..." }
// Кэш: пара «дата рождения + фокус + kind» (расчёт детерминирован).
// Лимит: 20 запросов с IP в сутки (эндпоинт платный, лимит — страховка от абьюза).
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = +(process.env.PORT || 8787);
const MODEL = process.env.MODEL || 'claude-haiku-4-5';
const API_KEY = process.env.ANTHROPIC_API_KEY;
const SKILL_PATH = process.env.SKILL_PATH || path.join(__dirname, 'matrix-interpreter_v1.md');
const ALLOW_ORIGIN = process.env.ALLOW_ORIGIN || '*';
const RATE_LIMIT = 20; // запросов с IP в сутки
if (!API_KEY) {
console.error('ANTHROPIC_API_KEY не задан');
process.exit(1);
}
const SKILL = fs.readFileSync(SKILL_PATH, 'utf8');
const FOCUSES = new Set(['general', 'money', 'love', 'purpose']);
const FOCUS_RU = { general: 'общий', money: 'деньги', love: 'отношения', purpose: 'предназначение' };
const cache = new Map(); // key → text
const rate = new Map(); // ip → { day, count }
function limited(ip) {
const day = new Date().toISOString().slice(0, 10);
const rec = rate.get(ip);
if (!rec || rec.day !== day) { rate.set(ip, { day, count: 1 }); return false; }
if (rec.count >= RATE_LIMIT) return true;
rec.count++;
return false;
}
async function generate(focus, kind, calc) {
const task = kind === 'teaser'
? 'Напиши ТИЗЕР (бесплатно, 450–550 слов): одно попадание и честно открытая петля. Петля открывает только те двери, которые закрывает платный продукт этого же фокуса.'
: 'Напиши ПОЛНЫЙ разбор (1200–1600 слов): имя узора → центр → узор целиком → тема фокуса развёрнуто → род → возрастное окно (самая сильная часть) → поворот → три открытых вопроса.';
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': API_KEY,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: MODEL,
max_tokens: kind === 'teaser' ? 1200 : 3000,
system: SKILL,
messages: [{
role: 'user',
content: `Фокус: ${FOCUS_RU[focus]}.\n${task}\nОбращение — на «ты».\n\nДанные расчёта:\n${JSON.stringify(calc, null, 1)}`,
}],
}),
});
if (!res.ok) throw new Error(`API ${res.status}: ${await res.text()}`);
const data = await res.json();
return data.content.map(b => b.text || '').join('');
}
function send(res, code, obj) {
res.writeHead(code, {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': ALLOW_ORIGIN,
'access-control-allow-headers': 'content-type',
'access-control-allow-methods': 'POST, OPTIONS',
});
res.end(JSON.stringify(obj));
}
http.createServer(async (req, res) => {
if (req.method === 'OPTIONS') return send(res, 204, {});
if (req.method !== 'POST' || req.url !== '/razbor') return send(res, 404, { error: 'not found' });
const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
if (limited(ip)) return send(res, 429, { error: 'rate limit' });
let body = '';
req.on('data', ch => { body += ch; if (body.length > 200000) req.destroy(); });
req.on('end', async () => {
try {
const { focus = 'general', kind = 'full', calc } = JSON.parse(body || '{}');
if (!FOCUSES.has(focus) || !calc || !calc.birthDate || !calc.core) {
return send(res, 400, { error: 'bad request: нужны focus и calc (JSON калькулятора)' });
}
const key = `${calc.birthDate}|${focus}|${kind}`;
if (cache.has(key)) return send(res, 200, { text: cache.get(key), cached: true });
const text = await generate(focus, kind, calc);
cache.set(key, text);
send(res, 200, { text });
} catch (e) {
console.error(e);
send(res, 500, { error: 'generation failed' });
}
});
}).listen(PORT, () => console.log(`razbor-server: http://localhost:${PORT}/razbor · модель ${MODEL}`));