Turning on discussion data shouldn't multiply a launch's votes in your dashboard.
You enable includeComments and includeReviews for the report due Friday. If engagement suddenly rises, there's a small, unpleasant question: did the audience grow, or did the join?
The Actor exports a record per launch, with discussion and reviews attached as arrays. Source deduplication does not prevent a downstream join from multiplying launch-level metrics.
The extra rows aren't extra engagement
Example synthetic fixture: these child shapes belong to the demonstration, rather than defining the Actor's child schema.
const exampleLaunch = {
id: 'Example launch',
votesCount: 50,
comments: [
{
id: 'Example comment A',
replies: [{ id: 'Example reply' }],
},
{ id: 'Example comment B' },
],
reviews: [
{ id: 'Example review A' },
{ id: 'Example review B' },
],
};
const naiveRows = exampleLaunch.comments.flatMap(() =>
exampleLaunch.reviews.map(() => ({
id: exampleLaunch.id,
votesCount: exampleLaunch.votesCount,
})),
);
const naiveVotes = naiveRows.reduce(
(total, row) => total + row.votesCount, 0,
);
Every comment pairs with every review. Each resulting row carries the launch's entire vote count.
Expand nested replies as well, and another collection enters the multiplication. Use inner joins, and launches without matching children disappear. The same query can inflate engagement and shrink your reporting cohort.
What broke is the assumption that an exported record remains a reporting row after expansion. Every source entity can be unique while every launch metric gets repeated.
SUM(DISTINCT votesCount) doesn't repair this. Different launches can legitimately share a vote total. That expression deduplicates a measurement when you need to preserve an identity.
Why the export keeps a launch envelope
The public frontend exposes launches alongside separately paginated discussion and review collections. The Actor retrieves those collections and attaches them to their launch envelope.
Comments paginate through 20 threads per page, using reply cursors. Reviews paginate independently. Both collections require additional requests, which is why they're opt-in.
The request strategy follows the public frontend's GraphQL queries. If a persisted hash stops matching, the Actor refreshes the query document, retries once, then falls back to embedded page state. A blocked or faulty empty response isn't accepted as a successful empty run.
That protects retrieval behavior. It doesn't define your warehouse joins.
Launch, comment, and review IDs are deduplicated during collection. Your reporting transformation needs a separate cardinality contract.
Keep the branches independent until each has the intended reporting grain:
launch export
├── launch metrics ──────────────────────────────┐
├── comments → replies → aggregate by launch ─────┤
└── attached reviews → aggregate by launch ───────┤
↓
launch-grain report
This preserves the useful relationship: discussion belongs beside launch metrics without becoming another copy of them.
What does a missing discussion array mean?
Empty values are omitted, including empty arrays. Missing comments therefore isn't a reliable statement that nobody commented.
The collection might not have been requested. Even when requested, maxComments and maxPages impose hard caps. An attached discussion isn't a completeness certificate either.
Example intake: validate the envelope, preserve opaque children, and retain the request settings alongside the retrieved batch.
import { ApifyClient } from 'apify-client';
import { z } from 'zod';
const Launch = z.object({
id: z.string().min(1),
votesCount: z.number().int().nonnegative().optional(),
commentsCount: z.number().int().nonnegative().optional(),
reviewsCount: z.number().int().nonnegative().optional(),
product: z.unknown().optional(),
comments: z.array(z.unknown()).optional(),
reviews: z.array(z.unknown()).optional(),
}).passthrough();
const run_input = {
mode: 'latest',
includeComments: true,
includeReviews: true,
maxComments: 50,
maxReviews: 20,
};
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client
.actor('nimbryxo-studio/product-hunt-launch-and-topic-data-exporter')
.call(run_input);
if (run.status !== 'SUCCEEDED') {
throw new Error(`Launch collection stopped: ${run.status}`);
}
const { items } = await client.dataset(run.defaultDatasetId).listItems();
const launches = items.map(item => Launch.parse(item));
const staged = launches.map(launch => ({
launchId: launch.id,
request: { ...run_input },
payload: launch,
}));
This stages the returned dataset batch. Dataset retrieval and source pagination are separate responsibilities; complete dataset traversal before claiming run-wide reconciliation.
Saving request settings gives your next n8n execution some context. Otherwise, “discussion wasn't requested” and “discussion wasn't attached” become indistinguishable after ingestion.
Keep published commentsCount and reviewsCount separate from your fetched entity counts. They answer different questions. A reply-aware traversal also measures something different from counting top-level threads.
Unknown votesCount must remain unknown. Replacing it with zero would convert missing evidence into an engagement claim.
Give replies a parent, not an array position
A reply needs its launch identity and its parent comment identity. Array position can't carry that relationship safely through retries, sorting, or independent ingestion batches.
Keep child extraction behind an adapter. The envelope exposes nested replies without establishing every child property path your integration needs.
Example adapter and normalization below apply only to the synthetic fixture; real payload extraction needs separately verified accessors. The parent map is consumer bookkeeping, keyed by comment identity.
type ChildAdapter = {
commentId: (payload: unknown) => string;
reviewId: (payload: unknown) => string;
replies: (payload: unknown) => readonly unknown[];
};
type CommentRow = {
launchId: string;
id: string;
payload: unknown;
};
type ReviewRow = {
launchId: string;
id: string;
payload: unknown;
};
function normalizeChildren(
launchId: string,
comments: readonly unknown[],
reviews: readonly unknown[],
adapter: ChildAdapter,
) {
const commentRows: CommentRow[] = [];
const reviewRows: ReviewRow[] = [];
const parentByComment = new Map<string, string | null>();
const reviewIds = new Set<string>();
function visit(payload: unknown, parent: string | null) {
const id = adapter.commentId(payload);
if (parentByComment.has(id)) {
throw new Error(`Repeated comment: ${id}`);
}
parentByComment.set(id, parent);
commentRows.push({ launchId, id, payload });
for (const reply of adapter.replies(payload)) visit(reply, id);
}
for (const comment of comments) visit(comment, null);
for (const payload of reviews) {
const id = adapter.reviewId(payload);
if (reviewIds.has(id)) continue;
reviewIds.add(id);
reviewRows.push({ launchId, id, payload });
}
return { commentRows, reviewRows, parentByComment };
}
const ExampleChild = z.object({
id: z.string().min(1),
replies: z.array(z.unknown()).optional(),
});
const exampleAdapter: ChildAdapter = {
commentId: payload => ExampleChild.parse(payload).id,
reviewId: payload => ExampleChild.parse(payload).id,
replies: payload => ExampleChild.parse(payload).replies ?? [],
};
const { commentRows, reviewRows, parentByComment } = normalizeChildren(
exampleLaunch.id, exampleLaunch.comments,
exampleLaunch.reviews, exampleAdapter,
);
The comment check rejects repeated identities instead of silently keeping whichever parent arrived first. It also stops a cyclic adapter result from recursing indefinitely.
Preserve the original payload even after normalization. You can revise an extraction rule without fetching everything again.
Let aggregates cross the reporting boundary
Give each warehouse relation an explicit responsibility:
| Relation | Key | Meaning |
|---|---|---|
| Launch metrics | Launch ID | Current launch-level measures |
| Comments | Launch ID and comment ID | Discussion entities with parent links |
| Reviews | Review ID | Review entities |
| Review attachments | Launch ID and review ID | Review appeared in this launch export |
The attachment relation matters because reviews are fetched per product. An attached review isn't automatically a review of that specific launch; its payload can include a source launch.
Don't silently turn product feedback into launch-specific sentiment.
For reporting, start from launch metrics. Group each child branch independently, then left join those aggregates by launch ID. Each branch must already contain unique launch identities at that boundary.
In Postgres, use separate grouped subqueries. In n8n, aggregate each branch before merging. Combining children first and grouping afterward preserves the multiplication inside your sums.
For reruns, upsert entity identities. Replace child snapshots only when your collection policy justifies replacement. A capped fetch is a poor deletion instruction.
Make the transformation prove its grain
A total alone is a weak alarm. Missing launches and repeated launches can offset each other.
Compare identity sets, enforce launch uniqueness, and reconcile votes over the same selected cohort. Checking each launch's vote value also catches changes that cancel out in the total.
Separately, give analysis inputs an explicit weighting label. The following helpers enforce both contracts.
Example checks below exercise the synthetic transformation and prepare its discussion and review analysis inputs.
type VoteRow = { id: string; votesCount?: number };
function reconcile(
before: readonly VoteRow[],
after: readonly VoteRow[],
): string[] {
const issues: string[] = [];
const baseline = new Map(before.map(row => [row.id, row.votesCount]));
const reported = new Set(after.map(row => row.id));
if (baseline.size !== before.length) issues.push('Repeated baseline ID');
if (reported.size !== after.length) issues.push('Repeated report ID');
if (baseline.size !== reported.size ||
[...baseline.keys()].some(id => !reported.has(id))) {
issues.push('Launch identity set changed');
}
if (after.some(row => baseline.get(row.id) !== row.votesCount)) {
issues.push('Launch vote values changed');
}
if ([...before, ...after].some(row => row.votesCount === undefined)) {
issues.push('Missing votes prevent total reconciliation');
} else {
const total = (rows: readonly VoteRow[]) =>
rows.reduce((sum, row) => sum + row.votesCount!, 0);
if (total(before) !== total(after)) issues.push('Vote total changed');
}
return issues;
}
function countByLaunch(rows: readonly { launchId: string }[]) {
const counts = new Map<string, number>();
for (const row of rows) {
counts.set(row.launchId, (counts.get(row.launchId) ?? 0) + 1);
}
return counts;
}
function analysisInputs<T extends { launchId: string }>(rows: readonly T[]) {
const counts = countByLaunch(rows);
return {
launchWeighted: {
label: 'Equal launch weight among launches with fetched entities',
rows: rows.map(row => ({
...row, weight: 1 / (counts.size * counts.get(row.launchId)!),
})),
},
entityWeighted: {
label: 'Equal fetched entity weight',
rows: rows.map(row => ({ ...row, weight: 1 / rows.length })),
},
};
}
const fetchedComments = countByLaunch(commentRows);
const fetchedReviews = countByLaunch(reviewRows);
const reportRows = [exampleLaunch].map(launch => ({
id: launch.id,
votesCount: launch.votesCount,
fetchedCommentEntities: fetchedComments.get(launch.id) ?? 0,
fetchedReviewEntities: fetchedReviews.get(launch.id) ?? 0,
}));
const badJoinIssues = reconcile([exampleLaunch], naiveRows);
const reportIssues = reconcile([exampleLaunch], reportRows);
if (reportIssues.length) throw new Error(reportIssues.join('; '));
const discussionAnalysis = analysisInputs(commentRows);
const reviewAnalysis = analysisInputs(reviewRows);
The bad join produces diagnostics without stopping the program. Gate publication on the reporting check, and retain the diagnostics with the batch.
For a deliberately filtered report, filter the baseline to the same cohort. Otherwise, selection changes and transformation errors share an alarm that can't tell you which repair to make.
The fetched counts can safely become zero here because they count supplied entities. That convention says nothing about whether collection was complete.
Which population should sentiment represent?
Discussion-weighted analysis asks what the fetched discussion expresses. Launch-weighted analysis asks what sentiment looks like across launches with fetched discussion.
Those are different populations. Discussion-heavy launches carry more influence under the former.
The weighted inputs make that choice inspectable. Within the launch-weighted input, each eligible launch contributes equal total weight, divided among its fetched entities.
Empty inputs produce empty weighted rows. They don't imply neutral sentiment, and launches without fetched discussion aren't represented in that sentiment estimate.
These weights assume a comparable score per entity. Have your classifier produce those scores, then calculate the weighted result in code. Asking an LLM to “consider each launch equally” isn't a numerical guarantee.
Replies count as discussion entities here. For a question about thread starters, filter with parentByComment.get(row.id) === null before constructing the inputs.
Keep review and discussion results separately labeled. Mixing them creates another weighting policy, usually chosen by whichever collection happens to be longer.
The worked source is Product Hunt Launch & Topic Data Exporter. The same pattern applies whenever independent child collections share a parent.
Before you publish the engagement table
- Keep votes in the launch relation.
- Preserve reply parentage and original child payloads.
- Retain collection settings with each batch.
- Aggregate child branches before merging.
- Reconcile identities and votes over the same cohort.
- Label sentiment populations and calculate weights in code.
A conversation can grow without creating another launch's worth of votes.
Top comments (0)