Side B introduces a real, tested feature (Reddit post thumbnails/full-image/link rendering via a new render::reddit module, CSS, and a fixture-backed unit test) that adds lasting UI/domain value, whereas Side A is a narrower bugfix wiring child imports correctly and adjusting integration tests. Side A's fix is legitimate and useful, but Side B's change is more substantial, well-structured (separate module, EntityData extension, tested parsing logic), and delivers durable functionality rather than just a targeted correction.
constitution · epochs · watch · epoch 3
c_cd965c070df3 (tommy-mor) vs c_410d9361a866 (tommy-mor)
download prompt · raw event · cmp_6d58f31671c813
council reasoning
A fixes core import correctness: children attach via apply_entity_under_parent without ensure_path pulling intermediate Reddit comment-path segments, and ranking shows real titles via child_label, with integration coverage for children SSE. B adds lasting Reddit UI (thumbs, image_url/link_url, render module) but is presentation polish on top of that wiring, not a structural bugfix.
Side A fixes a core correctness issue by changing Reddit children imports to attach entities directly under the requested parent without `ensure_path` creating incorrect `/comments/...` hierarchy, and updates the ranking panel to show imported child titles by looking them up in the global tree. It also propagates the new API through UI rendering and adds integration tests covering SSE updates and child fetching, whereas Side B is primarily a feature enhancement adding richer Reddit-specific rendering (thumbnails, images, links, CSS) and parsing fields without addressing a fundamental data-model bug.
sides
A — c_cd965c070df3 (tommy-mor)
message
[993d359c] Fix Reddit children import wiring and unranked child labels. Listing imports attach posts directly under the subreddit without ensure_path pulling comment-path segments in, and the ranking panel shows imported titles. Update integration tests for JS SSE morphs and children fetch. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index d1defd28242fd2ca3b886adc91a7070bef75e653..e649a7d192feade465e19ce6187a829f6ec74372 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -58,7 +58,7 @@ pub async fn post_ui_html(
let tree = state.tree.read().await;
let empty = crate::reducer::NodeState::default();
let node = tree.get(&parent).unwrap_or(&empty);
- let panel = ranking_panel(&parent, node);
+ let panel = ranking_panel(&parent, node, &tree);
JsBuilder::new()
.morph_selector("#ranking-panel", panel)
.into_response()
diff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs
index 0177bb161cea1b72a100b52efdfc5e710271c9eb..35f968c21c69ca557bab7951413e3cfbbccfebfd 100644
--- a/server/src/fetch/mod.rs
+++ b/server/src/fetch/mod.rs
@@ -107,7 +107,7 @@ pub fn fetch_entity_stream(
let mut b = JsBuilder::new()
.morph_selector("#entity-section", html::entity_section(&id, node, false));
if kind == FetchKind::Children {
- b = b.morph_selector("#ranking-panel", ranking_panel(&id, node));
+ b = b.morph_selector("#ranking-panel", ranking_panel(&id, node, &tree));
}
yield Ok(js_event(b.build()));
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 27ce9118c73ec5643e04363ab1a36cf5da6101bf..e88cc43ddc9d8100f7994be6f5960ec4d8f22c55 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -15,7 +15,7 @@ use crate::{
ranking::{
connected_components_from_voted_pairs, ranked_items_subset, RankedItem, MAX_ITERS, TOL,
},
- reducer::NodeState,
+ reducer::{GlobalTree, NodeState},
state::AppState,
ui_action::UI_RPC_FIELD,
};
@@ -182,8 +182,15 @@ fn display_label(id: &ItemId) -> String {
.to_string()
}
+fn child_label(tree: &GlobalTree, id: &ItemId) -> String {
+ tree.get(id)
+ .and_then(|n| n.data.as_ref())
+ .map(|d| d.title.clone())
+ .unwrap_or_else(|| display_label(id))
+}
+
/// Plain (unscored) list of children that have no votes yet.
-fn unranked_list(label: &str, items: &[ItemId]) -> Markup {
+fn unranked_list(label: &str, items: &[ItemId], tree: &GlobalTree) -> Markup {
html! {
@if !items.is_empty() {
h3 class="rank-heading muted small" { (label) }
@@ -191,7 +198,7 @@ fn unranked_list(label: &str, items: &[ItemId]) -> Markup {
@for it in items {
li {
a href=(item_href(it)) {
- strong { (display_label(it)) }
+ strong { (child_label(tree, it)) }
}
}
}
@@ -200,7 +207,7 @@ fn unranked_list(label: &str, items: &[ItemId]) -> Markup {
}
}
-pub fn ranking_panel(item: &ItemId, node: &NodeState) -> Markup {
+pub fn ranking_panel(item: &ItemId, node: &NodeState, tree: &GlobalTree) -> Markup {
let group = &node.local_ranking;
let n = group.idx_to_item.len();
let (comps, _isolates) =
@@ -248,7 +255,7 @@ pub fn ranking_panel(item: &ItemId, node: &NodeState) -> Markup {
@let label = if multi { format!("Ranking group {}", gi + 1) } else { "Ranking".to_string() };
(rank_list(&label, ranked, 1))
}
- (unranked_list("Unranked", &unranked))
+ (unranked_list("Unranked", &unranked, tree))
}
}
}
@@ -297,7 +304,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {
(input_panel("", None))
(breadcrumb_path(&item))
(entity_section(&item, node, false))
- (ranking_panel(&item, node))
+ (ranking_panel(&item, node, &tree))
};
layout("sorter2", body, views)
}
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index a0eb688709478ee0185b953b41a5d26cc354764d..69d979bc7e4a1cb078f70114dc539bdc1986b574 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -300,9 +300,16 @@ async fn reddit_worker(
}
{
let mut tree = tree.write().await;
- apply_entity_import(&mut tree, &child_id, child_payload);
if kind == FetchKind::Children {
- tree.link_child(&fetch_id, &child_id);
+ let view = entity_view_from_payload(&child_id, &child_payload);
+ tree.apply_entity_under_parent(
+ &fetch_id,
+ &child_id,
+ child_payload,
+ view,
+ );
+ } else {
+ apply_entity_import(&mut tree, &child_id, child_payload);
}
}
written += 1;
diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index a36cd5c9287d61536f9f4a3f6b0df2342388857a..4e42d0369dab50bb2f8ca664aa69b628292f6c07 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -209,14 +209,24 @@ impl GlobalTree {
}
}
- /// Directly attach `child` under `parent`, bypassing path-based nesting.
- /// Used for imported listings (e.g. a subreddit's posts) so they show up
- /// as children of the subreddit rather than a deep `…/comments/<id>` path.
- pub fn link_child(&mut self, parent: &ItemId, child: &ItemId) {
+ /// Import entity data for `id` and attach it as a direct child of `parent`
+ /// without running [`Self::ensure_path`] on `id` (avoids Reddit `/comments/`
+ /// parent rules pulling intermediate path segments into the subreddit).
+ pub fn apply_entity_under_parent(
+ &mut self,
+ parent: &ItemId,
+ id: &ItemId,
+ payload: Value,
+ view: Option<EntityData>,
+ ) {
self.ensure_path(parent);
- self.ensure_path(child);
+ self.ensure_node(id);
+ if let Some(node) = self.nodes.get_mut(id) {
+ node.entity_raw = Some(payload);
+ node.data = view;
+ }
if let Some(p) = self.nodes.get_mut(parent) {
- p.children.insert(child.clone());
+ p.children.insert(id.clone());
}
}
}
diff --git a/test/reddit_import.clj b/test/reddit_import.clj
index 84cbdcf7965e50290313cbce2243a16b583d2097..45a2a19f20799d77e84d8aa64735ab5e7e45f97c 100644
--- a/test/reddit_import.clj
+++ b/test/reddit_import.clj
@@ -67,6 +67,36 @@
(do (Thread/sleep 200) (recur))
false)))))
+(defn- run-reddit-fetch-assertions [app-base data-dir]
+ (let [browse-url (str app-base "/~/https://reddit.com/r/rust")
+ log-path (str data-dir "/events.jsonl")
+ before (:out (process/shell {:out :string :err :string}
+ "curl" "-sf" browse-url))]
+ (is (str/includes? before "Fetch from Reddit"))
+ (is (not (str/includes? before "The Rust Programming Language")))
+ (let [sse (curl-fetch-ui-sse app-base "reddit.com/r/rust" "self")]
+ (is (zero? (:exit sse)) "POST /ui fetch_entity (self) SSE succeeds")
+ (is (str/includes? (:out sse) "Idiomorph.morph"))
+ (is (str/includes? (:out sse) "The Rust Programming Language"))
+ (is (wait-event-log log-path 2000) "event log written"))
+ (let [after (:out (process/shell {:out :string :err :string}
+ "curl" "-sf" browse-url))
+ log (slurp (io/file log-path))]
+ (is (str/includes? after "The Rust Programming Language"))
+ (is (str/includes? log "\"type\":\"entity_imported\""))
+ (is (str/includes? log "\"subscribers\":350000"))
+ (is (str/includes? log "\"display_name\":\"rust\"")))
+ (let [children-sse (curl-fetch-ui-sse app-base "reddit.com/r/rust" "children")]
+ (is (zero? (:exit children-sse)) "POST /ui fetch_entity (children) SSE succeeds")
+ (is (str/includes? (:out children-sse) "Idiomorph.morph"))
+ (is (str/includes? (:out children-sse) "Announcing Rust 1.99")))
+ (let [after-children (:out (process/shell {:out :string :err :string}
+ "curl" "-sf" browse-url))
+ log2 (slurp (io/file log-path))]
+ (is (str/includes? after-children "Announcing Rust 1.99"))
+ (is (str/includes? after-children "Unranked"))
+ (is (str/includes? log2 "announcing_rust_199")))))
+
(deftest reddit-fetch-via-mock-api
(testing "Fetch more queues import; event log stores full payload; page shows title"
(let [root (repo-root)
@@ -102,24 +132,7 @@
bin)]
(try
(is (wait-health app-base 20000) "app healthz")
- (let [browse-url (str app-base "/~/https://reddit.com/r/rust")
- before (:out (process/shell {:out :string :err :string}
- "curl" "-sf" browse-url))]
- (is (str/includes? before "Fetch from Reddit"))
- (is (not (str/includes? before "The Rust Programming Language")))
- (let [log-path (str data-dir "/events.jsonl")
- sse (curl-fetch-ui-sse app-base "reddit.com/r/rust")]
- (is (zero? (:exit sse)) "POST /ui fetch_entity SSE succeeds")
- (is (str/includes? (:out sse) "event: complete"))
- (is (str/includes? (:out sse) "The Rust Programming Language"))
- (is (wait-event-log log-path 2000) "event log written")
- (let [after (:out (process/shell {:out :string :err :string}
- "curl" "-sf" browse-url))
- log (slurp (io/file log-path))]
- (is (str/includes? after "The Rust Programming Language"))
- (is (str/includes? log "\"type\":\"entity_imported\""))
- (is (str/includes? log "\"subscribers\":350000"))
- (is (str/includes? log "\"display_name\":\"rust\"")))))
+ (run-reddit-fetch-assertions app-base data-dir)
(finally
(process/destroy proc))))
(finally
diff --git a/test/smoke.clj b/test/smoke.clj
index 11887c48282088e140d823a88ba616f6325835b3..ce9f958c84b9a89ae55e215ab519f1df6435e24b 100644
--- a/test/smoke.clj
+++ b/test/smoke.clj
@@ -49,7 +49,7 @@
(is (wait-health base 15000) "server responds to /healthz")
(let [home (:out (process/shell {:out :string :err :string}
"curl" "-sf" (str base "/")))]
- (is (str/includes? home "vote-panel"))
+ (is (str/includes? home "entity-section"))
(is (str/includes? home "ranking-panel"))
(is (str/includes? home "parser-panel"))
(is (str/includes? home "__rpc__")))
B — c_410d9361a866 (tommy-mor)
message
[78964dd7] nice
diff preview
diff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs
index 9b6c2d0964640a157bca2a6a64cb0820238d8e4f..3e0309ff1c2ac1b17923922d2340ba6710e0f9d1 100644
--- a/server/src/fetch/html.rs
+++ b/server/src/fetch/html.rs
@@ -11,6 +11,9 @@ use crate::{
};
fn entity_panel(node: &NodeState) -> Markup {
+ if let Some(markup) = crate::render::reddit::entity_markup(node) {
+ return markup;
+ }
html! {
@if let Some(data) = &node.data {
div id="entity-panel" class="entity-card" {
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index e88cc43ddc9d8100f7994be6f5960ec4d8f22c55..3bf9fc7e92e50beed24e2c25106a77421038c90b 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -153,16 +153,21 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {
}
}
-fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
+fn rank_list(label: &str, items: &[RankedItem], start_rank: usize, tree: &GlobalTree) -> Markup {
html! {
@if !items.is_empty() {
h3 class="rank-heading muted small" { (label) }
ol class="rank-list" {
@for (i, r) in items.iter().enumerate() {
- li {
+ @let href = item_href(&r.item);
+ li class=(if crate::render::reddit::is_reddit_post(&r.item) { "reddit-post-row" } else { "" }) {
span class="rank-num" { (start_rank + i) ". " }
- a href=(item_href(&r.item)) {
- strong { (display_label(&r.item)) }
+ @if let Some(row) = crate::render::reddit::child_row_markup(tree, &r.item, &href) {
+ (row)
+ } @else {
+ a href=(href) {
+ strong { (display_label(&r.item)) }
+ }
}
span class="muted" {
" — "
@@ -196,9 +201,14 @@ fn unranked_list(label: &str, items: &[ItemId], tree: &GlobalTree) -> Markup {
h3 class="rank-heading muted small" { (label) }
ul class="rank-list unranked" {
@for it in items {
- li {
- a href=(item_href(it)) {
- strong { (child_label(tree, it)) }
+ @let href = item_href(it);
+ li class=(if crate::render::reddit::is_reddit_post(it) { "reddit-post-row" } else { "" }) {
+ @if let Some(row) = crate::render::reddit::child_row_markup(tree, it, &href) {
+ (row)
+ } @else {
+ a href=(href) {
+ strong { (child_label(tree, it)) }
+ }
}
}
}
@@ -253,7 +263,7 @@ pub fn ranking_panel(item: &ItemId, node: &NodeState, tree: &GlobalTree) -> Mark
} @else {
@for (gi, ranked) in ranked_groups.iter().enumerate() {
@let label = if multi { format!("Ranking group {}", gi + 1) } else { "Ranking".to_string() };
- (rank_list(&label, ranked, 1))
+ (rank_list(&label, ranked, 1, tree))
}
(unranked_list("Unranked", &unranked, tree))
}
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 0677363e0ed21d244bfa065f7ec728549ddd9e50..da5f3ebecec1794b05a2a69cc78379551b2ad769 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -8,6 +8,7 @@ pub mod parser;
pub mod path_types;
pub mod ranking;
pub mod reddit;
+pub mod render;
pub mod reducer;
pub mod journal;
pub mod state;
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index 69d979bc7e4a1cb078f70114dc539bdc1986b574..1168ec2afc77c092514eec91b513bac301cbe225 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -608,6 +608,8 @@ fn parse_subreddit_about(v: &Value) -> Option<crate::reducer::EntityData> {
author: None,
body_html,
thumb_url,
+ image_url: None,
+ link_url: None,
})
}
@@ -635,15 +637,64 @@ fn parse_post_listing(v: &Value) -> Option<crate::reducer::EntityData> {
.and_then(|t| t.as_str())
.filter(|s| s.starts_with("http"))
.map(|s| s.to_string());
+ let image_url = reddit_post_image_url(child);
+ let link_url = reddit_post_link_url(child);
Some(crate::reducer::EntityData {
title,
author,
body_html,
thumb_url,
+ image_url,
+ link_url,
})
}
+fn reddit_post_link_url(data: &Value) -> Option<String> {
+ for key in ["url_overridden_by_dest", "url"] {
+ if let Some(u) = data.get(key).and_then(|v| v.as_str()) {
+ if u.starts_with("http") {
+ return Some(u.to_string());
+ }
+ }
+ }
+ None
+}
+
+/// Full-size still for post detail: direct image `url`, else Reddit preview source.
+fn reddit_post_image_url(data: &Value) -> Option<String> {
+ for key in ["url", "url_overridden_by_dest"] {
+ if let Some(u) = data.get(key).and_then(|v| v.as_str()) {
+ if reddit_direct_image_url(u) {
+ return Some(u.to_string());
+ }
+ }
+ }
+ reddit_preview_source_url(data)
+}
+
+fn reddit_preview_source_url(data: &Value) -> Option<String> {
+ data.pointer("/preview/images/0/source/url")
+ .and_then(|v| v.as_str())
+ .filter(|s| s.starts_with("http"))
+ .map(str::to_string)
+}
+
+fn reddit_direct_image_url(url: &str) -> bool {
+ let u = url.to_ascii_lowercase();
+ if u.contains("redgifs.com") {
+ return false;
+ }
+ u.contains("i.redd.it")
+ || u.contains("preview.redd.it")
+ || u.contains("external-preview.redd.it")
+ || u.ends_with(".jpg")
+ || u.ends_with(".jpeg")
+ || u.ends_with(".png")
+ || u.ends_with(".gif")
+ || u.ends_with(".webp")
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -672,4 +723,20 @@ mod tests {
.unwrap();
assert_eq!(entity.title, "The Rust Programming Language");
}
+
+ #[test]
+ fn parse_post_listing_extracts_thumb_and_full_preview() {
+ let json = include_str!("../../test/fixtures/reddit/post_preview.json");
+ let v: Value = serde_json::from_str(json).unwrap();
+ let id = ItemId::parse("reddit.com/r/nsfw/comments/1tpy6a1/angel_eyes").unwrap();
+ let entity = entity_view_from_payload(&id, &v).unwrap();
+ assert_eq!(entity.title, "Angel Eyes");
+ assert!(entity.thumb_url.as_ref().unwrap().contains("width=140"));
+ assert!(entity.image_url.as_ref().unwrap().contains("auto=webp"));
+ assert!(!entity.image_url.as_ref().unwrap().contains("redgifs"));
+ assert_eq!(
+ entity.link_url.as_deref(),
+ Some("http://v3.redgifs.com/watch/impossibleprestigioushedgehog")
+ );
+ }
}
diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index 4e42d0369dab50bb2f8ca664aa69b628292f6c07..336e78b77d3ab59361b89af5c9868e13da4ac962 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -122,7 +122,12 @@ pub struct EntityData {
pub title: String,
pub author: Option<String>,
pub body_html: Option<String>,
+ /// Small preview (subreddit listing / child rows).
pub thumb_url: Option<String>,
+ /// Full-size still image for the post detail view.
+ pub image_url: Option<String>,
+ /// Outbound link for link/video posts (`url` / `url_overridden_by_dest`).
+ pub link_url: Option<String>,
}
/// One node in the fractal tree: entity + ranked children.
diff --git a/server/src/render/mod.rs b/server/src/render/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..c2c41404abe01c671139658fcccdeced5be388e4
--- /dev/null
+++ b/server/src/render/mod.rs
@@ -0,0 +1,3 @@
+//! Domain-specific HTML fragments for imported entities.
+
+pub mod reddit;
diff --git a/server/src/render/reddit.rs b/server/src/render/reddit.rs
new file mode 100644
index 0000000000000000000000000000000000000000..4a18de93cf57d8395ded3caa373a708f39b3f43f
--- /dev/null
+++ b/server/src/render/reddit.rs
@@ -0,0 +1,64 @@
+//! Reddit post cards: thumbnail in child lists, full image on the post page.
+
+use maud::{html, Markup};
+
+use crate::{
+ path_types::ItemId,
+ reducer::{EntityData, GlobalTree, NodeState},
+};
+
+pub fn is_reddit_post(id: &ItemId) -> bool {
+ id.as_str().starts_with("reddit.com/") && id.as_str().contains("/comments/")
+}
+
+/// Post detail card (`#entity-panel`).
+pub fn entity_markup(node: &NodeState) -> Option<Markup> {
+ if !is_reddit_post(&node.id) {
+ return None;
+ }
+ let data = node.data.as_ref()?;
+ Some(post_entity_card(data))
+}
+
+/// One row in a parent ranking list (thumbnail + title).
+pub fn child_row_markup(tree: &GlobalTree, id: &ItemId, href: &str) -> Option<Markup> {
+ if !is_reddit_post(id) {
+ return None;
+ }
+ let data = tree.get(id)?.data.as_ref()?;
+ Some(html! {
+ @if let Some(thumb) = &data.thumb_url {
+ a class="reddit-post-thumb-link" href=(href) {
+ img class="reddit-post-thumb" src=(thumb) alt="" loading="lazy";
+ }
+ }
+ a href=(href) {
+ strong { (data.title) }
+ }
+ })
+}
+
+fn post_entity_card(data: &EntityData) -> Markup {
+ let image = data.image_url.as_ref().or(data.thumb_url.as_ref());
+ html! {
+ div id="entity-panel" class="entity-card reddit-post" {
+ h2 { (data.title) }
+ @if let Some(author) = &data.author {
+ p class="muted small" { "by " (author) }
+ }
+ @if let Some(url) = &data.link_url {
+ p class="reddit-post-url muted small" {
+ a href=(url) rel="noopener noreferrer" { (url) }
+ }
+ }
+ @if let Some(src) = image {
+ figure class="reddit-post-figure" {
+ img class="reddit-post-image" src=(src) alt="" loading="lazy";
+ }
+ }
+ @if let Some(body) = &data.body_html {
+ div class="entity-body" { (maud::PreEscaped(body)) }
+ }
+ }
+ }
+}
diff --git a/server/static/sorter.css b/server/static/sorter.css
index 1f9f5158414902d43a380dd899232c70d8cf5d63..021918f8e778b84e4b3eac8559fab7d223a3d490 100644
--- a/server/static/sorter.css
+++ b/server/static/sorter.css
@@ -171,6 +171,41 @@ code {
margin-top: 0;
}
+.reddit-post-row {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+}
+
+.reddit-post-thumb-link {
+ flex-shrink: 0;
+ line-height: 0;
+}
+
+.reddit-post-thumb {
+ width: 64px;
+ height: 64px;
+ object-fit: cover;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+}
+
+.reddit-post-url a {
+ word-break: break-all;
+}
+
+.reddit-post-figure {
+ margin: 0.75rem 0;
+}
+
+.reddit-post-image {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+}
+
.scope-name {
color: var(--accent);
font-weight: 600;
diff --git a/test/fixtures/reddit/post_preview.json b/test/fixtures/reddit/post_preview.json
new file mode 100644
index 0000000000000000000000000000000000000000..c6ed31ae397dc0f7b6e07e5e010e617df78d9c18
--- /dev/null
+++ b/test/fixtures/reddit/post_preview.json
@@ -0,0 +1,22 @@
+{
+ "kind": "t3",
+ "data": {
+ "title": "Angel Eyes",
+ "permalink": "/r/nsfw/comments/1tpy6a1/angel_eyes/",
+ "author": "alice",
+ "thumbnail": "https://external-preview.redd.it/Kb6Nf5Q4RAucat-2RFcYMeQb2d6vgYNo5EPlf6kGLbs.jpeg?width=140&height=140&auto=webp&s=d7238240ebf191b954ef5f42b0cf61b67aecec88",
+ "url": "http://v3.red
… preview truncated; 410 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.