feat(assistant): headless-RAG — индексация JS-рендеримых учебников
scripts/index-textbooks-headless.js: puppeteer-core + системный Chrome/Edge рендерит каждый учебник через локальный сервер (служебный JWT в localStorage, т.к. /textbook требует логина), кликает по параграфам и забирает рендерный текст движков (математика/физика и т.п.) в textbook_chunks. Дополняет статический индексатор. npm: index:textbooks / index:textbooks:full (headless). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
/* index-textbooks-headless.js — полный RAG-индекс: рендерит каждый учебник
|
||||
* настоящим браузером (puppeteer-core + системный Chrome/Edge) через локальный
|
||||
* сервер и забирает РЕНДЕРНЫЙ текст параграфов. Покрывает и JS-рендеримые
|
||||
* учебники (математика/физика-движки), которых нет в статическом HTML.
|
||||
*
|
||||
* Требует запущенный сервер (localhost:3000). Долгая операция (минуты).
|
||||
* Запуск: node backend/scripts/index-textbooks-headless.js
|
||||
* Дополняет/замещает чанки только для успешно отрендеренных учебников. */
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../src/db/db');
|
||||
|
||||
/* Текстовые страницы учебника требуют логина — выпускаем служебный JWT. */
|
||||
function authToken() {
|
||||
const u = db.prepare("SELECT id, role, token_version FROM users WHERE is_banned = 0 AND role IN ('admin','teacher') ORDER BY id LIMIT 1").get()
|
||||
|| db.prepare('SELECT id, role, token_version FROM users WHERE is_banned = 0 ORDER BY id LIMIT 1').get();
|
||||
if (!u || !process.env.JWT_SECRET) return null;
|
||||
return jwt.sign({ id: u.id, role: u.role, tv: u.token_version }, process.env.JWT_SECRET, { algorithm: 'HS256', expiresIn: '4h' });
|
||||
}
|
||||
|
||||
const BASE = process.env.ASSISTANT_INDEX_BASE || ('http://localhost:' + (process.env.PORT || 3000));
|
||||
const BROWSERS = [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
|
||||
];
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
async function run() {
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const exe = BROWSERS.find(p => { try { return fs.existsSync(p); } catch (e) { return false; } });
|
||||
if (!exe) { console.error('Браузер не найден (Chrome/Edge)'); process.exit(1); }
|
||||
const books = db.prepare('SELECT slug, title FROM textbooks WHERE is_active = 1 ORDER BY slug').all();
|
||||
const del = db.prepare('DELETE FROM textbook_chunks WHERE slug = ?');
|
||||
const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text) VALUES (?, ?, ?, ?)');
|
||||
|
||||
const token = authToken();
|
||||
if (!token) { console.error('Не удалось выпустить токен (нет пользователя или JWT_SECRET)'); process.exit(1); }
|
||||
const browser = await puppeteer.launch({ executablePath: exe, headless: true, args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1100, height: 900 });
|
||||
await page.evaluateOnNewDocument((t) => { try { localStorage.setItem('ls_token', t); } catch (e) {} }, token);
|
||||
let totalChunks = 0, okBooks = 0;
|
||||
|
||||
for (const b of books) {
|
||||
let chunks = [];
|
||||
try {
|
||||
await page.goto(`${BASE}/textbook/${b.slug}`, { waitUntil: 'networkidle2', timeout: 25000 });
|
||||
await page.waitForSelector('.psel-card, .sec', { timeout: 12000 }).catch(() => {});
|
||||
await sleep(400);
|
||||
const ids = await page.$$eval('.psel-card[data-id]', els => els.map(e => ({ id: e.dataset.id, name: ((e.querySelector('.psel-name') || {}).textContent || '').trim() })));
|
||||
if (ids.length) {
|
||||
for (const s of ids) {
|
||||
try {
|
||||
await page.evaluate(id => { const c = document.querySelector('.psel-card[data-id="' + id + '"]'); if (c) c.click(); }, s.id);
|
||||
await sleep(550);
|
||||
const text = await page.evaluate(() => { const a = document.querySelector('.sec.active'); return a ? a.innerText.replace(/\s+/g, ' ').trim() : ''; });
|
||||
if (text && text.length >= 80) chunks.push({ section: s.name.slice(0, 160), text: text.slice(0, 2000) });
|
||||
} catch (e) {}
|
||||
}
|
||||
} else {
|
||||
const secs = await page.$$eval('.sec', els => els.map(e => e.innerText.replace(/\s+/g, ' ').trim()));
|
||||
secs.forEach(t => { if (t && t.length >= 80) chunks.push({ section: '', text: t.slice(0, 2000) }); });
|
||||
}
|
||||
} catch (e) { /* книга не отрендерилась — оставляем как было */ }
|
||||
|
||||
if (chunks.length) {
|
||||
del.run(b.slug);
|
||||
for (const c of chunks) ins.run(b.slug, b.title || b.slug, c.section, c.text);
|
||||
okBooks++; totalChunks += chunks.length;
|
||||
console.log(` ${b.slug}: ${chunks.length}`);
|
||||
} else {
|
||||
console.log(` ${b.slug}: — (нет рендера, оставлено как есть)`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`[headless] готово: ${okBooks}/${books.length} учебников, ${totalChunks} чанков (перезаписаны).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(e => { console.error('[headless] ошибка:', e.message); process.exit(1); });
|
||||
Reference in New Issue
Block a user