Reddit threads have something most AI training datasets lack: a built-in human approval score (upvotes and downvotes) from niche communities. Instead of scraping random web chatter, you're getting feedback from subreddits filled with people who actively care about the topic.
A comment with 4,000 upvotes versus one with two gives you a strong signal on what resonates most with particular audiences, and Reddit attaches that number to every opinion shared.
While Reddit is arguably the internet’s largest archive of candid human opinions, extracting its data has never been more challenging. Reddit recently shut down unauthenticated .json endpoints in May 2026, severing the easiest routes for bulk data extraction.
This guide solves that problem using Apify. You'll learn how to extract posts and comments with upvote counts intact, categorize reaction types using OpenAI, and store the enriched data with embeddings in Supabase for fine-tuning or retrieval.
Extract Reddit posts, comments, and vote counts with Apify
Reddit Scraper is a ready-made Apify Actor that reads Reddit's public pages directly, so it doesn't depend on the endpoints Reddit retired. Every run returns:
- Post titles, body text, subreddit, and flair
- Score and upvote ratio on every post
- Full comment trees, each comment carrying its own score
- Authors, permalinks, and timestamps
With Reddit Scraper, you can:
- Pull posts from any subreddit, sorted by hot, new, top, or rising
- Search across all of Reddit or inside a single community
- Pull the full comment tree for a specific post
- Run it from Apify Console, on a schedule, or from n8n, which is what you'll do here
Pricing starts at $1.20 per 1,000 results.
Workflow summary

Reddit Scraper is scheduled to extract posts and comments from Reddit, along with their respective vote counts. From there, the pipeline splits into two distinct workflows: the post branch stores and embeds the original post as the primary context, while the comment branch classifies each opinion, logs a unique signal score, and generates its embedding, before joining it back to the parent post via its ID. The stored posts and comments serve as the core training dataset and can be easily exported in JSONL or Parquet formats for LLM fine-tuning or training.
Prerequisites
- Apify account.
- Self-hosted n8n instance via Docker (skip if you prefer n8n’s cloud tier).
- OpenAI account with an API key (used to generate embeddings and for data classification).
- Supabase account.
Phase 1: Accounts setup
Step #1: Grab your Apify API token
- Sign up at apify.com if you don’t have an account.
- Open Apify Console.
- In the left sidebar, click Settings, then open the API & Integrations tab.
- Under "Personal API tokens," copy your default token, or click Add new token to create one and name it.
- Paste it somewhere safe for a moment.
This token lets n8n run the scrapers on your behalf. Keep it private, and revoke or regenerate it from this same screen if it ever leaks.

Step #2: Get your OpenAI API key
- Go to platform.openai.com and sign in or create an account.
- Open the API keys section, click Create new secret key, name it, and copy the key as soon as it appears.
- Paste it somewhere safe.
- Open Billing under Settings, add a payment method, and buy a small amount of credit; $5 is plenty for testing.
You’ll use this key in two separate n8n nodes: one to label each comment, the other to generate embeddings. Copy it and paste it somewhere safe.

Step #3: Create the Supabase project and keys
- Sign in at supabase.com and click New project.
- Create an organization, name the project, input a database password, choose the region nearest you, then click Create new project.
- In the left sidebar, click the gear to open Project Settings, then click Data API.
- On the Data API page, copy the Project URL.
- Under API keys, find the service_role key, click Reveal, and copy it.
The service_role key can write to your tables, which is what the pipeline needs. Keep it server-side inside n8n and never put it in client code, since it bypasses row-level security.

