Created
July 5, 2026 13:31
-
-
Save AndrejGajdos/9eb53c7b3ab1e3facd5c1c563661932b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ===== 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, '&').replace(/</g, '<').replace(/>/g, '>'); | |
| } | |
| 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