Spaces:
Running
Running
| let currentData = null; | |
| let semanticData = null; | |
| let optimizerData = null; | |
| let semanticTermSortBy = 'target_weight'; | |
| let semanticTermSortDir = 'desc'; | |
| let availableUserAgents = []; | |
| const PROJECT_SCHEMA_VERSION = 2; | |
| /** Без операторов ?? / ?. — совместимость с SES lockdown на Hugging Face Spaces */ | |
| function nv(v, d) { | |
| return (v !== undefined && v !== null) ? v : d; | |
| } | |
| let optimizerStreamJobId = null; | |
| const OPT_DIFF_ORIG_BODY_KEY = 'optimizerOriginalTargetText_v1'; | |
| const OPT_DIFF_ORIG_TITLE_KEY = 'optimizerOriginalTargetTitle_v1'; | |
| function getOptimizerDiffModeValue() { | |
| const el = document.getElementById('optimizerDiffMode'); | |
| const v = el && el.value ? String(el.value) : 'diff_from_input'; | |
| return v === 'diff_from_original' ? 'diff_from_original' : 'diff_from_input'; | |
| } | |
| function loadOptimizerOriginalSnapshot() { | |
| try { | |
| const body = localStorage.getItem(OPT_DIFF_ORIG_BODY_KEY); | |
| const title = localStorage.getItem(OPT_DIFF_ORIG_TITLE_KEY); | |
| return { body: body, title: title }; | |
| } catch (e) { | |
| return { body: null, title: null }; | |
| } | |
| } | |
| function ensureOptimizerOriginalSnapshot() { | |
| // Создаём снимок “оригинала” при первом запуске в режиме diff_from_original. | |
| const snapshot = loadOptimizerOriginalSnapshot(); | |
| let body = snapshot.body; | |
| let title = snapshot.title; | |
| const currentBody = (document.getElementById('targetText').value || ''); | |
| const currentTitle = (document.getElementById('targetTitle').value || ''); | |
| try { | |
| if (body === null) { | |
| localStorage.setItem(OPT_DIFF_ORIG_BODY_KEY, currentBody); | |
| body = currentBody; | |
| } | |
| if (title === null) { | |
| localStorage.setItem(OPT_DIFF_ORIG_TITLE_KEY, currentTitle); | |
| title = currentTitle; | |
| } | |
| } catch (e) { | |
| // Best-effort: если localStorage недоступен, просто вернём то, что сейчас в форме. | |
| body = currentBody; | |
| title = currentTitle; | |
| } | |
| return { body: body, title: title }; | |
| } | |
| function resetOptimizerDiffOriginal() { | |
| const currentBody = (document.getElementById('targetText').value || ''); | |
| const currentTitle = (document.getElementById('targetTitle').value || ''); | |
| try { | |
| localStorage.setItem(OPT_DIFF_ORIG_BODY_KEY, currentBody); | |
| localStorage.setItem(OPT_DIFF_ORIG_TITLE_KEY, currentTitle); | |
| alert('Оригинал для сравнения обновлён.'); | |
| } catch (e) { | |
| alert('Не удалось обновить снимок в localStorage. Проверьте настройки браузера.'); | |
| } | |
| } | |
| const OPT_STAGE_ORDER = ['bert', 'bm25', 'ngram', 'semantic', 'title']; | |
| const OPT_STAGE_LABELS = { | |
| bert: 'BERT', | |
| bm25: 'BM25', | |
| ngram: 'N-gram', | |
| semantic: 'Semantic', | |
| title: 'Title' | |
| }; | |
| /** Порог Title BERT в сводке (совпадает с текстом рекомендаций). */ | |
| const SUMMARY_TITLE_BERT_THRESHOLD = 0.65; | |
| /** Стоп-слова для Semantic Core в сводке и авто-целях оптимизатора. */ | |
| const SUMMARY_STOP_WORDS = { | |
| en: new Set(['a', 'an', 'and', 'or', 'the', 'to', 'of', 'for', 'in', 'on', 'at', 'by', 'with', 'from', 'as', 'is', 'are', 'be', 'was', 'were']), | |
| ru: new Set(['и', 'или', 'в', 'во', 'на', 'по', 'с', 'со', 'к', 'ко', 'для', 'из', 'за', 'что', 'это', 'как', 'а', 'но', 'у', 'о', 'от']), | |
| de: new Set(['und', 'oder', 'der', 'die', 'das', 'zu', 'von', 'mit', 'für', 'in', 'auf', 'ist', 'sind']), | |
| es: new Set(['y', 'o', 'el', 'la', 'los', 'las', 'de', 'del', 'en', 'con', 'para', 'por', 'es', 'son']), | |
| it: new Set(['e', 'o', 'il', 'lo', 'la', 'i', 'gli', 'le', 'di', 'del', 'in', 'con', 'per', 'da', 'è', 'sono']), | |
| pl: new Set(['i', 'oraz', 'lub', 'w', 'na', 'z', 'ze', 'do', 'od', 'po', 'dla', 'to', 'jest', 'są']), | |
| pt: new Set(['e', 'ou', 'o', 'a', 'os', 'as', 'de', 'do', 'da', 'em', 'no', 'na', 'com', 'para', 'por', 'é', 'são']) | |
| }; | |
| let optimizerStageGoalAutoCache = {}; | |
| function _escHtml(v) { | |
| return String(v == null ? '' : v) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| function _uniqStrList(arr) { | |
| const out = []; | |
| const seen = {}; | |
| (arr || []).forEach((x) => { | |
| const v = String(x || '').trim(); | |
| if (!v) return; | |
| const k = v.toLowerCase(); | |
| if (seen[k]) return; | |
| seen[k] = true; | |
| out.push(v); | |
| }); | |
| return out; | |
| } | |
| /** | |
| * Единая логика «Сводки» и авто-целей оптимизатора: те же пороги, фильтры и сортировки. | |
| * Возвращает диагностику, карточки рекомендаций и списки целей по стадиям. | |
| */ | |
| function buildSummaryData(analysisData, semData) { | |
| const emptyGoals = { bert: [], bm25: [], ngram: [], semantic: [], title: [] }; | |
| const bertThreshold = Number(document.getElementById('optimizerBertStageTarget').value || 0.70); | |
| if (!analysisData) { | |
| return { diagnostics: [], recommendations: [], optimizerAutoGoals: emptyGoals, bertThreshold: bertThreshold }; | |
| } | |
| const recommendations = []; | |
| const diagnostics = []; | |
| let semanticGapsForGoals = []; | |
| const wcComp2 = analysisData.word_counts && analysisData.word_counts.competitors; | |
| const competitorCount = Array.isArray(wcComp2) ? wcComp2.length : 0; | |
| const minCompetitorSignal = competitorCount <= 1 ? 1 : 2; | |
| const keywordsRaw = (document.getElementById('keywordsInput').value || '') | |
| .split('\n') | |
| .map(function (v) { return v.trim(); }) | |
| .filter(Boolean); | |
| const bertDetails = analysisData.bert_analysis && analysisData.bert_analysis.detailed | |
| ? analysisData.bert_analysis.detailed | |
| : []; | |
| const lowBert = bertDetails.filter(function (i) { | |
| return Number(i.my_max_score || 0) < bertThreshold; | |
| }); | |
| diagnostics.push({ | |
| metric: 'BERT', | |
| value: String(lowBert.length) + '/' + String(bertDetails.length || 0) + ' ниже ' + String(bertThreshold), | |
| status: lowBert.length > 0 ? 'warning' : 'ok' | |
| }); | |
| if (lowBert.length > 0) { | |
| const topPhrases = lowBert | |
| .slice() | |
| .sort(function (a, b) { return Number(a.my_max_score || 0) - Number(b.my_max_score || 0); }) | |
| .slice(0, 8) | |
| .map(function (i) { | |
| return { | |
| phrase: i.phrase, | |
| my: Number(i.my_max_score || 0), | |
| comp: Number(i.comp_max_score || 0) | |
| }; | |
| }); | |
| recommendations.push({ | |
| priority: 'high', | |
| title: 'Поднять BERT-релевантность по слабым ключам', | |
| why: 'Найдено ' + String(lowBert.length) + ' ключей с релевантностью ниже ' + String(bertThreshold) + '.', | |
| action: 'Усилить смысловое раскрытие этих фраз в тексте (не только точные вхождения, но и контекст).', | |
| details_table: { | |
| headers: ['Ключевая фраза', 'My score', 'Best Comp'], | |
| rows: topPhrases.map(function (x) { return [x.phrase, x.my, x.comp]; }) | |
| } | |
| }); | |
| } | |
| const bm25 = Array.isArray(analysisData.bm25_recommendations) ? analysisData.bm25_recommendations : []; | |
| const bm25Remove = bm25.filter(function (r) { return r.action === 'remove'; }); | |
| diagnostics.push({ | |
| metric: 'BM25', | |
| value: 'REMOVE-рекомендаций: ' + String(bm25Remove.length), | |
| status: bm25Remove.length >= 4 ? 'warning' : 'ok' | |
| }); | |
| if (bm25Remove.length >= 4) { | |
| const topSpam = bm25Remove | |
| .slice() | |
| .sort(function (a, b) { return Number(b.count || 0) - Number(a.count || 0); }) | |
| .slice(0, 10) | |
| .map(function (r) { return { word: r.word, count: Number(r.count || 0) }; }); | |
| recommendations.push({ | |
| priority: 'high', | |
| title: 'Снизить спамность ключевых вхождений', | |
| why: 'BM25 дал ' + String(bm25Remove.length) + ' рекомендаций на сокращение (порог риска: 4+).', | |
| action: 'Сократить частоту этих терминов и перераспределить формулировки в пользу естественных синонимов.', | |
| details_table: { | |
| headers: ['Термин', 'Сократить на'], | |
| rows: topSpam.map(function (x) { return [x.word, x.count]; }) | |
| } | |
| }); | |
| } | |
| const ngramSignals = []; | |
| const ngramStats = analysisData.ngram_stats || {}; | |
| const kwUnigrams = new Set(); | |
| keywordsRaw.forEach(function (kw) { | |
| String(kw || '') | |
| .toLowerCase() | |
| .replace(/[^\p{L}\p{N}\s-]+/gu, ' ') | |
| .split(/\s+/) | |
| .map(function (v) { return v.trim(); }) | |
| .filter(function (v) { return v.length >= 2; }) | |
| .forEach(function (t) { kwUnigrams.add(t); }); | |
| }); | |
| const isOutsideTolerance = function (targetCount, compAvg) { | |
| if (compAvg <= 0) return false; | |
| const tol = compAvg >= 4 ? 0.20 : 0.50; | |
| return targetCount < compAvg * (1 - tol) || targetCount > compAvg * (1 + tol); | |
| }; | |
| const isEligibleNgram = function (ngram, compOcc) { | |
| const toks = String(ngram || '') | |
| .toLowerCase() | |
| .replace(/[^\p{L}\p{N}\s-]+/gu, ' ') | |
| .split(/\s+/) | |
| .map(function (v) { return v.trim(); }) | |
| .filter(function (v) { return v.length >= 2; }); | |
| if (!toks.length) return false; | |
| if (competitorCount > 1) { | |
| if (compOcc < 2) return false; | |
| if (toks.length >= 2) return true; | |
| return kwUnigrams.has(toks[0]); | |
| } | |
| return compOcc >= 1; | |
| }; | |
| Object.values(ngramStats).forEach(function (bucket) { | |
| (Array.isArray(bucket) ? bucket : []).forEach(function (item) { | |
| const compOcc = Number(item.comp_occurrence || 0); | |
| const targetCount = Number(item.target_count || 0); | |
| const compAvg = Number(item.competitor_avg || 0); | |
| if (!isEligibleNgram(item.ngram, compOcc)) return; | |
| if (!isOutsideTolerance(targetCount, compAvg)) return; | |
| const devRatio = compAvg > 0 ? Math.abs(targetCount - compAvg) / compAvg : 0; | |
| const direction = targetCount < compAvg ? 'under' : 'over'; | |
| ngramSignals.push({ | |
| ngram: item.ngram, | |
| compOcc: compOcc, | |
| targetCount: targetCount, | |
| compAvg: compAvg, | |
| devRatio: devRatio, | |
| direction: direction | |
| }); | |
| }); | |
| }); | |
| const underrepresentedNgrams = ngramSignals.filter(function (x) { return x.direction === 'under'; }); | |
| const overrepresentedNgrams = ngramSignals.filter(function (x) { return x.direction === 'over'; }); | |
| diagnostics.push({ | |
| metric: 'N-граммы', | |
| value: 'Сигналов: ' + String(ngramSignals.length) + ' (недобор: ' + String(underrepresentedNgrams.length) + ', перебор: ' + String(overrepresentedNgrams.length) + ')', | |
| status: ngramSignals.length > 0 ? 'warning' : 'ok' | |
| }); | |
| if (underrepresentedNgrams.length > 0) { | |
| const topSignals = underrepresentedNgrams | |
| .slice() | |
| .sort(function (a, b) { | |
| return (b.devRatio - a.devRatio) || (b.compOcc - a.compOcc) || (b.compAvg - a.compAvg); | |
| }) | |
| .slice(0, 10) | |
| .map(function (x) { | |
| return { | |
| ngram: x.ngram, | |
| target: x.targetCount, | |
| avg: x.compAvg, | |
| freq: String(x.compOcc) | |
| }; | |
| }); | |
| recommendations.push({ | |
| priority: 'medium', | |
| title: 'Добавить недопредставленные слова/фразы', | |
| why: competitorCount <= 1 | |
| ? 'В режиме 1x1 у конкурента эти n-граммы встречаются заметно чаще (или у вас отсутствуют).' | |
| : 'У 2+ конкурентов эти n-граммы встречаются заметно чаще (или у вас отсутствуют).', | |
| action: competitorCount <= 1 | |
| ? 'Добавить релевантные вхождения для выравнивания с единственным конкурентом.' | |
| : 'Добавить релевантные вхождения в естественные абзацы (особенно сигналы с K=3+).', | |
| details_table: { | |
| headers: ['N-грамма', 'My', 'Avg K', 'Freq(K)'], | |
| rows: topSignals.map(function (x) { return [x.ngram, x.target, x.avg, x.freq]; }) | |
| } | |
| }); | |
| } | |
| if (overrepresentedNgrams.length > 0) { | |
| const topOver = overrepresentedNgrams | |
| .slice() | |
| .sort(function (a, b) { | |
| return (b.devRatio - a.devRatio) || (b.targetCount - a.targetCount); | |
| }) | |
| .slice(0, 10) | |
| .map(function (x) { | |
| return { | |
| ngram: x.ngram, | |
| target: x.targetCount, | |
| avg: x.compAvg, | |
| freq: String(x.compOcc) | |
| }; | |
| }); | |
| recommendations.push({ | |
| priority: 'low', | |
| title: 'Переизбыточные n-граммы (инфо)', | |
| why: 'Эти n-граммы встречаются у вас заметно чаще среднего по конкурентам.', | |
| action: 'Информационный блок: снижение частоты регулируется через рекомендации BM25 (раздел спамности), а не через блок недопредставленности.', | |
| details_table: { | |
| headers: ['N-грамма', 'My', 'Avg K', 'Freq(K)'], | |
| rows: topOver.map(function (x) { return [x.ngram, x.target, x.avg, x.freq]; }) | |
| } | |
| }); | |
| } | |
| const titleBert = analysisData.title_analysis && analysisData.title_analysis.bert | |
| ? Number(analysisData.title_analysis.bert.target_score || 0) | |
| : null; | |
| diagnostics.push({ | |
| metric: 'Title BERT', | |
| value: titleBert === null ? 'нет данных' : titleBert.toFixed(3), | |
| status: (titleBert !== null && titleBert < SUMMARY_TITLE_BERT_THRESHOLD) ? 'warning' : 'ok' | |
| }); | |
| if (titleBert !== null && titleBert < SUMMARY_TITLE_BERT_THRESHOLD) { | |
| recommendations.push({ | |
| priority: 'high', | |
| title: 'Переписать Title под ключевой интент', | |
| why: 'Семантическая релевантность Title = ' + titleBert.toFixed(3) + ' (< ' + String(SUMMARY_TITLE_BERT_THRESHOLD) + ').', | |
| action: 'Уточнить формулировку Title: добавить главные ключевые слова и намерение пользователя без переспама.', | |
| details: [] | |
| }); | |
| } | |
| if (semData && semData.comparison && Array.isArray(semData.comparison.term_power_table)) { | |
| const table = semData.comparison.term_power_table; | |
| const _lsel = document.getElementById('languageSelect'); | |
| const lang = ((_lsel && _lsel.value) || 'en').toLowerCase(); | |
| const stopWords = SUMMARY_STOP_WORDS[lang] || SUMMARY_STOP_WORDS.en; | |
| const tokenize = function (text) { | |
| return String(text || '') | |
| .toLowerCase() | |
| .replace(/[^\p{L}\p{N}\s-]+/gu, ' ') | |
| .split(/\s+/) | |
| .map(function (v) { return v.trim(); }) | |
| .filter(function (v) { return v.length >= 2; }); | |
| }; | |
| const filterStopWords = function (tokens) { | |
| return tokens.filter(function (t) { return !stopWords.has(t); }); | |
| }; | |
| const keywordTermSet = new Set(); | |
| keywordsRaw.forEach(function (kw) { | |
| const rawTokens = tokenize(kw); | |
| const tokens = filterStopWords(rawTokens); | |
| if (tokens.length === 0) return; | |
| tokens.forEach(function (t) { keywordTermSet.add(t); }); | |
| for (let n = 2; n <= 3; n++) { | |
| if (tokens.length < n) continue; | |
| for (let i = 0; i <= tokens.length - n; i++) { | |
| keywordTermSet.add(tokens.slice(i, i + n).join(' ')); | |
| } | |
| } | |
| }); | |
| const keywordTerms = Array.from(keywordTermSet); | |
| const byTerm = new Map(table.map(function (row) { return [String(row.term || '').toLowerCase(), row]; })); | |
| const buildGap = function (term, row) { | |
| const targetWeight = Number(row.target_weight || 0); | |
| const compAvgWeight = Number(row.competitor_avg_weight || 0); | |
| const gap = compAvgWeight - targetWeight; | |
| return { | |
| term: term, | |
| targetWeight: targetWeight, | |
| compAvgWeight: compAvgWeight, | |
| gap: gap, | |
| compOcc: Number(row.comp_occurrence || 0), | |
| compTotal: Number(row.comp_total || 0) | |
| }; | |
| }; | |
| const keywordSemanticGaps = keywordTerms | |
| .map(function (term) { | |
| const row = byTerm.get(term); | |
| if (!row) return null; | |
| return buildGap(term, row); | |
| }) | |
| .filter(Boolean) | |
| .filter(function (x) { return x.gap > 0 && x.compOcc >= minCompetitorSignal; }) | |
| .sort(function (a, b) { return (b.gap - a.gap) || (b.compOcc - a.compOcc); }); | |
| const otherSemanticGaps = table | |
| .map(function (row) { | |
| const term = String(row.term || '').toLowerCase(); | |
| if (!term || keywordTermSet.has(term)) return null; | |
| const tokens = tokenize(term); | |
| if (tokens.length === 0) return null; | |
| if (tokens.every(function (t) { return stopWords.has(t); })) return null; | |
| return buildGap(term, row); | |
| }) | |
| .filter(Boolean) | |
| .filter(function (x) { return x.gap > 0 && x.compOcc >= minCompetitorSignal; }) | |
| .sort(function (a, b) { return (b.gap - a.gap) || (b.compAvgWeight - a.compAvgWeight); }); | |
| const semanticGaps = keywordSemanticGaps.concat(otherSemanticGaps); | |
| semanticGapsForGoals = semanticGaps; | |
| diagnostics.push({ | |
| metric: 'Semantic Core', | |
| value: 'Ключевых n-грамм с отставанием: ' + String(keywordSemanticGaps.length) + ' (порог K>=' + String(minCompetitorSignal) + ')', | |
| status: semanticGaps.length > 0 ? 'warning' : 'ok' | |
| }); | |
| if (semanticGaps.length > 0) { | |
| const topSemantic = semanticGaps | |
| .slice(0, 10) | |
| .map(function (x) { | |
| return { | |
| source: keywordTermSet.has(x.term) ? 'KW' : 'TOP', | |
| term: x.term, | |
| target: x.targetWeight, | |
| avg: x.compAvgWeight, | |
| freq: String(x.compOcc) + '/' + String(x.compTotal) | |
| }; | |
| }); | |
| recommendations.push({ | |
| priority: 'high', | |
| title: 'Усилить смысловой вес ключевых терминов (слова + n-граммы)', | |
| why: competitorCount <= 1 | |
| ? 'В режиме 1x1 ключевые слова/bi- и tri-граммы из ваших фраз слабее, чем у конкурента.' | |
| : 'По Semantic Core ключевые слова/bi- и tri-граммы из ваших фраз слабее, чем в среднем у конкурентов.', | |
| action: 'Сначала усилить [KW]-термины в смысловых узлах текста, затем добрать [TOP]-термины с высоким разрывом.', | |
| details_table: { | |
| headers: ['Тип', 'Термин', 'My вес', 'Avg K', 'Freq'], | |
| rows: topSemantic.map(function (x) { return [x.source, x.term, x.target, x.avg, x.freq]; }) | |
| } | |
| }); | |
| } | |
| } else { | |
| diagnostics.push({ | |
| metric: 'Semantic Core', | |
| value: 'нет данных (не запускался)', | |
| status: 'pending' | |
| }); | |
| recommendations.push({ | |
| priority: 'medium', | |
| title: 'Запустить Semantic Core для итоговой оценки', | |
| why: 'Без графовых весов нельзя рассчитать разрыв по смысловой мощности ключевых слов.', | |
| action: 'Запустите вкладку Semantic Core и затем обновите сводку.', | |
| details: [] | |
| }); | |
| } | |
| const nu = underrepresentedNgrams | |
| .slice() | |
| .sort(function (a, b) { | |
| return (b.devRatio - a.devRatio) || (b.compOcc - a.compOcc) || (b.compAvg - a.compAvg); | |
| }) | |
| .slice(0, 10); | |
| const no = overrepresentedNgrams | |
| .slice() | |
| .sort(function (a, b) { | |
| return (b.devRatio - a.devRatio) || (b.targetCount - a.targetCount); | |
| }) | |
| .slice(0, 10); | |
| const optimizerAutoGoals = { | |
| bert: _uniqStrList( | |
| lowBert | |
| .slice() | |
| .sort(function (a, b) { return Number(a.my_max_score || 0) - Number(b.my_max_score || 0); }) | |
| .map(function (i) { return String(i.phrase || '').trim(); }) | |
| ), | |
| bm25: bm25Remove.length >= 4 | |
| ? _uniqStrList( | |
| bm25Remove | |
| .slice() | |
| .sort(function (a, b) { return Number(b.count || 0) - Number(a.count || 0); }) | |
| .slice(0, 10) | |
| .map(function (r) { return String(r.word || '').trim(); }) | |
| ) | |
| : [], | |
| ngram: _uniqStrList( | |
| nu.map(function (x) { return String(x.ngram || '').trim(); }).concat( | |
| no.map(function (x) { return String(x.ngram || '').trim(); }) | |
| ) | |
| ), | |
| semantic: semanticGapsForGoals.slice(0, 10).map(function (x) { return x.term; }), | |
| title: (titleBert !== null && titleBert < SUMMARY_TITLE_BERT_THRESHOLD) | |
| ? _uniqStrList(keywordsRaw).slice(0, 20) | |
| : [] | |
| }; | |
| return { | |
| diagnostics: diagnostics, | |
| recommendations: recommendations, | |
| optimizerAutoGoals: optimizerAutoGoals, | |
| bertThreshold: bertThreshold | |
| }; | |
| } | |
| function _buildOptimizerAutoGoals() { | |
| if (!currentData) { | |
| return { bert: [], bm25: [], ngram: [], semantic: [], title: [] }; | |
| } | |
| return buildSummaryData(currentData, semanticData).optimizerAutoGoals; | |
| } | |
| function _getOptimizerStageConfigFromUI() { | |
| const enabled = []; | |
| const overrides = {}; | |
| OPT_STAGE_ORDER.forEach((stage) => { | |
| const enEl = document.getElementById('optStage' + stage.charAt(0).toUpperCase() + stage.slice(1)); | |
| if (enEl && enEl.checked) enabled.push(stage); | |
| const modeEl = document.getElementById('optStageMode-' + stage); | |
| const mode = modeEl ? String(modeEl.value || 'auto') : 'auto'; | |
| const selectedEls = document.querySelectorAll('.opt-goal-check[data-stage="' + stage + '"]'); | |
| const selected = []; | |
| for (let i = 0; i < selectedEls.length; i++) { | |
| if (selectedEls[i].checked) selected.push(String(selectedEls[i].value || '').trim()); | |
| } | |
| const customEl = document.getElementById('optStageCustom-' + stage); | |
| const customAdd = customEl | |
| ? _uniqStrList(String(customEl.value || '').split('\n').map((x) => x.trim()).filter(Boolean)) | |
| : []; | |
| overrides[stage] = { | |
| mode: (mode === 'manual' || mode === 'mixed') ? mode : 'auto', | |
| selected: _uniqStrList(selected), | |
| custom_add: customAdd | |
| }; | |
| }); | |
| return { enabled_stages: enabled, stage_goal_overrides: overrides }; | |
| } | |
| function optimizerSelectAllStages(flag) { | |
| OPT_STAGE_ORDER.forEach((stage) => { | |
| const enEl = document.getElementById('optStage' + stage.charAt(0).toUpperCase() + stage.slice(1)); | |
| if (enEl) enEl.checked = !!flag; | |
| }); | |
| } | |
| function _renderOptimizerStageGoalConfig(savedCfg) { | |
| const wrap = document.getElementById('optimizerStageGoalConfigContainer'); | |
| if (!wrap) return; | |
| const cfg = savedCfg || {}; | |
| const html = OPT_STAGE_ORDER.map((stage) => { | |
| const auto = optimizerStageGoalAutoCache[stage] || []; | |
| const stCfg = cfg[stage] || {}; | |
| const mode = (stCfg.mode === 'manual' || stCfg.mode === 'mixed') ? stCfg.mode : 'auto'; | |
| const selectedSaved = {}; | |
| (stCfg.selected || []).forEach((v) => { selectedSaved[String(v || '').toLowerCase()] = true; }); | |
| const rows = auto.map((goal, idx) => { | |
| const g = String(goal || '').trim(); | |
| const key = g.toLowerCase(); | |
| const checked = selectedSaved[key] ? 'checked' : ''; | |
| return `<div class="form-check"> | |
| <input class="form-check-input opt-goal-check" type="checkbox" data-stage="${stage}" id="optGoal-${stage}-${idx}" value="${_escHtml(g)}" ${checked}> | |
| <label class="form-check-label small" for="optGoal-${stage}-${idx}">${_escHtml(g)}</label> | |
| </div>`; | |
| }).join(''); | |
| const customText = Array.isArray(stCfg.custom_add) ? stCfg.custom_add.join('\n') : ''; | |
| return `<div class="border rounded p-2 mb-2 bg-white"> | |
| <div class="d-flex justify-content-between align-items-center mb-1"> | |
| <strong class="small">${_escHtml(OPT_STAGE_LABELS[stage] || stage)}</strong> | |
| <span class="small text-muted">Авто-целей: ${auto.length}</span> | |
| </div> | |
| <div class="row g-2"> | |
| <div class="col-md-3"> | |
| <label class="form-label small text-muted mb-1">Режим целей</label> | |
| <select id="optStageMode-${stage}" class="form-select form-select-sm"> | |
| <option value="auto" ${mode === 'auto' ? 'selected' : ''}>Auto</option> | |
| <option value="manual" ${mode === 'manual' ? 'selected' : ''}>Manual</option> | |
| <option value="mixed" ${mode === 'mixed' ? 'selected' : ''}>Mixed</option> | |
| </select> | |
| </div> | |
| <div class="col-md-5"> | |
| <label class="form-label small text-muted mb-1">Авто-цели стадии</label> | |
| <div class="border rounded p-2" style="max-height:120px;overflow:auto;"> | |
| ${rows || '<div class="small text-muted">Нет авто-целей. Добавьте вручную.</div>'} | |
| </div> | |
| </div> | |
| <div class="col-md-4"> | |
| <label class="form-label small text-muted mb-1">Custom цели (по строкам)</label> | |
| <textarea id="optStageCustom-${stage}" class="form-control form-control-sm" rows="4" placeholder="Добавьте свои цели">${_escHtml(customText)}</textarea> | |
| </div> | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| wrap.innerHTML = html; | |
| } | |
| function refreshOptimizerStageGoalConfig(savedOverrides) { | |
| const currentUiCfg = _getOptimizerStageConfigFromUI().stage_goal_overrides; | |
| optimizerStageGoalAutoCache = _buildOptimizerAutoGoals(); | |
| _renderOptimizerStageGoalConfig(savedOverrides || currentUiCfg || {}); | |
| } | |
| function optimizerLogAppend(line) { | |
| const el = document.getElementById('optimizerRunLog'); | |
| if (!el) return; | |
| const t = new Date().toISOString().split('T')[1].split('.')[0]; | |
| el.textContent += '[' + t + '] ' + line + '\n'; | |
| el.scrollTop = el.scrollHeight; | |
| } | |
| function optimizerRunUiClear() { | |
| const log = document.getElementById('optimizerRunLog'); | |
| if (log) log.textContent = ''; | |
| const bar = document.getElementById('optimizerProgressBar'); | |
| if (bar) { | |
| bar.style.width = '0%'; | |
| bar.setAttribute('aria-valuenow', '0'); | |
| } | |
| const elp = document.getElementById('optimizerElapsed'); | |
| if (elp) elp.textContent = ''; | |
| } | |
| function applyOptimizerStreamEvent(data) { | |
| if (!data || typeof data !== 'object') return; | |
| const bar = document.getElementById('optimizerProgressBar'); | |
| const ev = data.event; | |
| if (ev === 'preparing') { | |
| if (bar) { | |
| bar.style.width = '2%'; | |
| bar.setAttribute('aria-valuenow', '2'); | |
| } | |
| optimizerLogAppend('Подготовка: ' + (data.message || data.phase || '')); | |
| return; | |
| } | |
| if (ev === 'started') { | |
| if (bar) { | |
| bar.style.width = '5%'; | |
| bar.setAttribute('aria-valuenow', '5'); | |
| } | |
| optimizerLogAppend('Старт: всего шагов ' + (data.total_steps || '?') + ', n-gram целей: ' + (data.ngram_targets != null ? data.ngram_targets : '?')); | |
| return; | |
| } | |
| if (ev === 'step_start') { | |
| const tot = Math.max(1, data.total_steps || 1); | |
| const pct = Math.min(95, Math.round(((data.step - 1) / tot) * 90 + 5)); | |
| if (bar) { | |
| bar.style.width = pct + '%'; | |
| bar.setAttribute('aria-valuenow', String(pct)); | |
| } | |
| optimizerLogAppend('Шаг ' + data.step + '/' + tot + ' | ' + (data.active_stage || '') + ' | ' + (data.goal_type || '') + (data.goal_label ? ': ' + data.goal_label : '') + (data.score != null ? ' | score ' + data.score : '')); | |
| return; | |
| } | |
| if (ev === 'llm_call') { | |
| optimizerLogAppend('LLM: кандидат ' + data.candidate_index + ', окно ' + data.span_trial + '/' + data.span_trials + ', стратегия ' + (data.strategy || '')); | |
| } | |
| } | |
| async function requestStopOptimizer() { | |
| if (!optimizerStreamJobId) return; | |
| optimizerLogAppend('Остановка запрошена, ждём частичный результат с сервера…'); | |
| try { | |
| await fetch('/api/v1/optimizer/cancel', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ job_id: optimizerStreamJobId }) | |
| }); | |
| } catch (e) { | |
| console.warn(e); | |
| } | |
| } | |
| // --- ФУНКЦИИ ИНТЕРФЕЙСА --- | |
| document.getElementById('targetTitle').addEventListener('input', function() { | |
| const len = this.value.length; | |
| const counter = document.getElementById('titleCharCount'); | |
| counter.textContent = len; | |
| counter.className = len > 60 ? 'text-danger fw-bold' : (len > 50 ? 'text-warning' : 'text-muted'); | |
| }); | |
| function addCompetitorTitleField() { | |
| const div = document.createElement('div'); | |
| div.innerHTML = '<input type="text" class="form-control mb-2" placeholder="Ещё Title конкурента..." maxlength="200">'; | |
| document.getElementById('competitorTitlesList').appendChild(div); | |
| } | |
| function addCompetitorField() { | |
| const div = document.createElement('div'); | |
| div.innerHTML = '<textarea class="form-control mb-2 competitor-input" rows="3" placeholder="Ещё конкурент..."></textarea>'; | |
| document.getElementById('competitorsList').appendChild(div); | |
| } | |
| function collectCompetitorTexts() { | |
| const compInputs = document.querySelectorAll('#competitorsList textarea'); | |
| const competitors = []; | |
| compInputs.forEach(input => { | |
| if (input.value.trim() !== '') competitors.push(input.value); | |
| }); | |
| return competitors; | |
| } | |
| function collectCompetitorTitles() { | |
| const compTitleInputs = document.querySelectorAll('#competitorTitlesList input'); | |
| const competitorTitles = []; | |
| compTitleInputs.forEach(input => { | |
| if (input.value.trim() !== '') competitorTitles.push(input.value); | |
| }); | |
| return competitorTitles; | |
| } | |
| function ensureCompetitorFieldCount(count) { | |
| const safeCount = Math.max(1, count || 1); | |
| const competitorsList = document.getElementById('competitorsList'); | |
| competitorsList.innerHTML = ''; | |
| for (let i = 0; i < safeCount; i++) { | |
| const textarea = document.createElement('textarea'); | |
| textarea.className = 'form-control mb-2' + (i > 0 ? ' competitor-input' : ''); | |
| textarea.rows = 3; | |
| textarea.placeholder = i === 0 ? 'Текст конкурента 1...' : 'Ещё конкурент...'; | |
| competitorsList.appendChild(textarea); | |
| } | |
| const compTitlesList = document.getElementById('competitorTitlesList'); | |
| compTitlesList.innerHTML = ''; | |
| for (let i = 0; i < safeCount; i++) { | |
| const input = document.createElement('input'); | |
| input.type = 'text'; | |
| input.className = 'form-control mb-2'; | |
| input.placeholder = i === 0 ? 'Title конкурента 1...' : 'Ещё Title конкурента...'; | |
| input.maxLength = 200; | |
| compTitlesList.appendChild(input); | |
| } | |
| } | |
| async function loadUserAgentOptions() { | |
| const select = document.getElementById('urlUserAgentSelect'); | |
| if (!select) return; | |
| const currentValue = select.value || 'chrome_desktop'; | |
| try { | |
| const response = await fetch('/api/v1/url/user-agents'); | |
| if (!response.ok) throw new Error(response.statusText); | |
| const data = await response.json(); | |
| availableUserAgents = Array.isArray(data.user_agents) ? data.user_agents : []; | |
| if (availableUserAgents.length === 0) return; | |
| select.innerHTML = availableUserAgents | |
| .map(ua => `<option value="${ua.key}">${ua.name}</option>`) | |
| .join(''); | |
| if ([...select.options].some(o => o.value === currentValue)) { | |
| select.value = currentValue; | |
| } else if ([...select.options].some(o => o.value === 'chrome_desktop')) { | |
| select.value = 'chrome_desktop'; | |
| } | |
| } catch (err) { | |
| console.warn('Failed to load user-agent presets', err); | |
| } | |
| } | |
| async function fetchUrlPayload(url) { | |
| const userAgent = document.getElementById('urlUserAgentSelect').value || 'chrome_desktop'; | |
| const response = await fetch('/api/v1/url/fetch', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| url: url, | |
| user_agent: userAgent, | |
| timeout_seconds: 20 | |
| }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Ошибка сервера: ' + response.statusText); | |
| } | |
| const data = await response.json(); | |
| if (!data.ok) { | |
| throw new Error(data.error || 'Не удалось извлечь текст по URL.'); | |
| } | |
| return data; | |
| } | |
| async function fetchTargetFromUrl() { | |
| const input = document.getElementById('targetUrlInput'); | |
| const url = (input.value || '').trim(); | |
| if (!url) { | |
| alert('Введите URL для Target.'); | |
| return; | |
| } | |
| document.getElementById('loader').style.display = 'block'; | |
| try { | |
| const data = await fetchUrlPayload(url); | |
| document.getElementById('targetText').value = data.text || ''; | |
| document.getElementById('targetTitle').value = data.title || ''; | |
| const len = (data.title || '').length; | |
| const counter = document.getElementById('titleCharCount'); | |
| counter.textContent = len; | |
| counter.className = len > 60 ? 'text-danger fw-bold' : (len > 50 ? 'text-warning' : 'text-muted'); | |
| alert('Target успешно загружен по URL.'); | |
| } catch (error) { | |
| alert('Ошибка загрузки Target URL: ' + error.message); | |
| console.error(error); | |
| } finally { | |
| document.getElementById('loader').style.display = 'none'; | |
| } | |
| } | |
| async function fetchCompetitorsFromUrls() { | |
| const urlsRaw = (document.getElementById('competitorUrlsInput').value || '').trim(); | |
| if (!urlsRaw) { | |
| alert('Введите хотя бы один URL конкурента (по одному в строке).'); | |
| return; | |
| } | |
| const urls = urlsRaw.split('\n').map(u => u.trim()).filter(Boolean); | |
| if (urls.length === 0) { | |
| alert('Не найдено корректных URL.'); | |
| return; | |
| } | |
| document.getElementById('loader').style.display = 'block'; | |
| const results = []; | |
| const errors = []; | |
| try { | |
| for (let i = 0; i < urls.length; i++) { | |
| const url = urls[i]; | |
| try { | |
| const parsed = await fetchUrlPayload(url); | |
| results.push(parsed); | |
| } catch (err) { | |
| errors.push(`K${i + 1}: ${err.message}`); | |
| } | |
| } | |
| if (results.length > 0) { | |
| ensureCompetitorFieldCount(results.length); | |
| const textAreas = document.querySelectorAll('#competitorsList textarea'); | |
| const titleInputs = document.querySelectorAll('#competitorTitlesList input'); | |
| results.forEach((doc, idx) => { | |
| if (textAreas[idx]) textAreas[idx].value = doc.text || ''; | |
| if (titleInputs[idx]) titleInputs[idx].value = doc.title || ''; | |
| }); | |
| } | |
| if (errors.length > 0) { | |
| alert(`Частично загружено. Успешно: ${results.length}/${urls.length}\n` + errors.join('\n')); | |
| } else { | |
| alert(`Конкуренты успешно загружены: ${results.length}/${urls.length}`); | |
| } | |
| } catch (error) { | |
| alert('Ошибка загрузки URL конкурентов: ' + error.message); | |
| console.error(error); | |
| } finally { | |
| document.getElementById('loader').style.display = 'none'; | |
| } | |
| } | |
| function saveProject() { | |
| const diffMode = getOptimizerDiffModeValue(); | |
| const origSnap = loadOptimizerOriginalSnapshot(); | |
| const stageCfg = _getOptimizerStageConfigFromUI(); | |
| const curBody = document.getElementById('targetText').value || ''; | |
| const curTitle = document.getElementById('targetTitle').value || ''; | |
| // В проекте всегда держим оригинал, даже если снимок ещё не создавался. | |
| const origBody = (origSnap && origSnap.body !== null) ? origSnap.body : curBody; | |
| const origTitle = (origSnap && origSnap.title !== null) ? origSnap.title : curTitle; | |
| const projectData = { | |
| app: "seo-ai-editor", | |
| schema_version: PROJECT_SCHEMA_VERSION, | |
| saved_at: new Date().toISOString(), | |
| inputs: { | |
| language: document.getElementById('languageSelect').value, | |
| target_url: document.getElementById('targetUrlInput').value, | |
| competitor_urls: document.getElementById('competitorUrlsInput').value, | |
| fetch_user_agent: document.getElementById('urlUserAgentSelect').value, | |
| target_text: curBody, | |
| keywords: document.getElementById('keywordsInput').value, | |
| competitors: collectCompetitorTexts(), | |
| target_title: curTitle, | |
| competitor_titles: collectCompetitorTitles(), | |
| semantic_threshold: Number(document.getElementById('semanticThreshold').value || 50), | |
| semantic_compression: Number(document.getElementById('semanticCompression').value || 0.1), | |
| semantic_query: document.getElementById('semanticQueryInput').value, | |
| optimizer_base_url: document.getElementById('optimizerBaseUrl').value, | |
| optimizer_model: document.getElementById('optimizerModel').value, | |
| optimizer_iterations: Number(document.getElementById('optimizerIterations').value || 2), | |
| optimizer_candidates: Number(document.getElementById('optimizerCandidates').value || 2), | |
| optimizer_temperature: Number(document.getElementById('optimizerTemp').value || 0.25), | |
| optimizer_bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70), | |
| optimizer_mode: document.getElementById('optimizerMode').value, | |
| optimizer_phrase_strategy: document.getElementById('optimizerPhraseStrategy').value, | |
| optimizer_enabled_stages: stageCfg.enabled_stages, | |
| optimizer_stage_goal_overrides: stageCfg.stage_goal_overrides, | |
| // Diff highlight settings (for persistence across sessions) | |
| optimizer_diff_mode: diffMode, | |
| optimizer_original_target_text: origBody, | |
| optimizer_original_target_title: origTitle | |
| }, | |
| state: { | |
| analysis_result: currentData, | |
| semantic_result: semanticData, | |
| optimizer_result: optimizerData | |
| } | |
| }; | |
| const blob = new Blob([JSON.stringify(projectData, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| const stamp = new Date().toISOString().replace(/[:.]/g, '-'); | |
| a.href = url; | |
| a.download = `seo_ai_project_${stamp}.json`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function loadProject() { | |
| const input = document.getElementById('projectFileInput'); | |
| input.value = ''; | |
| input.click(); | |
| } | |
| function clearProject() { | |
| const ok = confirm("Сбросить текущий проект? Все несохраненные данные будут потеряны."); | |
| if (!ok) return; | |
| // Inputs | |
| document.getElementById('languageSelect').value = 'ru'; | |
| document.getElementById('targetUrlInput').value = ''; | |
| document.getElementById('competitorUrlsInput').value = ''; | |
| document.getElementById('urlUserAgentSelect').value = 'chrome_desktop'; | |
| document.getElementById('targetText').value = ''; | |
| document.getElementById('keywordsInput').value = ''; | |
| document.getElementById('targetTitle').value = ''; | |
| document.getElementById('titleCharCount').textContent = '0'; | |
| document.getElementById('titleCharCount').className = 'text-muted'; | |
| document.getElementById('semanticThreshold').value = 50; | |
| document.getElementById('semanticCompression').value = 0.1; | |
| document.getElementById('semanticQueryInput').value = ''; | |
| document.getElementById('optimizerApiKey').value = ''; | |
| document.getElementById('optimizerBaseUrl').value = 'https://api.deepseek.com/v1'; | |
| document.getElementById('optimizerModel').value = 'deepseek-chat'; | |
| document.getElementById('optimizerIterations').value = 2; | |
| document.getElementById('optimizerCandidates').value = 2; | |
| document.getElementById('optimizerTemp').value = 0.25; | |
| document.getElementById('optimizerBertStageTarget').value = 0.70; | |
| document.getElementById('optimizerMode').value = 'balanced'; | |
| document.getElementById('optimizerPhraseStrategy').value = 'auto'; | |
| const diffSel = document.getElementById('optimizerDiffMode'); | |
| if (diffSel) diffSel.value = 'diff_from_input'; | |
| optimizerSelectAllStages(true); | |
| // Competitor text fields | |
| const competitorsList = document.getElementById('competitorsList'); | |
| competitorsList.innerHTML = '<textarea class="form-control mb-2" rows="3" placeholder="Текст конкурента 1..."></textarea>'; | |
| // Competitor title fields | |
| const compTitlesList = document.getElementById('competitorTitlesList'); | |
| compTitlesList.innerHTML = '<input type="text" class="form-control mb-2" placeholder="Title конкурента 1..." maxlength="200">'; | |
| // Clear state | |
| currentData = null; | |
| semanticData = null; | |
| optimizerData = null; | |
| // Reset result blocks | |
| document.getElementById('generalStats').innerHTML = ''; | |
| document.getElementById('bertResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Нажмите "Анализировать", чтобы увидеть результаты.</div>'; | |
| document.getElementById('bm25TableBody').innerHTML = ''; | |
| const bm25Msg = document.getElementById('bm25EmptyMsg'); | |
| bm25Msg.style.display = 'block'; | |
| bm25Msg.textContent = 'Нет критических рекомендаций.'; | |
| document.getElementById('ngramTableBody').innerHTML = ''; | |
| document.getElementById('titleResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Заполните поле "Ваш Title" и нажмите "Анализировать".</div>'; | |
| document.getElementById('semanticDocSelect').innerHTML = '<option value="target">Мой текст</option>'; | |
| document.getElementById('semanticResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Нажмите "Запустить Semantic Core", чтобы построить граф и разметку.</div>'; | |
| document.getElementById('summaryResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Запустите анализ, чтобы увидеть итоговые рекомендации.</div>'; | |
| document.getElementById('optimizerResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Запустите основной анализ и затем оптимизацию.</div>'; | |
| // Чтобы режим "оригинал" не сравнивался с прошлым проектом. | |
| try { | |
| localStorage.removeItem(OPT_DIFF_ORIG_BODY_KEY); | |
| localStorage.removeItem(OPT_DIFF_ORIG_TITLE_KEY); | |
| } catch (e) { | |
| // ignore | |
| } | |
| refreshOptimizerStageGoalConfig({ | |
| bert: { mode: 'auto', selected: [], custom_add: [] }, | |
| bm25: { mode: 'auto', selected: [], custom_add: [] }, | |
| ngram: { mode: 'auto', selected: [], custom_add: [] }, | |
| semantic: { mode: 'auto', selected: [], custom_add: [] }, | |
| title: { mode: 'auto', selected: [], custom_add: [] } | |
| }); | |
| } | |
| function applyProjectData(project) { | |
| if (!project || !project.inputs) { | |
| throw new Error("Некорректный формат файла проекта."); | |
| } | |
| const inp = project.inputs; | |
| // Base fields | |
| document.getElementById('languageSelect').value = inp.language || 'ru'; | |
| document.getElementById('targetUrlInput').value = inp.target_url || ''; | |
| document.getElementById('competitorUrlsInput').value = inp.competitor_urls || ''; | |
| document.getElementById('urlUserAgentSelect').value = inp.fetch_user_agent || 'chrome_desktop'; | |
| document.getElementById('targetText').value = inp.target_text || ''; | |
| document.getElementById('keywordsInput').value = inp.keywords || ''; | |
| document.getElementById('targetTitle').value = inp.target_title || ''; | |
| document.getElementById('semanticThreshold').value = nv(inp.semantic_threshold, 50); | |
| document.getElementById('semanticCompression').value = nv(inp.semantic_compression, 0.1); | |
| document.getElementById('semanticQueryInput').value = inp.semantic_query || ''; | |
| document.getElementById('optimizerBaseUrl').value = inp.optimizer_base_url || 'https://api.deepseek.com/v1'; | |
| document.getElementById('optimizerModel').value = inp.optimizer_model || 'deepseek-chat'; | |
| document.getElementById('optimizerIterations').value = nv(inp.optimizer_iterations, 2); | |
| document.getElementById('optimizerCandidates').value = nv(inp.optimizer_candidates, 2); | |
| document.getElementById('optimizerTemp').value = nv(inp.optimizer_temperature, 0.25); | |
| document.getElementById('optimizerBertStageTarget').value = nv(inp.optimizer_bert_stage_target, 0.70); | |
| document.getElementById('optimizerMode').value = inp.optimizer_mode || 'balanced'; | |
| document.getElementById('optimizerPhraseStrategy').value = inp.optimizer_phrase_strategy || 'auto'; | |
| optimizerSelectAllStages(true); | |
| const savedEnabledStages = Array.isArray(inp.optimizer_enabled_stages) ? inp.optimizer_enabled_stages : []; | |
| if (savedEnabledStages.length > 0) { | |
| optimizerSelectAllStages(false); | |
| savedEnabledStages.forEach((st) => { | |
| const stage = String(st || '').trim().toLowerCase(); | |
| const id = 'optStage' + stage.charAt(0).toUpperCase() + stage.slice(1); | |
| const el = document.getElementById(id); | |
| if (el) el.checked = true; | |
| }); | |
| } | |
| const savedOverrides = inp.optimizer_stage_goal_overrides && typeof inp.optimizer_stage_goal_overrides === 'object' | |
| ? inp.optimizer_stage_goal_overrides | |
| : {}; | |
| refreshOptimizerStageGoalConfig(savedOverrides); | |
| // Restore diff-mode and original snapshot from project (if present). | |
| try { | |
| const savedMode = String(nv(inp.optimizer_diff_mode, '') || '').trim(); | |
| const mode = (savedMode === 'diff_from_original') ? 'diff_from_original' : 'diff_from_input'; | |
| const diffSel = document.getElementById('optimizerDiffMode'); | |
| if (diffSel) diffSel.value = mode; | |
| const savedOrigBody = nv(inp.optimizer_original_target_text, null); | |
| const savedOrigTitle = nv(inp.optimizer_original_target_title, null); | |
| if (savedOrigBody !== null || savedOrigTitle !== null) { | |
| localStorage.setItem(OPT_DIFF_ORIG_BODY_KEY, String(savedOrigBody || '')); | |
| localStorage.setItem(OPT_DIFF_ORIG_TITLE_KEY, String(savedOrigTitle || '')); | |
| } else { | |
| // If project has no snapshot, clear any leftovers. | |
| localStorage.removeItem(OPT_DIFF_ORIG_BODY_KEY); | |
| localStorage.removeItem(OPT_DIFF_ORIG_TITLE_KEY); | |
| } | |
| } catch (e) { | |
| // ignore | |
| } | |
| // Title character counter refresh | |
| const titleLen = (inp.target_title || '').length; | |
| const counter = document.getElementById('titleCharCount'); | |
| counter.textContent = titleLen; | |
| counter.className = titleLen > 60 ? 'text-danger fw-bold' : (titleLen > 50 ? 'text-warning' : 'text-muted'); | |
| // Competitor texts | |
| const competitorsList = document.getElementById('competitorsList'); | |
| competitorsList.innerHTML = ''; | |
| const compTexts = Array.isArray(inp.competitors) && inp.competitors.length > 0 ? inp.competitors : ['']; | |
| compTexts.forEach((txt, idx) => { | |
| const textarea = document.createElement('textarea'); | |
| textarea.className = 'form-control mb-2' + (idx > 0 ? ' competitor-input' : ''); | |
| textarea.rows = 3; | |
| textarea.placeholder = idx === 0 ? 'Текст конкурента 1...' : 'Ещё конкурент...'; | |
| textarea.value = txt || ''; | |
| competitorsList.appendChild(textarea); | |
| }); | |
| // Competitor titles | |
| const compTitlesList = document.getElementById('competitorTitlesList'); | |
| compTitlesList.innerHTML = ''; | |
| const compTitles = Array.isArray(inp.competitor_titles) && inp.competitor_titles.length > 0 ? inp.competitor_titles : ['']; | |
| compTitles.forEach((ttl, idx) => { | |
| const titleInput = document.createElement('input'); | |
| titleInput.type = 'text'; | |
| titleInput.className = 'form-control mb-2'; | |
| titleInput.placeholder = idx === 0 ? 'Title конкурента 1...' : 'Ещё Title конкурента...'; | |
| titleInput.maxLength = 200; | |
| titleInput.value = ttl || ''; | |
| compTitlesList.appendChild(titleInput); | |
| }); | |
| // Restore cached analysis results if present | |
| currentData = project.state && project.state.analysis_result ? project.state.analysis_result : null; | |
| semanticData = project.state && project.state.semantic_result ? project.state.semantic_result : null; | |
| optimizerData = project.state && project.state.optimizer_result ? project.state.optimizer_result : null; | |
| if (currentData) renderResults(currentData); | |
| if (semanticData) renderSemanticResults(semanticData); | |
| refreshOptimizerStageGoalConfig(savedOverrides); | |
| renderActionSummary(currentData, semanticData); | |
| renderOptimizerResults(optimizerData); | |
| } | |
| document.getElementById('projectFileInput').addEventListener('change', function(e) { | |
| const file = e.target.files && e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = function(evt) { | |
| try { | |
| const text = evt.target.result; | |
| const project = JSON.parse(text); | |
| applyProjectData(project); | |
| alert("Проект успешно загружен."); | |
| } catch (err) { | |
| alert("Не удалось загрузить проект: " + err.message); | |
| console.error(err); | |
| } | |
| }; | |
| reader.readAsText(file, 'utf-8'); | |
| }); | |
| async function runAnalysis() { | |
| // Сбор данных | |
| const targetText = document.getElementById('targetText').value; | |
| const lang = document.getElementById('languageSelect').value; | |
| const keywordsRaw = document.getElementById('keywordsInput').value.split('\n').filter(k => k.trim() !== ''); | |
| // Сбор конкурентов | |
| const competitors = collectCompetitorTexts(); | |
| if(!targetText) { alert("Введите ваш текст!"); return; } | |
| // Title fields | |
| const targetTitle = document.getElementById('targetTitle').value; | |
| const competitorTitles = collectCompetitorTitles(); | |
| // UI Loading | |
| document.getElementById('loader').style.display = 'block'; | |
| const payload = { | |
| target_text: targetText, | |
| competitors: competitors, | |
| keywords: keywordsRaw, | |
| language: lang, | |
| target_title: targetTitle, | |
| competitor_titles: competitorTitles | |
| }; | |
| try { | |
| const response = await fetch('/analyze', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText); | |
| const data = await response.json(); | |
| currentData = data; | |
| renderResults(data); | |
| optimizerData = null; | |
| renderOptimizerResults(null); | |
| refreshOptimizerStageGoalConfig(); | |
| } catch (error) { | |
| alert("Ошибка: " + error.message); | |
| console.error(error); | |
| } finally { | |
| document.getElementById('loader').style.display = 'none'; | |
| } | |
| } | |
| async function runSemanticAnalysis() { | |
| const targetText = document.getElementById('targetText').value; | |
| const lang = document.getElementById('languageSelect').value; | |
| const threshold = Number(document.getElementById('semanticThreshold').value || 50); | |
| const compression = Number(document.getElementById('semanticCompression').value || 0.1); | |
| const competitors = collectCompetitorTexts(); | |
| if (!targetText.trim()) { | |
| alert("Введите ваш текст, чтобы запустить Semantic Core."); | |
| return; | |
| } | |
| document.getElementById('loader').style.display = 'block'; | |
| const payload = { | |
| text: targetText, | |
| competitors: competitors, | |
| language: lang, | |
| threshold: Math.max(1, Math.min(100, threshold)), | |
| compression_ratio: Math.max(0.05, Math.min(0.8, compression)) | |
| }; | |
| try { | |
| const response = await fetch('/api/v1/semantic/analyze', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText); | |
| semanticData = await response.json(); | |
| renderSemanticResults(semanticData); | |
| refreshOptimizerStageGoalConfig(); | |
| renderActionSummary(currentData, semanticData); | |
| } catch (error) { | |
| alert("Ошибка Semantic Core: " + error.message); | |
| console.error(error); | |
| } finally { | |
| document.getElementById('loader').style.display = 'none'; | |
| } | |
| } | |
| function renderOptimizerResults(data) { | |
| const container = document.getElementById('optimizerResultsContainer'); | |
| if (!container) return; | |
| if (!data) { | |
| container.innerHTML = '<div class="text-center text-muted py-5">Запустите основной анализ и затем оптимизацию.</div>'; | |
| return; | |
| } | |
| if (!data.ok) { | |
| container.innerHTML = `<div class="alert alert-danger">Ошибка оптимизации: ${data.error || 'unknown'}</div>`; | |
| return; | |
| } | |
| const earlyBanner = data.stopped_early | |
| ? `<div class="alert alert-warning mb-3"><strong>Остановлено вручную.</strong> Ниже — частичный результат на момент остановки. Шагов применено: <strong>${nv(data.applied_changes, 0)}</strong>.</div>` | |
| : ''; | |
| const safeHtml = (v) => String(nv(v, '')) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| const optTitle = getOptimizedTitleFromResult(data); | |
| const titleBanner = optTitle | |
| ? `<div class="alert alert-light border mb-3"><strong>Title после оптимизации:</strong><br><code class="small">${safeHtml(optTitle)}</code><div class="small text-muted mt-1">Кнопка «Применить в Target» подставит этот текст в поле Title.</div></div>` | |
| : ''; | |
| const diffModeLabel = data.diff_mode === 'diff_from_original' ? 'Оригинал (снимок)' : 'Входной текст'; | |
| const diffChanges = Array.isArray(data.diff_changes) ? data.diff_changes : []; | |
| const diffChangesRows = diffChanges | |
| .slice(0, 50) | |
| .map((c) => { | |
| const kind = c.type || '-'; | |
| const fromTxt = c.from ? safeHtml(c.from) : '-'; | |
| const toTxt = c.to ? safeHtml(c.to) : '-'; | |
| return `<tr> | |
| <td>${safeHtml(kind)}</td> | |
| <td><div style="max-width: 420px; white-space: normal;">${fromTxt}</div></td> | |
| <td><div style="max-width: 420px; white-space: normal;">${toTxt}</div></td> | |
| </tr>`; | |
| }).join(''); | |
| const diffCard = data.diff_body_html | |
| ? `<div class="stat-card mt-3"> | |
| <h6 class="card-title">Подсветка изменений в Target</h6> | |
| <div class="small mb-2 text-muted">Режим: <strong>${safeHtml(diffModeLabel)}</strong></div> | |
| <div class="border rounded p-3" style="background:#fff; line-height: 1.8; word-break: break-word;">${data.diff_body_html}</div> | |
| <div class="mt-3"> | |
| <div class="small fw-semibold text-muted mb-1">Фрагменты (что было / что стало)</div> | |
| <div class="table-responsive"> | |
| <table class="table table-sm table-bordered mb-0"> | |
| <thead class="table-light"> | |
| <tr><th>Тип</th><th>Было</th><th>Стало</th></tr> | |
| </thead> | |
| <tbody> | |
| ${diffChangesRows || '<tr><td colspan="3" class="text-center text-muted">Изменений не найдено.</td></tr>'} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div>` | |
| : ''; | |
| const base = data.baseline_metrics || {}; | |
| const fin = data.final_metrics || {}; | |
| const rows = [ | |
| ['Composite score', base.score, fin.score], | |
| ['BERT низких ключей', base.bert_low_count, fin.bert_low_count], | |
| ['BM25 remove', base.bm25_remove_count, fin.bm25_remove_count], | |
| ['N-gram signals', base.ngram_signal_count, fin.ngram_signal_count], | |
| ['Title BERT', nv(base.title_bert_score, '-'), nv(fin.title_bert_score, '-')], | |
| ['Semantic gaps', base.semantic_gap_count, fin.semantic_gap_count], | |
| ].map(r => `<tr><td>${r[0]}</td><td>${r[1]}</td><td>${r[2]}</td></tr>`).join(''); | |
| const iterRows = (data.iterations || []).map(it => { | |
| const before = it.metrics_before ? it.metrics_before.score : '-'; | |
| const after = it.metrics_after ? it.metrics_after.score : '-'; | |
| const baseline = nv(it.current_score, before); | |
| const reason = it.reason || ((it.status === 'rejected' && it.candidates) ? 'all candidates rejected by constraints' : '-'); | |
| const stage = (it.stage || (it.goal && it.goal.type) || '-'); | |
| const advanced = it.advanced_to_stage ? ` → ${it.advanced_to_stage}` : ''; | |
| return `<tr> | |
| <td>${it.step}</td> | |
| <td>${it.status}</td> | |
| <td>${stage}${advanced}</td> | |
| <td>${it.goal ? (it.goal.type + ': ' + (it.goal.label || '')) : '-'}</td> | |
| <td>L${it.cascade_level || '-'} / ${it.operation || '-'}</td> | |
| <td>${baseline}</td> | |
| <td>${before}</td> | |
| <td>${after}</td> | |
| <td>${nv(it.delta_score, '-')}</td> | |
| <td>${reason}</td> | |
| </tr>`; | |
| }).join(''); | |
| const iterationDebugHtml = (data.iterations || []).map(it => { | |
| const candidates = Array.isArray(it.candidates) ? it.candidates : []; | |
| const candidateRows = candidates.map(c => { | |
| const reasons = Array.isArray(c.invalid_reasons) ? c.invalid_reasons.join(', ') : ''; | |
| const sentAfter = c.sentence_after ? safeHtml(c.sentence_after) : '-'; | |
| const strategy = c.phrase_strategy_used || (c.llm_prompt_debug && c.llm_prompt_debug.phrase_strategy_mode) || '-'; | |
| const relBefore = nv(c.chunk_relevance_before, '-'); | |
| const relAfter = nv(c.chunk_relevance_after, '-'); | |
| const termDiff = c.term_diff ? safeHtml(JSON.stringify(c.term_diff)) : '-'; | |
| const metricDelta = c.metrics_delta ? safeHtml(JSON.stringify(c.metrics_delta)) : '-'; | |
| const promptDbg = c.llm_prompt_debug ? safeHtml(JSON.stringify(c.llm_prompt_debug, null, 2)) : '-'; | |
| const rationale = c.llm_rationale ? safeHtml(c.llm_rationale) : '-'; | |
| return ` | |
| <tr> | |
| <td>${nv(c.candidate_index, '-')}</td> | |
| <td>${safeHtml(strategy)}</td> | |
| <td>${c.valid ? 'yes' : 'no'}</td> | |
| <td>${c.goal_improved ? 'yes' : 'no'}</td> | |
| <td>${nv(c.bert_phrase_delta, '-')}</td> | |
| <td>${c.local_chunk_improved ? 'yes' : 'no'}</td> | |
| <td>${nv(c.chunk_goal_delta, '-')}</td> | |
| <td>${relBefore} → ${relAfter}</td> | |
| <td>${nv(c.delta_score, '-')}</td> | |
| <td>${nv(c.candidate_score, '-')}</td> | |
| <td> | |
| <div>${safeHtml(reasons || c.error || '-')}</div> | |
| <div class="small text-muted mt-1">term diff: ${termDiff}</div> | |
| <div class="small text-muted mt-1">metric Δ: ${metricDelta}</div> | |
| </td> | |
| <td> | |
| <div style="max-width: 520px; white-space: normal;">${sentAfter}</div> | |
| <div class="small text-muted mt-1">rationale: ${rationale}</div> | |
| <details class="mt-1"> | |
| <summary class="small">LLM input</summary> | |
| <pre class="small mb-0" style="white-space: pre-wrap;">${promptDbg}</pre> | |
| </details> | |
| </td> | |
| </tr> | |
| `; | |
| }).join(''); | |
| const sentBefore = it.sentence_before ? safeHtml(it.sentence_before) : '-'; | |
| const sentAfterChosen = it.sentence_after | |
| ? safeHtml(it.sentence_after) | |
| : (it.best_candidate_sentence_after ? safeHtml(it.best_candidate_sentence_after) : '-'); | |
| return ` | |
| <div class="card mb-3 border-0 shadow-sm"> | |
| <div class="card-body"> | |
| <div class="d-flex justify-content-between align-items-center mb-2"> | |
| <div><strong>Шаг ${it.step}</strong> — ${safeHtml(it.status || '-')}</div> | |
| <span class="badge bg-secondary">${safeHtml(it.goal ? (it.goal.type + ': ' + (it.goal.label || '')) : '-')}</span> | |
| </div> | |
| <div class="small mb-2"><strong>Этап:</strong> ${safeHtml(it.stage || (it.goal ? it.goal.type : '-') || '-')}</div> | |
| <div class="small mb-2"><strong>Каскад:</strong> L${safeHtml(it.cascade_level || '-')} / ${safeHtml(it.operation || '-')}</div> | |
| <div class="small mb-2"><strong>Причина:</strong> ${safeHtml(it.reason || '-')}</div> | |
| ${it.escalated_to_level ? `<div class="small mb-2 text-warning"><strong>Эскалация:</strong> переход на L${safeHtml(it.escalated_to_level)}</div>` : ''} | |
| ${it.advanced_to_stage ? `<div class="small mb-2 text-info"><strong>Смена этапа:</strong> переход на ${safeHtml(it.advanced_to_stage)}</div>` : ''} | |
| <div class="small mb-2"><strong>Исходное предложение:</strong><br><span class="text-muted">${sentBefore}</span></div> | |
| <div class="small mb-2"><strong>Выбранный вариант:</strong><br><span class="text-muted">${sentAfterChosen}</span></div> | |
| <div class="table-responsive"> | |
| <table class="table table-sm table-bordered mb-0"> | |
| <thead class="table-light"> | |
| <tr> | |
| <th>#cand</th><th>strategy</th><th>valid</th><th>goal+</th><th>bert Δ</th><th>local+</th><th>chunk Δ</th><th>rel b→a</th><th>Δ</th><th>score</th><th>reject reason/error</th><th>кандидат правки</th> | |
| </tr> | |
| </thead> | |
| <tbody>${candidateRows || '<tr><td colspan="12" class="text-center text-muted">Нет кандидатов</td></tr>'}</tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| container.innerHTML = earlyBanner + titleBanner + diffCard + ` | |
| <div class="stat-card"> | |
| <h6 class="card-title">Результат оптимизации</h6> | |
| <div class="small mb-2">Применено правок: <strong>${data.applied_changes || 0}</strong></div> | |
| <div class="small mb-2"> | |
| Режим: <strong>${data.optimization_mode || 'balanced'}</strong> | |
| · Phrase Strategy: <strong>${data.phrase_strategy_mode || 'auto'}</strong> | |
| · BERT target A-stage: <strong>${nv(data.bert_stage_target, 0.7)}</strong> | |
| </div> | |
| <div class="table-responsive"> | |
| <table class="table table-sm table-bordered mb-0"> | |
| <thead class="table-light"><tr><th>Метрика</th><th>До</th><th>После</th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="stat-card"> | |
| <h6 class="card-title">Лог итераций</h6> | |
| <div class="table-responsive"> | |
| <table class="table table-sm table-hover mb-0"> | |
| <thead><tr><th>#</th><th>Статус</th><th>Этап</th><th>Цель</th><th>Каскад</th><th>Baseline шага</th><th>Score до</th><th>Score после</th><th>Δ</th><th>Причина/комментарий</th></tr></thead> | |
| <tbody>${iterRows || '<tr><td colspan="10" class="text-muted text-center">Нет данных</td></tr>'}</tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="stat-card"> | |
| <h6 class="card-title">Подробный debug-лог итераций</h6> | |
| ${iterationDebugHtml || '<div class="text-muted">Нет данных.</div>'} | |
| </div>`; | |
| } | |
| function getOptimizedTitleFromResult(data) { | |
| if (!data) return ''; | |
| var t = (data.optimized_title || '').trim(); | |
| if (t) return t; | |
| var fm = data.final_metrics || {}; | |
| t = (fm.resolved_title || '').trim(); | |
| return t || ''; | |
| } | |
| function applyOptimizedText() { | |
| if (!optimizerData || !optimizerData.ok || !optimizerData.optimized_text) { | |
| alert('Нет результата оптимизации для применения.'); | |
| return; | |
| } | |
| document.getElementById('targetText').value = optimizerData.optimized_text; | |
| var ot = getOptimizedTitleFromResult(optimizerData); | |
| var titleEl = document.getElementById('targetTitle'); | |
| if (titleEl && ot) { | |
| titleEl.value = ot; | |
| try { | |
| titleEl.dispatchEvent(new Event('input', { bubbles: true })); | |
| } catch (e) { /* ignore */ } | |
| } | |
| alert( | |
| ot | |
| ? 'Подставлены Target и Title. Рекомендуется заново запустить анализ.' | |
| : 'Оптимизированный текст подставлен в поле Target (Title не менялся или не найден в ответе). Рекомендуется заново запустить анализ.' | |
| ); | |
| } | |
| async function runLlmOptimization() { | |
| if (!currentData) { | |
| alert('Сначала выполните основной анализ текста.'); | |
| return; | |
| } | |
| const apiKey = (document.getElementById('optimizerApiKey').value || '').trim(); | |
| if (!apiKey) { | |
| alert('Введите API key для LLM.'); | |
| return; | |
| } | |
| const diffMode = getOptimizerDiffModeValue(); | |
| let originalTargetText = null; | |
| let originalTargetTitle = null; | |
| if (diffMode === 'diff_from_original') { | |
| const snap = ensureOptimizerOriginalSnapshot(); | |
| originalTargetText = snap.body; | |
| originalTargetTitle = snap.title; | |
| } | |
| const stageCfg = _getOptimizerStageConfigFromUI(); | |
| if (!stageCfg.enabled_stages || stageCfg.enabled_stages.length === 0) { | |
| alert('Выберите хотя бы одну стадию оптимизации.'); | |
| return; | |
| } | |
| const payload = { | |
| target_text: document.getElementById('targetText').value || '', | |
| competitors: collectCompetitorTexts(), | |
| keywords: (document.getElementById('keywordsInput').value || '').split('\n').map(v => v.trim()).filter(Boolean), | |
| language: document.getElementById('languageSelect').value || 'en', | |
| target_title: document.getElementById('targetTitle').value || '', | |
| competitor_titles: collectCompetitorTitles(), | |
| api_key: apiKey, | |
| api_base_url: (document.getElementById('optimizerBaseUrl').value || '').trim(), | |
| model: (document.getElementById('optimizerModel').value || '').trim(), | |
| max_iterations: Number(document.getElementById('optimizerIterations').value || 2), | |
| candidates_per_iteration: Number(document.getElementById('optimizerCandidates').value || 2), | |
| temperature: Number(document.getElementById('optimizerTemp').value || 0.25), | |
| bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70), | |
| optimization_mode: document.getElementById('optimizerMode').value || 'balanced', | |
| phrase_strategy_mode: document.getElementById('optimizerPhraseStrategy').value || 'auto', | |
| enabled_stages: stageCfg.enabled_stages, | |
| stage_goal_overrides: stageCfg.stage_goal_overrides, | |
| diff_mode: diffMode, | |
| original_target_text: originalTargetText, | |
| original_target_title: originalTargetTitle | |
| }; | |
| const runBtn = document.getElementById('btnRunLlmOpt'); | |
| const stopBtn = document.getElementById('btnStopLlmOpt'); | |
| const panel = document.getElementById('optimizerProgressPanel'); | |
| optimizerStreamJobId = null; | |
| if (runBtn) runBtn.disabled = true; | |
| if (stopBtn) stopBtn.disabled = false; | |
| if (panel) panel.classList.remove('d-none'); | |
| optimizerRunUiClear(); | |
| optimizerLogAppend('Соединение с сервером (SSE)…'); | |
| const t0 = Date.now(); | |
| const tick = setInterval(function () { | |
| const el = document.getElementById('optimizerElapsed'); | |
| if (el) el.textContent = 'Прошло: ' + Math.floor((Date.now() - t0) / 1000) + ' с'; | |
| }, 1000); | |
| try { | |
| const response = await fetch('/api/v1/optimizer/run-stream', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) throw new Error('Ошибка сервера: ' + response.statusText); | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| let finalResult = null; | |
| while (true) { | |
| const chunk = await reader.read(); | |
| if (chunk.done) break; | |
| buffer += decoder.decode(chunk.value, { stream: true }); | |
| for (;;) { | |
| const sep = buffer.indexOf('\n\n'); | |
| if (sep < 0) break; | |
| const raw = buffer.slice(0, sep); | |
| buffer = buffer.slice(sep + 2); | |
| const rawLines = raw.split('\n'); | |
| for (let li = 0; li < rawLines.length; li++) { | |
| const line = rawLines[li]; | |
| if (line.indexOf('data: ') !== 0) continue; | |
| let parsed; | |
| try { | |
| parsed = JSON.parse(line.slice(6)); | |
| } catch (e) { | |
| continue; | |
| } | |
| if (parsed.job_id) optimizerStreamJobId = parsed.job_id; | |
| if (parsed.event === 'complete') { | |
| finalResult = parsed.result; | |
| } else if (parsed.event === 'error') { | |
| throw new Error(parsed.error || 'Ошибка оптимизатора'); | |
| } else { | |
| applyOptimizerStreamEvent(parsed); | |
| } | |
| } | |
| } | |
| } | |
| if (!finalResult) throw new Error('Поток завершился без результата'); | |
| optimizerData = finalResult; | |
| renderOptimizerResults(optimizerData); | |
| optimizerLogAppend('Готово.'); | |
| if (optimizerData.stopped_early) { | |
| alert('Остановлено: показан частичный результат, можно «Применить в Target».'); | |
| } | |
| } catch (error) { | |
| alert('Ошибка LLM оптимизации: ' + error.message); | |
| console.error(error); | |
| } finally { | |
| clearInterval(tick); | |
| optimizerStreamJobId = null; | |
| if (runBtn) runBtn.disabled = false; | |
| if (stopBtn) stopBtn.disabled = true; | |
| if (panel) panel.classList.add('d-none'); | |
| } | |
| } | |
| async function runSemanticSearch() { | |
| const query = document.getElementById('semanticQueryInput').value; | |
| const lang = document.getElementById('languageSelect').value; | |
| const container = document.getElementById('semanticSearchResults'); | |
| const selectedDocKey = document.getElementById('semanticDocSelect').value; | |
| if (!query.trim()) { | |
| alert("Введите запрос для смыслового поиска."); | |
| return; | |
| } | |
| if (!semanticData) { | |
| alert("Сначала запустите Semantic Core."); | |
| return; | |
| } | |
| let docText = ""; | |
| if (selectedDocKey === "target") { | |
| docText = semanticData.target ? (semanticData.target.text || "") : ""; | |
| } else { | |
| const compId = Number(selectedDocKey.replace("comp_", "")); | |
| const compDoc = (semanticData.competitors || []).find(c => c.id === compId); | |
| docText = compDoc ? (compDoc.text || "") : ""; | |
| } | |
| if (!docText.trim()) { | |
| alert("Не найден текст выбранного документа."); | |
| return; | |
| } | |
| const payload = { | |
| query_text: query, | |
| text: docText, | |
| language: lang, | |
| top_n: 20 | |
| }; | |
| try { | |
| const response = await fetch('/api/v1/semantic/search', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText); | |
| const data = await response.json(); | |
| if (!container) return; | |
| if (!data.results || data.results.length === 0) { | |
| container.innerHTML = '<div class="text-muted small">Совпадений не найдено.</div>'; | |
| return; | |
| } | |
| const badges = data.results | |
| .map(r => `<span class="badge bg-secondary me-1 mb-1">${r.lemma}: ${r.score}</span>`) | |
| .join(''); | |
| container.innerHTML = badges; | |
| } catch (error) { | |
| alert("Ошибка semantic search: " + error.message); | |
| console.error(error); | |
| } | |
| } | |
| function setSemanticTermSortBy(value) { | |
| semanticTermSortBy = value || 'target_weight'; | |
| if (semanticData) renderSemanticResults(semanticData); | |
| } | |
| function toggleSemanticTermSortDir() { | |
| semanticTermSortDir = semanticTermSortDir === 'desc' ? 'asc' : 'desc'; | |
| if (semanticData) renderSemanticResults(semanticData); | |
| } | |
| function renderSemanticResults(data) { | |
| const container = document.getElementById('semanticResultsContainer'); | |
| const docSelect = document.getElementById('semanticDocSelect'); | |
| if (!data || !data.target) { | |
| container.innerHTML = '<div class="text-center text-muted py-5">Нет данных.</div>'; | |
| return; | |
| } | |
| // Обновляем список документов (target + competitors) | |
| const prevValue = docSelect.value; | |
| let optionsHtml = '<option value="target">Мой текст</option>'; | |
| (data.competitors || []).forEach((c, idx) => { | |
| optionsHtml += `<option value="comp_${c.id}">K${idx + 1}: ${c.name}</option>`; | |
| }); | |
| docSelect.innerHTML = optionsHtml; | |
| if ([...docSelect.options].some(o => o.value === prevValue)) { | |
| docSelect.value = prevValue; | |
| } | |
| // Выбираем документ для рендера | |
| const selectedDocKey = docSelect.value || "target"; | |
| let docData = data.target; | |
| if (selectedDocKey !== "target") { | |
| const compId = Number(selectedDocKey.replace("comp_", "")); | |
| const found = (data.competitors || []).find(c => c.id === compId); | |
| if (found) docData = found; | |
| } | |
| const nodes = (docData.graph && docData.graph.nodes) ? docData.graph.nodes : []; | |
| const links = (docData.graph && docData.graph.links) ? docData.graph.links : []; | |
| const topKeywords = docData.top_keywords || []; | |
| const summary = docData.summary || []; | |
| const markup = docData.markup_text || []; | |
| const topNodesRows = nodes | |
| .slice() | |
| .sort((a, b) => (b.weight || 0) - (a.weight || 0)) | |
| .slice(0, 20) | |
| .map(n => `<tr><td>${n.label}</td><td class="text-center">${n.weight}</td><td class="text-center">${n.frequency}</td></tr>`) | |
| .join(''); | |
| const topLinksRows = links | |
| .slice() | |
| .sort((a, b) => (b.weight || 0) - (a.weight || 0)) | |
| .slice(0, 30) | |
| .map(l => `<tr><td>${l.source}</td><td>${l.target}</td><td class="text-center">${Number(l.weight).toFixed(1)}</td></tr>`) | |
| .join(''); | |
| const keywordsHtml = topKeywords.length > 0 | |
| ? topKeywords.map(k => `<span class="badge bg-dark me-1 mb-1">${k.lemma}: ${k.weight}</span>`).join('') | |
| : '<span class="text-muted">Нет данных</span>'; | |
| const summaryHtml = summary.length > 0 | |
| ? summary.map(s => `<li class="list-group-item"><span class="badge bg-primary me-2">${s.score}</span>${s.text}</li>`).join('') | |
| : '<li class="list-group-item text-muted">Реферат пуст.</li>'; | |
| const markupHtml = markup.map(sent => { | |
| const sentBlocks = (sent.blocks || []).map(b => { | |
| if (b.is_link) { | |
| const lemmas = (b.lemmas || []).join(", "); | |
| return `<span class="text-success fw-bold text-decoration-underline" title="lemmas: ${lemmas}; weight: ${b.weight}">${b.text}</span>`; | |
| } | |
| return `<span>${b.text}</span>`; | |
| }).join(''); | |
| return `<div class="mb-2">${sentBlocks}</div>`; | |
| }).join(''); | |
| let compareHtml = ''; | |
| if (data.comparison) { | |
| compareHtml = ` | |
| <div class="stat-card"> | |
| <h6 class="card-title">Сравнение с конкурентами</h6> | |
| <div class="row"> | |
| <div class="col-md-4"><strong>Target nodes:</strong> ${data.comparison.target_nodes}</div> | |
| <div class="col-md-4"><strong>Avg Comp nodes:</strong> ${data.comparison.avg_comp_nodes}</div> | |
| <div class="col-md-4"><strong>Кол-во конкурентов:</strong> ${data.comparison.num_competitors}</div> | |
| </div> | |
| <div class="row mt-2"> | |
| <div class="col-md-4"><strong>Target links:</strong> ${data.comparison.target_links}</div> | |
| <div class="col-md-4"><strong>Avg Comp links:</strong> ${data.comparison.avg_comp_links}</div> | |
| <div class="col-md-4"><strong>Текущий документ:</strong> ${docData.name || '—'}</div> | |
| </div> | |
| </div>`; | |
| } | |
| // Сравнительная таблица весов терминов (слова + фразы) | |
| const powerTable = data.comparison && data.comparison.term_power_table ? data.comparison.term_power_table : []; | |
| const powerCompTotal = data.comparison ? (data.comparison.num_competitors || 0) : 0; | |
| let powerCompHeaders = ''; | |
| for (let i = 0; i < powerCompTotal; i++) { | |
| powerCompHeaders += `<th>K${i + 1}</th>`; | |
| } | |
| const sortedPowerTable = powerTable.slice().sort((a, b) => { | |
| const dir = semanticTermSortDir === 'asc' ? 1 : -1; | |
| if (semanticTermSortBy === 'target_weight') { | |
| const diff = (Number(a.target_weight) || 0) - (Number(b.target_weight) || 0); | |
| if (diff !== 0) return diff * dir; | |
| } else if (semanticTermSortBy === 'competitor_avg_weight') { | |
| const diff = (Number(a.competitor_avg_weight) || 0) - (Number(b.competitor_avg_weight) || 0); | |
| if (diff !== 0) return diff * dir; | |
| } else if (semanticTermSortBy === 'freq') { | |
| const aOcc = Number(a.comp_occurrence) || 0; | |
| const bOcc = Number(b.comp_occurrence) || 0; | |
| const aTotal = Math.max(Number(a.comp_total) || 0, 1); | |
| const bTotal = Math.max(Number(b.comp_total) || 0, 1); | |
| const aRatio = aOcc / aTotal; | |
| const bRatio = bOcc / bTotal; | |
| const ratioDiff = aRatio - bRatio; | |
| if (ratioDiff !== 0) return ratioDiff * dir; | |
| const occDiff = aOcc - bOcc; | |
| if (occDiff !== 0) return occDiff * dir; | |
| } | |
| // Предсказуемый тай-брейк: по алфавиту | |
| return String(a.term || '').localeCompare(String(b.term || ''), 'ru', { sensitivity: 'base', numeric: true }); | |
| }); | |
| const powerRows = sortedPowerTable.map(item => { | |
| let compWeightCells = ''; | |
| (item.competitor_weights || []).forEach(w => { | |
| const cls = w > 0 ? 'fw-bold text-dark' : 'text-muted text-opacity-25'; | |
| compWeightCells += `<td class="${cls}">${w}</td>`; | |
| }); | |
| const freqText = powerCompTotal > 0 | |
| ? `${item.comp_occurrence}<span class="text-muted small">/${item.comp_total}</span>` | |
| : '-'; | |
| const termBadge = item.term_type === 'phrase' | |
| ? '<span class="badge bg-info text-dark ms-1">phrase</span>' | |
| : ''; | |
| return ` | |
| <tr> | |
| <td>${item.term}${termBadge}</td> | |
| <td class="fw-bold">${item.target_weight}</td> | |
| <td class="table-light fw-bold">${item.competitor_avg_weight}</td> | |
| <td class="table-secondary fw-bold text-center">${freqText}</td> | |
| ${compWeightCells} | |
| </tr>`; | |
| }).join(''); | |
| container.innerHTML = ` | |
| ${compareHtml} | |
| <div class="stat-card"> | |
| <h6 class="card-title">Сравнительная таблица мощных терминов</h6> | |
| <p class="text-muted small mb-2">Все термины анализируемых текстов (слова + фразы): вес в моем тексте, средний вес у конкурентов, и встречаемость по конкурентам (X/Y).</p> | |
| <div class="d-flex gap-2 align-items-center mb-2"> | |
| <label for="semanticTermSortBy" class="small text-muted mb-0">Сортировка:</label> | |
| <select id="semanticTermSortBy" class="form-select form-select-sm" style="max-width: 220px;" onchange="setSemanticTermSortBy(this.value)"> | |
| <option value="target_weight" ${semanticTermSortBy === 'target_weight' ? 'selected' : ''}>Мой вес</option> | |
| <option value="competitor_avg_weight" ${semanticTermSortBy === 'competitor_avg_weight' ? 'selected' : ''}>Avg K</option> | |
| <option value="freq" ${semanticTermSortBy === 'freq' ? 'selected' : ''}>Freq (X/Y)</option> | |
| </select> | |
| <button class="btn btn-sm btn-outline-secondary" type="button" onclick="toggleSemanticTermSortDir()"> | |
| ${semanticTermSortDir === 'desc' ? 'По убыванию' : 'По возрастанию'} | |
| </button> | |
| </div> | |
| <div class="scrollable-table"> | |
| <table class="table table-sm table-hover"> | |
| <thead> | |
| <tr> | |
| <th>Термин</th> | |
| <th>Мой вес</th> | |
| <th class="table-light">Avg K</th> | |
| <th class="table-secondary">Freq</th> | |
| ${powerCompHeaders} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${powerRows || '<tr><td colspan="' + (4 + powerCompTotal) + '" class="text-center text-muted">Нет данных</td></tr>'} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="stat-card"> | |
| <h6 class="card-title">Граф (сводка)</h6> | |
| <div class="row"> | |
| <div class="col-md-4"><strong>Узлы:</strong> ${nodes.length}</div> | |
| <div class="col-md-4"><strong>Связи:</strong> ${links.length}</div> | |
| <div class="col-md-4"><strong>Ключевые понятия:</strong> ${topKeywords.length}</div> | |
| </div> | |
| </div> | |
| <div class="stat-card"> | |
| <h6 class="card-title">Top Keywords</h6> | |
| <div>${keywordsHtml}</div> | |
| </div> | |
| <div class="stat-card"> | |
| <h6 class="card-title">Окно 1: Узлы и связи (табличный режим)</h6> | |
| <div class="row"> | |
| <div class="col-md-6"> | |
| <div class="scrollable-table"> | |
| <table class="table table-sm table-hover"> | |
| <thead><tr><th>Node</th><th class="text-center">Weight</th><th class="text-center">Freq</th></tr></thead> | |
| <tbody>${topNodesRows || '<tr><td colspan="3" class="text-center text-muted">Нет данных</td></tr>'}</tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="col-md-6"> | |
| <div class="scrollable-table"> | |
| <table class="table table-sm table-hover"> | |
| <thead><tr><th>From</th><th>To</th><th class="text-center">P(B|A)</th></tr></thead> | |
| <tbody>${topLinksRows || '<tr><td colspan="3" class="text-center text-muted">Нет данных</td></tr>'}</tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="stat-card"> | |
| <h6 class="card-title">Окно 3: Разметка текста</h6> | |
| <div class="border rounded p-3" style="line-height: 1.8;">${markupHtml || '<span class="text-muted">Нет данных</span>'}</div> | |
| </div> | |
| <div class="stat-card"> | |
| <h6 class="card-title">Реферат</h6> | |
| <ul class="list-group">${summaryHtml}</ul> | |
| </div> | |
| `; | |
| } | |
| function renderActionSummary(analysisData, semData) { | |
| const container = document.getElementById('summaryResultsContainer'); | |
| if (!container) return; | |
| if (!analysisData) { | |
| container.innerHTML = '<div class="text-center text-muted py-5">Запустите основной анализ (`Анализировать`), чтобы собрать сводку.</div>'; | |
| return; | |
| } | |
| const built = buildSummaryData(analysisData, semData); | |
| const recommendations = built.recommendations; | |
| const diagnostics = built.diagnostics; | |
| const bertThreshold = built.bertThreshold; | |
| const priorityRank = { high: 3, medium: 2, low: 1 }; | |
| const sortedRecs = recommendations | |
| .slice() | |
| .sort((a, b) => (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0)); | |
| const recCards = sortedRecs.length > 0 | |
| ? sortedRecs.map((r, idx) => { | |
| const badge = r.priority === 'high' | |
| ? 'bg-danger' | |
| : (r.priority === 'medium' ? 'bg-warning text-dark' : 'bg-secondary'); | |
| const detailsTableHtml = (r.details_table && Array.isArray(r.details_table.rows) && r.details_table.rows.length > 0) | |
| ? ` | |
| <div class="table-responsive mt-2"> | |
| <table class="table table-sm table-bordered mb-0"> | |
| <thead class="table-light"> | |
| <tr>${(r.details_table.headers || []).map(h => `<th>${h}</th>`).join('')}</tr> | |
| </thead> | |
| <tbody> | |
| ${r.details_table.rows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')} | |
| </tbody> | |
| </table> | |
| </div>` | |
| : ''; | |
| const detailHtml = (r.details && r.details.length > 0) | |
| ? `<div class="small text-muted mt-2">${r.details.slice(0, 8).join(' • ')}</div>` | |
| : ''; | |
| return ` | |
| <div class="card mb-3 border-0 shadow-sm"> | |
| <div class="card-body"> | |
| <div class="d-flex justify-content-between align-items-start mb-1"> | |
| <h6 class="mb-0">${idx + 1}. ${r.title}</h6> | |
| <span class="badge ${badge}">${r.priority.toUpperCase()}</span> | |
| </div> | |
| <div class="small mb-1"><strong>Почему:</strong> ${r.why}</div> | |
| <div class="small"><strong>Что сделать:</strong> ${r.action}</div> | |
| ${detailsTableHtml} | |
| ${detailHtml} | |
| </div> | |
| </div>`; | |
| }).join('') | |
| : '<div class="alert alert-success mb-0">Критичных сигналов не найдено. Поддерживайте текущий баланс.</div>'; | |
| const diagRows = diagnostics.map(d => { | |
| const badge = d.status === 'ok' | |
| ? 'bg-success' | |
| : (d.status === 'warning' ? 'bg-warning text-dark' : 'bg-secondary'); | |
| const label = d.status === 'ok' ? 'OK' : (d.status === 'warning' ? 'Внимание' : 'Ожидает'); | |
| return `<tr><td>${d.metric}</td><td>${d.value}</td><td><span class="badge ${badge}">${label}</span></td></tr>`; | |
| }).join(''); | |
| container.innerHTML = ` | |
| <div class="stat-card"> | |
| <h5 class="card-title mb-3">Итоговые рекомендации (что сделать в первую очередь)</h5> | |
| <p class="text-muted small mb-3">Сводка и авто-цели оптимизатора используют одни правила: BERT < ${bertThreshold}, BM25 remove ≥ 4, n-граммы по допускам (±20% при Avg≥4, ±50% при Avg<4) с фильтром K для multi-competitor, Title BERT < ${SUMMARY_TITLE_BERT_THRESHOLD}, Semantic Core — разрыв весов (KW затем TOP), порог частоты K как в диагностике (1×1: K≥1, иначе K≥2).</p> | |
| ${recCards} | |
| </div> | |
| <div class="stat-card"> | |
| <h6 class="card-title mb-2">Диагностика по модулям</h6> | |
| <table class="table table-sm table-hover mb-0"> | |
| <thead><tr><th>Модуль</th><th>Метрика</th><th>Статус</th></tr></thead> | |
| <tbody>${diagRows}</tbody> | |
| </table> | |
| </div> | |
| `; | |
| } | |
| function renderResults(data) { | |
| // 0. General Stats Render (Word Count Dual Mode) | |
| const statsContainer = document.getElementById('generalStats'); | |
| statsContainer.innerHTML = ''; | |
| if (data.word_counts) { | |
| const myTotal = data.word_counts.target.total; | |
| const mySig = data.word_counts.target.significant; | |
| const avgTotal = data.word_counts.avg.total; | |
| const avgSig = data.word_counts.avg.significant; | |
| // Цвет зависит от ОБЩЕГО количества | |
| let totalColor = 'text-dark'; | |
| if (avgTotal > 0) { | |
| if (myTotal < avgTotal * 0.8) totalColor = 'text-danger'; | |
| else if (myTotal > avgTotal * 1.2) totalColor = 'text-success'; | |
| } | |
| // HTML для конкурентов | |
| let compsHtml = ''; | |
| data.word_counts.competitors.forEach((c, idx) => { | |
| compsHtml += ` | |
| <div class="px-3 border-end text-center"> | |
| <small class="text-muted d-block mb-1">K${idx+1}</small> | |
| <div class="fw-bold">${c.total}</div> | |
| <div class="text-muted small" style="font-size: 0.75em;">(${c.significant})</div> | |
| </div>`; | |
| }); | |
| const html = ` | |
| <div class="card border-0 shadow-sm mb-4"> | |
| <div class="card-body py-3"> | |
| <div class="row align-items-center"> | |
| <!-- Мой результат --> | |
| <div class="col-md-4 border-end"> | |
| <h6 class="text-uppercase text-muted small fw-bold mb-2">Мой текст</h6> | |
| <div class="d-flex align-items-baseline"> | |
| <span class="display-6 fw-bold ${totalColor} me-2">${myTotal}</span> | |
| <span class="text-muted">слов</span> | |
| </div> | |
| <div class="text-muted small"> | |
| Значимых: <strong>${mySig}</strong> | |
| </div> | |
| </div> | |
| <!-- Среднее --> | |
| <div class="col-md-3 border-end text-center"> | |
| <h6 class="text-uppercase text-muted small fw-bold mb-2">Среднее (Рынок)</h6> | |
| <div class="h3 fw-bold mb-0">${avgTotal}</div> | |
| <div class="text-muted small">Значимых: ${avgSig}</div> | |
| </div> | |
| <!-- Конкуренты --> | |
| <div class="col-md-5"> | |
| <h6 class="text-uppercase text-muted small fw-bold mb-2 text-center">Детализация</h6> | |
| <div class="d-flex justify-content-center align-items-center"> | |
| ${compsHtml} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div>`; | |
| statsContainer.innerHTML = html; | |
| } | |
| // 1. BERT Render (ИСПРАВЛЕННЫЙ ПОД НОВУЮ СТРУКТУРУ) | |
| const bertContainer = document.getElementById('bertResultsContainer'); | |
| bertContainer.innerHTML = ''; | |
| // Получаем объект данных. Теперь это объект, а не массив! | |
| const bertData = data.bert_analysis; | |
| // Проверяем, есть ли поле detailed (список фраз) | |
| if (!bertData || !bertData.detailed || bertData.detailed.length === 0) { | |
| bertContainer.innerHTML = '<div class="alert alert-warning">Добавьте ключевые фразы для анализа.</div>'; | |
| } else { | |
| // А. Рендерим ГЛОБАЛЬНЫЙ СЧЕТ (Global Score) | |
| if (bertData.global_scores && bertData.global_scores.length > 0) { | |
| let globalHtml = '<div class="card mb-4 border-primary"><div class="card-body">'; | |
| globalHtml += '<h6 class="card-title text-primary fw-bold mb-3">🏆 Общий рейтинг релевантности (Global Score)</h6>'; | |
| bertData.global_scores.forEach(gs => { | |
| const scorePct = Math.round(gs.score * 100); | |
| const isMe = gs.is_me; | |
| const barColor = isMe ? 'bg-primary' : 'bg-secondary'; | |
| const rowBg = isMe ? 'bg-light border-start border-primary border-3' : ''; | |
| const nameLabel = isMe ? `<strong>${gs.name} (Вы)</strong>` : gs.name; | |
| globalHtml += ` | |
| <div class="d-flex align-items-center mb-2 p-2 rounded ${rowBg}"> | |
| <div style="width: 150px;">${nameLabel}</div> | |
| <div class="flex-grow-1 mx-3"> | |
| <div class="progress" style="height: 20px;"> | |
| <div class="progress-bar ${barColor}" role="progressbar" style="width: ${scorePct}%">${scorePct}%</div> | |
| </div> | |
| </div> | |
| <div class="fw-bold">${gs.score}</div> | |
| </div>`; | |
| }); | |
| globalHtml += '</div></div>'; | |
| bertContainer.insertAdjacentHTML('beforeend', globalHtml); | |
| } | |
| // Б. Рендерим ДЕТАЛИЗАЦИЮ (Аккордеоны) | |
| // Итерируемся именно по .detailed, так как это массив | |
| bertData.detailed.forEach((item, index) => { | |
| let badgeClass = 'bg-secondary'; | |
| if(item.status === 'good') badgeClass = 'bg-success'; | |
| if(item.status === 'warning') badgeClass = 'bg-warning text-dark'; | |
| if(item.status === 'bad') badgeClass = 'bg-danger'; | |
| const collapseId = `collapseBert${index}`; | |
| // Мои чанки | |
| const myChunksHtml = item.my_top_chunks.map(c => | |
| `<li class="list-group-item d-flex justify-content-between align-items-start border-0 border-bottom"> | |
| <div class="small me-2">"${c.text}"</div> | |
| <span class="badge bg-primary rounded-pill opacity-75">${c.score}</span> | |
| </li>` | |
| ).join(''); | |
| // Чанки конкурентов (С АТРИБУЦИЕЙ ИСТОЧНИКА) | |
| const compChunksHtml = (item.comp_top_chunks && item.comp_top_chunks.length > 0) | |
| ? item.comp_top_chunks.map(c => | |
| `<li class="list-group-item d-flex justify-content-between align-items-start border-0 border-bottom list-group-item-light"> | |
| <div class="me-2"> | |
| <span class="badge bg-secondary mb-1" style="font-size: 0.7em;">${c.source}</span> | |
| <div class="small text-muted">"${c.text}"</div> | |
| </div> | |
| <span class="badge bg-dark rounded-pill opacity-50">${c.score}</span> | |
| </li>` | |
| ).join('') | |
| : '<li class="list-group-item text-muted small border-0">Нет данных</li>'; | |
| const html = ` | |
| <div class="card mb-3 border"> | |
| <div class="card-header bg-white d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#${collapseId}"> | |
| <div> | |
| <div class="fw-bold text-dark">${item.phrase}</div> | |
| <div class="text-muted small"> | |
| My: <b>${item.my_max_score}</b> vs Best Comp: <b>${item.comp_max_score}</b> | |
| </div> | |
| </div> | |
| <span class="badge ${badgeClass}">${item.status.toUpperCase()}</span> | |
| </div> | |
| <div id="${collapseId}" class="collapse"> | |
| <div class="card-body bg-light"> | |
| <p class="small mb-3"><strong>Совет:</strong> ${item.recommendation}</p> | |
| <div class="row"> | |
| <div class="col-md-6"> | |
| <h6 class="small fw-bold text-primary">Мой текст</h6> | |
| <ul class="list-group shadow-sm mb-3">${myChunksHtml || '<li class="list-group-item small">Нет вхождений</li>'}</ul> | |
| </div> | |
| <div class="col-md-6 border-start"> | |
| <h6 class="small fw-bold text-secondary">Лучшее у конкурентов</h6> | |
| <ul class="list-group shadow-sm">${compChunksHtml}</ul> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div>`; | |
| bertContainer.insertAdjacentHTML('beforeend', html); | |
| }); | |
| } | |
| // 2. BM25 Render (ОБНОВЛЕННЫЙ v2 - Полный список) | |
| const bm25Body = document.getElementById('bm25TableBody'); | |
| bm25Body.innerHTML = ''; | |
| const bm25Msg = document.getElementById('bm25EmptyMsg'); | |
| // Теперь мы ожидаем, что список не пуст, если были введены ключи | |
| if (data.bm25_recommendations && data.bm25_recommendations.length > 0) { | |
| bm25Msg.style.display = 'none'; | |
| data.bm25_recommendations.forEach(item => { | |
| let colorClass = ''; | |
| let actionText = ''; | |
| let countText = ''; | |
| let rowBg = ''; | |
| // Определяем стили в зависимости от действия | |
| if (item.action === 'add') { | |
| colorClass = 'text-success'; | |
| actionText = 'ДОБАВИТЬ'; | |
| countText = `+${item.count}`; | |
| rowBg = 'table-success'; // Легкая зеленая подсветка всей строки (Bootstrap класс) | |
| // Но лучше не красить всю строку, чтобы не рябило, покрасим только текст действия | |
| rowBg = ''; | |
| } else if (item.action === 'remove') { | |
| colorClass = 'text-danger'; | |
| actionText = 'УБРАТЬ'; | |
| countText = `-${item.count}`; | |
| } else { | |
| colorClass = 'text-muted'; // Серый цвет | |
| actionText = 'НОРМА'; // Или OK | |
| countText = '<span class="text-muted">-</span>'; | |
| } | |
| // Жирный шрифт для важных действий | |
| const weight = item.action === 'ok' ? 'fw-normal' : 'fw-bold'; | |
| const row = ` | |
| <tr> | |
| <td class="fw-bold text-dark">${item.word}</td> | |
| <td class="${colorClass} ${weight}">${actionText}</td> | |
| <td class="${colorClass} ${weight}">${countText}</td> | |
| <td>${item.my_score}</td> | |
| <td>${item.avg_comp_score}</td> | |
| </tr>`; | |
| bm25Body.insertAdjacentHTML('beforeend', row); | |
| }); | |
| } else { | |
| // Если список пуст (например, не ввели ключевые слова) | |
| bm25Msg.style.display = 'block'; | |
| bm25Msg.textContent = "Введите ключевые фразы для расчета BM25."; | |
| } | |
| // 3. N-grams | |
| showNgramTable('unigrams'); | |
| // 4. Title | |
| renderTitleResults(data); | |
| // 5. Сводка | |
| renderActionSummary(data, semanticData); | |
| } | |
| function showNgramTable(type) { | |
| if(!currentData) return; | |
| document.querySelectorAll('#ngrams .btn').forEach(b => { | |
| if(type === 'unigrams' && b.innerText.includes('1')) b.classList.add('active'); | |
| else if(type === 'bigrams' && b.innerText.includes('2')) b.classList.add('active'); | |
| else if(type === 'trigrams' && b.innerText.includes('3')) b.classList.add('active'); | |
| else b.classList.remove('active'); | |
| }); | |
| const table = document.querySelector('#ngrams table'); | |
| const thead = table.querySelector('thead'); | |
| const tbody = document.getElementById('ngramTableBody'); | |
| tbody.innerHTML = ''; | |
| thead.innerHTML = ''; | |
| const list = currentData.ngram_stats[type]; | |
| let numCompetitors = 0; | |
| if (list && list.length > 0 && list[0].competitor_detailed) { | |
| numCompetitors = list[0].competitor_detailed.length; | |
| } | |
| // ЗАГОЛОВОК | |
| let headerRow = '<tr>'; | |
| headerRow += '<th style="width: 35%;">Фраза</th>'; | |
| headerRow += '<th>Target</th>'; | |
| headerRow += '<th class="table-light">AVG</th>'; | |
| // НОВАЯ КОЛОНКА | |
| headerRow += '<th class="table-secondary" title="Встречаемость у конкурентов">Freq</th>'; | |
| for (let i = 0; i < numCompetitors; i++) { | |
| headerRow += `<th>K${i + 1}</th>`; | |
| } | |
| headerRow += '</tr>'; | |
| thead.innerHTML = headerRow; | |
| // ТЕЛО | |
| if(list && list.length > 0) { | |
| list.forEach(item => { | |
| let rowClass = ""; | |
| let countClass = ""; | |
| let icon = ""; | |
| if (item.target_count === 0 && item.competitor_avg > 0) { | |
| rowClass = "table-warning"; | |
| countClass = "text-danger fw-bold"; | |
| icon = "⚠️"; | |
| } else if (item.target_count > 0 && item.competitor_avg === 0) { | |
| countClass = "text-success"; | |
| } | |
| let compCells = ""; | |
| if (item.competitor_detailed) { | |
| item.competitor_detailed.forEach(count => { | |
| const style = count > 0 ? 'fw-bold text-dark' : 'text-muted text-opacity-25'; | |
| compCells += `<td class="${style}">${count}</td>`; | |
| }); | |
| } | |
| // ЗНАЧЕНИЕ ДЛЯ НОВОЙ КОЛОНКИ (Например: "3/5") | |
| const freqText = numCompetitors > 0 ? `${item.comp_occurrence}<span class="text-muted small">/${numCompetitors}</span>` : '-'; | |
| const row = ` | |
| <tr class="${rowClass}"> | |
| <td>${item.ngram} ${icon}</td> | |
| <td class="fw-bold ${countClass} border-end">${item.target_count}</td> | |
| <td class="table-light fw-bold border-end">${item.competitor_avg}</td> | |
| <!-- НОВАЯ ЯЧЕЙКА --> | |
| <td class="table-secondary fw-bold text-center border-end">${freqText}</td> | |
| ${compCells} | |
| </tr>`; | |
| tbody.insertAdjacentHTML('beforeend', row); | |
| }); | |
| } else { | |
| tbody.innerHTML = `<tr><td colspan="${4 + numCompetitors}" class="text-center text-muted">Нет данных</td></tr>`; | |
| } | |
| } | |
| function renderTitleResults(data) { | |
| const container = document.getElementById('titleResultsContainer'); | |
| if (!data.title_analysis || !data.title_analysis.target_title) { | |
| container.innerHTML = '<div class="text-center text-muted py-5">Заполните поле "Ваш Title" и нажмите "Анализировать".</div>'; | |
| return; | |
| } | |
| const ta = data.title_analysis; | |
| let html = ''; | |
| const escapeHtml = (value) => String(value || '') | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| const escapeRegex = (value) => String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| const keywordTerms = (() => { | |
| const terms = new Set(); | |
| (ta.keyword_coverage || []).forEach(kw => { | |
| const phrase = String(kw.phrase || '').trim(); | |
| if (!phrase) return; | |
| terms.add(phrase.toLowerCase()); | |
| phrase.split(/\s+/).forEach(w => { | |
| const word = w.trim().toLowerCase(); | |
| if (word.length >= 2) terms.add(word); | |
| }); | |
| }); | |
| return Array.from(terms).sort((a, b) => b.length - a.length); | |
| })(); | |
| const highlightTitleKeywords = (titleText) => { | |
| const source = String(titleText || ''); | |
| if (!source) return ''; | |
| if (!keywordTerms.length) return escapeHtml(source); | |
| const pattern = new RegExp(`(${keywordTerms.map(escapeRegex).join('|')})`, 'gi'); | |
| let out = ''; | |
| let last = 0; | |
| let match; | |
| while ((match = pattern.exec(source)) !== null) { | |
| const start = match.index; | |
| const value = match[0]; | |
| out += escapeHtml(source.slice(last, start)); | |
| out += `<mark class="px-1 rounded bg-warning-subtle border border-warning-subtle">${escapeHtml(value)}</mark>`; | |
| last = start + value.length; | |
| if (value.length === 0) pattern.lastIndex += 1; | |
| } | |
| out += escapeHtml(source.slice(last)); | |
| return out; | |
| }; | |
| // === BLOCK 1: Length === | |
| const len = ta.length; | |
| let charBadge = 'bg-success'; | |
| let charHint = 'Оптимально'; | |
| if (len.status === 'too_long') { charBadge = 'bg-danger'; charHint = 'Слишком длинный (>60)'; } | |
| else if (len.status === 'too_short') { charBadge = 'bg-warning text-dark'; charHint = 'Слишком короткий (<30)'; } | |
| let compLenRows = ''; | |
| // Показываем и ваш Title, и конкурентов в одной таблице. | |
| compLenRows += ` | |
| <tr class="table-light"> | |
| <td class="fw-bold">Мой Title</td> | |
| <td title="${escapeHtml(ta.target_title)}">${highlightTitleKeywords(ta.target_title)}</td> | |
| <td class="text-center fw-bold">${len.target_chars}</td> | |
| <td class="text-center fw-bold">${len.target_words.total}</td> | |
| </tr>`; | |
| if (ta.competitor_titles && ta.competitor_titles.length > 0 && len.comp_data) { | |
| ta.competitor_titles.forEach((ct, idx) => { | |
| const cd = len.comp_data[idx]; | |
| if (!cd) return; | |
| compLenRows += ` | |
| <tr> | |
| <td class="fw-bold">K${idx+1}</td> | |
| <td class="text-truncate" style="max-width: 420px;" title="${escapeHtml(ct)}">${highlightTitleKeywords(ct)}</td> | |
| <td class="text-center">${cd.chars}</td> | |
| <td class="text-center">${cd.words.total}</td> | |
| </tr>`; | |
| }); | |
| } | |
| html += ` | |
| <div class="stat-card"> | |
| <h5 class="card-title mb-3">Длина Title</h5> | |
| <div class="d-flex align-items-center mb-3"> | |
| <span class="display-6 fw-bold me-3">${len.target_chars}</span> | |
| <div> | |
| <span class="badge ${charBadge}">${charHint}</span> | |
| <div class="text-muted small mt-1">Слов: ${len.target_words.total} (значимых: ${len.target_words.significant})</div> | |
| </div> | |
| </div> | |
| <table class="table table-sm table-bordered"> | |
| <thead><tr><th style="width:120px;">Источник</th><th>Title</th><th class="text-center">Символы</th><th class="text-center">Слова</th></tr></thead> | |
| <tbody>${compLenRows}</tbody> | |
| ${ta.competitor_titles && ta.competitor_titles.length > 0 ? `<tfoot><tr class="table-secondary fw-bold"><td colspan="2">Среднее по конкурентам</td><td class="text-center">${len.avg_chars}</td><td class="text-center">${len.avg_words_total}</td></tr></tfoot>` : ''} | |
| </table> | |
| <div class="small text-muted mt-2">Подсветка в Title показывает найденные слова и фразы из блока ключевых фраз.</div> | |
| </div>`; | |
| // === BLOCK 2: Keyword Coverage === | |
| if (ta.keyword_coverage && ta.keyword_coverage.length > 0) { | |
| let coverageRows = ''; | |
| ta.keyword_coverage.forEach(kw => { | |
| let badge = 'bg-danger'; | |
| let label = 'Нет'; | |
| if (kw.exact_match) { badge = 'bg-success'; label = 'Точное'; } | |
| else if (kw.word_coverage >= 50) { badge = 'bg-warning text-dark'; label = 'Частично'; } | |
| const missingHtml = kw.words_missing.length > 0 | |
| ? `<span class="text-danger small">${kw.words_missing.join(', ')}</span>` | |
| : '<span class="text-success small">-</span>'; | |
| const compPresence = kw.comp_total > 0 ? `${kw.comp_presence}/${kw.comp_total}` : '-'; | |
| coverageRows += ` | |
| <tr> | |
| <td class="fw-bold">${kw.phrase}</td> | |
| <td class="text-center"><span class="badge ${badge}">${label}</span></td> | |
| <td class="text-center">${kw.word_coverage}%</td> | |
| <td>${missingHtml}</td> | |
| <td class="text-center">${compPresence}</td> | |
| </tr>`; | |
| }); | |
| html += ` | |
| <div class="stat-card"> | |
| <h5 class="card-title mb-3">Покрытие ключевых фраз</h5> | |
| <table class="table table-sm table-hover"> | |
| <thead> | |
| <tr> | |
| <th>Фраза</th> | |
| <th class="text-center">Вхождение</th> | |
| <th class="text-center">Покрытие</th> | |
| <th>Отсутствуют</th> | |
| <th class="text-center">У конк.</th> | |
| </tr> | |
| </thead> | |
| <tbody>${coverageRows}</tbody> | |
| </table> | |
| </div>`; | |
| } | |
| // === BLOCK 3: Title N-grams === | |
| if (ta.ngrams) { | |
| ['unigrams', 'bigrams'].forEach(type => { | |
| const list = ta.ngrams[type]; | |
| if (!list || list.length === 0) return; | |
| const typeName = type === 'unigrams' ? '1 слово' : '2 слова'; | |
| let numComp = 0; | |
| if (list[0].competitor_detailed) numComp = list[0].competitor_detailed.length; | |
| let tHead = '<tr><th>Слово</th><th>Target</th><th class="table-light">AVG</th><th class="table-secondary">Freq</th>'; | |
| for (let i = 0; i < numComp; i++) tHead += `<th>K${i+1}</th>`; | |
| tHead += '</tr>'; | |
| let tBody = ''; | |
| list.forEach(item => { | |
| let rc = ''; | |
| let cc = ''; | |
| if (item.target_count === 0 && item.competitor_avg > 0) { rc = 'table-warning'; cc = 'text-danger fw-bold'; } | |
| else if (item.target_count > 0 && item.competitor_avg === 0) { cc = 'text-success'; } | |
| let cells = ''; | |
| if (item.competitor_detailed) { | |
| item.competitor_detailed.forEach(cnt => { | |
| const s = cnt > 0 ? 'fw-bold text-dark' : 'text-muted text-opacity-25'; | |
| cells += `<td class="${s}">${cnt}</td>`; | |
| }); | |
| } | |
| const freq = numComp > 0 ? `${item.comp_occurrence}<span class="text-muted small">/${numComp}</span>` : '-'; | |
| tBody += ` | |
| <tr class="${rc}"> | |
| <td>${item.ngram}</td> | |
| <td class="fw-bold ${cc} border-end">${item.target_count}</td> | |
| <td class="table-light fw-bold border-end">${item.competitor_avg}</td> | |
| <td class="table-secondary fw-bold text-center border-end">${freq}</td> | |
| ${cells} | |
| </tr>`; | |
| }); | |
| html += ` | |
| <div class="stat-card"> | |
| <h6 class="card-title">N-граммы Title (${typeName})</h6> | |
| <div class="scrollable-table"> | |
| <table class="table table-sm"> | |
| <thead>${tHead}</thead> | |
| <tbody>${tBody}</tbody> | |
| </table> | |
| </div> | |
| </div>`; | |
| }); | |
| } | |
| // === BLOCK 4: BERT Semantic === | |
| if (ta.bert && ta.bert.per_keyword && ta.bert.per_keyword.length > 0) { | |
| let bertRows = ''; | |
| ta.bert.per_keyword.forEach(kw => { | |
| const compScores = kw.comp_scores.map((s, i) => `<td>${s}</td>`).join(''); | |
| const best = Math.max(...kw.comp_scores, 0); | |
| let cls = ''; | |
| if (kw.target_score >= best - 0.05) cls = 'text-success'; | |
| else if (kw.target_score < best - 0.15) cls = 'text-danger'; | |
| else cls = 'text-warning'; | |
| bertRows += ` | |
| <tr> | |
| <td class="fw-bold">${kw.keyword}</td> | |
| <td class="fw-bold ${cls} border-end">${kw.target_score}</td> | |
| ${compScores} | |
| </tr>`; | |
| }); | |
| let compHeaders = ''; | |
| if (ta.bert.comp_scores) { | |
| ta.bert.comp_scores.forEach((s, i) => { compHeaders += `<th>K${i+1}</th>`; }); | |
| } | |
| html += ` | |
| <div class="stat-card"> | |
| <h5 class="card-title mb-3">Семантика Title (BERT)</h5> | |
| <div class="d-flex align-items-baseline mb-3"> | |
| <span class="h4 fw-bold me-2">Мой: ${ta.bert.target_score}</span> | |
| ${ta.bert.comp_scores ? ta.bert.comp_scores.map((s,i) => `<span class="badge bg-secondary me-1">K${i+1}: ${s}</span>`).join('') : ''} | |
| </div> | |
| <table class="table table-sm table-hover"> | |
| <thead><tr><th>Ключ</th><th class="border-end">Target</th>${compHeaders}</tr></thead> | |
| <tbody>${bertRows}</tbody> | |
| </table> | |
| </div>`; | |
| } | |
| container.innerHTML = html; | |
| } | |
| const _optimizerBertTargetEl = document.getElementById('optimizerBertStageTarget'); | |
| if (_optimizerBertTargetEl) { | |
| _optimizerBertTargetEl.addEventListener('input', function () { | |
| if (currentData) { | |
| renderActionSummary(currentData, semanticData); | |
| refreshOptimizerStageGoalConfig(); | |
| } | |
| }); | |
| } | |
| loadUserAgentOptions(); | |
| refreshOptimizerStageGoalConfig({ | |
| bert: { mode: 'auto', selected: [], custom_add: [] }, | |
| bm25: { mode: 'auto', selected: [], custom_add: [] }, | |
| ngram: { mode: 'auto', selected: [], custom_add: [] }, | |
| semantic: { mode: 'auto', selected: [], custom_add: [] }, | |
| title: { mode: 'auto', selected: [], custom_add: [] } | |
| }); | |