DEV Community

Devil Scrapes
Devil Scrapes

Posted on

We debugged the same bug for 3 days because it was never actually deployed

Quick answer

We spent three days reading FAILED runs from code that had already been fixed. The fix was merged to main; it just never shipped, because the account was over its Apify usage cap and apify push never ran — every cloud run kept executing the old build. Under a deploy block, "committed" and "deployed" silently diverge. Underneath that process failure was a real bug: one browser-impersonation profile got a bare HTTP 200 that tripped neither our challenge detector nor our geo-splash marker — an un-hydrated React shell — and our escalation path only reached for a real browser on an explicit challenge error, so that shell never got rendered. The Quora Questions & Answers Scraper now escalates to a browser on any parse failure, and its parser is rebuilt against Quora's actual markup — its own puppeteer_test_* test-id classes, not the hashed CSS-in-JS classes around them. It returns question text, topic tags, and answer bodies with author, upvotes, and timestamp, at $0.20 per run plus $0.002 per result$2.20 for 1,000 rows.

Three days of debugging the wrong build 🔧

Here's the failure that should worry anyone running an autonomous release pipeline: 12 consecutive cloud runs FAILED, across three days, and every fix we shipped for them looked correct in isolation and changed nothing in production. The reason was structural, not logical. A fix had genuinely landed on main — proxy tier, browser-escalation logic, all of it committed and reviewed. But the account was sitting over its Apify usage cap at the time, so the apify push step that ships a new build to the platform never ran. Every one of those 12 FAILED runs, including one dated after the fix was merged, was still executing the old build — datacenter proxy, no browser-escalation path at all.

That's the trap in one sentence: a merge is not a deploy, and under a deploy block the gap between them is invisible from the outside. The logs looked like the same bug recurring. It wasn't recurring — it had never actually been tried.

The 200 that wasn't a challenge and wasn't content

Once the real build finally shipped, a second, genuine bug surfaced. Our fetch layer rotates through four browser-impersonation profiles per request. Three of them hit Cloudflare's managed challenge cleanly — a recognizable "Just a moment" page our detector catches without trouble. The fourth came back with a plain 200 that matched neither the challenge signature nor our geo-splash marker for the wrong-region case. It looked, by every check we had, like a normal successful page load.

It wasn't one. Quora served an un-hydrated React/Relay shell on that response: a <noscript> telling the visitor to enable JavaScript, an og:title meta tag with real content in it, and nothing else — no rendered question, no answers, no JSON-LD. Because our escalation logic only reached for a full browser engine when it saw an explicit challenge-exhausted error, this shell never got that chance. It looked like success right up until the parser tried to read a question out of it and found nothing there, and the run died on a raw parse error instead of trying the one thing that would have worked.

The fix generalizes the escalation trigger: any parse failure on the fast HTTP path now falls through to one retry via a real browser engine, not only an explicit challenge signature. That's the difference between "we only defend against the block we recognize" and "we defend against not getting real content, whatever shape the failure takes."

Cloudflare wasn't actually the wall

Once that escalation path actually fired, something worth stating plainly: Quora is not walled the way some of our other targets are. The browser engine cleared Cloudflare's challenge outright — confirmed by a residential-proxy data transfer roughly an order of magnitude higher than the HTTP-only run, the signature of an actual page render happening. The zero-row failures that followed after that point were an ordinary parser problem, not an anti-bot wall: our answer selectors (div.answer, .answer-body, and similar) were, by their own docstring, an unvalidated guess, and Quora renders its DOM with hashed CSS-in-JS class names that don't hold still across builds.

The stable target turned out to be something else entirely: Quora ships its own puppeteer_test_* classes — internal end-to-end test hooks that, unlike the hashed styling classes around them, don't get renamed on every deploy. The parser was rebuilt to read those first, with older guessed selectors kept only as a last-resort fallback.

Ground-truthing against real rendered markup surfaced two more bugs no fixture had caught: each answer card actually carries two profile links — an avatar-only link with no visible text, document-order first, then the name-bearing one — and a naive "grab the first link" always returned the empty one, shipping answer_author_name as blank on every row. Separately, the upvote button doesn't live at the same DOM depth as the byline; it sits one level further out, so a card-boundary search scoped to "has a profile link" was too narrow to ever contain it, and answer_upvotes came back null on every row. Both are fixed now — the extraction requires both signals before accepting a scope as one answer's card, and picks whichever profile link actually has text.

