]*>([\s\S]*?)<\/p>/g)].map((x) => x[1]); + return ps.length ? stripTags(ps.join(' ')) : ''; +} + +// Fetch a post's comment tree via the keyless shreddit endpoint. Returns +// [total, comments] in DOM (tree pre-order) across ALL depths — each comment +// carries `depth` so callers can render nesting or flatten. sort=top front-loads +// the highest-scored comments on the first page (so big threads still surface +// their best). No pagination: only the first page of the tree is returned — +// deep "load more" branches are not followed. +async function shredditComments(sub, postId) { + const url = `https://www.reddit.com/svc/shreddit/comments/r/${sub}/t3_${postId}?sort=top`; let html; try { html = await get(url, { timeoutMs: 12000 }); } catch { return [null, []]; } const totalM = html.match(/total-comments="(\d+)"/); @@ -62,23 +93,63 @@ async function shredditComments(sub, postId, max = 3) { let m; while ((m = re.exec(html))) { const attrs = m[1]; - const attr = (k) => (attrs.match(new RegExp(`${k}="([^"]*)"`, 'i')) || [])[1]; - if (Number(attr('depth') || '0') !== 0) continue; // top-level only - const thingId = attr('thingId'); - let body = ''; - if (thingId) { - const bIdx = html.indexOf(`id="${thingId}-post-rtjson-content"`); - if (bIdx !== -1) { - const seg = html.slice(bIdx, bIdx + 2500); - const ps = [...seg.matchAll(/
]*>([\s\S]*?)<\/p>/g)].map((x) => x[1]);
- body = stripTags(ps.join(' ')).slice(0, 280);
- }
- }
+ const attr = (k) => (attrs.match(new RegExp(`\\b${k}="([^"]*)"`, 'i')) || [])[1];
+ const author = attr('author') || null;
+ if (author === '[deleted]' || author === '[removed]') continue;
+ const body = bodyFor(html, attr('thingId'));
+ if (!body) continue;
const score = attr('score');
- comments.push({ author: attr('author') || null, score: score != null ? Number(score) : null, body });
+ comments.push({
+ author,
+ score: score != null && score !== '' ? Number(score) : null,
+ depth: Number(attr('depth') || '0'),
+ body: body.slice(0, 300),
+ });
}
- comments.sort((x, y) => (y.score ?? -1) - (x.score ?? -1));
- return [total, comments.slice(0, max)];
+ return [total, comments];
+}
+
+// Parse a Reddit thread URL into {sub, id, slug}. The slug (5th path segment) is
+// the title in underscore form — a free fallback title, since the comments
+// endpoint carries no post title.
+function extractPostRef(url) {
+ const m = (url || '').match(/\/r\/([^/]+)\/comments\/([A-Za-z0-9]+)(?:\/([^/?#]+))?/);
+ return m ? { sub: m[1], id: m[2], slug: m[3] || '' } : null;
+}
+
+// Best-effort post title + selftext from old.reddit (server-rendered) — the
+// shreddit comments endpoint carries neither, so a full "post + comments" grabs
+// the body here. Defensive: any failure returns {} and the caller falls back to
+// the slug title with no selftext. (Same server-rendered source web_fetch uses.)
+async function fetchRedditPost(sub, id) {
+ let html;
+ try {
+ html = await get(`https://old.reddit.com/r/${sub}/comments/${id}/`, { accept: 'text/html', timeoutMs: 12000 });
+ } catch { return {}; }
+ // Title: old.reddit renders " scan from leaking into comments. Link/image
+ // posts with no body yield ''.
+ let selftext = '';
+ let postIdx = html.indexOf(`thing_t3_${id}`);
+ if (postIdx === -1) postIdx = html.indexOf(`t3_${id}`);
+ const caIdx = html.indexOf('commentarea');
+ if (postIdx !== -1) {
+ const region = html.slice(postIdx, caIdx > postIdx ? caIdx : postIdx + 20000);
+ const ub = region.indexOf('usertext-body');
+ if (ub !== -1) {
+ const win = region.slice(ub, ub + 8000);
+ const ps = [...win.matchAll(/ ]*>([\s\S]*?)<\/p>/g)].map((x) => x[1]);
+ selftext = stripTags(ps.join(' ')).slice(0, 1500);
+ }
+ }
+ return { title, selftext };
}
async function fetchReddit(query, limit, time, withComments) {
@@ -109,7 +180,9 @@ async function fetchReddit(query, limit, time, withComments) {
if (!pid) return;
const [total, comments] = await shredditComments(it.subreddit, pid);
it.num_comments = total;
- if (comments.length) it.comments = comments;
+ // Discovery wants a flavour, not the tree: top 3 by score across all depths.
+ const top = comments.slice().sort((a, b) => (b.score ?? -1) - (a.score ?? -1)).slice(0, 3);
+ if (top.length) it.comments = top;
}));
}
return ranked;
@@ -136,11 +209,45 @@ const SOURCES = { reddit: fetchReddit, hackernews: fetchHackerNews };
async function main() {
const query = (process.env.SOCIAL_QUERY || '').trim();
+ const url = (process.env.SOCIAL_URL || '').trim();
const sources = (process.env.SOCIAL_SOURCES || 'reddit,hackernews').split(',').map((s) => s.trim()).filter(Boolean);
const limit = Math.max(1, Math.min(50, Number(process.env.SOCIAL_LIMIT || '25')));
const time = process.env.SOCIAL_TIME || 'month';
const withComments = ['1', 'true', 'yes'].includes((process.env.SOCIAL_WITH_COMMENTS || '').toLowerCase());
+ // ── URL/thread mode: fetch ONE Reddit post + its full (nested) comment tree ──
+ if (url) {
+ const out = { query: url, counts: {}, errors: {}, results: [] };
+ const ref = extractPostRef(url);
+ if (!ref) {
+ out.errors.reddit = 'unrecognized Reddit thread URL';
+ out.counts.reddit = 0;
+ } else {
+ try {
+ const [post, [total, comments]] = await Promise.all([
+ fetchRedditPost(ref.sub, ref.id),
+ shredditComments(ref.sub, ref.id),
+ ]);
+ const slugTitle = ref.slug ? ref.slug.replace(/[_-]+/g, ' ').trim() : '';
+ out.results.push({
+ platform: 'reddit', type: 'post',
+ title: post.title || slugTitle || '(untitled)',
+ url, author: null, subreddit: ref.sub,
+ score: null, num_comments: total, created: null,
+ selftext: post.selftext || '', snippet: '', relevance: 0,
+ comments: comments.slice(0, 50), // first page, top-sorted; huge threads clamp
+ });
+ out.counts.reddit = 1;
+ } catch (e) {
+ out.errors.reddit = `${e.name}: ${e.message}`;
+ out.counts.reddit = 0;
+ }
+ }
+ console.log('__SOCIAL_JSON__');
+ console.log(JSON.stringify(out));
+ return;
+ }
+
const out = { query, counts: {}, errors: {}, results: [] };
if (!query) { console.log('__SOCIAL_JSON__'); console.log(JSON.stringify({ ...out, error: 'empty query' })); return; }
diff --git a/packages/iclaw-runtime/src/agent/social.ts b/packages/iclaw-runtime/src/agent/social.ts
index e750ef0..8da3154 100644
--- a/packages/iclaw-runtime/src/agent/social.ts
+++ b/packages/iclaw-runtime/src/agent/social.ts
@@ -25,18 +25,21 @@ export const SOCIAL_SEARCH_TOOL = {
function: {
name: 'social_search',
description:
- 'Search social platforms by KEYWORDS and get real posts — free, no API keys. ' +
- 'Sources: reddit (posts + optional top comments), hackernews (stories with points/comments). ' +
- 'Runs in the sandbox. Use with_comments:true to also pull top comments for the top Reddit posts. ' +
- 'Note: Reddit post upvote counts are not available without a paid key (comment counts/scores are).',
+ 'Reddit & HackerNews — free, no API keys, runs in the sandbox. Two modes: ' +
+ '(1) DISCOVERY — pass `query` keywords to find posts; with_comments:true also pulls ' +
+ 'top comments for the top Reddit posts. ' +
+ '(2) THREAD — pass a Reddit post `url` to fetch THAT post plus its FULL, nested comment ' +
+ 'tree (scored, one call). Prefer this over web_fetch for any specific Reddit link. ' +
+ 'Note: Reddit post upvote counts need a paid key (comment counts/scores do not).',
parameters: {
type: 'object',
properties: {
- query: { type: 'string', description: 'Keywords / search terms' },
+ query: { type: 'string', description: 'Discovery keywords / search terms. Provide this OR `url`.' },
+ url: { type: 'string', description: 'A specific Reddit post URL (…/r//comments/ Hi there
Paste your OpenRouter key to unlock:
@@ -807,7 +806,7 @@
padding: 0 var(--space-5) var(--space-5);
}
-/* ───────────── Voice & Ask (OpenRouter) ───────────── */
+/* ───────────── Voice (OpenRouter) ───────────── */
.or-card {
background: var(--surface);
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
@@ -1511,7 +1510,7 @@
const disconnectBtn = document.getElementById('or-disconnect');
if (disconnectBtn) {
disconnectBtn.addEventListener('click', async function () {
- if (!confirm('Disconnect OpenRouter? Voice messages, Ask mode, and Work mode turn off until you add a key again.')) return;
+ if (!confirm('Disconnect OpenRouter? Voice messages and Work mode turn off until you add a key again.')) return;
disconnectBtn.disabled = true;
const orig = disconnectBtn.textContent;
disconnectBtn.textContent = 'Disconnecting…';