Step #4: Create the database tables
- In the left sidebar, click the SQL Editor icon, then click New query.
- Paste the first block below into the editor, then click Run. You should see "Success. No rows returned."
create extension if not exists vector;
create table documents (
id bigserial primary key,
content text,
metadata jsonb,
embedding vector(1536)
);
create function match_documents (
query_embedding vector(1536),
match_count int default null,
filter jsonb default '{}'
) returns table (
id bigint,
content text,
metadata jsonb,
similarity float
)
language plpgsql
as $$
begin
return query
select
documents.id,
documents.content,
documents.metadata,
1 - (documents.embedding <=> query_embedding) as similarity
from documents
where documents.metadata @> filter
order by documents.embedding <=> query_embedding
limit match_count;
end;
$$;
- Open a new query tab, paste this second block, and click Run again to create the relational tables.
create table posts (
post_id text primary key,
subreddit text,
title text,
self_text text,
score bigint,
upvote_ratio numeric,
num_comments int,
link_flair text,
domain text,
author text,
url text,
permalink text,
context_text text,
created_at timestamptz,
scraped_at timestamptz,
source_sort text
);
create table comments (
comment_id text primary key,
post_id text,
subreddit text,
author text,
text text,
score bigint,
reaction_type text,
permalink text,
created_at timestamptz,
scraped_at timestamptz,
source_sort text
);
create index on comments (post_id);
- Confirm the tables exist by opening the Table Editor in the left sidebar; you should see
documents,posts, andcommentslisted.
The posts table stores the context, while the comments table captures the reactions by logging the type of opinion (reaction_type) and using the vote score to measure how deeply it resonated. The documents table handles the embeddings for AI retrieval. Because everything is linked by a shared post_id, you can easily take any post and instantly pull up every community reaction it sparked.

Phase 2: Install and set up n8n
Self-hosting n8n lets you build and run unlimited workflows for free, with full control over your data privacy. But, if you don’t want to manage your own infrastructure, the n8n cloud tier works fine.
Step #1: Install Docker Desktop
- Download and install Docker Desktop for your OS.
- Confirm it's running by opening your terminal and running:
docker --version
- You should see a version number. If you see "command not found," Docker isn't in your PATH yet, so restart your terminal or reboot.
- Afterward, confirm the engine is running by typing:
docker ps

Step #2: Run n8n in Docker
- Open your terminal.
- Run the command below to pull and start n8n:
docker run -d --name n8n -p 5678:5678 -v n8n_data:/home/node/.n8n n8nio/n8n
- The first time you run this command, Docker will pause to pull the official n8n image from the internet. After downloading, it will assign and print your Container ID.
- Open a browser and go to
http://localhost:5678. - You'll see n8n's initial setup screen. Create an owner account with your email and a password.

Step #3: Install the Apify community node
n8n doesn't feature the Apify node natively. You’ll need to install it as an official community node before you can use it. Here’s how to do that:
- In your n8n workspace, click Settings at the bottom of the left sidebar, then navigate to Community Nodes and click “Install”.
- In the npm package field, paste exactly this:
@apify/n8n-nodes-apify - Check the risk acknowledgment box and click Install.

Phase 3: Build the pipeline
The Actor feeds two branches, both of which read its output: a post branch that writes context (post content and metadata), and a comment branch that writes opinions and their scores. Build the post branch first, then come back to the scraper and start the comment branch from the same output.
Step #1: Schedule Trigger node
- Create a new workflow by clicking Add workflow in the top left.
- On the empty canvas, click the large + in the center or the "Add first step" option.
- In the search panel, type
Schedule Triggerand select it to place it on the canvas. - Set Trigger Interval to Weeks
- Set Weeks Between Triggers to
1and Trigger on Weekdays to Monday.

Step #2: Define dataset inputs
- On the Schedule Trigger node, click the + on its right-hand output dot.
- In the search panel, type
Edit Fieldsand select the Edit Fields (Set) node. - Open the node and click its title to rename it to
Define Dataset Inputs. - Leave Mode on "Manual Mapping".
- Click Add Field once for each input below. For each one, type the name in the left box, open the Type dropdown on the right to select the shown type, and enter the value in the value box.
subreddits, type String, valueAskReddit: 40, changemyview: 40, technology: 40, movies: 40.sort, type String, valuetop.timeFilter, type String, valueweek.minScore, type Number, value50.maxCommentsPerPost, type Number, value100.
Keeping all your inputs in a single node makes it easier to update your settings. For example, the subreddits field uses a simple name:count format, allowing you to pull more posts from massive communities and fewer from niche ones. If you omit the count, it defaults to 100.

