/* 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', questions: 'sc_q_state', // {phrases, questions, index, context, site_profile} 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 [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 [questionsList, setQuestionsList] = useState([]); const [questionIndex, setQuestionIndex] = useState(0); const [questionOpen, setQuestionOpen] = useState(false); const [questionLoading, setQuestionLoading] = useState(false); const [businessContext, setBusinessContext] = useState(''); const [pendingPhrases, setPendingPhrases] = useState([]); const [siteProfile, setSiteProfile] = useState(''); 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 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]); // ── Persist questions state ────────────────────────────────── useEffect(() => { if (questionOpen && questionsList.length > 0) { lsSet(SK.questions, { phrases: pendingPhrases, questions: questionsList, index: questionIndex, context: businessContext, site_profile: siteProfile }); } else if (!questionOpen) { lsDel(SK.questions); } }, [questionOpen, questionsList, questionIndex, businessContext, pendingPhrases, siteProfile]); // ── Mount: restore state ───────────────────────────────────── useEffect(() => { loadCookieStatus(); loadHistory(); const savedQ = lsGet(SK.questions); if (savedQ?.questions?.length > 0) { // Восстанавливаем вопросы — пользователь не успел ответить setPendingPhrases(savedQ.phrases || []); setQuestionsList(savedQ.questions); setQuestionIndex(savedQ.index || 0); setBusinessContext(savedQ.context || ''); setSiteProfile(savedQ.site_profile || ''); setQuestionOpen(true); return; // Если есть вопросы — job ещё не запущен, ждём ответов } 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(''); 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 doStartRun = useCallback(async (phrases, ctx, sp = '') => { lsDel(SK.questions); // Вопросы пройдены — очищаем const fullCtx = [sp, ctx].filter(Boolean).join('\n\n'); setRunState('running'); setActiveStep(0); setCurrentAction('Запуск...'); setStats({ phrases: 0, sites: 0, suggest: 0, wordstatTotal: 0 }); setLiveItems([]); setRows([]); setTotalCount(0); setErrorMsg(''); try { const resp = await Api.post('/api/run', { phrases, interactive_review: manual, business_context: fullCtx }); 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, connectWS]); const handleQuestionAnswer = useCallback((answer) => { const newCtx = answer ? businessContext + `\nВопрос: ${questionsList[questionIndex]}\nОтвет: ${answer}` : businessContext; if (questionIndex + 1 < questionsList.length) { setBusinessContext(newCtx); setQuestionIndex(questionIndex + 1); } else { setQuestionOpen(false); setBusinessContext(''); setQuestionsList([]); setQuestionIndex(0); doStartRun(pendingPhrases, newCtx, siteProfile); } }, [questionsList, questionIndex, businessContext, pendingPhrases, siteProfile, 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 data = await Api.postJson('/api/questions', { phrases, site_urls: urls }); const qs = data.questions || []; const sp = data.site_profile || ''; setSiteProfile(sp); if (qs.length > 0) { setQuestionsList(qs); setQuestionIndex(0); setBusinessContext(''); setQuestionOpen(true); // useEffect сохранит в localStorage } 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}`); 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 loadAllRows = useCallback(async () => { if (!currentJobId) return; try { const j = await Api.getJson(`/api/job/${currentJobId}?limit=0`); setRows((j.result || []).map(mapRow)); setTotalCount(j.phrase_count || j.result?.length || 0); } 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 (