Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save AndrejGajdos/9eb53c7b3ab1e3facd5c1c563661932b to your computer and use it in GitHub Desktop.

Select an option

Save AndrejGajdos/9eb53c7b3ab1e3facd5c1c563661932b to your computer and use it in GitHub Desktop.
// ===== Configuration =====
const SUBREDDITS = ['Notion', 'notiontemplates', 'NotionGeeks'];
const USER_AGENT = 'script:nac-lead-digest:v1.0 (by /u/YOUR_REDDIT_USERNAME)';
const CLAUDE_MODEL = 'claude-sonnet-4-6';
const MAX_POSTS_PER_SUB = 100; // per run; plenty for a daily cadence
const FIRST_RUN_LOOKBACK_HOURS = 24;
const PROPS = PropertiesService.getScriptProperties();
function getRedditToken_() {
const clientId = PROPS.getProperty('REDDIT_CLIENT_ID');
const secret = PROPS.getProperty('REDDIT_CLIENT_SECRET');
const res = UrlFetchApp.fetch('https://www.reddit.com/api/v1/access_token', {
method: 'post',
payload: { grant_type: 'client_credentials' },
headers: {
Authorization: 'Basic ' + Utilities.base64Encode(clientId + ':' + secret),
'User-Agent': USER_AGENT,
},
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) {
throw new Error('Reddit token request failed: ' + res.getResponseCode() + ' ' + res.getContentText());
}
return JSON.parse(res.getContentText()).access_token; // valid ~24h; we fetch fresh each run
}
function fetchNewPosts_(subreddit, token, sinceUtcSeconds) {
const url = 'https://oauth.reddit.com/r/' + subreddit + '/new?limit=' + MAX_POSTS_PER_SUB + '&raw_json=1';
const res = UrlFetchApp.fetch(url, {
headers: {
Authorization: 'Bearer ' + token,
'User-Agent': USER_AGENT,
},
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) {
// Log and skip this sub rather than killing the whole run
console.error('Fetch failed for r/' + subreddit + ': ' + res.getResponseCode());
return [];
}
const children = JSON.parse(res.getContentText()).data.children || [];
return children
.map(c => c.data)
.filter(p => p.created_utc > sinceUtcSeconds)
.map(p => ({
id: p.name, // fullname, e.g. "t3_abc123"
subreddit: p.subreddit,
title: p.title,
body: (p.selftext || '').slice(0, 2000), // cap runaway posts
url: 'https://www.reddit.com' + p.permalink,
created_utc: p.created_utc,
}));
}
function getWatermark_() {
const stored = PROPS.getProperty('LAST_SEEN_UTC');
if (stored) return Number(stored);
return Math.floor(Date.now() / 1000) - FIRST_RUN_LOOKBACK_HOURS * 3600;
}
function setWatermark_(posts) {
if (!posts.length) return;
const newest = Math.max.apply(null, posts.map(p => p.created_utc));
const current = Number(PROPS.getProperty('LAST_SEEN_UTC') || 0);
if (newest > current) PROPS.setProperty('LAST_SEEN_UTC', String(newest));
}
const SYSTEM_PROMPT = `You are a lead-qualification analyst for Note API Connector (noteapiconnector.com),
a SaaS that pulls data from any REST API or GraphQL endpoint into Notion databases,
without code. Typical sources: Stripe, Google Analytics 4, Google Search Console, Ahrefs, CoinMarketCap,
Salesforce, Meta Ads, internal/custom APIs.
You will receive a JSON array of new Reddit posts. Classify EVERY post.
Labels:
- "strong": the user explicitly wants external data (an API, service, or dataset) pulled INTO Notion,
or complains about existing tools (Zapier, Make, custom scripts) for doing so.
- "workable": the underlying need is external-data-into-Notion, but the user has not framed it as an
API problem, or it needs positioning to fit.
- "skip": no external data source involved. Notion-internal questions (formulas, templates,
Notion-to-Notion sync), pushing data OUT of Notion only, general productivity chat, memes, support rants.
Rules:
- Judge only from the post text. Do not invent needs the user did not express.
- When genuinely uncertain between workable and skip, choose skip. Precision beats recall;
the human reviews strong/workable posts manually.
- "summary": 1-2 sentences for a daily email digest: what the user wants and why it is (or is not
yet) a fit. For skip, use null.
- "category": a short bucket like "Payments", "SEO/Analytics", "Custom API", "Crypto", "CRM",
"Notion-internal".
Return ONLY a JSON array, one object per input post, same order, no prose, no markdown fences.
Each object: {"id", "relevance", "category", "rationale", "summary"}.`;
function classifyPosts_(posts) {
const apiKey = PROPS.getProperty('ANTHROPIC_API_KEY');
const payload = {
model: CLAUDE_MODEL,
max_tokens: 8000,
system: SYSTEM_PROMPT,
messages: [
{ role: 'user', content: JSON.stringify(posts.map(p => ({
id: p.id, subreddit: p.subreddit, title: p.title, body: p.body,
}))) },
{ role: 'assistant', content: '[' }, // prefill: forces raw JSON array output
],
};
const res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
method: 'post',
contentType: 'application/json',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
payload: JSON.stringify(payload),
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) {
throw new Error('Claude API error: ' + res.getResponseCode() + ' ' + res.getContentText());
}
const data = JSON.parse(res.getContentText());
const text = data.content
.filter(b => b.type === 'text')
.map(b => b.text)
.join('');
let verdicts;
try {
verdicts = JSON.parse('[' + text); // re-attach the prefilled bracket
} catch (e) {
console.error('Unparseable Claude response: ' + text);
throw new Error('Claude returned invalid JSON: ' + e.message);
}
// Join verdicts back to posts by id (never trust ordering blindly)
const byId = {};
verdicts.forEach(v => { byId[v.id] = v; });
return posts.map(p => Object.assign({}, p, byId[p.id] || {
relevance: 'skip', category: 'unclassified', rationale: 'missing from model output', summary: null,
}));
}
function sendDigest_(classified) {
const email = PROPS.getProperty('DIGEST_EMAIL');
const leads = classified.filter(p => p.relevance !== 'skip');
const dateStr = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd');
if (!leads.length) {
// Heartbeat: absence of email must mean breakage, not "no leads"
MailApp.sendEmail(email, '[NAC Leads] ' + dateStr + ' - 0 relevant posts',
'Scanned ' + classified.length + ' new posts across ' + SUBREDDITS.join(', ') + '. Nothing relevant today.');
return;
}
const strong = leads.filter(p => p.relevance === 'strong');
const workable = leads.filter(p => p.relevance === 'workable');
const section = (title, items) => !items.length ? '' :
'<h3>' + title + ' (' + items.length + ')</h3>' +
items.map(p =>
'<p><b><a href="' + p.url + '">' + escapeHtml_(p.title) + '</a></b>' +
' <i>r/' + p.subreddit + ' · ' + escapeHtml_(p.category) + '</i><br>' +
escapeHtml_(p.summary || p.rationale) + '</p>'
).join('');
const html =
'<p>Scanned ' + classified.length + ' new posts. ' + leads.length + ' worth a look.</p>' +
section('Strong', strong) + section('Workable', workable);
MailApp.sendEmail({
to: email,
subject: '[NAC Leads] ' + dateStr + ' - ' + strong.length + ' strong, ' + workable.length + ' workable',
htmlBody: html,
});
}
function escapeHtml_(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function runDailyDigest() {
try {
const since = getWatermark_();
const token = getRedditToken_();
let posts = [];
SUBREDDITS.forEach(sub => {
posts = posts.concat(fetchNewPosts_(sub, token, since));
Utilities.sleep(1100); // ~1 req/sec: nowhere near the limit, but polite
});
if (!posts.length) {
sendDigest_([]);
return;
}
const classified = classifyPosts_(posts);
sendDigest_(classified);
setWatermark_(posts); // advance ONLY after successful send
console.log('Done: ' + posts.length + ' posts, ' +
classified.filter(p => p.relevance !== 'skip').length + ' leads.');
} catch (err) {
const email = PROPS.getProperty('DIGEST_EMAIL');
MailApp.sendEmail(email, '[NAC Leads] FAILED', 'Run failed:\n\n' + err.stack);
throw err; // also surfaces in Apps Script's execution log / failure notifications
}
}
function createTrigger() {
ScriptApp.newTrigger('runDailyDigest')
.timeBased()
.everyDays(1)
.atHour(7) // script timezone; set it in Project Settings
.create();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment