/* global React */ (function () { const { useState, useEffect, useRef, useCallback } = React; const { mapRow, mapJob, parseStats } = window.SC_Utils; const SRC_MAP = window.SC_SrcMap; const Api = window.SC_Api; // ── localStorage helpers ────────────────────────────────────── const SK = { seeds: 'sc_seeds', manual: 'sc_manual', siteUrls: 'sc_site_urls', minus: 'sc_minus_words', questions: 'sc_q_rolling', jobId: 'sc_job_id', }; const lsGet = (key) => { try { return JSON.parse(localStorage.getItem(key)); } catch { return null; } }; const lsSet = (key, val) => { try { localStorage.setItem(key, JSON.stringify(val)); } catch {} }; const lsDel = (key) => { try { localStorage.removeItem(key); } catch {} }; function MainPage() { const [seeds, setSeeds] = useState(() => lsGet(SK.seeds) || ''); const [siteUrls, setSiteUrls] = useState(() => lsGet(SK.siteUrls) || ''); const [minusWords, setMinusWords] = useState(() => lsGet(SK.minus) || ''); const [manual, setManual] = useState(() => lsGet(SK.manual) ?? true); const [runState, setRunState] = useState('idle'); const [activeStep, setActiveStep] = useState(0); const [currentAction, setCurrentAction] = useState(''); const [errorMsg, setErrorMsg] = useState(''); const [stats, setStats] = useState({ phrases: 0, sites: 0, suggest: 0, wordstatTotal: 0 }); const [rows, setRows] = useState([]); const [totalCount, setTotalCount] = useState(0); const [currentJobId, setCurrentJobId] = useState(null); const [liveItems, setLiveItems] = useState([]); const [sitesList, setSitesList] = useState([]); const [suggestPhrases, setSuggestPhrases] = useState([]); const [wordstatPhrases, setWordstatPhrases] = useState([]); const [cookieStatus, setCookieStatus] = useState({ ok: false, count: 0 }); const [history, setHistory] = useState([]); const [questionOpen, setQuestionOpen] = useState(false); const [questionLoading, setQuestionLoading] = useState(false); // Rolling Q state: currentBatch=[Q_a,Q_b], posInBatch=0|1, batchNum=0-3, history=[{question,answer}] const [qRolling, setQRolling] = useState(null); const [pendingPhrases, setPendingPhrases] = useState([]); const [siteProfile, setSiteProfile] = useState(''); const nextBatchRef = useRef(null); // Promise<{questions:string[],site_profile:string}> const [reviewPhrase, setReviewPhrase] = useState(''); const [reviewIndex, setReviewIndex] = useState(0); const [reviewTotal, setReviewTotal] = useState(0); const [reviewOpen, setReviewOpen] = useState(false); const [toast, setToast] = useState(null); const [showAdGen, setShowAdGen] = useState(false); const [adPhrases, setAdPhrases] = useState([]); const wsRef = useRef(null); // ── Persist seeds / manual / siteUrls ─────────────────────── useEffect(() => { lsSet(SK.seeds, seeds); }, [seeds]); useEffect(() => { lsSet(SK.manual, manual); }, [manual]); useEffect(() => { lsSet(SK.siteUrls, siteUrls); }, [siteUrls]); useEffect(() => { lsSet(SK.minus, minusWords); }, [minusWords]); // ── Persist questions state ────────────────────────────────── useEffect(() => { if (questionOpen && qRolling) { lsSet(SK.questions, { phrases: pendingPhrases, qRolling, siteProfile }); } else if (!questionOpen) { lsDel(SK.questions); } }, [questionOpen, qRolling, pendingPhrases, siteProfile]); // ── Mount: restore state ───────────────────────────────────── useEffect(() => { loadCookieStatus(); loadHistory(); const savedQ = lsGet(SK.questions); if (savedQ?.qRolling?.currentBatch?.length > 0) { setPendingPhrases(savedQ.phrases || []); setSiteProfile(savedQ.siteProfile || ''); setQRolling(savedQ.qRolling); setQuestionOpen(true); return; } const savedJobId = lsGet(SK.jobId); if (savedJobId) { // Тихое восстановление задания без тоста resumeJobSilent(savedJobId); } }, []); // ── Helpers ────────────────────────────────────────────────── const showToast = useCallback((kind, msg) => { setToast({ kind, msg }); setTimeout(() => setToast(null), 2800); }, []); const resetRunState = useCallback(() => { lsDel(SK.jobId); lsDel(SK.questions); setRunState('idle'); setActiveStep(0); setCurrentAction(''); setStats({ phrases: 0, sites: 0, suggest: 0, wordstatTotal: 0 }); setLiveItems([]); setSitesList([]); setSuggestPhrases([]); setWordstatPhrases([]); setRows([]); setTotalCount(0); setCurrentJobId(null); setErrorMsg(''); setShowAdGen(false); setAdPhrases([]); setQRolling(null); nextBatchRef.current = null; if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } }, []); const loadCookieStatus = async () => { try { const r = await Api.getJson('/api/cookies'); setCookieStatus({ ok: r.ok, count: r.count || 0 }); } catch {} }; const saveCookies = async (str) => { try { const r = await Api.postJson('/api/cookies', { cookie_string: str }); if (r.ok) { showToast('ok', `Куки сохранены: ${r.count} шт.`); loadCookieStatus(); } else showToast('err', r.error || 'Ошибка сохранения'); } catch { showToast('err', 'Ошибка'); } }; const loadHistory = async () => { try { const jobs = await Api.getJson('/api/jobs'); setHistory(jobs.slice(0, 5).map(mapJob)); } catch {} }; // Загрузка задания с тостом (по клику из истории) const resumeJob = async (jobId) => { await _loadJob(jobId, true); }; // Тихое восстановление при перезагрузке страницы (без тоста) const resumeJobSilent = async (jobId) => { await _loadJob(jobId, false); }; const _loadJob = async (jobId, withToast) => { try { const j = await Api.getJson(`/api/job/${jobId}`); setCurrentJobId(jobId); lsSet(SK.jobId, jobId); setLiveItems([]); setSitesList([]); setSuggestPhrases([]); setWordstatPhrases([]); if (j.status === 'done') { if (j.result?.length) { const mapped = j.result.map(mapRow); setRows(mapped); setTotalCount(j.phrase_count || mapped.length); // Считаем по реальным строкам — mapRow уже применяет SRC_MAP, // поэтому 'похожие' → 'wordstat', 'вордстат' → 'wordstat' и т.д. const bySrc = {}; mapped.forEach((r) => { bySrc[r.src] = (bySrc[r.src] || 0) + 1; }); setStats({ phrases: j.phrase_count || mapped.length, seed: bySrc.seed || 0, ai: bySrc.ai || 0, suggest: bySrc.suggest || 0, wordstat: bySrc.wordstat || 0, sites: 0, wordstatTotal: 0, }); } else { // Результат не в памяти (после рестарта сервера, старая запись без result) setTotalCount(j.phrase_count || 0); setStats({ phrases: j.phrase_count || 0, seed: 0, ai: 0, suggest: 0, wordstat: 0, sites: 0, wordstatTotal: 0 }); } setRunState('done'); if (withToast) showToast('ok', `Задача #${jobId}`); } else if (j.status === 'running') { setRunState('running'); connectWS(jobId); if (withToast) showToast('ok', `Задача #${jobId} — переподключаемся…`); } else if (j.status === 'error') { if (j.result?.length) { // Задача упала (перезапуск сервера), но есть данные из чекпоинта — показываем как done const mapped = j.result.map(mapRow); setRows(mapped); setTotalCount(j.phrase_count || mapped.length); const bySrc = {}; mapped.forEach((r) => { bySrc[r.src] = (bySrc[r.src] || 0) + 1; }); setStats({ phrases: j.phrase_count || mapped.length, seed: bySrc.seed || 0, ai: bySrc.ai || 0, suggest: bySrc.suggest || 0, wordstat: bySrc.wordstat || 0, sites: 0, wordstatTotal: 0 }); setRunState('done'); if (withToast) showToast('warn', `Задача #${jobId} — данные из чекпоинта (шаг ${j.checkpoint_step ?? '?'} из 4)`); } else { setErrorMsg(j.error || 'Ошибка'); setRunState('error'); lsDel(SK.jobId); if (withToast) showToast('err', `Задача #${jobId} завершилась с ошибкой`); } } else { // pending или неизвестный статус — очищаем lsDel(SK.jobId); } } catch { lsDel(SK.jobId); if (withToast) showToast('err', 'Не удалось загрузить задачу'); } }; const connectWS = useCallback((jobId) => { if (wsRef.current) wsRef.current.close(); const ws = new WebSocket(Api.wsUrl(`/api/ws/${jobId}`)); wsRef.current = ws; ws.onmessage = (e) => { let msg; try { msg = JSON.parse(e.data); } catch { return; } if (msg.type === 'log') { const text = msg.text || ''; setStats((cur) => parseStats(text, cur)); const m = text.match(/Фраза[:\s]+«([^»]+)»/); if (m) { const phrase = m[1].trim(); const src = text.includes('suggest') ? 'suggest' : text.includes('Wordstat') ? 'wordstat' : text.includes('ИИ') ? 'ai' : 'seed'; setLiveItems((prev) => [...prev.slice(-20), { phrase, src }]); } } if (msg.type === 'site_data') { const ph = msg.phrases || []; setSitesList((prev) => [...prev, { url: msg.url, phrases: ph }]); setStats((cur) => ({ ...cur, sites: cur.sites + 1, phrases: cur.phrases + ph.length })); if (ph.length > 0) setLiveItems((prev) => [...prev, ...ph.slice(0, 3).map((p) => ({ phrase: p, src: 'ai' }))].slice(-20)); } if (msg.type === 'suggest_data') { setSuggestPhrases(msg.phrases || []); setStats((cur) => ({ ...cur, suggest: (msg.phrases || []).length })); } if (msg.type === 'wordstat_data') setWordstatPhrases(msg.phrases || []); if (msg.type === 'progress') { setActiveStep(msg.step - 1); if (msg.label) setCurrentAction(msg.label); } if (msg.type === 'status') { if (msg.status === 'running') setRunState('running'); if (msg.status === 'error') setRunState('error'); } if (msg.type === 'result') { const mapped = (msg.data || []).map(mapRow); setRows(mapped); setTotalCount(msg.phrase_count || mapped.length); // source_counts содержит русские ключи («затравка», «ИИ», «suggest», «вордстат», «похожие») // Надёжнее считать по уже маппированным строкам (mapRow применяет SRC_MAP) const bySrc = {}; mapped.forEach((r) => { bySrc[r.src] = (bySrc[r.src] || 0) + 1; }); // Для полного phrase_count (может быть > 500 переданных строк) доверяем source_counts const byEng = {}; for (const [k, v] of Object.entries(msg.source_counts || {})) { const e = SRC_MAP[k] || k; byEng[e] = (byEng[e] || 0) + v; } setStats({ phrases: msg.phrase_count || mapped.length, seed: byEng.seed || bySrc.seed || 0, ai: byEng.ai || bySrc.ai || 0, suggest: byEng.suggest || bySrc.suggest || 0, wordstat: byEng.wordstat || bySrc.wordstat || 0, sites: 0, wordstatTotal: 0, }); setActiveStep(4); setRunState('done'); showToast('ok', `Готово — ${msg.phrase_count || mapped.length} фраз`); loadHistory(); } if (msg.type === 'review_phrase') { setReviewPhrase(msg.phrase || ''); setReviewIndex(msg.index ?? 0); setReviewTotal(msg.total ?? 1); setReviewOpen(true); } if (msg.type === 'review_complete') setReviewOpen(false); if (msg.type === 'error') { setErrorMsg(msg.text || 'Ошибка'); setRunState('error'); lsDel(SK.jobId); } }; ws.onerror = () => {}; ws.onclose = () => {}; }, [showToast]); const TOTAL_Q = 8; // всего вопросов const BATCH_SZ = 2; // по 2 за раз const doStartRun = useCallback(async (phrases, history, sp = '') => { lsDel(SK.questions); const ctx = history .filter((qa) => qa.answer) .map((qa) => `Вопрос: ${qa.question}\nОтвет: ${qa.answer}`) .join('\n'); setRunState('running'); setActiveStep(0); setCurrentAction('Запуск...'); setStats({ phrases: 0, sites: 0, suggest: 0, wordstatTotal: 0 }); setLiveItems([]); setRows([]); setTotalCount(0); setErrorMsg(''); setQRolling(null); nextBatchRef.current = null; try { const mw = minusWords.split('\n').map((w) => w.trim()).filter(Boolean); const resp = await Api.post('/api/run', { phrases, interactive_review: manual, business_context: ctx, site_profile: sp, minus_words: mw }); if (!resp.ok) { const err = await resp.json(); setErrorMsg(err.detail || 'Ошибка сервера'); setRunState('error'); return; } const { job_id } = await resp.json(); lsSet(SK.jobId, job_id); setCurrentJobId(job_id); connectWS(job_id); loadHistory(); } catch { setErrorMsg('Не удалось подключиться к серверу'); setRunState('error'); } }, [manual, minusWords, connectWS]); // Запрашивает следующий батч в фоне; сохраняет Promise в nextBatchRef const prefetchNextBatch = useCallback((phrases, sp, history) => { const mw = minusWords.split('\n').map((w) => w.trim()).filter(Boolean); nextBatchRef.current = Api.postJson('/api/questions/next', { phrases, site_profile: sp, previous_qa: history.filter((qa) => qa.answer), minus_words: mw, batch_size: BATCH_SZ, }); }, [minusWords]); const handleQuestionAnswer = useCallback(async (answer) => { const q = qRolling; const qa = { question: q.currentBatch[q.posInBatch], answer: answer || '' }; const newHistory = [...q.history, qa]; const totalAnswered = newHistory.length; // Все вопросы пройдены if (totalAnswered >= TOTAL_Q) { setQuestionOpen(false); doStartRun(pendingPhrases, newHistory, siteProfile); return; } const posInBatch = q.posInBatch; const nextPos = posInBatch + 1; if (nextPos < BATCH_SZ) { // Переходим к следующему вопросу внутри текущего батча // После первого вопроса в батче — запускаем prefetch следующего батча if (posInBatch === 0) { prefetchNextBatch(pendingPhrases, siteProfile, newHistory); } setQRolling({ ...q, posInBatch: nextPos, history: newHistory }); } else { // Конец батча — ждём prefetch и переходим к следующему батчу setQuestionLoading(true); let nextBatch = []; try { const data = await (nextBatchRef.current || Promise.resolve({ questions: [] })); nextBatch = data.questions || []; } catch {} nextBatchRef.current = null; setQuestionLoading(false); if (!nextBatch.length) { // Фолбэк: сгенерировали хотя бы минимальный контекст — запускаем setQuestionOpen(false); doStartRun(pendingPhrases, newHistory, siteProfile); return; } setQRolling({ currentBatch: nextBatch, posInBatch: 0, history: newHistory, batchNum: q.batchNum + 1 }); } }, [qRolling, pendingPhrases, siteProfile, prefetchNextBatch, doStartRun]); const startRun = async () => { const phrases = seeds.split('\n').map((p) => p.trim()).filter(Boolean); if (!phrases.length) { showToast('err', 'Введите хотя бы одну фразу'); return; } if (phrases.length > 10) { showToast('err', 'Максимум 10 фраз'); return; } const urls = siteUrls.split('\n').map((u) => u.trim()).filter(Boolean); setPendingPhrases(phrases); setQuestionLoading(true); try { const mw = minusWords.split('\n').map((w) => w.trim()).filter(Boolean); const data = await Api.postJson('/api/questions/next', { phrases, site_urls: urls, previous_qa: [], minus_words: mw, batch_size: BATCH_SZ }); const qs = data.questions || []; const sp = data.site_profile || ''; setSiteProfile(sp); if (qs.length > 0) { setQRolling({ currentBatch: qs, posInBatch: 0, history: [], batchNum: 0 }); setQuestionOpen(true); } else { doStartRun(phrases, [], sp); } } catch { doStartRun(phrases, [], ''); } finally { setQuestionLoading(false); } }; const submitRating = useCallback((rating) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) wsRef.current.send(JSON.stringify({ type: 'phrase_rating', rating })); }, []); const exportExcel = async () => { if (!currentJobId) { showToast('err', 'Файл не найден'); return; } try { const resp = await Api.get(`/api/download/${currentJobId}?_=${Date.now()}`); if (!resp.ok) { showToast('err', 'Не удалось скачать файл'); return; } const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const cd = resp.headers.get('content-disposition') || ''; const m = cd.match(/filename[^;=\n]*=["']?([^"'\n;]+)/); a.href = url; a.download = m ? m[1] : `semantic_core_${currentJobId}.xlsx`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch { showToast('err', 'Ошибка при скачивании'); } }; const deleteJob = useCallback(async (jobId) => { try { const resp = await Api.del(`/api/job/${jobId}`); if (resp.ok) { if (jobId === currentJobId) resetRunState(); loadHistory(); } } catch {} }, [currentJobId, resetRunState]); const handleRowsUpdate = useCallback((newRows) => { setRows(newRows); setTotalCount(newRows.length); const bySrc = {}; newRows.forEach((r) => { bySrc[r.src] = (bySrc[r.src] || 0) + 1; }); setStats((prev) => ({ ...prev, phrases: newRows.length, seed: bySrc.seed || 0, ai: bySrc.ai || 0, suggest: bySrc.suggest || 0, wordstat: bySrc.wordstat || 0 })); }, []); const loadAllRows = useCallback(async () => { if (!currentJobId) return; try { const j = await Api.getJson(`/api/job/${currentJobId}?limit=0`); const mapped = (j.result || []).map(mapRow); setRows(mapped); setTotalCount(j.phrase_count || mapped.length || 0); return mapped; } catch {} }, [currentJobId]); const seedCount = stats.seed ?? rows.filter((r) => r.src === 'seed').length; const aiCount = stats.ai ?? rows.filter((r) => r.src === 'ai').length; const suggestCount = stats.suggest ?? rows.filter((r) => r.src === 'suggest').length; const wordstatCount = stats.wordstat ?? rows.filter((r) => r.src === 'wordstat').length; const T = window.SC_T; return (