Step #3: Build subreddit list
- On the Define dataset inputs node, click the + on its output dot.
- Search
Codeand select the Code node. Rename it toBuild subreddit list. - Set Mode to "Run Once for All Items" and Language to "JavaScript".
- Delete the sample code and paste the code below.
const plan = $('Define Dataset Inputs').first().json.subreddits || '';
return plan
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const [name, count] = entry.split(':').map((s) => s.trim());
return {
json: {
subreddit: name,
maxResults: Number(count) || 100,
},
};
});
This converts the single subreddits string into one item per community, each carrying its own maxResults. It matters because, without it, the Actor spends its entire result budget on the first subreddit before moving to the next, so pointing it at four communities at once would exhaust the first and never reach the others. By emitting a separate item per subreddit, you run the scraper once for each subreddit.

Step #4: Reddit scraper (Apify) node
- On the Build subreddit list node, click the + on its output dot.
- Search
Apify, select the Run an Actor and get dataset operation, and rename it toReddit scraper. - Click the Credential to open the dropdown, then choose + Create new credential.
- Paste your Apify API token into the API token field, click Save, then close.
- Confirm Resource is "Actor" and Operation is "Run an Actor and Get Dataset".
- Under Actor, choose "From list" then type and select
labrat011/reddit-scraper - Scroll to the Input field for the Actor, set it to Expression, and paste this JSON:
{
"mode": "subreddit_posts",
"subreddits": [{{ JSON.stringify($json.subreddit) }}],
"sort": "{{ $('Define Dataset Inputs').first().json.sort }}",
"timeFilter": "{{ $('Define Dataset Inputs').first().json.timeFilter }}",
"maxResults": {{ $json.maxResults }},
"includeComments": true,
"maxCommentsPerPost": {{ $('Define Dataset Inputs').first().json.maxCommentsPerPost || 100 }},
"proxyConfiguration": { "useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"] }
}
This node runs once per community. It uses {{ $json.subreddit }} and {{ $json.maxResults }} to read the current subreddit and its target post count, while referencing the inputs node for global settings like sort and comment depth.

Step #5: Code node for post rows
- On the Reddit scraper node, click the + on its output dot.
- Search
Codeand select the Code node. Rename it toBuild post rows. - Set Mode to "Run Once for All Items" and Language to "JavaScript".
- Delete the sample code and paste the code below.
const items = $input.all();
const inputs = $('Define Dataset Inputs').first().json || {};
const minScore = Number(inputs.minScore) || 0;
const sortTag = inputs.sort || '';
return items
.filter((item) => item.json.type === 'post' && (Number(item.json.score) || 0) >= minScore)
.map((item) => {
const p = item.json;
const title = p.title || '';
const selfText = p.selftext || '';
const subreddit = p.subreddit || '';
const contextText = [
`Subreddit: r/${subreddit}`,
`Title: ${title}`,
selfText ? `Body: ${selfText}` : '',
].filter(Boolean).join('\n\n');
return {
json: {
post_id: p.id,
subreddit,
title,
self_text: selfText,
score: Number(p.score) || 0,
upvote_ratio: Number(p.upvoteRatio) || 0,
num_comments: Number(p.numComments) || 0,
link_flair: p.flair || '',
domain: p.domain || '',
author: p.author || '',
url: p.url || '',
permalink: p.url || '',
context_text: contextText,
created_at: p.created || null,
scraped_at: new Date().toISOString(),
source_sort: sortTag,
},
};
});
- Click Execute step once, with the scraper already run, to confirm the output shows post rows.
This step prepares your posts for the database. It filters out everything except post records that meet your minScore, and translates the raw fields into snake_case (like selftext to self_text). This exact matching allows the next node to auto-map the columns easily.

Step #6: Create a row (Supabase) node for posts
- On the Build post rows node, click the + on its output dot.
- Search
Supabaseand select the Create a row operation. Rename it toInsert post row. - Click Credential to connect with, choose Create new credential, paste your Supabase Project URL into the Host field and your service_role key into the Service Role Secret field, click Save, and close the dialog.
- Note that the host is the bare project URL,
https://YOUR-REF.supabase.co, with no/rest/v1/path on the end. - Set Table to
posts. - Set the Mapping Column Mode to "Map Automatically".
- Open the Settings tab, set On Error to "Continue", and leave Retry On Fail off.
This step writes a single context row for each post. Because your incoming data already carries every necessary column, including the newly created context_text and source_sort, you can rely entirely on auto-map to handle the insert without manual configuration.

