diff --git a/Script/SOCMINT-Twitter/Readme.md b/Script/SOCMINT-Twitter/Readme.md
index 039e22b..75154b3 100644
--- a/Script/SOCMINT-Twitter/Readme.md
+++ b/Script/SOCMINT-Twitter/Readme.md
@@ -274,11 +274,11 @@ Dir Output
Sentiment Analysis
-
+
-
+
-
+
Follower and Following
diff --git a/Script/SOCMINT-Twitter/sentiment.py b/Script/SOCMINT-Twitter/sentiment.py
index 243bcbc..7f42f5e 100644
--- a/Script/SOCMINT-Twitter/sentiment.py
+++ b/Script/SOCMINT-Twitter/sentiment.py
@@ -295,6 +295,21 @@ def top_users(items: list[dict], top_n: int | None = None) -> list[dict]:
"avatar": item.get("avatar") or item.get("user_avatar"),
"verified": item.get("verified"),
"is_blue_verified": item.get("is_blue_verified"),
+ # Same account-age fields id_forensics.py's enrich_account_age()
+ # already stamps onto every raw item at the /api/run choke point
+ # (app.py) — index.html/archive.html/graph.html already surface
+ # these as a badge; the analytics dashboard just wasn't pulling
+ # them through to its own account list yet.
+ "account_created": item.get("account_created"),
+ "account_age": item.get("account_age"),
+ "account_age_flag": item.get("account_age_flag"),
+ # account_age_flag is None whenever id_forensics.py's
+ # enrich_account_age() lands in its own "unknown" bucket (an id
+ # just outside the calibrated range — see that module's
+ # docstring) — account_age_precision is what tells the frontend
+ # "this account really is unresolvable," as opposed to just
+ # never having had an account id to enrich in the first place.
+ "account_age_precision": item.get("account_age_precision"),
}
ranked = []
for handle, count in counts.most_common(top_n):
diff --git a/Script/SOCMINT-Twitter/templates/analytics.html b/Script/SOCMINT-Twitter/templates/analytics.html
index 27560df..6db7803 100644
--- a/Script/SOCMINT-Twitter/templates/analytics.html
+++ b/Script/SOCMINT-Twitter/templates/analytics.html
@@ -200,6 +200,22 @@
.user-count { font-size: 12px; color: var(--accent); font-weight: 600; flex-shrink: 0; }
.verified-badge { color: var(--accent); font-weight: 700; font-size: 0.85em; margin-left: 3px; }
+ /* Same classes/colors as index.html/archive.html/graph.html's own
+ account_age_flag badge — new/recent accounts are a classic bot/
+ sockpuppet signal, worth being just as visible here as it already is
+ everywhere else this data shows up. */
+ .age-badge {
+ display: inline-block; font-size: 10px; font-weight: 500; padding: 1px 7px;
+ border-radius: 20px; border: 1px solid var(--border); margin-left: 6px; vertical-align: middle;
+ }
+ .age-badge.age-new { color: var(--danger); border-color: #7f1d1d; background: var(--danger-bg); }
+ .age-badge.age-recent { color: #f59e0b; border-color: #78350f; background: #1c0e02; }
+ .age-badge.age-established { color: var(--success); border-color: #14532d; background: var(--success-bg); }
+ /* No colored bucket, since there genuinely isn't one here (id_forensics.py
+ couldn't place this id in either range) — muted like sent-neutral,
+ not styled to look like a real new/recent/established verdict. */
+ .age-badge.age-unknown { color: var(--muted); border-color: var(--border); background: var(--bg); }
+
.engagement-list { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.eng-row { padding: 10px 12px; border-bottom: 1px solid rgba(42,45,58,0.6); }
.eng-row:last-child { border-bottom: none; }
@@ -413,6 +429,38 @@ function verifiedBadgeHtml(u) {
return `✓`;
}
+// Same account_age_flag badge index.html/archive.html/graph.html already
+// render as a plain field row — a new/recent account is a classic bot/
+// sockpuppet signal, worth surfacing here at a glance too, not just buried
+// in a raw field somewhere this dashboard doesn't even show. `u`/`item`
+// works the same as verifiedBadgeHtml() above: an accountSentiment() entry,
+// sentiment.py's top_users() entry, or a raw scored-item's `.item` all
+// carry the same field name.
+const AGE_LABELS = { new: 'New account', recent: 'Recent account', established: 'Established account' };
+function ageBadgeHtml(u) {
+ if (!u) return '';
+ if (u.account_age_flag) {
+ const label = AGE_LABELS[u.account_age_flag] || u.account_age_flag;
+ return `${esc(label)}`;
+ }
+ // account_age_flag is null in two different situations that look
+ // identical unless account_age_precision is checked too: no account id
+ // was ever available to enrich (nothing to show — most items, no badge
+ // at all is correct), vs. id_forensics.py's enrich_account_age() DID
+ // have an id but it fell into that module's own documented "unknown"
+ // gap (an id just past the calibrated range — see id_forensics.py's
+ // module docstring). The second case is still worth a visible marker:
+ // index.html/archive.html/graph.html all still show this account's own
+ // account_age text as a plain field row even here, and this dashboard
+ // has no equivalent raw-field fallback view, so silently showing
+ // nothing would make an intentionally-labeled "we don't know" look
+ // exactly like "there was never anything to know" instead.
+ if (u.account_age_precision === 'unknown') {
+ return `Age unknown`;
+ }
+ return '';
+}
+
// Same allowlist index.html/graph.html apply to avatar/banner URLs — https
// only, no quote/angle-bracket/whitespace/paren characters. Not strictly
// required for a plain
(no second CSS-parsing pass involved the
@@ -888,6 +936,8 @@ function buildSnaGraph() {
label: '@' + h + ' (' + a.total + ')',
name: a.name, avatar: a.avatar,
verified: a.verified, is_blue_verified: a.is_blue_verified,
+ account_age_flag: a.account_age_flag, account_age: a.account_age,
+ account_age_precision: a.account_age_precision,
pro: a.pro, neutral: a.neutral, con: a.con, total: a.total,
proPct: pct(a.pro), neutralPct: pct(a.neutral), conPct: pct(a.con),
},
@@ -959,7 +1009,7 @@ function showSnaNodeInfo(nd) {
panel.innerHTML = `