Complaint Radar is a small Python script for support, product and marketing teams. Give it a brand name and it returns a short morning report of public Twitter complaints about it, sorted by theme and by how much attention each post got.
The problem
Customers often complain in public before they open a ticket. Reading every mention by hand takes time, and most mentions are not complaints at all. Teams need a quick daily view: what are people unhappy about, is it growing, and which posts are getting the most attention.
What the app does
- Takes a brand name, other names for it, and the brand's own support account to leave out.
- Searches recent public X posts that pair the brand with complaint words such as "not working", "refund" or "can't log in".
- Removes reposts, duplicate posts and the brand's own posts.
- Sorts each post into a theme: outage, account, billing, bug, support, feature request or other.
- Writes a Markdown report: posts per theme, volume per day, the top posts by engagement, and the loudest posts per theme.
- Saves a JSON snapshot with every post and every API call, plus the total cost of the run.
How it works
- Build the search. The script turns your input into one X search query, for example
(PlayStation OR PSN) ("not working" OR refund OR outage ...) -from:AskPlayStation. The query supports X search operators such asOR, quotes,-wordandfrom:. - Search one day at a time. For each day it calls
GET /twitterwithsort=Latestand a start and end date. If a page is full, it asks for the next page. - Optional replies. For the top complaint posts it calls
GET /twitter/replies/postto show what other people answered. - Sort locally. Simple keyword rules assign a theme. Engagement score is likes + 2 x replies + reposts + quotes + bookmarks.
- Write the report and add up the cost from the
X-Desearch-Cost-Usdresponse header.
The code
The core call is one HTTP request:
def get(self, path, params, label):
if self.spent() >= self.max_spend:
print(f"spend cap ${self.max_spend} reached, skipping {label}", file=sys.stderr)
return None
for attempt in (1, 2):
when = time.strftime("%Y-%m-%d %H:%M:%S %z")
t0 = time.perf_counter()
try:
r = self.s.get(BASE + path, params=params, timeout=120)
status, hdr, text = r.status_code, r.headers, r.text
except Exception as e: # network error
status, hdr, text = None, {}, str(e)
lat = round(time.perf_counter() - t0, 2)
try:
body = json.loads(text)
except ValueError:
body = {"_raw_text": text[:2000]}
items = body if isinstance(body, list) else []
c = {"when": when, "label": label, "path": path, "params": params, "status": status, "latency_s": lat,
"items": len(items), "units": int(hdr.get("X-Desearch-Usage-Count") or 0) or None,
"cost_usd": float(hdr.get("X-Desearch-Cost-Usd") or 0), "attempt": attempt}
self.calls.append(c)
print(f"[{when}] {status} {path} {json.dumps(params)} items={c['items']} units={c['units']} "
f"cost=${c['cost_usd']:.5f} {lat}s", file=sys.stderr)
if self.raw_dir:
s = json.dumps({"call": c, "request": {"url": BASE + path, "params": params, "headers": {"Authorization": "<redacted>"}},
"response": body}, ensure_ascii=False, indent=1)
assert self.key not in s
open(os.path.join(self.raw_dir, f"{len(self.calls):03d}-{label}.json"), "w").write(s)
if status == 200:
return body
if status in (429, 500, 502, 503, 504, None) and attempt == 1:
time.sleep(2)
continue
return None
The repo folder has one script, complaintradar.py (about 380 lines: query builder, day-by-day paging, themes, report writer), and offline tests in test_complaintradar.py.
Endpoints
GET /twitterGET /twitter/replies/post
Example output
From a run for PlayStation on 2026-10-08, covering 3 days (2026-10-06 to 2026-10-08 UTC). 123 posts after removing duplicates.
| Theme | Posts | Share |
|---|---|---|
| bug | 59 | 48% |
| billing | 27 | 22% |
| outage | 18 | 15% |
| support | 4 | 3% |
| account | 1 | 1% |
| other | 14 | 11% |
The full report also lists posts per day, the 10 complaint posts with the most engagement, and the loudest posts in each theme, each with a link to the post.
Run it yourself
The public examples folder linked above includes the script and a README. Install its requirements, set DESEARCH_API_KEY from console.desearch.ai, and use the run command in that README.
Useful flags: --days (how many days back), --keywords (your own complaint words), --exclude (words to filter out), --max-spend (stop at a dollar limit, default $0.10).
What it costs
The example run above made 10 API calls (7 searches returning 127 posts and 3 reply lookups) and cost $0.028, read from the X-Desearch-Cost-Usd header on 2026-10-08. A full page of 20 posts cost $0.003. See pricing.
Ideas to extend it
- Run it every morning with cron and send the report to a team chat.
- Track two or three brands side by side to compare complaint themes.
- Replace the keyword themes with an LLM classifier for more nuance.
- Alert when a theme jumps above its usual daily count.