Step #7: Supabase Vector Store node for post context
- On the Insert post row node, click the + on its output dot.
- Search
Supabase Vector Storeand select it. Rename it toEmbed post context. - Set Operation Mode to "Insert Documents".
- Under Credential, reuse your Supabase credential.
- Set Table Name to
documents. - This node has two connection points beneath it: Embedding and Document. On the Embedding point, click the +, add
Embeddings OpenAI, set its Model totext-embedding-3-small, and pick your OpenAI credential. - On the Document point, click the +, add
Default Data Loader. Set Type of Data to "JSON" and Mode to "Load Specific Data". In the data field, switch it to Expression and enter{{ $('Build post rows').all()[$itemIndex].json.context_text }}. - Under the loader's Metadata, click Add Property three times, switching each value to Expression and input the following accordingly:
source_typeset topost,post_idset to{{ $('Build post rows').all()[$itemIndex].json.post_id }}, andsubredditset to{{ $('Build post rows').all()[$itemIndex].json.subreddit }}.
This step embeds the post context, storing the content as searchable vectors tagged with source_type: post. Pay close attention to the syntax: you must use .all()[$itemIndex] instead of the usual .item. The previous insert skips duplicates, so fetching by index is required to grab the right row.

Step #8: Code node for comment rows
- Scroll back to the Reddit scraper node and click the + on its output dot a second time. A node output can feed more than one path, so this starts the parallel comment branch.
- Search
Codeand select the Code node. Rename it toBuild comment rows. - Set Mode to "Run Once for All Items" and Language to "JavaScript".
- Delete the sample code and paste this:
const items = $input.all();
return items
.filter((item) => item.json.type === 'comment')
.map((item) => {
const c = item.json;
return {
json: {
comment_id: c.id,
post_id: c.postId || null,
subreddit: c.subreddit || '',
author: c.author || '',
text: c.body || '',
score: Number(c.score) || 0,
permalink: c.url || '',
created_at: c.created || null,
scraped_at: new Date().toISOString(),
},
};
})
.filter((row) => {
const t = (row.json.text || '').trim();
return t !== '' && t !== '[deleted]' && t !== '[removed]';
});
This branch processes only the comment records. Since your Actor automatically attaches a postId to each comment, they link cleanly back to their parent posts without any permalink parsing. The opinion itself is pulled from the body field, while the score acts as your resonance signal.

Step #9: OpenAI node to classify the reaction
- On the Build comment rows node, click the + on its output dot.
- Search
OpenAIand, under the OpenAI node's actions, select Message a Model. Rename it toClassify reaction type. - Under Credential to connect with, reuse your OpenAI credential.
- Open the Model dropdown and pick
gpt-4.1-mini. - Under Messages, click Add Message. Set its Role to "System" and paste the prompt below into the Content box.
You are labeling Reddit comments for a machine learning dataset about how people react to and endorse opinions. You will be given a single comment. Classify the nature of the opinion or reaction it expresses into exactly one category from this list: "agreement", "disagreement", "insight", "humor", "personal experience", "critique", "question", "other". Judge only the comment text. Return a single JSON object with two keys: "reaction_type" (one of the categories) and "reasoning" (a short phrase under 10 words). Return only the JSON object.
- Click Add Message again. Set its Role to "User", switch its Content box to Expression, and enter
{{ $('Build comment rows').item.json.text }}. - Leave Simplify Output on; the prompt already forces JSON. If your version shows a Response Format option, set it to "JSON".
This step labels each opinion, while shielding the vote score from the model and feeding it only the text, ensuring that each label evaluates the opinion purely on its own terms.

