feat: attribute top comments with u/ and @ handles in evidence lines (#292)
Reddit, TikTok, YouTube, Instagram, Bluesky, X and Threads top comments now render as u/author or @handle in the evidence block, instead of the generic "Comment (...)" label. The enrichment adapters already captured author; only the render layer was dropping it. Also fixes the TikTok adapter to prefer user.unique_id (the @handle) over user.nickname (display name) so attribution round-trips to a profile URL. Legacy "Comment (...)" shape is preserved when author is empty, [deleted], or [removed]. Bumps to 3.0.10. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "last30days",
|
"name": "last30days",
|
||||||
"version": "3.0.9",
|
"version": "3.0.10",
|
||||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Matt Van Horn",
|
"name": "Matt Van Horn",
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.0.10] - 2026-04-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Commenter handles on evidence lines.** Top-comment rendering now includes the commenter's handle - `u/author` for Reddit, `@handle` for TikTok/YouTube/Instagram/Bluesky/X/Threads. The enrichment adapters already captured `author`; the render layer just was not using it. Evidence lines change from `- Comment (6822 upvotes): Finally, John Apple` to `- u/Cyrisaurus (6822 upvotes): Finally, John Apple`. Person-level citations make synthesis-side inline markdown links per LAW 8 much more natural. Both the compact and full render paths are covered.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **TikTok author preference.** `_fetch_post_comments` in `scripts/lib/tiktok.py` preferred `user.nickname` over `user.unique_id`, so the engine captured display names ("Moosa Noormahomed") instead of @handles ("moosanoormahomed"). Flipped to prefer `unique_id`. Nickname still wins as a fallback when `unique_id` is missing. Display names can contain emoji, spaces, and non-Latin characters that do not round-trip to a profile URL; the @handle is the stable identifier.
|
||||||
|
|
||||||
|
### Behavior fallback
|
||||||
|
|
||||||
|
- When an author is empty, `[deleted]`, or `[removed]`, the render falls back to the legacy `Comment (...)` shape - no `u/` or `@` prefix with an empty handle is ever emitted.
|
||||||
|
|
||||||
## [3.0.9] - 2026-04-18 - The Self-Debug Release
|
## [3.0.9] - 2026-04-18 - The Self-Debug Release
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|||||||
+32
-2
@@ -462,7 +462,8 @@ def render_full(report: schema.Report) -> str:
|
|||||||
for tc in top_comments[:3]:
|
for tc in top_comments[:3]:
|
||||||
excerpt = tc.get("excerpt", tc.get("text", ""))[:200]
|
excerpt = tc.get("excerpt", tc.get("text", ""))[:200]
|
||||||
tc_score = tc.get("score", "")
|
tc_score = tc.get("score", "")
|
||||||
lines.append(f" Top comment ({tc_score} {vote_label}): {excerpt}")
|
attribution = _comment_attribution(item.source, tc.get("author"))
|
||||||
|
lines.append(f" Top comment {attribution} ({tc_score} {vote_label}): {excerpt}")
|
||||||
# Comment insights for Reddit
|
# Comment insights for Reddit
|
||||||
insights = item.metadata.get("comment_insights", [])
|
insights = item.metadata.get("comment_insights", [])
|
||||||
if insights:
|
if insights:
|
||||||
@@ -581,7 +582,9 @@ def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
|
|||||||
excerpt = tc.get("excerpt") or tc.get("text") or ""
|
excerpt = tc.get("excerpt") or tc.get("text") or ""
|
||||||
score = tc.get("score", "")
|
score = tc.get("score", "")
|
||||||
vote_label = _vote_label_for(primary.source) if primary else "upvotes"
|
vote_label = _vote_label_for(primary.source) if primary else "upvotes"
|
||||||
lines.append(f" - Comment ({score} {vote_label}): {_truncate(excerpt.strip(), 240)}")
|
source = primary.source if primary else None
|
||||||
|
attribution = _comment_attribution(source, tc.get("author"))
|
||||||
|
lines.append(f" - {attribution} ({score} {vote_label}): {_truncate(excerpt.strip(), 240)}")
|
||||||
insight = _comment_insight(primary)
|
insight = _comment_insight(primary)
|
||||||
if insight:
|
if insight:
|
||||||
lines.append(f" - Insight: {_truncate(insight, 220)}")
|
lines.append(f" - Insight: {_truncate(insight, 220)}")
|
||||||
@@ -1232,6 +1235,33 @@ def _vote_label_for(source: str) -> str:
|
|||||||
return _TOP_COMMENT_VOTE_LABEL.get(source, "votes")
|
return _TOP_COMMENT_VOTE_LABEL.get(source, "votes")
|
||||||
|
|
||||||
|
|
||||||
|
# Handle prefixes for commenter attribution. Reddit uses `u/`; everyone else
|
||||||
|
# uses `@`. Missing source or unknown platform falls back to plain-text so
|
||||||
|
# we never emit `u/` or `@` with no handle attached.
|
||||||
|
_HANDLE_PREFIX: dict[str, str] = {
|
||||||
|
"reddit": "u/",
|
||||||
|
"tiktok": "@",
|
||||||
|
"youtube": "@",
|
||||||
|
"instagram": "@",
|
||||||
|
"bluesky": "@",
|
||||||
|
"x": "@",
|
||||||
|
"threads": "@",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _comment_attribution(source: str | None, author: str | None) -> str:
|
||||||
|
"""Build the attribution prefix for a top comment line.
|
||||||
|
|
||||||
|
Returns a string like ``u/Cyrisaurus`` or ``@moosanoormahomed`` when an
|
||||||
|
author is captured, or the legacy ``Comment`` marker when the author is
|
||||||
|
missing, empty, deleted, or removed.
|
||||||
|
"""
|
||||||
|
if not author or author in ("[deleted]", "[removed]"):
|
||||||
|
return "Comment"
|
||||||
|
prefix = _HANDLE_PREFIX.get(source or "", "")
|
||||||
|
return f"{prefix}{author}" if prefix else author
|
||||||
|
|
||||||
|
|
||||||
def _top_comments_list(item: schema.SourceItem | None, limit: int = 3, min_score: int | None = None) -> list[dict]:
|
def _top_comments_list(item: schema.SourceItem | None, limit: int = 3, min_score: int | None = None) -> list[dict]:
|
||||||
"""Return up to `limit` top comments with score at or above the source's minimum.
|
"""Return up to `limit` top comments with score at or above the source's minimum.
|
||||||
|
|
||||||
|
|||||||
@@ -658,7 +658,9 @@ def _fetch_post_comments(
|
|||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
user = c.get("user") if isinstance(c.get("user"), dict) else {}
|
user = c.get("user") if isinstance(c.get("user"), dict) else {}
|
||||||
author = user.get("nickname") or user.get("unique_id") or ""
|
# Prefer unique_id (the @handle) over nickname (display name) so
|
||||||
|
# downstream render can cite @handle consistently across platforms.
|
||||||
|
author = user.get("unique_id") or user.get("nickname") or ""
|
||||||
create_time = c.get("create_time")
|
create_time = c.get("create_time")
|
||||||
date_str = ""
|
date_str = ""
|
||||||
if create_time:
|
if create_time:
|
||||||
|
|||||||
+44
-16
@@ -267,31 +267,31 @@ class RenderTopCommentsTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
report = self._make_report_with_comments(top_comments=comments)
|
report = self._make_report_with_comments(top_comments=comments)
|
||||||
text = render.render_compact(report)
|
text = render.render_compact(report)
|
||||||
self.assertIn("Comment (500 upvotes):", text)
|
# Reddit authors render with u/ prefix now.
|
||||||
self.assertIn("Comment (200 upvotes):", text)
|
self.assertIn("u/user1 (500 upvotes):", text)
|
||||||
self.assertIn("Comment (50 upvotes):", text)
|
self.assertIn("u/user2 (200 upvotes):", text)
|
||||||
self.assertNotIn("Comment (8 upvotes):", text)
|
self.assertIn("u/user3 (50 upvotes):", text)
|
||||||
self.assertNotIn("Comment (3 upvotes):", text)
|
self.assertNotIn("u/user4 (8 upvotes):", text)
|
||||||
|
self.assertNotIn("u/user5 (3 upvotes):", text)
|
||||||
|
|
||||||
def test_reddit_1_comment_renders_1(self):
|
def test_reddit_1_comment_renders_1(self):
|
||||||
"""Reddit candidate with 1 comment renders 1."""
|
"""Reddit candidate with 1 comment renders 1."""
|
||||||
comments = [{"score": 100, "excerpt": "Single comment", "author": "user1"}]
|
comments = [{"score": 100, "excerpt": "Single comment", "author": "user1"}]
|
||||||
report = self._make_report_with_comments(top_comments=comments)
|
report = self._make_report_with_comments(top_comments=comments)
|
||||||
text = render.render_compact(report)
|
text = render.render_compact(report)
|
||||||
self.assertIn("Comment (100 upvotes): Single comment", text)
|
self.assertIn("u/user1 (100 upvotes): Single comment", text)
|
||||||
|
|
||||||
def test_reddit_0_comments_no_section(self):
|
def test_reddit_0_comments_no_section(self):
|
||||||
"""Reddit candidate with 0 comments renders no comment section."""
|
"""Reddit candidate with 0 comments renders no comment section."""
|
||||||
report = self._make_report_with_comments(top_comments=[])
|
report = self._make_report_with_comments(top_comments=[])
|
||||||
text = render.render_compact(report)
|
text = render.render_compact(report)
|
||||||
self.assertNotIn("Comment (", text)
|
|
||||||
self.assertNotIn("upvotes)", text)
|
self.assertNotIn("upvotes)", text)
|
||||||
|
|
||||||
def test_non_reddit_no_comments(self):
|
def test_non_reddit_no_comments(self):
|
||||||
"""Non-Reddit candidate doesn't render comments when metadata has none."""
|
"""Non-Reddit candidate doesn't render comments when metadata has none."""
|
||||||
report = self._make_report_with_comments(source="grounding", top_comments=[])
|
report = self._make_report_with_comments(source="grounding", top_comments=[])
|
||||||
text = render.render_compact(report)
|
text = render.render_compact(report)
|
||||||
self.assertNotIn("Comment (", text)
|
self.assertNotIn("upvotes)", text)
|
||||||
self.assertIn("Test cluster", text)
|
self.assertIn("Test cluster", text)
|
||||||
|
|
||||||
def test_all_comments_below_score_10_no_section(self):
|
def test_all_comments_below_score_10_no_section(self):
|
||||||
@@ -303,7 +303,6 @@ class RenderTopCommentsTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
report = self._make_report_with_comments(top_comments=comments)
|
report = self._make_report_with_comments(top_comments=comments)
|
||||||
text = render.render_compact(report)
|
text = render.render_compact(report)
|
||||||
self.assertNotIn("Comment (", text)
|
|
||||||
self.assertNotIn("upvotes)", text)
|
self.assertNotIn("upvotes)", text)
|
||||||
|
|
||||||
def test_youtube_comments_use_likes_label_and_50_threshold(self):
|
def test_youtube_comments_use_likes_label_and_50_threshold(self):
|
||||||
@@ -314,9 +313,38 @@ class RenderTopCommentsTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
report = self._make_report_with_comments(source="youtube", top_comments=comments)
|
report = self._make_report_with_comments(source="youtube", top_comments=comments)
|
||||||
text = render.render_compact(report)
|
text = render.render_compact(report)
|
||||||
self.assertIn("Comment (120 likes): legit fire tutorial", text)
|
# YouTube authors render with @ prefix now.
|
||||||
self.assertIn("Comment (60 likes): saved me hours", text)
|
self.assertIn("@alice (120 likes): legit fire tutorial", text)
|
||||||
self.assertNotIn("Comment (10 likes)", text)
|
self.assertIn("@bob (60 likes): saved me hours", text)
|
||||||
|
self.assertNotIn("@carol (10 likes)", text)
|
||||||
|
|
||||||
|
def test_reddit_comment_without_author_falls_back_to_legacy_label(self):
|
||||||
|
"""When author is missing or [deleted], render falls back to 'Comment (...)'."""
|
||||||
|
comments = [
|
||||||
|
{"score": 500, "excerpt": "No author field", "author": ""},
|
||||||
|
{"score": 200, "excerpt": "Deleted user", "author": "[deleted]"},
|
||||||
|
{"score": 50, "excerpt": "Removed user", "author": "[removed]"},
|
||||||
|
]
|
||||||
|
report = self._make_report_with_comments(top_comments=comments)
|
||||||
|
text = render.render_compact(report)
|
||||||
|
# Legacy format preserved - no u/ prefix leaks with empty/deleted handles.
|
||||||
|
self.assertIn("Comment (500 upvotes): No author field", text)
|
||||||
|
self.assertIn("Comment (200 upvotes): Deleted user", text)
|
||||||
|
self.assertIn("Comment (50 upvotes): Removed user", text)
|
||||||
|
self.assertNotIn("u/ (", text)
|
||||||
|
self.assertNotIn("u/[deleted]", text)
|
||||||
|
self.assertNotIn("u/[removed]", text)
|
||||||
|
|
||||||
|
def test_tiktok_comments_render_with_at_handle(self):
|
||||||
|
"""TikTok source renders @handle attribution on comment lines."""
|
||||||
|
comments = [
|
||||||
|
{"score": 3986, "excerpt": "oh no. who's going to make the same phone every year now..", "author": "moosanoormahomed"},
|
||||||
|
{"score": 925, "excerpt": "This is either going to go so well or so bad", "author": "Muna9e"},
|
||||||
|
]
|
||||||
|
report = self._make_report_with_comments(source="tiktok", top_comments=comments)
|
||||||
|
text = render.render_compact(report)
|
||||||
|
self.assertIn("@moosanoormahomed (3986 likes):", text)
|
||||||
|
self.assertIn("@Muna9e (925 likes):", text)
|
||||||
# Render must not silently label YT as upvotes.
|
# Render must not silently label YT as upvotes.
|
||||||
self.assertNotIn("Comment (120 upvotes)", text)
|
self.assertNotIn("Comment (120 upvotes)", text)
|
||||||
|
|
||||||
@@ -329,10 +357,10 @@ class RenderTopCommentsTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
report = self._make_report_with_comments(source="tiktok", top_comments=comments)
|
report = self._make_report_with_comments(source="tiktok", top_comments=comments)
|
||||||
text = render.render_compact(report)
|
text = render.render_compact(report)
|
||||||
self.assertIn("Comment (2000 likes): this aged well", text)
|
self.assertIn("@a (2000 likes): this aged well", text)
|
||||||
self.assertIn("Comment (600 likes): so real", text)
|
self.assertIn("@b (600 likes): so real", text)
|
||||||
self.assertNotIn("Comment (400 likes)", text)
|
self.assertNotIn("@c (400 likes)", text)
|
||||||
self.assertNotIn("Comment (50 likes)", text)
|
self.assertNotIn("@d (50 likes)", text)
|
||||||
|
|
||||||
|
|
||||||
class RenderBestTakesCompactTests(unittest.TestCase):
|
class RenderBestTakesCompactTests(unittest.TestCase):
|
||||||
|
|||||||
@@ -175,6 +175,44 @@ class TestTikTokEnrichWithComments(unittest.TestCase):
|
|||||||
self.assertEqual("2024-03-01", out[0]["date"])
|
self.assertEqual("2024-03-01", out[0]["date"])
|
||||||
self.assertEqual(3, out[1]["digg_count"])
|
self.assertEqual(3, out[1]["digg_count"])
|
||||||
|
|
||||||
|
def test_fetch_post_comments_prefers_unique_id_over_nickname(self):
|
||||||
|
"""Author prefers unique_id (@handle) over nickname (display name)."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
from lib import tiktok
|
||||||
|
|
||||||
|
fake_sc_response = {
|
||||||
|
"comments": [
|
||||||
|
{"text": "first", "user": {"unique_id": "moosanoormahomed", "nickname": "Moosa Noormahomed"},
|
||||||
|
"digg_count": 3986, "create_time": 1709251200},
|
||||||
|
{"text": "second", "user": {"nickname": "Muna9e"}, # no unique_id, falls back to nickname
|
||||||
|
"digg_count": 925, "create_time": 1709251300},
|
||||||
|
{"text": "third", "user": {}, # neither - empty string
|
||||||
|
"digg_count": 100, "create_time": 1709251400},
|
||||||
|
],
|
||||||
|
"total": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeResp:
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
def json(self):
|
||||||
|
return fake_sc_response
|
||||||
|
|
||||||
|
with patch.object(tiktok, "_requests") as mock_req:
|
||||||
|
mock_req.get.return_value = FakeResp()
|
||||||
|
out = tiktok._fetch_post_comments(
|
||||||
|
"https://www.tiktok.com/@u/video/1",
|
||||||
|
token="k",
|
||||||
|
max_comments=5,
|
||||||
|
)
|
||||||
|
self.assertEqual(3, len(out))
|
||||||
|
# unique_id wins over nickname when both present
|
||||||
|
self.assertEqual("moosanoormahomed", out[0]["author"])
|
||||||
|
# nickname used when unique_id missing
|
||||||
|
self.assertEqual("Muna9e", out[1]["author"])
|
||||||
|
# both missing → empty string, comment still included
|
||||||
|
self.assertEqual("", out[2]["author"])
|
||||||
|
|
||||||
def test_fetch_post_comments_swallows_http_error(self):
|
def test_fetch_post_comments_swallows_http_error(self):
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from lib import tiktok
|
from lib import tiktok
|
||||||
|
|||||||
Reference in New Issue
Block a user