okcan commited on
Commit
543484d
·
unverified ·
0 Parent(s):

Initial commit

Browse files
Files changed (2) hide show
  1. README.md +21 -0
  2. index.html +629 -0
README.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: review-analyzer
3
+ emoji: ⭐
4
+ colorFrom: yellow
5
+ colorTo: green
6
+ sdk: static
7
+ pinned: false
8
+ license: apache-2.0
9
+ ---
10
+
11
+ # Product Review Analyzer
12
+
13
+ Paste product reviews and get instant AI-powered analysis:
14
+
15
+ - **Sentiment analysis** via `cardiffnlp/twitter-roberta-base-sentiment-latest` (positive / neutral / negative %)
16
+ - **Predicted star rating** (1–5) mapped from sentiment scores
17
+ - **Pros & Cons** extracted from review sentences
18
+ - **Aspect sentiment grid** — quality, price, shipping, packaging, size, color, material, customer service, durability
19
+ - **AI summary** via `facebook/bart-large-cnn` for reviews over 100 words
20
+
21
+ Supports multiple reviews separated by `---` or blank lines.
index.html ADDED
@@ -0,0 +1,629 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Product Review Analyzer</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
11
+ background: #0f0f0f;
12
+ color: #e0e0e0;
13
+ min-height: 100vh;
14
+ padding: 32px 16px;
15
+ }
16
+ .container { max-width: 860px; margin: 0 auto; }
17
+ h1 {
18
+ font-size: 1.8rem;
19
+ font-weight: 700;
20
+ color: #fff;
21
+ margin-bottom: 6px;
22
+ }
23
+ .subtitle { color: #888; font-size: 0.95rem; margin-bottom: 32px; }
24
+
25
+ .card {
26
+ background: #1a1a1a;
27
+ border: 1px solid #2a2a2a;
28
+ border-radius: 12px;
29
+ padding: 24px;
30
+ margin-bottom: 20px;
31
+ }
32
+ label {
33
+ display: block;
34
+ font-size: 0.85rem;
35
+ color: #aaa;
36
+ margin-bottom: 8px;
37
+ font-weight: 500;
38
+ }
39
+ input, textarea {
40
+ width: 100%;
41
+ background: #111;
42
+ border: 1px solid #333;
43
+ border-radius: 8px;
44
+ color: #e0e0e0;
45
+ font-size: 0.95rem;
46
+ padding: 12px 14px;
47
+ outline: none;
48
+ transition: border-color 0.2s;
49
+ font-family: inherit;
50
+ }
51
+ input:focus, textarea:focus { border-color: #2ecc71; }
52
+ textarea { resize: vertical; min-height: 160px; }
53
+
54
+ .token-hint { font-size: 0.78rem; color: #666; margin-top: 6px; }
55
+ .token-hint a { color: #2ecc71; text-decoration: none; }
56
+ .token-hint a:hover { text-decoration: underline; }
57
+
58
+ button {
59
+ background: #2ecc71;
60
+ color: #0f0f0f;
61
+ border: none;
62
+ border-radius: 8px;
63
+ padding: 13px 28px;
64
+ font-size: 1rem;
65
+ font-weight: 700;
66
+ cursor: pointer;
67
+ transition: background 0.2s;
68
+ width: 100%;
69
+ margin-top: 8px;
70
+ }
71
+ button:hover { background: #27ae60; }
72
+ button:disabled { background: #333; color: #666; cursor: not-allowed; }
73
+
74
+ .status {
75
+ padding: 12px 16px;
76
+ border-radius: 8px;
77
+ font-size: 0.9rem;
78
+ margin-top: 12px;
79
+ display: none;
80
+ }
81
+ .status.info { background: #0d1f17; color: #7af5b0; border: 1px solid #1a4a30; display: block; }
82
+ .status.error { background: #2a1a1a; color: #f57a7a; border: 1px solid #6a2a2a; display: block; }
83
+ .status.success { background: #0d1f17; color: #2ecc71; border: 1px solid #1a5a30; display: block; }
84
+
85
+ .spinner {
86
+ display: inline-block;
87
+ width: 14px; height: 14px;
88
+ border: 2px solid #444;
89
+ border-top-color: #2ecc71;
90
+ border-radius: 50%;
91
+ animation: spin 0.7s linear infinite;
92
+ margin-right: 8px;
93
+ vertical-align: middle;
94
+ }
95
+ @keyframes spin { to { transform: rotate(360deg); } }
96
+
97
+ /* Results */
98
+ .results { display: none; }
99
+ .results.visible { display: block; }
100
+
101
+ .review-block {
102
+ background: #1a1a1a;
103
+ border: 1px solid #2a2a2a;
104
+ border-radius: 12px;
105
+ padding: 24px;
106
+ margin-bottom: 24px;
107
+ }
108
+ .review-block-header {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 12px;
112
+ margin-bottom: 20px;
113
+ padding-bottom: 16px;
114
+ border-bottom: 1px solid #252525;
115
+ }
116
+ .review-number {
117
+ background: #2ecc71;
118
+ color: #0f0f0f;
119
+ font-weight: 700;
120
+ font-size: 0.8rem;
121
+ padding: 4px 10px;
122
+ border-radius: 20px;
123
+ }
124
+ .review-snippet {
125
+ color: #666;
126
+ font-size: 0.85rem;
127
+ flex: 1;
128
+ white-space: nowrap;
129
+ overflow: hidden;
130
+ text-overflow: ellipsis;
131
+ }
132
+
133
+ /* Star rating */
134
+ .stars-row {
135
+ display: flex;
136
+ align-items: center;
137
+ gap: 10px;
138
+ margin-bottom: 20px;
139
+ }
140
+ .stars {
141
+ display: flex;
142
+ gap: 3px;
143
+ }
144
+ .star {
145
+ font-size: 1.5rem;
146
+ color: #333;
147
+ transition: color 0.3s;
148
+ }
149
+ .star.filled { color: #f1c40f; }
150
+ .star.half { color: #f1c40f; opacity: 0.5; }
151
+ .star-label {
152
+ font-size: 1.1rem;
153
+ font-weight: 700;
154
+ color: #f1c40f;
155
+ }
156
+ .star-sub { font-size: 0.8rem; color: #666; }
157
+
158
+ /* Sentiment bar */
159
+ .section-label {
160
+ font-size: 0.78rem;
161
+ color: #888;
162
+ text-transform: uppercase;
163
+ letter-spacing: 0.06em;
164
+ font-weight: 600;
165
+ margin-bottom: 8px;
166
+ }
167
+ .sentiment-bar {
168
+ height: 10px;
169
+ border-radius: 5px;
170
+ background: #222;
171
+ overflow: hidden;
172
+ display: flex;
173
+ margin-bottom: 6px;
174
+ }
175
+ .bar-pos { background: #2ecc71; transition: width 0.5s; }
176
+ .bar-neu { background: #888; transition: width 0.5s; }
177
+ .bar-neg { background: #e74c3c; transition: width 0.5s; }
178
+ .sentiment-labels {
179
+ display: flex;
180
+ justify-content: space-between;
181
+ font-size: 0.8rem;
182
+ color: #777;
183
+ margin-bottom: 20px;
184
+ }
185
+ .sentiment-labels .lbl-pos b { color: #2ecc71; }
186
+ .sentiment-labels .lbl-neu b { color: #aaa; }
187
+ .sentiment-labels .lbl-neg b { color: #e74c3c; }
188
+
189
+ /* Pros / Cons */
190
+ .pros-cons {
191
+ display: grid;
192
+ grid-template-columns: 1fr 1fr;
193
+ gap: 16px;
194
+ margin-bottom: 20px;
195
+ }
196
+ @media (max-width: 580px) { .pros-cons { grid-template-columns: 1fr; } }
197
+ .pros-col, .cons-col {
198
+ background: #111;
199
+ border-radius: 8px;
200
+ padding: 14px;
201
+ border: 1px solid #252525;
202
+ }
203
+ .pros-col .col-title { color: #2ecc71; font-size: 0.8rem; font-weight: 700; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
204
+ .cons-col .col-title { color: #e74c3c; font-size: 0.8rem; font-weight: 700; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
205
+ .phrase-item {
206
+ display: flex;
207
+ align-items: flex-start;
208
+ gap: 6px;
209
+ font-size: 0.88rem;
210
+ color: #ccc;
211
+ margin-bottom: 6px;
212
+ line-height: 1.4;
213
+ }
214
+ .phrase-item:last-child { margin-bottom: 0; }
215
+ .phrase-dot-pos { color: #2ecc71; font-size: 0.7rem; margin-top: 4px; flex-shrink: 0; }
216
+ .phrase-dot-neg { color: #e74c3c; font-size: 0.7rem; margin-top: 4px; flex-shrink: 0; }
217
+ .empty-phrase { color: #555; font-size: 0.85rem; font-style: italic; }
218
+
219
+ /* Aspect grid */
220
+ .aspect-grid {
221
+ display: flex;
222
+ flex-wrap: wrap;
223
+ gap: 8px;
224
+ margin-bottom: 20px;
225
+ }
226
+ .aspect-chip {
227
+ display: flex;
228
+ align-items: center;
229
+ gap: 6px;
230
+ background: #111;
231
+ border: 1px solid #2a2a2a;
232
+ border-radius: 8px;
233
+ padding: 7px 12px;
234
+ font-size: 0.83rem;
235
+ }
236
+ .aspect-name { color: #bbb; text-transform: capitalize; }
237
+ .aspect-sentiment {
238
+ font-weight: 700;
239
+ font-size: 0.78rem;
240
+ padding: 2px 7px;
241
+ border-radius: 12px;
242
+ }
243
+ .asp-pos { background: #0d2a18; color: #2ecc71; }
244
+ .asp-neg { background: #2a0d0d; color: #e74c3c; }
245
+ .asp-mix { background: #1a1a0d; color: #f39c12; }
246
+ .asp-neutral { background: #1a1a1a; color: #666; }
247
+
248
+ /* Summary */
249
+ .summary-box {
250
+ background: #111;
251
+ border: 1px solid #1e3a2a;
252
+ border-radius: 8px;
253
+ padding: 14px;
254
+ font-size: 0.92rem;
255
+ color: #ccc;
256
+ line-height: 1.65;
257
+ }
258
+ .summary-box.loading { color: #555; font-style: italic; }
259
+
260
+ .section-divider {
261
+ border: none;
262
+ border-top: 1px solid #252525;
263
+ margin: 18px 0;
264
+ }
265
+
266
+ .badge-row {
267
+ display: flex;
268
+ gap: 8px;
269
+ flex-wrap: wrap;
270
+ margin-bottom: 6px;
271
+ }
272
+ .badge {
273
+ font-size: 0.75rem;
274
+ padding: 3px 10px;
275
+ border-radius: 12px;
276
+ font-weight: 600;
277
+ }
278
+ .badge-green { background: #0d2a18; color: #2ecc71; border: 1px solid #1a4a28; }
279
+ .badge-gray { background: #1a1a1a; color: #888; border: 1px solid #2a2a2a; }
280
+ </style>
281
+ </head>
282
+ <body>
283
+ <div class="container">
284
+ <h1>⭐ Product Review Analyzer</h1>
285
+ <p class="subtitle">Paste product reviews to get sentiment, star rating, pros &amp; cons, aspect breakdown, and AI summary.</p>
286
+
287
+ <div class="card">
288
+ <label>Hugging Face API Token</label>
289
+ <input type="password" id="hfToken" placeholder="hf_..." />
290
+ <p class="token-hint">
291
+ Get a free token at <a href="https://huggingface.co/settings/tokens" target="_blank">huggingface.co/settings/tokens</a>
292
+ — Read access is sufficient.
293
+ </p>
294
+ </div>
295
+
296
+ <div class="card">
297
+ <label>Product Reviews</label>
298
+ <textarea id="reviewsInput" placeholder="Paste one or more product reviews here.&#10;&#10;Separate multiple reviews with a blank line or ---&#10;&#10;Example:&#10;Great product! The quality is excellent and shipping was fast. Packaging was a bit flimsy though.&#10;&#10;---&#10;&#10;Terrible experience. The item broke after two days and customer service was unhelpful."></textarea>
299
+ <p class="token-hint" style="margin-top:6px;">Separate multiple reviews with a blank line or <code style="color:#2ecc71;background:#0d1f17;padding:1px 5px;border-radius:3px;">---</code></p>
300
+ <button id="analyzeBtn" onclick="analyzeReviews()">Analyze Reviews</button>
301
+ <div class="status" id="status"></div>
302
+ </div>
303
+
304
+ <div class="results" id="results"></div>
305
+ </div>
306
+
307
+ <script>
308
+ // ── Aspect keywords ──────────────────────────────────────────────────────────
309
+ const ASPECTS = {
310
+ quality: ['quality','durable','build','material','construction','sturdy','flimsy','solid','cheap','premium','well-made','poorly made'],
311
+ price: ['price','cost','expensive','cheap','affordable','value','worth','overpriced','bargain','deal','money'],
312
+ shipping: ['shipping','delivery','arrived','fast','slow','late','quick','dispatch','courier','package arrived'],
313
+ packaging: ['packaging','package','box','wrapped','packing','unboxing','damaged box','bubble wrap'],
314
+ size: ['size','small','large','big','tiny','fits','dimensions','compact','oversized','too small','too big'],
315
+ color: ['color','colour','shade','bright','dark','faded','vibrant','matches','looks'],
316
+ material: ['material','fabric','plastic','metal','leather','cotton','rubber','glass','wood','texture','feel'],
317
+ 'customer service':['customer service','support','refund','return','helpful','rude','responsive','ignored','resolved','staff'],
318
+ durability: ['durability','lasted','broke','broken','held up','long lasting','fell apart','still works','years','months','days'],
319
+ };
320
+
321
+ const POS_WORDS = new Set([
322
+ 'great','good','excellent','amazing','fantastic','wonderful','love','loved','perfect','best',
323
+ 'awesome','superb','outstanding','quality','recommend','satisfied','happy','pleased','fast',
324
+ 'quick','smooth','easy','nice','solid','sturdy','premium','reliable','accurate','beautiful',
325
+ 'well','works','helpful','professional','friendly','impressive','comfortable','clean','clear',
326
+ 'effective','efficient','lightweight','durable','worth','affordable','value','bargain',
327
+ ]);
328
+
329
+ const NEG_WORDS = new Set([
330
+ 'bad','poor','terrible','awful','horrible','worst','hate','broken','broke','cheap','flimsy',
331
+ 'slow','late','difficult','hard','ugly','disappointing','disappointed','useless','waste',
332
+ 'defective','damaged','wrong','missing','misleading','fake','overpriced','expensive','rude',
333
+ 'unhelpful','ignored','failed','fails','stopped','fell','apart','crack','scratch','smells',
334
+ 'sticky','loose','wobbly','blurry','noisy','loud','heavy','bulky','small','tight',
335
+ ]);
336
+
337
+ // ── Helpers ──────────────────────────────────────────────────────────────────
338
+ function setStatus(msg, type) {
339
+ const el = document.getElementById('status');
340
+ el.innerHTML = msg;
341
+ el.className = 'status ' + type;
342
+ }
343
+
344
+ function splitReviews(raw) {
345
+ // Split on "---" separator or double blank lines
346
+ return raw
347
+ .split(/\n\s*---\s*\n|\n{2,}/)
348
+ .map(r => r.trim())
349
+ .filter(r => r.length > 20);
350
+ }
351
+
352
+ function wordCount(text) {
353
+ return text.trim().split(/\s+/).length;
354
+ }
355
+
356
+ // ── HF API ───────────────────────────────────────────────────────────────────
357
+ async function hfInference(token, model, inputs, params = {}) {
358
+ const res = await fetch(`https://api-inference.huggingface.co/models/${model}`, {
359
+ method: 'POST',
360
+ headers: {
361
+ 'Authorization': `Bearer ${token}`,
362
+ 'Content-Type': 'application/json',
363
+ },
364
+ body: JSON.stringify({ inputs, parameters: params }),
365
+ });
366
+ if (!res.ok) {
367
+ const err = await res.text();
368
+ throw new Error(`HF API error ${res.status}: ${err.slice(0, 200)}`);
369
+ }
370
+ return res.json();
371
+ }
372
+
373
+ // ── Sentiment via HF ─────────────────────────────────────────────────────────
374
+ async function getSentiment(token, text) {
375
+ // Truncate to ~500 chars for the model
376
+ const snippet = text.slice(0, 512);
377
+ try {
378
+ const res = await hfInference(token, 'cardiffnlp/twitter-roberta-base-sentiment-latest', snippet);
379
+ const arr = Array.isArray(res[0]) ? res[0] : res;
380
+ const scores = { positive: 0, neutral: 0, negative: 0 };
381
+ for (const item of arr) {
382
+ const label = item.label?.toLowerCase();
383
+ if (label in scores) scores[label] = item.score;
384
+ }
385
+ const total = scores.positive + scores.neutral + scores.negative || 1;
386
+ return {
387
+ positive: Math.round(scores.positive / total * 100),
388
+ neutral: Math.round(scores.neutral / total * 100),
389
+ negative: Math.round(scores.negative / total * 100),
390
+ raw: scores,
391
+ };
392
+ } catch (e) {
393
+ return { positive: 33, neutral: 34, negative: 33, raw: {}, error: e.message };
394
+ }
395
+ }
396
+
397
+ // ── Star rating (JS mapping) ─────────────────────────────────────────────────
398
+ function sentimentToStars(sentiment) {
399
+ // Weighted formula: positive pulls toward 5, negative toward 1
400
+ const score = (sentiment.positive * 5 + sentiment.neutral * 3 + sentiment.negative * 1) / 100;
401
+ return Math.max(1, Math.min(5, score));
402
+ }
403
+
404
+ // ── Aspect extraction (JS) ───────────────────────────────────────────────────
405
+ function extractAspects(text) {
406
+ const lower = text.toLowerCase();
407
+ const words = lower.match(/\b[a-z]+\b/g) || [];
408
+ const results = {};
409
+
410
+ for (const [aspect, keywords] of Object.entries(ASPECTS)) {
411
+ let found = false;
412
+ for (const kw of keywords) {
413
+ if (lower.includes(kw)) { found = true; break; }
414
+ }
415
+ if (!found) continue;
416
+
417
+ // Score the sentiment of surrounding words
418
+ let pos = 0, neg = 0;
419
+ for (let i = 0; i < words.length; i++) {
420
+ const w = words[i];
421
+ if (keywords.includes(w)) {
422
+ // Look at window of ±5 words
423
+ const window = words.slice(Math.max(0, i - 5), i + 6);
424
+ for (const ww of window) {
425
+ if (POS_WORDS.has(ww)) pos++;
426
+ if (NEG_WORDS.has(ww)) neg++;
427
+ }
428
+ }
429
+ }
430
+ const sentiment = pos > 0 && neg > 0 ? 'mixed' : pos > 0 ? 'positive' : neg > 0 ? 'negative' : 'neutral';
431
+ results[aspect] = { pos, neg, sentiment };
432
+ }
433
+ return results;
434
+ }
435
+
436
+ // ── Pros & Cons (JS) ─────────────────────────────────────────────────────────
437
+ function extractProsConsFromText(text) {
438
+ const sentences = text.match(/[^.!?]+[.!?]?/g) || [text];
439
+ const pros = [], cons = [];
440
+
441
+ for (const sent of sentences) {
442
+ const lower = sent.toLowerCase();
443
+ const words = lower.match(/\b[a-z]+\b/g) || [];
444
+ let posCount = 0, negCount = 0;
445
+ for (const w of words) {
446
+ if (POS_WORDS.has(w)) posCount++;
447
+ if (NEG_WORDS.has(w)) negCount++;
448
+ }
449
+ const trimmed = sent.trim().replace(/^[-•*]+\s*/, '');
450
+ if (trimmed.length < 10) continue;
451
+ if (posCount > negCount && posCount > 0) pros.push(trimmed);
452
+ else if (negCount > posCount && negCount > 0) cons.push(trimmed);
453
+ }
454
+
455
+ return {
456
+ pros: pros.slice(0, 5),
457
+ cons: cons.slice(0, 5),
458
+ };
459
+ }
460
+
461
+ // ── HF Summarization ─────────────────────────────────────────────────────────
462
+ async function summarizeText(token, text) {
463
+ const res = await hfInference(token, 'facebook/bart-large-cnn', text.slice(0, 1024), {
464
+ max_length: 130,
465
+ min_length: 30,
466
+ do_sample: false,
467
+ });
468
+ return res[0]?.summary_text || null;
469
+ }
470
+
471
+ // ── Render helpers ────────────────────────────────────────────────────────────
472
+ function renderStars(rating) {
473
+ const full = Math.floor(rating);
474
+ const hasHalf = (rating - full) >= 0.4;
475
+ let html = '';
476
+ for (let i = 1; i <= 5; i++) {
477
+ if (i <= full) html += `<span class="star filled">★</span>`;
478
+ else if (i === full + 1 && hasHalf) html += `<span class="star half">★</span>`;
479
+ else html += `<span class="star">★</span>`;
480
+ }
481
+ return html;
482
+ }
483
+
484
+ function renderSentimentBar(s) {
485
+ return `
486
+ <div class="section-label">Sentiment Analysis</div>
487
+ <div class="sentiment-bar">
488
+ <div class="bar-pos" style="width:${s.positive}%"></div>
489
+ <div class="bar-neu" style="width:${s.neutral}%"></div>
490
+ <div class="bar-neg" style="width:${s.negative}%"></div>
491
+ </div>
492
+ <div class="sentiment-labels">
493
+ <span class="lbl-pos">Positive <b>${s.positive}%</b></span>
494
+ <span class="lbl-neu">Neutral <b>${s.neutral}%</b></span>
495
+ <span class="lbl-neg">Negative <b>${s.negative}%</b></span>
496
+ </div>`;
497
+ }
498
+
499
+ function renderPros(phrases) {
500
+ if (!phrases.length) return `<span class="empty-phrase">None detected</span>`;
501
+ return phrases.map(p => `<div class="phrase-item"><span class="phrase-dot-pos">●</span>${p}</div>`).join('');
502
+ }
503
+
504
+ function renderCons(phrases) {
505
+ if (!phrases.length) return `<span class="empty-phrase">None detected</span>`;
506
+ return phrases.map(p => `<div class="phrase-item"><span class="phrase-dot-neg">●</span>${p}</div>`).join('');
507
+ }
508
+
509
+ function renderAspects(aspects) {
510
+ const entries = Object.entries(aspects);
511
+ if (!entries.length) return `<span style="color:#555;font-size:0.85rem;font-style:italic;">No product aspects detected.</span>`;
512
+ return entries.map(([name, data]) => {
513
+ const cls = { positive: 'asp-pos', negative: 'asp-neg', mixed: 'asp-mix', neutral: 'asp-neutral' }[data.sentiment] || 'asp-neutral';
514
+ const label = data.sentiment.charAt(0).toUpperCase() + data.sentiment.slice(1);
515
+ return `<div class="aspect-chip">
516
+ <span class="aspect-name">${name}</span>
517
+ <span class="aspect-sentiment ${cls}">${label}</span>
518
+ </div>`;
519
+ }).join('');
520
+ }
521
+
522
+ // ── Main analyze ─────────────────────────────────────────────────────────────
523
+ async function analyzeReviews() {
524
+ const token = document.getElementById('hfToken').value.trim();
525
+ if (!token) { setStatus('HF API token is required.', 'error'); return; }
526
+
527
+ const raw = document.getElementById('reviewsInput').value.trim();
528
+ if (!raw) { setStatus('Please paste at least one review.', 'error'); return; }
529
+
530
+ const reviews = splitReviews(raw);
531
+ if (!reviews.length) { setStatus('No valid reviews found (each review must be at least 20 characters).', 'error'); return; }
532
+
533
+ const btn = document.getElementById('analyzeBtn');
534
+ btn.disabled = true;
535
+ const resultsEl = document.getElementById('results');
536
+ resultsEl.innerHTML = '';
537
+ resultsEl.classList.remove('visible');
538
+
539
+ try {
540
+ for (let i = 0; i < reviews.length; i++) {
541
+ const review = reviews[i];
542
+ setStatus(`<span class="spinner"></span>Analyzing review ${i + 1} of ${reviews.length}…`, 'info');
543
+
544
+ // 1. Sentiment (HF)
545
+ const sentiment = await getSentiment(token, review);
546
+
547
+ // 2. Star rating (JS)
548
+ const starRating = sentimentToStars(sentiment);
549
+
550
+ // 3. Aspects (JS)
551
+ const aspects = extractAspects(review);
552
+
553
+ // 4. Pros & Cons (JS)
554
+ const { pros, cons } = extractProsConsFromText(review);
555
+
556
+ // 5. Summary (HF) — only if > 100 words
557
+ let summary = null;
558
+ const wc = wordCount(review);
559
+ if (wc > 100) {
560
+ setStatus(`<span class="spinner"></span>Generating summary for review ${i + 1}…`, 'info');
561
+ try {
562
+ summary = await summarizeText(token, review);
563
+ } catch (e) {
564
+ summary = `(Summary unavailable: ${e.message})`;
565
+ }
566
+ }
567
+
568
+ // ── Build block HTML ────────────────────────────────────────────────
569
+ const ratingFixed = starRating.toFixed(1);
570
+ const snippet = review.slice(0, 80).replace(/</g, '&lt;') + (review.length > 80 ? '…' : '');
571
+
572
+ const summaryHtml = summary
573
+ ? `<hr class="section-divider">
574
+ <div class="section-label">AI Summary (BART)</div>
575
+ <div class="summary-box">${summary}</div>`
576
+ : (wc <= 100
577
+ ? `<hr class="section-divider">
578
+ <div class="section-label">AI Summary</div>
579
+ <div class="summary-box" style="color:#555;font-style:italic;">Review too short for summarization (&lt;100 words).</div>`
580
+ : '');
581
+
582
+ const blockHtml = `
583
+ <div class="review-block">
584
+ <div class="review-block-header">
585
+ <span class="review-number">Review ${i + 1}</span>
586
+ <span class="review-snippet">${snippet}</span>
587
+ </div>
588
+
589
+ <div class="stars-row">
590
+ <div class="stars">${renderStars(starRating)}</div>
591
+ <span class="star-label">${ratingFixed}</span>
592
+ <span class="star-sub">/ 5.0 predicted</span>
593
+ </div>
594
+
595
+ ${renderSentimentBar(sentiment)}
596
+
597
+ <hr class="section-divider">
598
+ <div class="pros-cons">
599
+ <div class="pros-col">
600
+ <div class="col-title">✓ Pros</div>
601
+ ${renderPros(pros)}
602
+ </div>
603
+ <div class="cons-col">
604
+ <div class="col-title">✗ Cons</div>
605
+ ${renderCons(cons)}
606
+ </div>
607
+ </div>
608
+
609
+ <hr class="section-divider">
610
+ <div class="section-label">Aspect Sentiment</div>
611
+ <div class="aspect-grid">${renderAspects(aspects)}</div>
612
+
613
+ ${summaryHtml}
614
+ </div>`;
615
+
616
+ resultsEl.innerHTML += blockHtml;
617
+ resultsEl.classList.add('visible');
618
+ }
619
+
620
+ setStatus(`Analysis complete — ${reviews.length} review${reviews.length > 1 ? 's' : ''} analyzed.`, 'success');
621
+ } catch (e) {
622
+ setStatus('Error: ' + e.message, 'error');
623
+ } finally {
624
+ btn.disabled = false;
625
+ }
626
+ }
627
+ </script>
628
+ </body>
629
+ </html>