Step #10: Assemble the comment row and insert it
Because the classifier replaces the current item with the model's output, your original comment fields are temporarily lost, and the new reaction label is buried in the response. This node fixes that by rebuilding a new comment row by reaching back to the 'Build comment rows' step to retrieve the original text and scores, extracts the label from the classifier, and appends your global sort preference.
- On the Classify reaction type node, click the + on its output dot.
- Search
Codeand select the Code node. Rename it toBuild comment insert row. - Set Mode to "Run Once for All Items" and Language to "JavaScript".
- Delete the sample code and paste the code below.
const comments = $('Build comment rows').all();
const sortInputs = $('Define Dataset Inputs').first().json || {};
const sort = sortInputs.sort || '';
// Dig the reaction label out of whatever shape the classifier returned, then parse it.
const readReaction = (j) => {
const text =
j?.output?.[0]?.content?.[0]?.text ??
j?.content?.[0]?.text ??
j?.content ??
j?.text ??
(typeof j?.message?.content === 'string' ? j.message.content : null);
if (!text || typeof text !== 'string') return null;
try {
return JSON.parse(text).reaction_type ?? null;
} catch {
const m = text.match(/"reaction_type"\s*:\s*"([^"]+)"/);
return m ? m[1] : null;
}
};
return $input.all().map((item, i) => {
const c = comments[i].json;
return {
json: {
comment_id: c.comment_id,
post_id: c.post_id,
subreddit: c.subreddit,
author: c.author,
text: c.text,
score: c.score,
reaction_type: readReaction(item.json),
permalink: c.permalink,
created_at: c.created_at,
scraped_at: c.scraped_at,
source_sort: sort,
},
};
});

- On the Build comment insert row node, click the + on its output dot.
- Search
Supabaseand select the Create a row operation. Rename it toInsert comment row. - Under Credential to connect with, reuse your Supabase credential.
- Set Table to
comments. - Set the Mapping Column Mode to "Map Automatically".
- Open the Settings tab, set On Error to "Continue", and leave Retry On Fail off, so a comment already stored gets skipped on its duplicate primary key instead of failing the run.

Step #11: Supabase Vector Store node for comments
- On the Insert comment row node, click the + on its output dot.
- Search
Supabase Vector Storeand select it. Rename it toEmbed comment. - Set Operation Mode to "Insert Documents".
- Under Credential, reuse your Supabase credential.
- Set Table Name to
documents. - On the Embedding point, add
Embeddings OpenAIwith modeltext-embedding-3-smalland your OpenAI credential. - On the Document point, add
Default Data Loader. Set Type of Data to "JSON" and Mode to "Load Specific Data", switch the data field to Expression, and enter{{ $('Build comment rows').all()[$itemIndex].json.text }}. - Under Metadata, add four properties, switching to Expression where needed:
source_typeset tocomment,comment_idset to{{ $('Build comment rows').all()[$itemIndex].json.comment_id }},post_idset to{{ $('Build comment rows').all()[$itemIndex].json.post_id }}, andscoreset to{{ $('Build comment rows').all()[$itemIndex].json.score }}.
This node embeds each opinion to make the comments searchable, saving the score directly into the metadata so you can easily filter and rank by resonance. Keep an eye on the syntax here, just like the post embedder, you must use .all()[$itemIndex] instead of .item .

Phase 4: Test and publish
Step #1: Execute the workflow
- Click Execute workflow at the bottom center of the canvas to run the whole pipeline once.
- Watch the nodes turn green in sequence.
Step #2: Check the tables in Supabase
- In Supabase, open the Table Editor in the left sidebar.
- Open
posts. You should see one context row per post, each with a realscore. - Open
comments. You should see the opinions, each with itsscore, areaction_typelabel, and apost_idthat matches a row inposts. - Open
documents. You should see the post and comment rows, distinguishable bysource_typein the metadata.

Step #3: Confirm the relationship with a join query
- Open the SQL Editor, click New query, paste the query below, and click Run.
select p.title, c.text, c.score, c.reaction_type
from comments c
join posts p on p.post_id = c.post_id
order by c.score desc
limit 20;
This script shows the relationship the dataset captures: each row pairs a post's content with one opinion about it, that opinion's reaction type, and how strongly the crowd endorsed it.

