The theme leading your draft may have more copies behind it than reviews.
You've merged targeted exports with pd.concat(), counted themes and started writing the headline. The uncomfortable question arrives just before publication: did the same review enter through several filters?
Our review Actor accepts product URLs and review URLs, combines review filters and applies the chosen sort order to the matches. Separate collections can therefore return overlapping reviews, even when each collection makes sense on its own.
Pooling overlapping review exports weights each review by the number of collected rows carrying its identity.
That's a property of your merge, not evidence of stronger agreement.
Which reviews are you actually counting?
Suppose your question is: “Which theme appears most often among the collected reviews?”
A collection filtered to small companies and another filtered to managers don't partition that population. A manager at a small company can belong to both.
Keep the collection definition beside every observation before combining anything. The metadata below belongs to your analysis; it isn't returned by the Actor.
Example: these inputs collect overlapping segments and label them as part of the same editorial batch.
import os import json import pandas as pd import duckdb from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) snapshot_id = "edition" analysis_fields = ["productSlug", "title", "useCase"] records = [] for collection_id, filters in [ ("small_companies", {"companySizes": ["small"]}), ("managers", {"reviewerRoles": ["manager"]}), ]: run_input = { "mode": "reviews", "productUrls": ["asana"], "maxItems": 100, "maxReviewsPerProduct": 100, "reviewSort": "newest", **filters, } run = client.actor( "dataspryva/trustradius-product-ratings-and-feature-score" ).call(run_input=run_input) if run["status"] != "SUCCEEDED": raise RuntimeError(f"Collection did not succeed: {collection_id}") for item in client.dataset(run["defaultDatasetId"]).iterate_items(): if item.get("recordType") == "review": records.append({ **item, "collection_id": collection_id, "snapshot_id": snapshot_id, "content_signature": json.dumps( {field: item.get(field) for field in analysis_fields}, sort_keys=True, ensure_ascii=False, ), }) Here, snapshot_id names a collection batch. It doesn't claim that the pages were observed simultaneously.
Restricting records to reviews also matters: the Actor can emit products, discovery cards and comparisons in other modes. Those aren't additional respondents.
A cap can make overlap look like consensus
Filters determine eligibility. Ordering and caps determine which eligible reviews you actually collect.
The Actor follows review pagination until maxReviewsPerProduct, maxItems or the end of the matching archive. With reviewSort set to newest, a capped segment represents the newest collected matches, rather than a random sample of that segment.
Pooling segments then introduces another selection mechanism:
small-company collection ─┐ ├─ same review ─ repeated rows manager collection ──────┘ A theme associated with the intersection can gain extra weight. Its rank might change after deduplication, or it might stay put. Your data has to answer that.
The analytical objects need different jobs:
| Object | What it preserves | Appropriate use |
|---|---|---|
| Observation | A record as collected in a run | Collection audits and version checks |
| Review | A resolved review identity and analysis version | Overall theme prevalence |
| Membership | A review's presence in a collection | Segment comparisons and overlap |
Segment counts remain useful. They simply aren't additive when membership overlaps.
Audit the URL before making it a key
reviewUrl is a candidate review identifier. productSlug provides a useful consistency check, but it identifies a product rather than an individual review.
We'd rather hold an identity out than quietly invent one from a title. Similar wording is weak evidence of sameness.
Empty fields are omitted, so build the expected columns explicitly. Preserve URL spelling apart from surrounding whitespace: stripping query parameters or rewriting paths requires evidence that the altered URLs still identify the same review.
This cell identifies missing URLs, conflicting product associations and timestamps that can't support version selection.
columns = [ "snapshot_id", "collection_id", "reviewUrl", "scrapedAt", "content_signature", *analysis_fields, ] observations = pd.DataFrame(records).reindex(columns=columns) for column in ["reviewUrl", "productSlug"]: observations[column] = ( observations[column].astype("string").str.strip() .replace("", pd.NA) ) observations["observed_at"] = pd.to_datetime( observations["scrapedAt"], errors="coerce", utc=True ) keys = ["snapshot_id", "reviewUrl"] keyed = observations.loc[observations["reviewUrl"].notna()].copy() product_conflict = ( keyed.groupby(keys)["productSlug"].transform("nunique").gt(1) ) bad_timestamp = ( keyed["observed_at"].isna().groupby( [keyed[column] for column in keys] ).transform("any") ) identity_audit = pd.Series({ "observations_without_url": observations["reviewUrl"].isna().sum(), "observations_without_product_slug": keyed["productSlug"].isna().sum(), "observations_with_product_conflict": product_conflict.sum(), "observations_in_timestamp_holdout": bad_timestamp.sum(), }) eligible = keyed.loc[~product_conflict & ~bad_timestamp].copy() The audit counts observations, not distinct identities. Keep those units in the labels when reporting exclusions.
A missing product association remains visible rather than becoming proof of a conflict. A URL associated with different known product slugs needs inspection before it becomes a trustworthy key.
Choose a version without losing membership
A repeated URL may contain changed text. Deduplicating with “keep whichever row arrived last” makes file order your editorial policy.
For this analysis, choose the latest parseable scrapedAt within the named batch. If observations at that latest timestamp disagree on the analysis fields, hold that review out.
This policy resolves the fields used here. It doesn't establish that every other part of the review stayed unchanged.
The next cell preserves membership, exposes changed analysis versions and creates the resolved review table.
membership = keyed[ [*keys, "collection_id"] ].drop_duplicates() version_counts = eligible.groupby( keys )["content_signature"].transform("nunique") version_conflicts = eligible.loc[version_counts.gt(1)].copy() latest_time = eligible.groupby(keys)["observed_at"].transform("max") latest = eligible.loc[eligible["observed_at"].eq(latest_time)].copy() ambiguous_latest = latest.groupby( keys )["content_signature"].transform("nunique").gt(1) version_holdouts = latest.loc[ambiguous_latest].copy() reviews = latest.loc[ ~ambiguous_latest, [*keys, *analysis_fields, "observed_at"] ].drop_duplicates(keys) The final deduplication is safe for this projection because any remaining tied observations agree on the selected fields and timestamp.
Keep membership even when a review is held out. It records provenance; joining it to reviews determines which memberships have a resolved version available for analysis.
Compare repetition with review prevalence
Before counting themes, decide what a theme count means.
A review mentioning a topic repeatedly can contribute a single presence flag, or several textual mentions. Those answer different questions. Neither becomes a count of people merely because the chart says “customers”.
For a compact audit, use presence flags over title and useCase. These are narrow word checks, not validated theme annotations: negation, synonyms and context still require review.
Example: these flags compare mentions of support and pricing in the selected text fields.
def text_value(value): if isinstance(value, str): return value if isinstance(value, list): return " ".join(part for part in value if isinstance(part, str)) return "" review_text = ( reviews["title"].map(text_value).astype("string") + " " + reviews["useCase"].map(text_value).astype("string") ) theme_terms = { "support": r"\bsupport\b", "pricing": r"\bpric(?:e|es|ing)\b", } review_flags = reviews[keys].copy() for theme, pattern in theme_terms.items(): review_flags[theme] = review_text.str.contains( pattern, case=False, regex=True, na=False ) review_flags = review_flags.melt( id_vars=keys, var_name="theme", value_name="mentions_theme" ) review_flags["mentions_theme"] = review_flags["mentions_theme"].astype(bool) row_flags = observations[[*keys, "collection_id"]].merge( review_flags, on=keys, how="inner", validate="many_to_many" ) Every collected copy now receives the same resolved annotation. That isolates repetition from changes in wording across observations.
The next cell compares a denominator of collected rows with a denominator of resolved reviews.
theme_comparison = duckdb.sql(""" SELECT 'collected_rows' AS basis, theme, COUNT(*) AS denominator, COUNT(*) FILTER (WHERE mentions_theme) AS theme_count, AVG(CAST(mentions_theme AS DOUBLE)) AS prevalence FROM row_flags GROUP BY theme UNION ALL SELECT 'resolved_reviews' AS basis, theme, COUNT(*) AS denominator, COUNT(*) FILTER (WHERE mentions_theme) AS theme_count, AVG(CAST(mentions_theme AS DOUBLE)) AS prevalence FROM review_flags GROUP BY theme """).df() Inspect theme_comparison for changes in prevalence and ranking. No change is also a useful finding: it means this particular repetition audit didn't alter those measures.
For publication, report the resolved-review denominator, the text fields analysed, the version policy and the exclusions. Treat the collected-row calculation as a sensitivity check, rather than a competing estimate of public opinion.
Collect a base population when the question needs it
The worked example uses TrustRadius Product Ratings & Feature Score Extractor, but separating identity from membership applies to any overlapping exports.
If your question concerns the broader product review archive, collecting targeted segments and stitching them together creates an awkward denominator. Eligible reviews outside every chosen segment never enter.
Where practical, define the base collection first and treat segments as separate analytical views. Don't silently substitute an absent reviewer attribute with an inferred value.
Example: this input removes segment filters and requests the entire archive through the per-product setting, while retaining the Actor's overall emission cap.
base_run_input = { "mode": "reviews", "productUrls": ["asana"], "maxReviewsPerProduct": 0, "maxItems": 5000, "reviewSort": "newest", "includeReviewDetails": True, } That cap still applies. Calling the input “entire archive” doesn't establish that collection reached the end.
Enabling includeReviewDetails opens each review for ROI, alternatives and full Q&A. It matters when your coding question needs that material; it doesn't make the title-and-use-case flags above cover the full review.
For a defensible methods note, describe the selection literally: products targeted, filters applied, sort order, caps, collection batch and resolved identities retained. Say “collected matching reviews” unless archive completion has been established separately.
A review still isn't a customer
Deduplication removes repeated review identities. It doesn't establish unique people, unique organisations or a representative sample of product users.
Published reviews exclude experiences that were never posted. Missing public attributes can also prevent you from checking how well your collected population matches the population named in your headline.
Your clean denominator can support a claim about the reviews analysed. It can't quietly expand into a claim about every customer.
A repeated review is another observation, not another vote.