# Changelog of Threads Reply Scraper — Conversation Graph (`devilscrapes/threads-reply-tree`) Actor

- **URL**: https://apify.com/devilscrapes/threads-reply-tree/changelog.md
- **Full Actor documentation**: https://apify.com/devilscrapes/threads-reply-tree.md

## Changelog

### \[0.7.2] — 2026-09-24 (fix: Relay payload schema migration — every run was failing, proxy or not)

#### Fixed

- **Root cause** (CEO daily report: 73% 30-day success, 19/72 failed; build `0.7.1` 15 days live): reproduced directly with two fresh cloud runs against build `0.7.1` — `useProxy: true` (the schema default) and `useProxy: false` (the QA fixture's setting) both FAILED, 0 rows, after exhausting all 5 rotate-and-retry attempts as "soft block: HTTP 200 without a usable payload." Both are false positives — Threads restructured the logged-out post page's SSR JSON between 2026-09-09 and 2026-09-24, and `extract_conversation_payload()`/`_walk_to_edges()` could no longer find `result.data.data.edges` anywhere in the response, so every real 200 response was misclassified as a login-wall soft block and the retry/rotation machinery burned through all 5 attempts on posts that were never actually blocked.
- **New shape** (verified live via a local curl-cffi capture, `@mosseri/post/DYX3oNcAO4r`, 2026-09-24): the conversation is now split across two separate `data-sjs` scripts instead of one. Root post: `result.data.media` is the full post object directly (same field names as before — `pk`, `user`, `caption`, `text_post_app_info`, `video_versions`, etc.). Replies: a **separate** script's `result.data.media.text_post_app_info.direct_replies.edges[i].node.posts.edges[j].node` holds the reply-chain nodes (same chain-depth concept as the old `thread_items`, renamed and one hop deeper).
- Fix: `src/parser.py` rewritten to parse every `data-sjs` script, walk each to `result.data.media`, and classify *structurally* (root = has `pk`+`user`; replies = has `text_post_app_info.direct_replies.edges`) rather than by literal marker substrings (`BarcelonaPostPage`/`thread_items`), which no longer reliably identify either script. `extract_conversation_payload()` now returns `{"root": <post>, "reply_edges": <edges>}`; `walk_threads()` and `_rows_from_thread_chain()` updated to match. `_post_to_row()`, `_extract_media()`, `_extract_view_count()`, `_extract_handles()` are unchanged — the individual post-object field shape did not move, only its location in the payload tree did.
- `tests/test_parser.py` / `tests/test_parser_media.py`: `_payload()`/`_edge()` test helpers rebuilt to the new two-script shape; `test_extract_payload_merges_root_and_replies_scripts` and `test_extract_payload_root_only_no_replies_script` replace the old single-script `test_extract_payload_picks_correct_script`. `tests/test_main.py::_minimal_valid_payload()` updated to match.
- `tests/fixtures/input.qa.json`: `useProxy` flipped `false` → `true` (the schema default, and what customers actually run). It had tested the no-proxy path exclusively since this Actor's first QA fixture — every prior cloud QA PASS, including `0.7.1`'s, verified a path most customers don't take. Both paths are confirmed fixed (see reproduction above), so this closes the coverage gap rather than papering over it.
- 64/64 hermetic + 1 live `-m smoke` green; ruff + pyright clean. Local `apify run --input-file=tests/fixtures/input.qa.json` (now `useProxy: true`) against the live target: 7 rows, no retries needed — confirms the fix, not just a proxy workaround.

### \[0.7] — 2026-09-09 (upgrade: media / mentions / hashtags / view_count — closes 3 of the 4 competitive gaps vs. the direct incumbent)

#### Added

- **Rationale** (`research/2026-09-09-upgrade-lane-top-earners.md`, "threads-reply-tree" section, briefs 5+6): the direct incumbent in this niche, `futurizerush/threads-replies-scraper` (1,338 users, 305 u30 — proof this exact niche pays for these fields), leads its listing with media metadata, mentions/hashtags, and post view counts. This Actor previously shipped none of them.
- `src/models.py` — new `MediaItem` model (`type: "image"|"video"`, `url`, `alt_text`) and four new `ResultRow` fields: `media: list[MediaItem]`, `mentions: list[str]`, `hashtags: list[str]`, `view_count: int | None`.
- `src/parser.py` — pure extraction functions, all reading fields already present in the SSR payload this Actor already parses (no new HTTP request):
  - `_extract_media()` / `_media_item_from_node()`: reads `video_versions` (preferred) or `image_versions2.candidates[0]`, with `accessibility_caption` as `alt_text`. Carousel-aware — `carousel_media` items are walked individually; a non-carousel post is treated as a one-slide carousel of itself.
  - `_extract_handles()`: shared regex helper for both `@mention` and `#hashtag` extraction from `reply_text`, deduped in first-seen order.
  - `_extract_view_count()`: best-effort read of `text_post_app_info.public_view_count_card_attachment_info` — root row only, `null` everywhere else (including when Threads doesn't publish the card, which was every live post captured during development; see Limitations).
- `.actor/dataset_schema.json` updated with the four new fields (all optional/nullable — no change to `required`, so existing consumers reading old rows are unaffected).
- `tests/fixtures/live_post_media_mentions_hashtags.json` — a **real** capture (curl-cffi chrome131 against `https://www.threads.com/@zuck/post/DcwLClnmOrR`, 2026-09-09), trimmed to the fields `parser.py` consumes: the root post (a real video attachment), one real reply with a `#hashtag`-only caption, and one real reply with an `@mention`. 9 new tests in `tests/test_parser_media.py` exercise both this live capture and synthetic edge cases (carousel media, no media, multiple mentions/hashtags in one caption, deduping).
- 63/63 hermetic + 1 live `-m smoke` green; ruff + pyright clean (one pre-existing, unrelated `pyright` finding in `tests/test_main.py:48` predates this change and is out of scope here).

#### Investigated, not shipped

- **Nested "Show replies" expansion (brief 5) — infeasible for this Actor's architecture, not implemented.** A live Camoufox recon (recording all `threads.net`/`threads.com` XHR/GraphQL requests while attempting to trigger "Show replies" on two high-reply-count posts, `@zuck/post/DctzKbqgOUy` and `@mosseri/post/DYX3oNcAO4r`) found **zero** such requests fired. Instead, the DOM renders a literal **"Log in to see more replies."** wall in place of any expansion affordance for a logged-out session — confirmed via `page.evaluate()` scanning all `div/span/a/button` text and `aria-label` content, not just the visible viewport. There is no anonymous continuation endpoint to call: Threads gates deeper reply pagination behind actual authentication, not a client-side XHR this Actor could reuse on its existing curl-cffi session. Since this Actor's core value proposition is "no Meta login" (see the README H1 and FAQ), building real session-based authentication to unlock this would be a different, much larger, and ToS-riskier product than what's shipped today — out of scope for a "reasonable effort" attempt. Documented in README → Limitations. If a future pass wants to revisit this, it needs actual Meta session cookies, not a bigger XHR search.

#### Not changed

- No behavior change to the retry/backoff/rotation state machine (`client.py`), the residential proxy group, or existing fields (`row_type` .. `depth`, `scraped_at`) — this is a strictly additive schema change plus the brief-5 investigation above.

#### Fixed (published as build 0.7.1)

- The pre-existing `pyright` finding in `tests/test_main.py:48` noted above (line 14) is fixed in commit `5a88ed5e` — `uv run ruff check .`, `uv run pyright`, `uv run pytest -q`, and `uv run pytest -q -m smoke` are all green.
- Build `0.7.1` cloud QA PASS: runId=`eNyBsEF3XifNYGF9a`, 8/8 rows, `chargedEventCounts` actor-start=1/result-row=8, new `media`/`mentions`/`hashtags`/`view_count` fields present on every row (root row carries a real video in `media`). No PPE event or pricing change. Spend $0.000494 (well under $0.30 budget).

### \[0.6.1] — 2026-09-02 (fix: HTTP 200 login wall was treated as a successful fetch — retry/rotation never fired)

#### Fixed

- **Root cause** (30-day success rate 79.5%, 14 users at the time; one
  consenting-user failure visible in Console Insights, run
  `GgAvIfJGeJ5KPUxzY`, 2026-08-26): `10:17:28 FAILED 0 rows 6s`,
  `10:18:55 SUCCEEDED 22 rows 9s`, `10:20:59 SUCCEEDED 22 rows 12s` — same
  user, same post, 90 seconds apart. A dead/private URL does not start
  working 90s later, and 6s is far too short for `MAX_RETRIES=5` with
  backoff to have actually run — it never ran. Meta serves a soft block as
  **HTTP 200 with a login-wall body**, not a 403/429, and
  `ThreadsSession._attempt()` returned any 2xx body as success. `main.py`
  then found no conversation payload in that body, returned `[]`, and
  `kept == 0` exited 1 (REQ-11) on the very first attempt — the retry and
  proxy-rotation machinery built in \[0.3]/\[0.4]/\[0.5] never triggered
  because nothing had signalled a block in the first place.
- Fix: `fetch_post_html()` in `src/client.py` takes an optional `is_usable`
  predicate; a 200 body the predicate rejects is now handled exactly like a
  403 — rotate identity, back off, retry — and exhaustion raises
  `RuntimeError` naming the soft blocks. `main.py` supplies the predicate
  (payload extractable?) and caches the parsed payload so a usable body is
  still parsed only once. `client.py` stays parser-agnostic per its module
  contract (predicate injected, not imported). REQ-11 is unchanged: after 5
  rotated attempts a run with zero rows is genuinely blocked and still
  exits non-zero.
- 3 new regression tests (recovers after a soft block; sustained soft block
  raises; no predicate preserves legacy behaviour). 54/54 hermetic + 1 live
  `-m smoke` green; ruff + pyright clean.
- Shipped as build `0.6.2` (actor.json version field intentionally held at
  `0.6` — no input/schema/pricing change, so this rides the same top-level
  version per Apify's own build-number sub-versioning). Cloud QA: build
  `0.6.2`, run `TM755kSbA8Kxbaeu6` SUCCEEDED, 18 rows,
  `chargedEventCounts={"actor-start": 1, "result-row": 18}`.
- Backfilled into this CHANGELOG + `docs/specs/threads-reply-tree/notes.md`
  on 2026-09-03 by `actor-fixer` triaging a fresh "79% success, 16/77
  failed, 35 users" report — the code and cloud build were already live
  and QA-verified when found; this entry documents that shipped fix, no
  new code change accompanies it. See notes.md for the 2026-09-03 session
  writeup, including why no further code change was made this pass.

### \[0.6] — 2026-09-01 (fix: one malformed reply node crashed the whole walk instead of being skipped)

#### Fixed

- **Root cause** (CEO daily report 2026-09-01: "30-day success rate 79% — 17
  of 83 customer runs failed"; `users=35`, the highest of any Actor in the
  fleet). Build `0.5.1` has been live since 2026-08-21 (11 days before this
  report), so the 30-day window is dominated by runs against the *current*
  build — this is not a stale-report artifact. `GET /v2/acts/{id}/runs`
  again shows only our own 7 runs (all SUCCEEDED, build `0.5.1`) — the 17
  failures are invisible customer runs, so the bug was reproduced directly
  from the source rather than from logs we don't have access to.
- Two highest-probability causes for this fleet were checked first
  (`reference-fleet-fault-isolation-pattern`, `scripts/os/empty_is_not_failure.py`):
  - **Empty-is-a-failure**: ruled out. `walk_threads` always emits at least
    the root row unless the payload itself can't be parsed, so a
    genuinely-zero-reply post already scores `kept=1`, not `kept=0` —
    `main.py`'s `SystemExit(1)` on `kept == 0` only fires when nothing at
    all could be scraped.
  - **Fault isolation**: confirmed. `src/parser.py::walk_threads` called
    `_post_to_row(...)` directly for the root post and for every reply node
    in a thread chain. `_post_to_row` coerces fields like `like_count` /
    `reply_count` with a bare `int(...)` — any one node with a
    non-numeric/uncoercible field (a deleted-but-visible stub reply, a bot
    account with a weird counter, any schema-drift edge case) raised
    `ValueError`/`TypeError`/etc. **uncaught inside `walk_threads` itself**,
    discarding the root row and every other good reply for that post — not
    just the bad node. `_scrape_one_url` in `main.py` already isolates
    failures *per post URL* (tested, `test_drain_inputs_one_bad_url_does_not_
    sink_the_others`), but this Actor's default and most common usage is a
    single post URL, where that per-URL isolation does nothing. Reproduced
    directly: `walk_threads` on a 3-edge payload (root + good reply + reply
    with `like_count="N/A"`) raised `ValueError` and returned zero rows for
    the whole post, confirmed locally before any code change.
- Fix: `src/parser.py` — new `_safe_post_to_row()` wraps `_post_to_row()` and
  returns `None` (never raises) on `(ValueError, TypeError, KeyError,
  AttributeError, IndexError)`; both call sites (`walk_threads`'s root row
  and `_rows_from_thread_chain`'s per-item loop) now use it and skip the bad
  node instead of propagating. A skipped mid-chain node doesn't advance
  `parent_id`, so later nodes in the same chain correctly re-link to the
  last successfully-emitted ancestor instead of losing their place in the
  tree. A malformed *root* post still yields zero rows for that URL (there's
  no valid anchor to link replies to), but no longer raises — the URL is
  now cleanly skipped by `_scrape_one_url`'s existing per-URL guard instead
  of crashing.
- New regression tests in `tests/test_parser.py`:
  `test_one_malformed_top_level_reply_does_not_crash_the_whole_walk`,
  `test_one_malformed_node_mid_chain_does_not_sink_the_rest_of_the_chain`,
  `test_malformed_root_post_returns_empty_instead_of_raising` — all three
  fail on pre-fix code (reproduced), pass post-fix. 54/54 hermetic + 1 live
  `-m smoke` green; ruff + pyright clean; `verify_input_prefill.py` and
  `verify_no_scaffold_stub.py` both OK. A local `apify run` against
  `tests/fixtures/input.qa.json` against the live target produced 8 real,
  fully-populated dataset rows (not a zero-row false-green).
- No change to the retry/backoff/rotation state machine in `client.py` or
  to the `RESIDENTIAL` proxy group set in \[0.5] — neither was implicated by
  this reproduction.
- Not verified in this pass (needs cloud QA + a few days of the fresh
  30-day window): whether this fully accounts for all 17 failures, or
  whether a residual slice is proxy/anti-bot related. If success rate is
  still below expectations after this propagates, check for schema drift
  in the Relay payload shape itself (a `_walk_to_edges` miss returns `[]`
  silently and is not distinguished from "genuinely no conversation
  payload" in the CHANGELOG's own \[0.2] framing) before touching rotation
  logic a third time.

### \[0.5] — 2026-08-21 (fix: proxy group was a small datacenter-ish USA pack, not residential — Meta was blocking the pool itself, not failing to rotate)

#### Fixed

- **Root cause** (CEO report 2026-08-21: "30-day success rate ~72% — 18 of
  66 runs failed"; unchanged from before the \[0.4] 403-rotation fix shipped
  2026-08-15, which fixed a real bug but did not move the success rate).
  `GET /v2/acts/{id}/runs` only returns runs started under our own token (3
  total, all our own QA, all SUCCEEDED) — the 18 failures are invisible
  customer runs, so the bug had to be reproduced and the infra verified
  directly:
  - A live cloud run against the exact Store-prefill input (build 0.4.1,
    `useProxy: true`, default depth/cap) — run `Vg7vXsyrLiCcL27Ua` —
    SUCCEEDED, but `scripts/os/run_usage.py` showed **zero**
    `PROXY_RESIDENTIAL_TRANSFER_GBYTES` billed against it, despite the code
    requesting a proxy on every run.
  - `GET /v2/users/me` shows the Console labels `BUYPROXIES94952` as
    *"Proxies from USA 2"* (27 IPs) — a small static/datacenter pack, not
    residential. `RESIDENTIAL` shows `availableCount: 0` in the same
    response, which is the known cardinality display artifact (see memory
    `reference-proxy-availablecount-trap` /
    `reference-residential-is-entitled`), not a real entitlement gap — a
    local probe authenticated against *both* groups successfully.
  - `src/main.py::PROXY_GROUP` was set to `BUYPROXIES94952` since this
    Actor's original 2026-05-16 scaffold, when it was the only proxy group
    provisioned on the account (FREE tier). The account moved to STARTER on
    2026-08-20, which provisions `RESIDENTIAL`, but nothing here was ever
    updated to use it — every "rotate on block" fix (\[0.3], \[0.4]) rotated
    *within* the same small non-residential pool. Meta blocks datacenter
    IPs aggressively (this Actor's own `useProxy` schema copy already said
    so) — rotating through 5-27 exits that are all in the same flagged
    range never produces a genuinely fresh identity. `amazon-reviews-scraper`
    — the sibling `client.py` explicitly says its retry/rotation logic
    mirrors — defaults `PROXY_GROUP` to `RESIDENTIAL`; this half of the
    pattern was never matched.
- Fix: `PROXY_GROUP = "RESIDENTIAL"` in `src/main.py`. Updated the
  `useProxy` copy in `.actor/input_schema.json`, `src/models.py`,
  `scaffold.json`, and `README.md` — all previously advertised the
  BUYPROXIES94952 pool as "residential" by name, which was never true.
- New regression test `test_create_proxy_configuration_uses_residential_group`
  — asserts `_create_proxy_configuration` requests `groups=["RESIDENTIAL"]`;
  fails on pre-fix code (`groups=["BUYPROXIES94952"]`), passes post-fix.
  48/48 hermetic + 1 live `-m smoke` green.
- No behavior change to the retry/backoff/rotation state machine in
  `client.py` — that logic (403+429 rotate, 408/429/5xx retry, per-URL
  fault isolation) was already correct; it was rotating within the wrong
  pool.

### \[0.4] — 2026-08-15 (fix: HTTP 403 never actually rotated — the 0.3 fix's core claim was unreachable code)

#### Fixed

- **Root cause** (CEO report 2026-08-15: "30-day success rate 68% — 21 of
  66 runs failed"; success rate did NOT recover after the 2026-08-10 \[0.3]
  proxy-rotation fix — `ops/reports/data/*.json` shows `success_rate_30d`
  actually *dropping* from 0.734 (08-09, pre-fix) to 0.687/0.682/0.672
  across the days immediately after 0.3 shipped, before a marginal
  recovery to 0.682 today). `recent_failures` on our own runs was empty of
  new signal (3 FAILED runs total, all either the pre-fix bug already
  documented in \[0.3] or a deliberate own negative-input test), so the
  regression had to be found by code inspection plus a live cloud
  reproduction, not guessed.
- `src/client.py::ThreadsSession._attempt` gated retries with
  `if status not in RETRY_STATUSES: raise RuntimeError(...)` **before**
  ever consulting `ROTATE_STATUSES`. `RETRY_STATUSES` is
  `{408, 429, 500, 502, 503, 504}` — it does not include `403`. Since
  `ROTATE_STATUSES = {403, 429}` is only checked inside
  `_handle_retriable_status`, which is itself only reached *after* the
  `RETRY_STATUSES` gate passes, every HTTP `403` — Meta's most common
  anti-bot block signal, arguably more common than `429` for an
  already-flagged residential exit — raised immediately on attempt 1 with
  **zero retry and zero rotation**. The `ThreadsSession` rotation logic the
  \[0.3] CHANGELOG entry credited with fixing the customer-failure spike was
  therefore unreachable for 403s the entire time; \[0.3]'s own
  reproduction and cloud QA only exercised 429 and connection-timeout
  cases, never 403, so the gap shipped untested. Confirmed against
  `actors/amazon-reviews-scraper/src/client.py` — the sibling module this
  code claims to "structurally mirror" — whose equivalent gate correctly
  reads `if status not in RETRY_STATUSES and status not in
  ROTATE_STATUSES: raise` (i.e. a status in `ROTATE_STATUSES` can never be
  treated as non-retriable). `threads-reply-tree` had dropped the
  `ROTATE_STATUSES` half of that condition.
- Fix: add `and status not in ROTATE_STATUSES` to the same gate, matching
  `amazon-reviews-scraper`. A 403 now reaches `_handle_retriable_status`,
  which rotates the browser-impersonation profile and mints a fresh
  Apify Proxy `session_id` before backing off and retrying — exactly the
  behavior the \[0.3] docstrings already claimed.
- New regression test `test_retry_on_403_rotates_session` (mirrors the
  existing `test_retry_on_429_rotates_session`) — fails on pre-fix code
  with `RuntimeError: HTTP 403 ... (non-retriable)`, passes post-fix.
  48/48 tests green (47 hermetic + 1 live `-m smoke`).
- No behavior change for 404/400/401/410 (still immediately non-retriable,
  per `test_no_retry_on_404`) or for 429/5xx (unchanged).

### \[0.3] — 2026-08-10 (fix: fixed proxy session reused across every retry — sustained blocks failed the whole run)

#### Fixed

- **Root cause** (CEO report 2026-08-10: "30-day success rate 69% — 20
  FAILED of 68 customer runs"; 5 new failures landed since 08-06 on build
  0.2.1). Our own runs mostly succeed (`GET /v2/acts/{id}/runs` only
  returns runs started under our token), so this had to be reproduced
  directly against the live Store build. Two throwaway cloud runs against
  the exact same real, currently-live post URL minutes apart:
  `bmn46iJqvQeE47f68` SUCCEEDED in ~5s; `NaX3kHbV8fQQNymwO` hit **five
  consecutive HTTP 429s** from Threads and FAILED with exit 91
  ("No rows emitted..."); a third run (`BbQqCwfKct710S0ab`) hit sustained
  `curl: (28) Connection timed out` on every attempt instead. In every
  case the log shows the identical failure repeating attempt after
  attempt with no change in outcome — because `main.py` opened **one**
  `curl-cffi` session (and therefore one fixed residential proxy exit IP)
  before the retry loop and reused it for every attempt and every URL in
  the batch. A blocked/rate-limited exit was retried against *itself*
  five times, in direct violation of the house anti-blocking rule
  ("rotate the session_id on every block"). Real content parsing was
  confirmed unaffected — six freshly-fetched real posts all parsed
  correctly during triage; this was purely a transport-identity bug.
- `src/client.py` replaced the stateless `fetch_post_html(session, url)`
  helper with a stateful `ThreadsSession` that rotates the browser
  impersonation profile *and* mints a fresh Apify Proxy `session_id` on
  every `403`/`429`/network-level error, before the next retry attempt.
  Structurally mirrors `actors/amazon-reviews-scraper/src/client.py`'s
  `AmazonReviewSession`, an already-shipped instance of this exact
  pattern.
- `src/main.py` now pins `country_code="US"` on the Apify Proxy
  configuration (`feedback-pin-proxy-country`) — defensive, since a
  geo-random exit can silently change what a target serves even without
  an outright error.
- Added `_guard_against_missing_replies`: if a post's own
  `direct_reply_count` says replies exist but the walk captured none, the
  Actor now logs `ERROR` and dumps the raw conversation payload to the
  KVS (`missing-replies-<id>` key) instead of letting that look like a
  clean, complete scrape. Posts that genuinely have zero replies are
  unaffected.

### \[0.2] — 2026-08-05 (fix: threads.com URLs hard-rejected the whole batch)

#### Fixed

- **Root cause** (CEO report 2026-08-05: "30-day success rate 76% — 13
  FAILED of 56 runs"): `recent_failures` on our own runs was empty, so
  this had to be reproduced directly. Live check confirmed
  `https://www.threads.net/...` now 301-redirects to
  `https://www.threads.com/...` — Meta migrated the canonical share-link
  domain in 2024, and every "Copy Link" / address-bar URL a customer
  pastes today is `threads.com`. `ActorInput.post_urls`'s regex only
  accepted `threads.net`, and Pydantic validates the whole list
  eagerly — a single `threads.com` URL anywhere in `postUrls` raised
  `ValidationError` and killed the **entire** run via `SystemExit(1)`
  before a single HTTP request was made. Reproduced locally with `apify
  run`: pre-fix, a `threads.com`-only input exits 91 (FAILED) inside
  `ActorInput.model_validate`; post-fix, the same input exits 0 with 7
  rows scraped.
- `POST_URL_PATTERN` now accepts both `threads.net` and `threads.com`.
- `ActorInput._normalize_urls` no longer fails the whole `postUrls`
  batch on one malformed item (wrong domain, wrong shape, non-string).
  It skips the bad item with a logged warning and keeps every valid
  URL — matching the REQ-7 "fail loud only when nothing is left"
  pattern already used elsewhere in this Actor. Still raises when
  *zero* items in the batch are usable.
- `main._scrape_one_url` now also catches unexpected parser exceptions
  (`ValueError`/`TypeError`/`KeyError`/`AttributeError`/`IndexError`)
  per-URL and skips that URL instead of letting a single malformed post
  object crash `_drain_inputs` for every other URL in the batch.
- Updated `input_schema.json` description, the `postUrls` field
  description, and the README "Finding a Threads post URL" section to
  say both domains work.
- No changes to the happy-path fetch/parse logic — `threads.net` still
  works unchanged (verified redirect + SSR payload extraction both
  succeed), and the QA fixture (`threads.net`) still scrapes 7 rows.

### \[0.1.0] — 2026-05-16

#### Added

- Initial release.
- HTML SSR extraction of Threads conversation tree via `curl-cffi`
  chrome131 impersonation.
- Per-post reply tree: root + every visible reply chain, with `depth`
  - `parent_reply_id` linkage for graph reconstruction.
- `max_depth` and `max_replies_per_node` caps for cost control.
- PPE pricing: $0.05 / start + $0.005 / row.
- Residential proxy (BUYPROXIES94952) recommended ON by default.
