{"slug": "monitor-reddit-for-mentions-of-your-product-in-python", "title": "Monitor Reddit for mentions of your product in Python", "summary": "A developer built Qovia, a paid API that wraps Reddit search, and published a Python tutorial showing how to poll it hourly from cron to find brand mentions, de-duplicate results via a state file, log them to CSV and alert on Slack. The script uses Reddit's search syntax with time_range and sort parameters, pages results about seven at a time via cursor, and retries once on HTTP 429 using the Retry-After header; empty results and errors are not billed.", "body_md": "I build Qovia, the API used in this post. The monitoring logic (search, de-duplicate, filter, alert) works with any Reddit data source; the state file, CSV and Slack parts are plain Python.\n\nIf you ship a developer tool or a SaaS product, Reddit is where people ask \"is there an alternative to X?\", complain about your onboarding, or recommend you to someone else, often without tagging you. Finding those threads early is worth a lot: a helpful answer in the first hour of a thread does more than a week of ads. Doing it by hand means refreshing Reddit search every morning, so let's automate it.\n\nIn this tutorial we'll write a script that:\n\nRun it from cron every hour and you have a basic social-listening tool for a few dollars a month.\n\nYou need Python 3.9+ and `requests`:\n\n```\npip install requests\nexport QOVIA_KEY=sk-qovia-...   # key from the Qovia console\nexport SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...   # optional\n```\n\nGet a key from [Qovia](https://qovia.ai/?utm_source=devto&utm_medium=article&utm_campaign=reddit-keyword-monitor-python). Requests authenticate with `Authorization: Bearer <key>` (an `x-api-key` header also works). The [Reddit search reference](https://qovia.ai/docs/api-reference/raw.reddit.search/?utm_source=devto&utm_medium=article&utm_campaign=reddit-keyword-monitor-python) shows every parameter with a real response; the other Reddit endpoints are listed next to it.\n\nYou don't need a Reddit account, OAuth app or client secret for this. The trade-off is that it's read-only and public content only (more under Limits).\n\nEvery response has the same envelope: the result is in `data`, and `meta` reports the charge (`meta.billed`, `meta.charged_usd`, `meta.empty`). Empty results come back as HTTP 200 with `meta.empty: true` and are not charged. Errors are `{\"error\": {\"code\", \"message\", ...}}` and are never charged. A post or subreddit that doesn't exist answers `404`, so the helper turns that into `None`. A `429` carries a `Retry-After` header, so the helper waits and retries once:\n\n``` python\nimport os\nimport time\nimport requests\n\nBASE = \"https://api.qovia.ai\"\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['QOVIA_KEY']}\"}\n\nspent = 0.0\n\ndef get(path, **params):\n    global spent\n    params = {k: v for k, v in params.items() if v is not None}\n    for attempt in range(2):\n        r = requests.get(f\"{BASE}{path}\", headers=HEADERS, params=params, timeout=60)\n        if r.status_code == 429 and attempt == 0:\n            time.sleep(int(r.headers.get(\"Retry-After\", \"10\")))\n            continue\n        if r.status_code == 404:\n            return None  # missing post or subreddit, not charged\n        r.raise_for_status()\n        body = r.json()\n        spent += float((body.get(\"meta\") or {}).get(\"charged_usd\") or 0)\n        return body.get(\"data\") or {}\n```\n\nUnknown parameters are rejected with `400 invalid_params`, which is why the helper drops `None` values instead of sending them.\n\n`GET /v1/raw/reddit/search` takes a `keyword` (1 to 200 characters) and Reddit's search syntax works inside it: `AND`, `OR`, `NOT`, quoted phrases, `subreddit:`, `author:`, `title:` and so on. Two more parameters matter for monitoring:\n\n`sort`: `relevance` (the default), `hot`, `top`, `new` or `comments`. For monitoring you want `time_range`: `all` (the default), `year`, `month`, `week`, `day` or `hour`. If you run hourly, Results come about 7 a page. Pass `cursor` back for the next page and stop when `has_more` is false. If Reddit spell-corrected your query, `corrected_query` tells you what it actually searched, which is worth logging: brand names get \"corrected\" more often than you'd think.\n\nEach post record has `text` (title and body), `author` (`username`), `metrics` (`likes`, which is the post's score, and `comments`) and `extra` with `source_id` (the post's fullname, `t3_...`), `title`, `subreddit`, `published_at` (UTC), `flair`, `content_type`, `link_url` and `url`.\n\n```\nQUERIES = {\n    \"brand\": '\"acme analytics\" OR acmeanalytics',\n    \"competitor\": '\"plausible analytics\" alternative',\n}\n\ndef search(query, time_range=\"day\", max_pages=2):\n    posts, cursor = [], None\n    for _ in range(max_pages):\n        page = get(\"/v1/raw/reddit/search\", keyword=query, sort=\"new\",\n                   time_range=time_range, cursor=cursor)\n        if not page:\n            break\n        if page.get(\"corrected_query\"):\n            print(f\"  note: Reddit searched {page['corrected_query']!r} instead of {query!r}\")\n        posts.extend(page.get(\"items\", []))\n        if not page.get(\"has_more\"):\n            break\n        cursor = page.get(\"cursor\")\n    return posts\n```\n\nQuote multi-word names. Without quotes, `acme analytics` matches any post containing both words anywhere, which for a common word gets noisy fast.\n\nSearch finds posts that name you. The more valuable posts often don't: \"what do you use for privacy-friendly analytics?\" in r/webdev never mentions any product. For those, watch the newest posts in the communities your users hang out in and filter locally.\n\n`GET /v1/raw/reddit/subreddit_posts` takes a `subreddit` (the name after `r/`) and a `sort` (`hot` by default; also `best`, `new`, `top`, `controversial`, `rising`). It's cursor-paginated: about 6 posts on the first page, then about 24. The list records carry the title and the first 300 characters of the body (`extra.truncated` tells you when it was cut), which is plenty for a phrase filter. Fields the list doesn't carry (language, author ID, images, upvote ratio) are null; `GET /v1/raw/reddit/post` has them if you need them.\n\n``` python\nimport re\n\nSUBREDDITS = [\"webdev\", \"SaaS\", \"selfhosted\"]\nTOPIC = re.compile(r\"analytics|tracking|pageviews\", re.I)\nINTENT = re.compile(r\"alternative to|looking for|recommend|what do you use|switch(ed|ing)? from\", re.I)\n\ndef intent_posts(subreddit, max_pages=2):\n    hits, cursor = [], None\n    for _ in range(max_pages):\n        page = get(\"/v1/raw/reddit/subreddit_posts\", subreddit=subreddit,\n                   sort=\"new\", cursor=cursor)\n        if not page:\n            break  # 404: no such subreddit\n        for p in page.get(\"items\", []):\n            text = p.get(\"text\") or \"\"\n            if TOPIC.search(text) and INTENT.search(text):\n                hits.append(p)\n        if not page.get(\"has_more\"):\n            break\n        cursor = page.get(\"cursor\")\n    return hits\n```\n\nThe two regexes are deliberately simple: a post must be about the topic *and* sound like someone is choosing a tool. Tune them on a week of output; this is the part of the script you'll edit most.\n\nBoth sources return the same record shape with the same IDs, so one set of seen fullnames is enough. A JSON file next to the script is fine for an hourly job:\n\n``` python\nimport json\nfrom pathlib import Path\n\nSTATE = Path(\"reddit_seen.json\")\n\ndef load_seen():\n    return set(json.loads(STATE.read_text())) if STATE.exists() else set()\n\ndef save_seen(seen):\n    STATE.write_text(json.dumps(sorted(seen)))\n\ndef only_new(posts, seen):\n    fresh = []\n    for p in posts:\n        pid = p[\"extra\"][\"source_id\"]\n        if pid not in seen:\n            seen.add(pid)\n            fresh.append(p)\n    return fresh\n```\n\nThis also de-duplicates a post that shows up in both a search and a subreddit feed during the same run.\n\nA title tells you a thread exists; the comments tell you whether it's worth answering. `GET /v1/raw/reddit/comments` takes the post's `post_id` (the same `t3_...` fullname) and an optional `sort` (`best` by default, or `top`, `new`, `old`, `controversial`). A page holds 8 comments: a few top-level ones plus the replies Reddit expands under them, flattened in Reddit's order. Each has `cid`, `text`, `likes` (the comment's score), `reply_count`, `created_at` (UTC), `pinned`, `author`, `depth` (0 for top-level) and `parent_cid`. The page also has `total`, `cursor` and `has_more`. A post without comments answers empty and isn't charged.\n\n``` python\ndef top_comments(post_id, keywords=None):\n    page = get(\"/v1/raw/reddit/comments\", post_id=post_id, sort=\"top\") or {}\n    out = []\n    for c in page.get(\"items\", []):\n        text = (c.get(\"text\") or \"\").replace(\"\\n\", \" \")\n        out.append({\n            \"cid\": c[\"cid\"],\n            \"depth\": c.get(\"depth\"),\n            \"score\": c.get(\"likes\"),\n            \"replies\": c.get(\"reply_count\"),\n            \"mentions\": bool(keywords and keywords.search(text)),\n            \"text\": text[:300],\n        })\n    return out, page.get(\"total\")\n```\n\nOne page per post keeps the cost predictable. If you need a full thread, keep paging with `cursor`; replies Reddit collapses come with a `replies_cursor` that `GET /v1/raw/reddit/comment_replies` expands (it takes `post_id` and that cursor). For alerts, the top 8 is enough to see whether people are already recommending someone.\n\n``` python\nimport csv\nfrom datetime import datetime, timezone\n\nFIELDS = [\"checked_at\", \"source\", \"post_id\", \"subreddit\", \"title\", \"author\",\n          \"score\", \"comments\", \"published_at\", \"url\", \"top_comment\"]\nMENTION = re.compile(r\"acme\", re.I)  # flags comments that name you\n\ndef now():\n    return datetime.now(timezone.utc).isoformat(timespec=\"seconds\")\n\ndef row(source, p, comments):\n    ex, m = p.get(\"extra\") or {}, p.get(\"metrics\") or {}\n    best = comments[0][\"text\"] if comments else \"\"\n    return {\n        \"checked_at\": now(),\n        \"source\": source,\n        \"post_id\": ex.get(\"source_id\"),\n        \"subreddit\": ex.get(\"subreddit\"),\n        \"title\": ex.get(\"title\"),\n        \"author\": (p.get(\"author\") or {}).get(\"username\"),\n        \"score\": m.get(\"likes\"),\n        \"comments\": m.get(\"comments\"),\n        \"published_at\": ex.get(\"published_at\"),\n        \"url\": ex.get(\"url\"),\n        \"top_comment\": best[:200],\n    }\n\ndef append_csv(path, rows):\n    path = Path(path)\n    new_file = not path.exists()\n    with path.open(\"a\", newline=\"\", encoding=\"utf-8\") as f:\n        w = csv.DictWriter(f, fieldnames=FIELDS)\n        if new_file:\n            w.writeheader()\n        w.writerows(rows)\n\ndef notify(rows):\n    url = os.environ.get(\"SLACK_WEBHOOK_URL\")\n    if not url or not rows:\n        return\n    lines = [f\"*{len(rows)} new Reddit posts*\"]\n    for r in rows[:15]:\n        lines.append(f\"• [{r['source']}] r/{r['subreddit']}: <{r['url']}|{r['title']}> \"\n                     f\"({r['score']} pts, {r['comments']} comments)\")\n    requests.post(url, json={\"text\": \"\\n\".join(lines)}, timeout=30)\n\ndef run():\n    seen = load_seen()\n    found = []\n    for name, q in QUERIES.items():\n        found += [(name, p) for p in only_new(search(q), seen)]\n    for sub in SUBREDDITS:\n        found += [(f\"r/{sub}\", p) for p in only_new(intent_posts(sub), seen)]\n\n    rows = []\n    for source, p in found:\n        comments, _ = top_comments(p[\"extra\"][\"source_id\"], MENTION)\n        if any(c[\"mentions\"] for c in comments):\n            source += \" +mentioned in comments\"\n        rows.append(row(source, p, comments))\n\n    append_csv(\"reddit_mentions.csv\", rows)\n    notify(rows)\n    save_seen(seen)\n    print(f\"{len(rows)} new posts, spent this run: ${spent:.4f}\")\n\nif __name__ == \"__main__\":\n    run()\n```\n\nThe state is saved only after the CSV and the Slack message go out, so a crash mid-run means the next run reports those posts again instead of losing them.\n\nRun it once by hand, then schedule it:\n\n```\npython3 reddit_monitor.py\n# crontab -e\n# .env holds: export QOVIA_KEY=... and export SLACK_WEBHOOK_URL=...\n0 * * * * cd /path/to/monitor && . ./.env && python3 reddit_monitor.py >> monitor.log 2>&1\n```\n\nOutput looks something like this (illustrative, not real data):\n\n```\n  note: Reddit searched 'acme analytic' instead of 'acmeanalytics'\n4 new posts, spent this run: $0.0140\n```\n\nAnd in Slack (also illustrative):\n\n```\n4 new Reddit posts\n• [brand] r/webdev: Anyone tried Acme Analytics for a static site? (12 pts, 9 comments)\n• [competitor] r/selfhosted: Plausible alternative that runs on SQLite? (31 pts, 22 comments)\n• [r/SaaS] r/SaaS: Looking for privacy-friendly analytics for a B2B app (5 pts, 3 comments)\n• [r/webdev +mentioned in comments] r/webdev: What do you use for pageview tracking in 2026? (48 pts, 61 comments)\n```\n\nThe last one is the interesting kind: a post that never names you, where someone in the comments already did.\n\nFor one-off research (\"what did r/selfhosted say about analytics tools this month?\") a script is overkill. The same endpoints are available through Qovia's MCP server at `https://api.qovia.ai/mcp` (Streamable HTTP, same API key in an `Authorization: Bearer` header, same billing as the HTTP API). In Claude Code:\n\n```\nclaude mcp add --transport http qovia https://api.qovia.ai/mcp \\\n  --header \"Authorization: Bearer $QOVIA_KEY\"\n```\n\nThen ask something like \"search Reddit for posts from the last week asking for a Plausible alternative, and summarize what the top comments recommend.\" Configs for Cursor, VS Code and Claude Desktop are in the [MCP docs](https://qovia.ai/docs/mcp/?utm_source=devto&utm_medium=article&utm_campaign=reddit-keyword-monitor-python). The assistant is good for digging into one question; the cron job is better for not missing anything.\n\nEvery Reddit endpoint used here (`search`, `subreddit_posts`, `comments`, and `post` / `comment_replies` if you add them) is a fixed $0.001 per successful call ($1 per 1,000). Empty results, errors and 404s are not charged, so a quiet hour where search finds nothing costs nothing for that query.\n\nAs a rough example (arithmetic only): with 2 queries x up to 2 search pages, 3 subreddits x up to 2 pages, and one comment page per new post, a run is at most 10 calls plus one per new post. With 4 new posts that's $0.014. Run hourly, the worst case is 240 calls a day ($0.24) plus one call per new post, so roughly $7 to $8 a month; it's less in practice because a search with no new results returns empty and isn't charged. The subreddit feeds are the fixed part: one page per subreddit, or running every three hours, cuts that by half or more. The `spent` counter prints the real number each run. New accounts get 50 trial calls, counted only when a call returns data; after that it's a prepaid balance with no monthly fee. Details are on the [pricing page](https://qovia.ai/pricing/?utm_source=devto&utm_medium=article&utm_campaign=reddit-keyword-monitor-python), and every parameter is in the [API docs](https://qovia.ai/docs/?utm_source=devto&utm_medium=article&utm_campaign=reddit-keyword-monitor-python).\n\nThe default rate limit is 60 requests per minute per account, far more than an hourly job needs.\n\n`subreddit_posts` carries only the first 300 characters of the body; fetch `GET /v1/raw/reddit/post` when you need the full text.`corrected_query` and test your queries on reddit.com once before trusting them.\nIf you build on this, tell me in the comments what you'd want it to flag besides mentions.\n\n*Drafted with AI assistance and checked against the live API docs.*", "url": "https://wpnews.pro/news/monitor-reddit-for-mentions-of-your-product-in-python", "canonical_source": "https://dev.to/qovia/monitor-reddit-for-mentions-of-your-product-in-python-386o", "published_at": "2026-11-01 07:37:08+00:00", "updated_at": "2026-11-01 07:52:06.091920+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools"], "entities": ["Qovia", "Reddit", "Slack", "Python"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/monitor-reddit-for-mentions-of-your-product-in-python", "markdown": "https://wpnews.pro/news/monitor-reddit-for-mentions-of-your-product-in-python.md", "text": "https://wpnews.pro/news/monitor-reddit-for-mentions-of-your-product-in-python.txt", "jsonld": "https://wpnews.pro/news/monitor-reddit-for-mentions-of-your-product-in-python.jsonld"}}