text-analyzer / index.html
okcan
Initial commit
ca3db9e unverified
Raw
History Blame Contribute Delete
20.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Text Readability & Tone Analyzer</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
padding: 32px 16px;
}
.container { max-width: 820px; margin: 0 auto; }
h1 {
font-size: 1.8rem;
font-weight: 700;
color: #fff;
margin-bottom: 6px;
}
.subtitle { color: #888; font-size: 0.95rem; margin-bottom: 32px; }
.card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
}
label {
display: block;
font-size: 0.85rem;
color: #aaa;
margin-bottom: 8px;
font-weight: 500;
}
input, textarea {
width: 100%;
background: #111;
border: 1px solid #333;
border-radius: 8px;
color: #e0e0e0;
font-size: 0.95rem;
padding: 12px 14px;
outline: none;
transition: border-color 0.2s;
font-family: inherit;
}
input:focus, textarea:focus { border-color: #2eb87a; }
textarea { resize: vertical; min-height: 180px; line-height: 1.6; }
.token-hint { font-size: 0.78rem; color: #666; margin-top: 6px; }
.token-hint a { color: #2eb87a; text-decoration: none; }
.token-hint a:hover { text-decoration: underline; }
button {
background: #2eb87a;
color: #fff;
border: none;
border-radius: 8px;
padding: 12px 28px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
width: 100%;
margin-top: 8px;
}
button:hover { background: #26a06a; }
button:disabled { background: #444; cursor: not-allowed; }
.status {
padding: 12px 16px;
border-radius: 8px;
font-size: 0.9rem;
margin-top: 12px;
display: none;
}
.status.info { background: #0e2018; color: #6ee4a8; border: 1px solid #1a4a30; display: block; }
.status.error { background: #2a1a1a; color: #f57a7a; border: 1px solid #6a2a2a; display: block; }
.status.success { background: #0e2018; color: #6ee4a8; border: 1px solid #2a6a48; display: block; }
.results { display: none; }
.results.visible { display: block; }
/* section title */
.section-title {
font-size: 0.78rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 600;
margin-bottom: 14px;
}
/* ── Summary card ── */
#summaryCard { margin-bottom: 20px; }
#summaryOut { font-size: 0.97rem; line-height: 1.7; color: #ddd; }
/* ── Tone card ── */
#toneCard { margin-bottom: 20px; }
.tone-chips { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 4px; }
.tone-chip {
background: #222;
border: 1px solid #333;
border-radius: 20px;
padding: 5px 14px;
font-size: 0.85rem;
color: #bbb;
position: relative;
overflow: hidden;
min-width: 100px;
text-align: center;
}
.tone-chip .chip-fill {
position: absolute;
left: 0; top: 0; bottom: 0;
background: #2eb87a22;
border-radius: 20px;
transition: width 0.6s ease;
}
.tone-chip.top-tone {
border-color: #2eb87a;
color: #6ee4a8;
font-weight: 600;
}
.tone-chip .chip-label { position: relative; z-index: 1; }
.tone-chip .chip-pct { font-size: 0.72rem; color: #888; margin-left: 4px; position: relative; z-index: 1; }
.tone-chip.top-tone .chip-pct { color: #4cad88; }
/* ── Readability card ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.stat-box {
background: #111;
border: 1px solid #2a2a2a;
border-radius: 8px;
padding: 14px 16px;
}
.stat-box .stat-val {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
line-height: 1;
margin-bottom: 4px;
}
.stat-box .stat-lbl { font-size: 0.78rem; color: #777; }
/* Flesch meter */
.meter-wrap { margin-top: 4px; }
.meter-label-row {
display: flex;
justify-content: space-between;
font-size: 0.82rem;
color: #888;
margin-bottom: 6px;
}
.meter-label-row b { color: #e0e0e0; }
.meter-track {
height: 10px;
border-radius: 5px;
background: #222;
overflow: hidden;
position: relative;
}
.meter-fill {
height: 100%;
border-radius: 5px;
transition: width 0.7s ease, background 0.7s ease;
}
.meter-level {
margin-top: 8px;
font-size: 0.88rem;
font-weight: 600;
}
/* ── Suggestions card ── */
.suggestion-list { list-style: none; }
.suggestion-list li {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #222;
font-size: 0.92rem;
color: #ccc;
line-height: 1.5;
}
.suggestion-list li:last-child { border-bottom: none; }
.suggestion-list .badge {
flex-shrink: 0;
font-size: 0.72rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
margin-top: 2px;
}
.badge.warn { background: #3a2800; color: #f0a832; border: 1px solid #6a4800; }
.badge.good { background: #0e2018; color: #6ee4a8; border: 1px solid #2a5a38; }
.badge.count { background: #1e1e2e; color: #9a9aff; border: 1px solid #3a3a6a; }
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid #555;
border-top-color: #6ee4a8;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 600px) {
h1 { font-size: 1.4rem; }
.stats-grid { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<div class="container">
<h1>πŸ“ Text Readability & Tone Analyzer</h1>
<p class="subtitle">Paste any text β€” get a summary, tone classification, readability score, and writing suggestions.</p>
<div class="card">
<label>HF API Token</label>
<input type="password" id="hfToken" placeholder="hf_..." autocomplete="off" />
<p class="token-hint">
Free token at <a href="https://huggingface.co/settings/tokens" target="_blank">huggingface.co/settings/tokens</a>
β€” Read access is enough. Used for summarization and tone classification.
</p>
</div>
<div class="card">
<label>Your Text</label>
<textarea id="textInput" placeholder="Paste a blog post, essay, email, report β€” anything. Minimum 20 words.&#10;&#10;Tip: longer texts produce more accurate results."></textarea>
<button id="analyzeBtn" onclick="analyze()">Analyze Text</button>
<div class="status" id="status"></div>
</div>
<!-- RESULTS -->
<div class="results" id="results">
<!-- Summary -->
<div class="card" id="summaryCard">
<div class="section-title">Summary</div>
<p id="summaryOut">β€”</p>
</div>
<!-- Tone -->
<div class="card" id="toneCard">
<div class="section-title">Tone Classification</div>
<div class="tone-chips" id="toneChips"></div>
</div>
<!-- Readability stats -->
<div class="card">
<div class="section-title">Readability Statistics</div>
<div class="stats-grid" id="statsGrid">
<div class="stat-box"><div class="stat-val" id="statWords">β€”</div><div class="stat-lbl">Words</div></div>
<div class="stat-box"><div class="stat-val" id="statSentences">β€”</div><div class="stat-lbl">Sentences</div></div>
<div class="stat-box"><div class="stat-val" id="statAvgWPS">β€”</div><div class="stat-lbl">Avg Words / Sentence</div></div>
<div class="stat-box"><div class="stat-val" id="statSyllables">β€”</div><div class="stat-lbl">Syllables</div></div>
</div>
<!-- Flesch meter -->
<div class="meter-wrap">
<div class="meter-label-row">
<span>Flesch Reading Ease</span>
<span><b id="fleschScore">β€”</b> / 100</span>
</div>
<div class="meter-track">
<div class="meter-fill" id="meterFill" style="width:0%"></div>
</div>
<div class="meter-level" id="meterLevel"></div>
</div>
</div>
<!-- Writing suggestions -->
<div class="card">
<div class="section-title">Writing Suggestions</div>
<ul class="suggestion-list" id="suggestionList"></ul>
</div>
</div><!-- /results -->
</div>
<script>
/* ───────────────────────────── helpers ───────────────────────────── */
function setStatus(msg, type) {
const el = document.getElementById('status');
el.innerHTML = msg;
el.className = 'status ' + type;
}
async function hfInference(token, model, inputs, params = {}) {
const res = await fetch(`https://api-inference.huggingface.co/models/${model}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ inputs, parameters: params })
});
if (!res.ok) {
const err = await res.text();
throw new Error(`HF API error ${res.status}: ${err.slice(0, 200)}`);
}
return res.json();
}
function chunkText(text, wordLimit = 800) {
const words = text.split(/\s+/);
const chunks = [];
for (let i = 0; i < words.length; i += wordLimit) {
chunks.push(words.slice(i, i + wordLimit).join(' '));
}
return chunks;
}
/* ───────────────────────────── readability (pure JS) ───────────────────────────── */
function countSyllables(word) {
word = word.toLowerCase().replace(/[^a-z]/g, '');
if (!word) return 0;
if (word.length <= 3) return 1;
// remove trailing silent e
word = word.replace(/e$/, '');
const matches = word.match(/[aeiouy]{1,2}/g);
return matches ? Math.max(1, matches.length) : 1;
}
function computeReadability(text) {
// sentences: split on . ! ?
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(s => s.length > 0);
const sentenceCount = Math.max(sentences.length, 1);
// words
const words = text.match(/\b[a-zA-Z']+\b/g) || [];
const wordCount = Math.max(words.length, 1);
// syllables
let syllableCount = 0;
for (const w of words) syllableCount += countSyllables(w);
const avgWPS = wordCount / sentenceCount;
const avgSPW = syllableCount / wordCount;
// Flesch Reading Ease = 206.835 - 1.015*(words/sentences) - 84.6*(syllables/words)
let flesch = 206.835 - 1.015 * avgWPS - 84.6 * avgSPW;
flesch = Math.min(100, Math.max(0, Math.round(flesch * 10) / 10));
return { wordCount, sentenceCount, syllableCount, avgWPS: Math.round(avgWPS * 10) / 10, flesch };
}
function fleschLabel(score) {
if (score >= 90) return { label: 'Very Easy', color: '#4caf50' };
if (score >= 70) return { label: 'Easy', color: '#8bc34a' };
if (score >= 50) return { label: 'Medium', color: '#ffc107' };
if (score >= 30) return { label: 'Difficult', color: '#ff9800' };
return { label: 'Very Difficult', color: '#f44336' };
}
/* ───────────────────────────── writing suggestions (pure JS) ───────────────────────────── */
function buildSuggestions(text, stats) {
const suggestions = [];
const words = text.match(/\b[a-zA-Z']+\b/g) || [];
const wordCount = words.length;
// 1. Passive voice: was/were/been + past participle (simple heuristic)
const passivePattern = /\b(was|were|been|is|are|am|being)\s+\w+ed\b/gi;
const passiveMatches = text.match(passivePattern) || [];
const passiveCount = passiveMatches.length;
if (passiveCount === 0) {
suggestions.push({ badge: 'good', text: 'No passive voice patterns detected β€” active voice throughout.' });
} else {
suggestions.push({
badge: 'warn',
text: `Passive voice: ${passiveCount} instance${passiveCount > 1 ? 's' : ''} detected (e.g. "was done", "were written"). Consider rewriting in active voice for clarity.`
});
}
// 2. Long sentences (> 30 words)
const sentences = text.split(/[.!?]+/).map(s => s.trim()).filter(s => s.length > 0);
const longSentences = sentences.filter(s => s.split(/\s+/).length > 30);
const longCount = longSentences.length;
if (longCount === 0) {
suggestions.push({ badge: 'good', text: 'No overly long sentences (>30 words) found β€” great sentence variety.' });
} else {
suggestions.push({
badge: 'warn',
text: `Long sentences: ${longCount} sentence${longCount > 1 ? 's' : ''} exceed 30 words. Breaking them up can improve readability.`
});
}
// 3. Filler words
const fillers = ['very', 'really', 'just', 'quite', 'basically', 'literally', 'actually'];
let fillerCount = 0;
const foundFillers = [];
for (const filler of fillers) {
const re = new RegExp(`\\b${filler}\\b`, 'gi');
const hits = (text.match(re) || []).length;
if (hits > 0) { fillerCount += hits; foundFillers.push(`"${filler}" Γ—${hits}`); }
}
if (fillerCount === 0) {
suggestions.push({ badge: 'good', text: 'No filler words detected β€” concise and direct writing.' });
} else {
suggestions.push({
badge: 'count',
text: `Filler words: ${fillerCount} found β€” ${foundFillers.join(', ')}. Removing them tightens your prose.`
});
}
// 4. Reading level note
const { flesch } = stats;
const lvl = fleschLabel(flesch);
if (flesch >= 70) {
suggestions.push({ badge: 'good', text: `Reading level: ${lvl.label} (Flesch ${flesch}). Your text is accessible to a broad audience.` });
} else if (flesch >= 50) {
suggestions.push({ badge: 'count', text: `Reading level: ${lvl.label} (Flesch ${flesch}). Suitable for high-school/college readers; simplify where possible if targeting a general audience.` });
} else {
suggestions.push({ badge: 'warn', text: `Reading level: ${lvl.label} (Flesch ${flesch}). Text is complex β€” consider shorter sentences and simpler vocabulary.` });
}
return suggestions;
}
/* ───────────────────────────── API calls ───────────────────────────── */
async function summarize(token, text) {
const chunks = chunkText(text, 800).slice(0, 3);
const summaries = [];
for (const chunk of chunks) {
const res = await hfInference(token, 'facebook/bart-large-cnn', chunk, {
max_length: 160, min_length: 40, do_sample: false
});
if (res[0]?.summary_text) summaries.push(res[0].summary_text);
}
return summaries.join(' ') || 'Could not generate summary.';
}
async function classifyTone(token, text) {
const labels = ['formal', 'casual', 'persuasive', 'informative', 'emotional', 'humorous'];
// use first 1000 chars for zero-shot (model limit)
const snippet = text.slice(0, 1000);
const res = await hfInference(token, 'facebook/bart-large-mnli', snippet, {
candidate_labels: labels,
multi_label: false
});
// res has .labels and .scores arrays
if (!res.labels) throw new Error('Unexpected tone API response.');
return res.labels.map((l, i) => ({ label: l, score: res.scores[i] }));
}
/* ───────────────────────────── render helpers ───────────────────────────── */
function renderStats(stats) {
document.getElementById('statWords').textContent = stats.wordCount.toLocaleString();
document.getElementById('statSentences').textContent = stats.sentenceCount.toLocaleString();
document.getElementById('statAvgWPS').textContent = stats.avgWPS;
document.getElementById('statSyllables').textContent = stats.syllableCount.toLocaleString();
const lvl = fleschLabel(stats.flesch);
document.getElementById('fleschScore').textContent = stats.flesch;
const fill = document.getElementById('meterFill');
fill.style.width = stats.flesch + '%';
fill.style.background = lvl.color;
const levelEl = document.getElementById('meterLevel');
levelEl.textContent = lvl.label;
levelEl.style.color = lvl.color;
}
function renderTone(toneData) {
const container = document.getElementById('toneChips');
container.innerHTML = '';
const sorted = [...toneData].sort((a, b) => b.score - a.score);
const topLabel = sorted[0].label;
for (const item of sorted) {
const pct = Math.round(item.score * 100);
const div = document.createElement('div');
div.className = 'tone-chip' + (item.label === topLabel ? ' top-tone' : '');
div.innerHTML = `
<div class="chip-fill" style="width:${pct}%"></div>
<span class="chip-label">${item.label}</span>
<span class="chip-pct">${pct}%</span>
`;
container.appendChild(div);
}
}
function renderSuggestions(suggestions) {
const ul = document.getElementById('suggestionList');
ul.innerHTML = '';
for (const s of suggestions) {
const li = document.createElement('li');
li.innerHTML = `<span class="badge ${s.badge}">${s.badge.toUpperCase()}</span><span>${s.text}</span>`;
ul.appendChild(li);
}
}
/* ───────────────────────────── main entry ───────────────────────────── */
async function analyze() {
const token = document.getElementById('hfToken').value.trim();
const text = document.getElementById('textInput').value.trim();
if (!token) { setStatus('Please enter your HF API token.', 'error'); return; }
if (!text) { setStatus('Please paste some text to analyze.', 'error'); return; }
const wordCount = (text.match(/\b\w+\b/g) || []).length;
if (wordCount < 20) { setStatus('Text is too short β€” please provide at least 20 words.', 'error'); return; }
const btn = document.getElementById('analyzeBtn');
btn.disabled = true;
document.getElementById('results').classList.remove('visible');
try {
// 1. Readability (instant, no API)
setStatus('<span class="spinner"></span>Computing readability stats...', 'info');
const stats = computeReadability(text);
renderStats(stats);
// 2. Summary (only if > 100 words)
if (wordCount > 100) {
setStatus('<span class="spinner"></span>Generating summary (BART)...', 'info');
const summary = await summarize(token, text);
document.getElementById('summaryOut').textContent = summary;
document.getElementById('summaryCard').style.display = '';
} else {
document.getElementById('summaryOut').textContent = 'Text is under 100 words β€” summary skipped.';
document.getElementById('summaryCard').style.display = '';
}
// 3. Tone classification
setStatus('<span class="spinner"></span>Classifying tone (zero-shot)...', 'info');
const toneData = await classifyTone(token, text);
renderTone(toneData);
// 4. Suggestions
const suggestions = buildSuggestions(text, stats);
renderSuggestions(suggestions);
document.getElementById('results').classList.add('visible');
setStatus('Analysis complete.', 'success');
} catch (e) {
setStatus('Error: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
}
// Allow Ctrl+Enter to submit
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('textInput').addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) analyze();
});
});
</script>
</body>
</html>