Skip to content

Instantly share code, notes, and snippets.

@intari
Created December 14, 2025 07:40
Show Gist options
  • Select an option

  • Save intari/73ca7a0295e39b415516bfd75ac809df to your computer and use it in GitHub Desktop.

Select an option

Save intari/73ca7a0295e39b415516bfd75ac809df to your computer and use it in GitHub Desktop.
Простой анализатор текста на эмоции
// ==UserScript==
// @name LLM Analyzer Emotional analyizer
// @namespace vikari-anatra
// @version 0.9
// @description Extract main text via Readability.js and analyze with LLM. Modes: dual(default)=JSON+local pretty, pretty, json. Copy + toggle.
// @match http*://*/*
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @require https://cdn.jsdelivr.net/npm/@mozilla/readability@0.5.0/Readability.js
// @connect litellm.viorsan.com
// ==/UserScript==
(function () {
'use strict';
const DEFAULTS = Object.freeze({
endpoint: 'https://litellm.viorsan.com/v1/chat/completions',
model: 'gpt-5-nano',
apiKey: '',
// Modes: 'dual' | 'pretty' | 'json'
mode: 'dual',
temperature: 0.2,
maxChars: 60000,
// Robust JSON
jsonRetryCount: 2,
jsonRetryTempBoost: 0.0, // usually keep 0
traceUserId: 'tampermonkey',
generationName: 'emo-prop-score',
debug: false
});
// --- GM APIs from window (no /* global GM_* */) ---
const gm = (typeof window !== 'undefined' && window) ? window : {};
const GM_xmlhttpRequest = gm.GM_xmlhttpRequest;
const GM_registerMenuCommand = gm.GM_registerMenuCommand;
const GM_getValue = gm.GM_getValue;
const GM_setValue = gm.GM_setValue;
if (!GM_xmlhttpRequest || !GM_registerMenuCommand || !GM_getValue || !GM_setValue) return;
function dbg(cfg, ...args) {
if (!cfg || !cfg.debug) return;
// eslint-disable-next-line no-console
console.log('[LLM Analyzer]', ...args);
}
/* ===================== PROMPTS ===================== */
function buildPromptPretty(text) {
return `
Системные инструкции:
Ты — аналитик текста. Работай строго по шагам алгоритма. Не добавляй внешние факты. Опирайся только на предоставленный текст.
Формат ответа соблюдай.
Шкалы:
1) Эмоциональная насыщенность (0..10): 0 — физика плазмы; 10 — максимум агитации.
2) Пропаганда (0..10): 0 — нейтрально; 10 — предельная пропаганда.
Шаги:
1) Статус мира: реальный современный / реальный исторический / художественный или альтернативный / неясно.
2) Конфликт: явный реальный / социальный-идеологический / локальный / отсутствует.
2.5) Направленность текста: внутрь мира / наружу / обе.
3) Нормы: современные человеческие; если заданы иные — указать.
4) Пропаганда: НЕ считать автоматически художественность/односторонность/жанр/документ стороны. Считать: подмена анализа, давление, дегуманизация, мобилизация/оправдание, образ врага, символическое насилие.
4.5) Доп. проверки:
Эмоции: плотность оценочной лексики; асимметрия эмоций; имплицитные эмоции (угрозы/неизбежность/нормализация насилия).
Пропаганда: нормализация крайних действий; моральная бинарность; смещение агентности; инверсия причин-следствий.
5) Если художественный: оценивать как наш мир; отметить влияние художественной формы.
Формат ответа:
1) Мир: ...
2) Конфликт: ...
2.5) Направленность: ...
3) Эмоциональная насыщенность: <0..10> (пункты)
4) Пропаганда: <0..10> (пункты)
5) Пристрастность автора: low/medium/high (почему)
6) Влияние статуса мира: ...
7) Доп. проверки: перечисли сработавшие
8) Уверенность: low/medium/high (почему)
Текст:
"""${text}"""
`.trim();
}
function buildPromptJson(text) {
return `
Системные инструкции:
Верни только валидный JSON (без Markdown и комментариев). Не добавляй внешние факты, опирайся только на текст.
КАЛИБРОВКА ШКАЛ:
Эмоции 0..10:
0: техничка/наука/инструкции ("физика плазмы") без общественно-политического контекста.
1-2: нейтральная новость/справка.
3-4: публицистичность/акценты без агрессии.
5-6: сильное риторическое давление.
7-8: агрессивная обвинительная подача.
9-10: агитация/мобилизация максимум.
Пропаганда 0..10:
0: почти только тех/научное или крайне сухая справка без риторики.
1-2: нейтральный отчёт/новость о политике без дегуманизации и мобилизации.
3-4: заметный фрейминг, но без дегуманизации.
5-6: явное оправдание/обвинение + давление.
7-8: устойчивый образ врага, дегуманизация.
9-10: предельная агитация.
ОСОБОЕ ПРАВИЛО:
Если текст относится к политике/обществу/конфликту (не техничка):
- emotional_intensity минимум 1.
- propaganda_intensity: 0 только если почти техническая сводка/документ без риторики; иначе минимум 1.
Если ставишь 0 — заполни zero_justification.
Направленность текста (text_direction):
inward | outward | both.
Доп. проверки (обязательные флаги):
Эмоции: emotional_density_high, emotional_asymmetry, implicit_emotional_pressure
Пропаганда: normalization_of_extreme_actions, moral_binarization, agency_shift, cause_effect_inversion
Верни JSON строго по схеме:
{
"world_status": "real_modern|real_historical|fictional_or_alt|unclear",
"world_status_rationale": ["...","..."],
"conflict_type": "real_conflict|social_ideological|local_personal|none",
"conflict_rationale": ["...","..."],
"text_direction": "inward|outward|both",
"text_direction_rationale": ["...","..."],
"emotional_intensity": 0.0,
"emotional_reasons": ["...","...","..."],
"propaganda_intensity": 0.0,
"propaganda_reasons": ["...","...","..."],
"propaganda_markers": {
"analysis_replaced_by_assertion": true/false,
"emotional_pressure": true/false,
"dehumanization": true/false,
"mobilizing_or_justifying": true/false,
"enemy_image": true/false,
"symbolic_violence": true/false
},
"additional_checks": {
"emotional_density_high": true/false,
"emotional_asymmetry": true/false,
"implicit_emotional_pressure": true/false,
"normalization_of_extreme_actions": true/false,
"moral_binarization": true/false,
"agency_shift": true/false,
"cause_effect_inversion": true/false
},
"author_bias": "low|medium|high",
"bias_notes": ["..."],
"effect_of_world_status_on_score": ["..."],
"effect_of_artistic_form": ["none_or_not_applicable" or "raises" or "lowers", "notes..."],
"confidence": "low|medium|high",
"confidence_notes": ["..."],
"zero_justification": ""
}
Текст:
"""${text}"""
`.trim();
}
/* ===================== SETTINGS ===================== */
function getNumberSetting(key, fallback) {
const v = Number(GM_getValue(key, fallback));
return Number.isFinite(v) ? v : fallback;
}
function getStringSetting(key, fallback) {
const v = GM_getValue(key, fallback);
return (typeof v === 'string' && v.trim()) ? v.trim() : fallback;
}
function getBoolSetting(key, fallback) {
const v = GM_getValue(key, fallback);
if (v === true || v === false) return v;
if (v === 'true') return true;
if (v === 'false') return false;
return fallback;
}
function getSettings() {
const mode = getStringSetting('mode', DEFAULTS.mode);
return {
endpoint: getStringSetting('endpoint', DEFAULTS.endpoint),
model: getStringSetting('model', DEFAULTS.model),
apiKey: getStringSetting('apiKey', DEFAULTS.apiKey),
mode: ['dual', 'pretty', 'json'].includes(mode) ? mode : DEFAULTS.mode,
temperature: getNumberSetting('temperature', DEFAULTS.temperature),
maxChars: getNumberSetting('maxChars', DEFAULTS.maxChars),
jsonRetryCount: Math.max(0, Math.floor(getNumberSetting('jsonRetryCount', DEFAULTS.jsonRetryCount))),
jsonRetryTempBoost: getNumberSetting('jsonRetryTempBoost', DEFAULTS.jsonRetryTempBoost),
traceUserId: getStringSetting('traceUserId', DEFAULTS.traceUserId),
generationName: getStringSetting('generationName', DEFAULTS.generationName),
debug: getBoolSetting('debug', DEFAULTS.debug)
};
}
function openSettings() {
const cfg = getSettings();
// eslint-disable-next-line no-restricted-globals
const apiKey = prompt('API key (вставь без "Bearer "):', cfg.apiKey || '');
if (apiKey !== null) GM_setValue('apiKey', apiKey.trim());
// eslint-disable-next-line no-restricted-globals
const model = prompt('Model:', cfg.model);
if (model !== null) GM_setValue('model', model.trim());
// eslint-disable-next-line no-restricted-globals
const temperature = prompt('Temperature (0..2):', String(cfg.temperature));
if (temperature !== null && temperature.trim() !== '') GM_setValue('temperature', Number(temperature));
// eslint-disable-next-line no-restricted-globals
const mode = prompt('Mode: dual | pretty | json', cfg.mode);
if (mode !== null && ['dual', 'pretty', 'json'].includes(mode.trim())) GM_setValue('mode', mode.trim());
// eslint-disable-next-line no-restricted-globals
const retry = prompt('JSON retries if invalid (0..5):', String(cfg.jsonRetryCount));
if (retry !== null && retry.trim() !== '') GM_setValue('jsonRetryCount', Number(retry));
// eslint-disable-next-line no-restricted-globals
const debug = prompt('Debug logs? 0/1', cfg.debug ? '1' : '0');
if (debug !== null) GM_setValue('debug', debug.trim() === '1');
toast('Settings saved');
}
function toggleMode() {
const cfg = getSettings();
const order = ['dual', 'pretty', 'json'];
const idx = Math.max(0, order.indexOf(cfg.mode));
const next = order[(idx + 1) % order.length];
GM_setValue('mode', next);
toast(`Mode: ${next.toUpperCase()}`);
}
/* ===================== TEXT EXTRACTION ===================== */
function safeHost() {
try {
return (typeof location !== 'undefined' && location.hostname) ? location.hostname : 'unknown';
} catch (_) {
return 'unknown';
}
}
function extractMainText(cfg) {
if (!document || !document.body) return '';
try {
const clone = document.cloneNode(true);
clone.querySelectorAll('script, style, noscript').forEach((e) => e.remove());
const ReaderCtor = (typeof window !== 'undefined' && window.Readability) ? window.Readability : null;
if (ReaderCtor) {
const reader = new ReaderCtor(clone, { charThreshold: 400 });
const article = reader.parse();
const content = (article && typeof article.textContent === 'string') ? article.textContent.trim() : '';
if (content.length > 300) return content;
}
} catch (e) {
dbg(cfg, 'Readability failed', e);
}
const fallback = (document.body && typeof document.body.innerText === 'string') ? document.body.innerText.trim() : '';
return fallback;
}
function getTextForAnalysis(cfg) {
try {
const sel = (window.getSelection && window.getSelection())
? String(window.getSelection().toString() || '').trim()
: '';
if (sel.length > 200) return sel;
} catch (e) {
dbg(cfg, 'Selection read failed', e);
}
return extractMainText(cfg);
}
/* ===================== UI ===================== */
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (m) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
}[m]));
}
function btnStyle() {
return 'background:#2b2b2b;border:1px solid #555;color:#eee;border-radius:10px;padding:6px 10px;cursor:pointer;';
}
function toast(msg) {
const id = '__llm_toast';
const old = document.getElementById(id);
if (old) old.remove();
const el = document.createElement('div');
el.id = id;
el.textContent = msg;
el.style = 'position:fixed;left:16px;bottom:16px;z-index:999999;background:rgba(20,20,20,0.95);color:#eee;border:1px solid #444;border-radius:12px;padding:10px 12px;font:13px/1.2 system-ui;box-shadow:0 10px 30px rgba(0,0,0,.45);';
document.body.appendChild(el);
setTimeout(() => el.remove(), 1800);
}
async function copyToClipboard(text, cfg) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(String(text));
return true;
}
} catch (e) {
dbg(cfg, 'Clipboard API failed', e);
}
try {
const ta = document.createElement('textarea');
ta.value = String(text);
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
} catch (e) {
dbg(cfg, 'execCommand copy failed', e);
return false;
}
}
function showOverlay(cfg, title, content, metaLine) {
const old = document.getElementById('__llm_overlay');
if (old) old.remove();
const wrap = document.createElement('div');
wrap.id = '__llm_overlay';
wrap.style = 'position:fixed;top:16px;right:16px;z-index:999999;width:min(920px,calc(100vw - 32px));max-height:calc(100vh - 32px);background:rgba(18,18,18,0.97);color:#eee;border-radius:14px;box-shadow:0 20px 50px rgba(0,0,0,.6);padding:14px;overflow:auto;font:13px/1.45 system-ui;';
wrap.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px;">
<div style="display:flex;flex-direction:column;gap:2px;min-width:0;">
<strong style="font-size:14px;">${escapeHtml(title)}</strong>
<div style="opacity:.75;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escapeHtml(metaLine || '')}</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
<button id="__llm_copy" style="${btnStyle()}">Copy</button>
<button id="__llm_mode" style="${btnStyle()}">Mode: ${escapeHtml(String(cfg.mode || '').toUpperCase())}</button>
<button id="__llm_close" style="${btnStyle()}">Close</button>
</div>
</div>
<pre style="white-space:pre-wrap;margin:0;word-break:break-word;">${escapeHtml(content || '')}</pre>
`;
document.body.appendChild(wrap);
const closeBtn = document.getElementById('__llm_close');
const modeBtn = document.getElementById('__llm_mode');
const copyBtn = document.getElementById('__llm_copy');
if (closeBtn) closeBtn.onclick = () => wrap.remove();
if (modeBtn) modeBtn.onclick = () => { toggleMode(); wrap.remove(); };
if (copyBtn) {
copyBtn.onclick = async () => {
const ok = await copyToClipboard(content || '', cfg);
toast(ok ? 'Copied ✅' : 'Copy failed ❌');
};
}
}
/* ===================== LLM + DUAL LOCAL ===================== */
function buildMetadata(cfg, stage) {
const host = safeHost();
const now = Date.now();
const rid = Math.random().toString(36).slice(2);
return {
generation_name: cfg.generationName,
generation_id: `gen-${now}-${stage || 'single'}`,
trace_id: `trace-${host}-${rid}`,
trace_user_id: cfg.traceUserId,
stage: stage || 'single'
};
}
function callLLM(cfg, prompt, stage, temperatureOverride) {
const temp = Number.isFinite(temperatureOverride) ? temperatureOverride : cfg.temperature;
const payload = {
model: cfg.model,
messages: [{ role: 'user', content: prompt }],
temperature: temp,
metadata: buildMetadata(cfg, stage)
};
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: cfg.endpoint,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${cfg.apiKey}`
},
data: JSON.stringify(payload),
onload: (resp) => {
try {
const data = JSON.parse(resp.responseText);
const out = data?.choices?.[0]?.message?.content ?? '';
resolve({ ok: true, raw: out, full: data, status: resp.status });
} catch (_) {
reject(new Error(`Bad wrapper JSON from endpoint (HTTP ${resp.status})`));
}
},
onerror: () => reject(new Error('Network/CORS error'))
});
});
}
function tryParseJsonStrict(s) {
const t = String(s || '').trim();
if (!t.startsWith('{') || !t.endsWith('}')) return null;
try { return JSON.parse(t); } catch (_) { return null; }
}
function prettyJson(obj) {
try { return JSON.stringify(obj, null, 2); } catch (_) { return String(obj); }
}
function mapWorldStatus(ws) {
const m = {
real_modern: 'реальный современный мир',
real_historical: 'реальный, но исторический',
fictional_or_alt: 'художественный / альтернативный / иной мир',
unclear: 'статус мира неясен'
};
return m[ws] || String(ws || 'неизвестно');
}
function mapConflict(ct) {
const m = {
real_conflict: 'явный реальный конфликт',
social_ideological: 'социальный / идеологический конфликт',
local_personal: 'локальный конфликт персонажей',
none: 'конфликт отсутствует'
};
return m[ct] || String(ct || 'неизвестно');
}
function mapDirection(d) {
const m = {
inward: 'внутрь мира (для персонажей/участников)',
outward: 'наружу (для читателя/наблюдателя)',
both: 'одновременно внутрь и наружу'
};
return m[d] || String(d || 'неизвестно');
}
function bullets(arr, max = 7) {
if (!Array.isArray(arr) || arr.length === 0) return '—';
return arr.slice(0, max).map((x) => `- ${String(x)}`).join('\n');
}
function listTrueChecks(checks) {
if (!checks || typeof checks !== 'object') return [];
return Object.entries(checks)
.filter(([, v]) => v === true)
.map(([k]) => k);
}
function humanizeCheckKey(k) {
const map = {
emotional_density_high: 'плотность оценочной/эмоциональной лексики высокая',
emotional_asymmetry: 'асимметрия эмоций (в основном на одну сторону)',
implicit_emotional_pressure: 'имплицитное эмоциональное давление (угрозы/неизбежность/нормализация насилия)',
normalization_of_extreme_actions: 'нормализация крайних действий как естественных/неизбежных',
moral_binarization: 'моральная бинарность (упрощение до “хорошие/плохие”)',
agency_shift: 'смещение агентности (кто субъект, кто “фактор/проблема”)',
cause_effect_inversion: 'инверсия причин и следствий'
};
return map[k] || k;
}
function clamp01to10(n) {
const x = Number(n);
if (!Number.isFinite(x)) return null;
if (x < 0) return 0;
if (x > 10) return 10;
return x;
}
function buildLocalPrettyFromJson(j) {
const ws = mapWorldStatus(j.world_status);
const ct = mapConflict(j.conflict_type);
const td = mapDirection(j.text_direction);
const emotional = clamp01to10(j.emotional_intensity);
const propaganda = clamp01to10(j.propaganda_intensity);
const checks = listTrueChecks(j.additional_checks).map(humanizeCheckKey);
const checksText = checks.length ? checks.map((x) => `- ${x}`).join('\n') : '—';
const worldWhy = bullets(j.world_status_rationale, 4);
const conflictWhy = bullets(j.conflict_rationale, 4);
const dirWhy = bullets(j.text_direction_rationale, 4);
const bias = String(j.author_bias || 'unknown');
const biasNotes = bullets(j.bias_notes, 4);
const worldEffect = bullets(j.effect_of_world_status_on_score, 4);
const artEffect = Array.isArray(j.effect_of_artistic_form)
? `- ${String(j.effect_of_artistic_form[0] || '')}\n- ${String(j.effect_of_artistic_form[1] || '')}`.trim()
: '—';
const conf = String(j.confidence || 'unknown');
const confNotes = bullets(j.confidence_notes, 4);
const zeroJust = String(j.zero_justification || '').trim();
const zeroLine = zeroJust ? `\nПримечание о нулях: ${zeroJust}\n` : '';
return [
`1) Мир: ${ws}`,
`${worldWhy !== '—' ? worldWhy : ''}`.trim(),
`\n2) Конфликт: ${ct}`,
`${conflictWhy !== '—' ? conflictWhy : ''}`.trim(),
`\n2.5) Направленность: ${td}`,
`${dirWhy !== '—' ? dirWhy : ''}`.trim(),
`\n3) Эмоциональная насыщенность: ${emotional !== null ? emotional : '—'}`,
bullets(j.emotional_reasons, 7),
`\n4) Пропагандистская направленность: ${propaganda !== null ? propaganda : '—'}`,
bullets(j.propaganda_reasons, 7),
`\n4.5) Дополнительные проверки (сработали):`,
checksText,
`\n5) Пристрастность автора: ${bias}`,
biasNotes,
`\n6) Влияние статуса мира на итог:`,
worldEffect,
`\n7) Художественная форма:`,
artEffect,
`\n8) Уверенность: ${conf}`,
confNotes,
zeroLine
].filter(Boolean).join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
async function runDual(cfg, text) {
let attempts = 0;
let jsonObj = null;
let lastRaw = '';
while (attempts <= cfg.jsonRetryCount) {
const temp = cfg.temperature + (attempts > 0 ? cfg.jsonRetryTempBoost : 0);
const res = await callLLM(cfg, buildPromptJson(text), 'json', temp);
lastRaw = res.raw || '';
jsonObj = tryParseJsonStrict(lastRaw);
if (jsonObj) break;
attempts += 1;
dbg(cfg, 'JSON invalid, retry', attempts, lastRaw.slice(0, 200));
}
if (!jsonObj) {
return `DUAL MODE FAILED: could not parse JSON after ${cfg.jsonRetryCount + 1} attempts.\n\nRaw output:\n${lastRaw}`;
}
const pretty = buildLocalPrettyFromJson(jsonObj);
const jsonBlock = prettyJson(jsonObj);
return `${pretty}\n\n---\nJSON (source of truth):\n${jsonBlock}\n`;
}
async function runSinglePretty(cfg, text) {
const res = await callLLM(cfg, buildPromptPretty(text), 'pretty');
return (res.raw || '').trim();
}
async function runSingleJson(cfg, text) {
const res = await callLLM(cfg, buildPromptJson(text), 'json');
return (res.raw || '').trim();
}
/* ===================== MAIN ===================== */
async function runAnalysis() {
const cfg = getSettings();
if (!cfg.apiKey) {
toast('API key not set: open Settings');
return;
}
const rawText = getTextForAnalysis(cfg);
if (!rawText || rawText.length < 200) {
showOverlay(cfg, 'LLM Analyzer', 'Недостаточно текста для анализа (слишком мало контента/выделения).', `${cfg.model} | temp=${cfg.temperature}`);
return;
}
const text = rawText.slice(0, cfg.maxChars);
showOverlay(cfg, 'LLM Analyzer', 'Анализирую…', `${cfg.model} | temp=${cfg.temperature} | mode=${cfg.mode} | chars=${text.length}`);
try {
let out = '';
if (cfg.mode === 'pretty') out = await runSinglePretty(cfg, text);
else if (cfg.mode === 'json') out = await runSingleJson(cfg, text);
else out = await runDual(cfg, text);
showOverlay(cfg, 'LLM Analyzer — Result', out, `${cfg.model} | temp=${cfg.temperature} | ${safeHost()} | mode=${cfg.mode}`);
} catch (e) {
showOverlay(cfg, 'LLM Analyzer — Error', String(e && e.message ? e.message : e), `${cfg.model} | temp=${cfg.temperature} | mode=${cfg.mode}`);
}
}
/* ===================== HOTKEYS / MENU ===================== */
GM_registerMenuCommand('LLM Analyzer: Run (Alt+L)', runAnalysis);
GM_registerMenuCommand('LLM Analyzer: Toggle Mode (Alt+M)', toggleMode);
GM_registerMenuCommand('LLM Analyzer: Settings', openSettings);
window.addEventListener('keydown', (e) => {
if (!e || !e.altKey) return;
const k = String(e.key || '').toLowerCase();
if (k === 'l') runAnalysis();
if (k === 'm') toggleMode();
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment