0119ea0f15
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>
87 lines
5.1 KiB
JavaScript
87 lines
5.1 KiB
JavaScript
'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); });
|