What we handle for you 🛡️

  • We rotate browser fingerprintscurl-cffi impersonation across Chrome, Firefox, and Safari TLS profiles for the fast path.
  • We escalate to a real browser engine on any parse failure, not only an explicit challenge signature — an un-hydrated shell doesn't get to masquerade as a dead end.
  • We rotate residential proxy sessions on every block, with the exit country pinned to your input.
  • We retry with exponential backoff on 408 / 429 / 503, up to 5 attempts per page, honoring Retry-After.
  • We fail loud on a real parse failure — a page that never yields real content doesn't get to report a hollow success.
  • We keep the dataset clean — Pydantic-validated rows, ISO-8601 timestamps, stable question/answer IDs.
  • You pay only for rows that land. No data, no charge, beyond the small run-start fee.

Full output schema 📦

Field Type Notes
question_id string Slug parsed from the question URL
question_text string Question text
question_url string Canonical Quora question URL
question_topics array Topic tags attached to the question
question_answer_count integer | null Total answer count, when exposed
answer_id string | null Anchor id from the answer permalink
answer_text string | null Rendered answer body, public view only
answer_is_truncated boolean | null True when the body ends in a "Continue Reading" gate
answer_author_name string | null Display name on the byline — the name-bearing link, not the avatar link
answer_author_handle string | null Profile slug from the byline link
answer_upvotes integer | null Upvote count, when server-rendered
answer_posted_at string | null ISO-8601 if absolute, else the raw relative string
answer_url string \ null
scraped_at string ISO-8601 scrape-time timestamp

Example row:

{
  "question_id": "What-is-the-best-way-to-learn-Python",
  "question_text": "What is the best way to learn Python?",
  "question_url": "https://www.quora.com/What-is-the-best-way-to-learn-Python",
  "question_topics": ["Python (programming language)", "Programming Languages"],
  "question_answer_count": 214,
  "answer_id": "answer-123456789",
  "answer_text": "Start with a small project instead of a course...",
  "answer_is_truncated": false,
  "answer_author_name": "Jane Doe",
  "answer_author_handle": "jane-doe-42",
  "answer_upvotes": 318,
  "answer_posted_at": "Updated 3d ago",
  "answer_url": "https://www.quora.com/What-is-the-best-way-to-learn-Python#answer-123456789",
  "scraped_at": "2026-08-31T12:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Who this is for

SEO / content-gap research — pull real Quora questions and top answers in your niche to find topics your own content doesn't cover yet.

AI-answer-quality corpus building — collect real question/answer pairs with upvote signal for LLM answer-quality training or eval work.

Community sentiment tracking — monitor how a topic's top answers change over time as new responses get upvoted.

Competitive research — see which questions in your industry get the most engagement and who's answering them.

Frequently asked questions

Why did every cloud run fail for three straight days after the fix shipped?

The fix was merged to main but never actually deployed — the account was over its Apify usage cap, so apify push never ran, and every run kept executing the old, unfixed build. Once the cap lifted and the real build shipped, the run behavior changed immediately.

Is Quora protected by a JS-execution wall like Cloudflare's managed challenge?

Not permanently. Cloudflare's challenge does appear on some requests, but our browser-engine escalation clears it — confirmed by a residential data-transfer signature an order of magnitude above a challenge-blocked run. The zero-row failures after that point were parser bugs against Quora's real (non-guessed) markup, not an anti-bot wall.

Why did an early build return zero rows even after the browser engine ran?

Our answer selectors were an unvalidated guess against Quora's hashed CSS-in-JS class names, which don't hold still across deploys. The fix targets Quora's own puppeteer_test_* test-id classes instead — internal hooks that don't get renamed the way styling classes do.

Why were author name and upvote count sometimes missing even after that fix?

Two separate structural surprises: each answer card carries two profile links (an empty-text avatar link before the name-bearing one), and the upvote button sits one DOM level further from the byline than the profile link does. Both are now accounted for explicitly.

What does 1,000 rows cost?

$2.20 — 1,000 × $0.002 per result, plus the $0.20 run-start charge.

Try it

Live on the Apify Store: Quora Questions & Answers Scraper.

Feed it question URLs, topic URLs, or search keywords and get back typed rows built against markup we actually ground-truthed — not selectors we guessed and hoped held. Pay-per-event, no subscription, no card required to try.


Built by Devil Scrapes — we publish the traps we hit, including the ones that cost us three days before we found them.

Top comments (0)