The workflow results
Understanding your database schema is critical for utilizing this dataset effectively. Here is a complete breakdown of what each column represents across the three tables:
1. posts
Stores the original context of every scraped post:
| Column | Description |
|---|---|
post_id |
The post's unique ID, which serves as the master key that other tables link back to. |
subreddit |
The community where the post originated. |
title |
The post's headline. |
self_text |
The post's body text (this will often be empty for link or title-only posts). |
score |
The post's net upvotes. |
upvote_ratio |
The share of total votes that were upvotes, expressed as a decimal from 0 to 1. |
num_comments |
The total number of replies the post received. |
link_flair |
The post's category tag, if applicable. |
domain |
The source of the link (e.g., self.AskReddit for text posts). |
author |
The original poster's username. |
url |
The direct link to the post's content. |
permalink |
The relative Reddit URL for the thread. |
context_text |
A single text block bundling the subreddit, title, and body. This is the exact string that gets vectorized for embeddings. |
created_at |
The timestamp of when the post was originally published. |
scraped_at |
The timestamp of when you captured the data. |
source_sort |
The specific filter used during extraction (e.g., top, new). |
2. comments
Captures human reactions and tabulates their resonance strength relative to the post's context:
| Column | Description |
|---|---|
comment_id |
The comment's unique ID and primary key. |
post_id |
The foreign key linking this opinion back to its parent post in the posts table. |
subreddit |
The community where the comment was made. |
author |
The commenter's username. |
text |
The actual words of the voiced opinion. |
score |
The comment's net upvotes. This is your core resonance signal. |
reaction_type |
The AI-assigned label categorizing the nature of the opinion (e.g., agreement, disagreement, humor). |
permalink |
The direct Reddit link to the comment. |
created_at |
The timestamp of when the comment was originally published. |
scraped_at |
The timestamp of when you captured the data. |
source_sort |
The specific filter used during extraction. |
3. documents
Functions as a standard vector-store table with a fixed schema:
| Column | Description |
|---|---|
id |
An auto-incrementing row number. |
content |
The raw text that was embedded (either a post's context_text or a comment's text). |
metadata |
JSON storing your tags. For posts, this includes the source_type, post_id, and subreddit. For comments, it includes the source_type, comment_id, post_id, and score. |
embedding |
The numerical vector representation of the content used for semantic search. |
The columns in your posts and comments tables directly mirror the outputs from your Build post rows and Build comment rows Code nodes in n8n. If you ever add or rename a field within those nodes, you must manually update your database tables to reflect the changes. The documents table, by contrast, is strictly controlled by the vector store node and should never be altered.
Summary
You now have a dataset where every opinion carries a real number for how hard a crowd endorsed it. Posts, comments, and scores are linked through post_id, ready for classifiers, retrieval, or resonance modeling.
Accessing this level of context would be nearly impossible without the Reddit Actor used in this build.
Apify Store offers one of the largest catalogs of scraping and automation tools on the web, with 50,000+ ready-made Actors designed to extract data from virtually any site with proxies, anti-bot handling, and infrastructure maintained for you.
Start with a free Apify account and $5 in platform credits every month.
FAQ
Can I see how many downvotes a comment got?
No. Reddit only exposes the net score (upvotes minus downvotes), so a comment sitting at 12 could have been 12 up and 0 down, or 40 up and 28 down. The split isn't published; this is a Reddit limitation, not a scraper limitation.
How do I estimate downvotes?
You can, but only on posts, not comments. Posts carry an upvote_ratio alongside the score, so you can work backward: Total Votes = Net Score / (2 × Upvote% − 1). A post with a score of 9 and an 80% upvote rate received 15 total votes: 12 up and 3 down. Comments have no published ratio, so the math doesn't work there.
Are the scores exact vote counts?
No. Reddit uses "vote fuzzing" to deliberately alter counts slightly on every refresh to frustrate bots and vote manipulators. Your scores are close approximations, reliable for ranking opinions against each other, but not exact tallies. That's fine for this dataset, since what matters is which opinions outscored which, not the precise numbers.
Why are almost all my comment scores positive?
Because you're pulling top comments by default, you're seeing the well-received end of the distribution. Raise maxCommentsPerPost to dig deeper into each thread and reach downvoted opinions. Even then, Reddit collapses and fuzzes heavily buried comments, so the negative tail is always partial.
Why is self_text empty on so many posts?
They're link or title-only posts, which is normal on subreddits like AskReddit, where the question is the entire post. There's no body to capture, and nothing is lost, since the title alone is the full prompt the comments are responding to.vv