Skip to content

Phoenix Rising corpus: LDN & Mestinon scraper, analysis scripts, S3 data upload - #41

Open
Ely-S wants to merge 11 commits into
mainfrom
phoenixrising-ldn-mestinon
Open

Phoenix Rising corpus: LDN & Mestinon scraper, analysis scripts, S3 data upload#41
Ely-S wants to merge 11 commits into
mainfrom
phoenixrising-ldn-mestinon

Conversation

@Ely-S

@Ely-S Ely-S commented Jun 10, 2026

Copy link
Copy Markdown
Owner

What this PR does

Adds end-to-end infrastructure to scrape the Phoenix Rising ME/CFS forum, build a queryable SQLite corpus, and produce real-world-evidence statistics for LDN and pyridostigmine (Mestinon). All generated data is uploaded to S3; only code and configuration are tracked in git.


Corpus — produced data

Location: s3://patientpunk/data/phoenixrising/

File Size Description
phoenixrising.db 5.6 MB SQLite database (schema from schema.sql)
phoenixrising_posts.json 5.9 MB Raw scraped threads + replies in PatientPunk corpus format
phoenixrising_metadata.json 1 KB Scrape run metadata (timing, discovery config, counts)
all_pr_targets.txt 27 KB Consolidated URL list used as scrape input (316 threads)
README.md 5 KB Full statistics and reproduction steps

Corpus statistics

Total: 6,657 posts/comments · 853 unique participants · 314 threads · Phoenix Rising ME/CFS forum · 2009–2026

Low-Dose Naltrexone (LDN)

Metric Value
Posts mentioning LDN 2,525
Unique participants 536
Discussion span 2009-07-22 → 2026-06-05
Posts with explicit mg dose 831 (1,824 dose figures)
Dose figures in LDN window (≤4.5mg) 68% of all dose mentions
Most-cited doses 4.5mg×243, 1mg×177, 1.5mg×177, 3mg×134, 2mg×106, 0.5mg×93
Sourcing / compounding barrier 171 posts
Cost / insurance barrier 188 posts
Prescriber reluctance 31 posts

Note: 50mg appears in 150 posts — full-dose naltrexone references, flagged but not excluded.

Pyridostigmine / Mestinon

Metric Value
Posts mentioning pyridostigmine/Mestinon 447
Unique participants 130
Discussion span 2011-01-28 → 2026-05-20
Posts with explicit mg dose 131 (338 dose figures)
Most-cited doses 30mg×48, 60mg×45, 10mg×34, 20mg×15, 120mg×13
Sourcing / compounding barrier 15 posts
Cost / insurance barrier 28 posts
Prescriber reluctance 8 posts

Exact reproduction steps

Requires uv, Python 3.13, BRAVE_SEARCH_API_KEY in environment.

Step 1 — Collect URLs

Combines three sources into one deduplicated target list:

uv run python Scrapers/scrape_phoenixrising.py \
  --from-search-api \
  --search-query-file Scrapers/phoenixrising_search_queries_ldn.txt \
  --search-query-file Scrapers/phoenixrising_search_queries_mestinon.txt \
  --search-pages 10 \
  --search-per-page 20 \
  --thread-list Scrapers/phoenixrising_targets.txt \
  --thread-list Scrapers/phoenixrising_recall_targets.txt \
  --thread-list Scrapers/phoenixrising_tag_targets.txt \
  --url-out output/all_pr_targets.txt

Sources merged:

  • Brave Search API — 9 queries (4 LDN + 5 Mestinon), 10 pages × 20 results each
  • phoenixrising_targets.txt — 276 hand-curated thread URLs
  • phoenixrising_recall_targets.txt — 17 body-mention recall threads
  • phoenixrising_tag_targets.txt — 5 tag-page threads

Result: 316 unique thread URLs.

Step 2 — Scrape

uv run python Scrapers/scrape_phoenixrising.py \
  --thread-list output/all_pr_targets.txt \
  --out output/phoenixrising_posts.json

Config: 2.5s polite delay, up to 500 pages/thread, usernames SHA-256 hashed before any disk write. Robots.txt compliant.

Result: 316 threads, 6,717 replies.

Step 3 — Import

sqlite3 data/phoenixrising.db < schema.sql
uv run python src/import_posts.py \
  --reddit-posts output/phoenixrising_posts.json \
  --output-db data/phoenixrising.db

Result: 853 users, 6,657 posts/comments.

Step 4 — Statistics

uv run python scripts/free_use_counts.py --db data/phoenixrising.db

Step 5 — Upload to S3

mc cp data/phoenixrising.db s3/patientpunk/data/phoenixrising/phoenixrising.db
mc cp output/phoenixrising_posts.json s3/patientpunk/data/phoenixrising/phoenixrising_posts.json
mc cp output/phoenixrising_metadata.json s3/patientpunk/data/phoenixrising/phoenixrising_metadata.json
mc cp output/all_pr_targets.txt s3/patientpunk/data/phoenixrising/all_pr_targets.txt
mc cp output/README.md s3/patientpunk/data/phoenixrising/README.md

Code changes

Scrapers/scrape_phoenixrising.py — two-step collect→scrape architecture

Replaced the mutually exclusive --from-sitemap / --from-search-api / --thread-list source group with composable flags:

  • --from-search-api and --thread-list can now be combined in one invocation
  • --thread-list and --search-query-file are both appendable (action="append") — pass each file as a separate flag
  • --url-out FILE — new flag: collect URLs from all sources, write deduplicated list, exit without scraping. Enables the clean two-step workflow
  • discover_from_search_api now accepts query_files: list[Path] instead of a single file
  • Added flush=True to all progress print() calls so background tasks produce live output

drugs/naltrexone.txt / drugs/pyridostigmine.txt — canonical alias files

Single source of truth for alias sets, read by all four analysis scripts. Previously each script had its own hardcoded list; they had silently diverged (e.g. export_for_manual_classification.py included "revia" / "vivitrol" which aggregate_census.py intentionally excluded).

scripts/aggregate_census.py

Replaced hardcoded DB = "data/phoenixrising.db" with --db CLI arg, consistent with the other three scripts.

scripts/export_for_manual_classification.py / export_full_for_classification.py / free_use_counts.py

Updated to read alias sets from drugs/*.txt via read_alias_file() (was already done on disk for these three; this commit locks it in and removes the old inline lists).

.gitignore

Added outputs/manual/ — 110 generated classification batch files (JSONL + JSON label outputs) were previously tracked; removed from index via git rm --cached -r.

bennett shepard and others added 7 commits June 9, 2026 15:37
- Scrapers/scrape_phoenixrising.py: robots-compliant XenForo scraper emitting the
  PatientPunk corpus schema (thread->post, replies->comments, quote->parent linkage).
- Scrapers/phoenixrising_targets.txt: 276 drug-focused thread URLs (sitemap-derived).
- drugs/{naltrexone,pyridostigmine}.txt: curated alias lists.
- scripts/: free use-counts, sample + full-census classification export, aggregation
  with gold-set validation.
- docs/phoenixrising_use_stats.md: write-up (use, dosing, barriers, sentiment).
- pyproject: add beautifulsoup4 (scraper dependency).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Raw real-world-evidence data backing the LDN/Mestinon analysis:
- output/phoenixrising_posts.json : full scraped corpus (276 threads, ~5,081 posts)
- data/phoenixrising.db           : SQLite with posts imported (ready for scripts)
- outputs/manual/                 : 400 hand-labels + full-census agent labels,
                                    per-batch inputs, census_summary.json, sentiment_summary.json

Usernames are SHA-256 hashed. Force-added past .gitignore intentionally (per maintainer decision).
(The 3-thread pr_test scaffold was left out as a redundant subset of the full corpus.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
External site-scoped web search surfaced 17 threads that discuss LDN/Mestinon in
post bodies but not the title (recovery-story lists, treatment guides, dysautonomia
threads, and a misspelled-title "Mestonin" thread). Adds 770 posts -> +46 LDN and
+53 Mestinon new direct mentions; all classified (stragglers + labels_recall_stragglers.json).

Combined corpus: 293 threads. Sentiment split essentially unchanged vs the title-based
core (robustness check). docs/phoenixrising_use_stats.md and census_summary.json updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deterministic (no-AI) /tags/ crawl (ldn, naltrexone, mestinon, pyridostigmine) added
5 threads not already captured (+23 LDN, +3 Mestinon mentions; all classified).

Final near-complete corpus: 298 threads — LDN 2,516 posts / 534 participants;
pyridostigmine 389 / 121. Sentiment unchanged across all four expansions
(LDN 38% positive / 63% some-benefit; Mestinon 42% / 60%), validation 84/90/93.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…unt print

- docs/recall_search_provenance.md: records that the 17 body-mention threads were
  discovered by Claude via web search (the 17 exact queries listed), with an explicit
  AI-assisted / not-deterministically-reproducible caveat for the methods write-up.
- free_use_counts.py: replace hardcoded "276 threads" with a computed thread count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Handle missing/anonymous authors by mapping to stable "anon" user ID
- Support non-Reddit forums via SOURCE_NAME_BY_HOST hostname mapping
- Repair dangling parent links (forum quotes referencing unexported posts)
- Add input validation for resumed imports and source detection
- Fix robots.txt URL path parsing and thread URL normalization

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Migrate Phoenix Rising search discovery to Brave Search API with documented operator usage, split LDN/Mestinon query sets, and add a paginated analyzer that writes per-query outputs plus exact minimum set cover after manual curl validation and 429-aware retries.

Co-authored-by: Cursor <cursoragent@cursor.com>
@qodo-code-review

qodo-code-review Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0)

Grey Divider


Action required

1. Robots redirect bypass 🐞 Bug ☼ Reliability
Description
fetch() enforces _check_robots(url) only on the initially requested URL, but it does not
validate the final URL after urlopen completes, so a redirect can lead to fetching a
robots-disallowed path while still passing the guard. This contradicts the scraper’s stated
guarantee that it “refuses any path in the robots.txt Disallow list.”
Code

Scrapers/scrape_phoenixrising.py[R100-116]

+def fetch(url: str, delay: float, retries: int = 5) -> str:
+    """GET a URL as text with polite delay and exponential backoff."""
+    _check_robots(url)
+    last_exc = None
+    for attempt in range(retries):
+        try:
+            req = urllib.request.Request(
+                url,
+                headers={
+                    "User-Agent": UA,
+                    "Accept": "text/html,application/xhtml+xml,application/xml",
+                    "Accept-Encoding": "identity",
+                },
+            )
+            with urllib.request.urlopen(req, timeout=60) as resp:
+                raw = resp.read()
+            time.sleep(delay)
Evidence
The file’s own documentation promises the guard blocks all disallowed paths, and the code’s disallow
list includes sensitive paths; however, fetch() validates only the original URL and then performs
the network request without any validation of the ultimately fetched URL.

Scrapers/scrape_phoenixrising.py[17-22]
Scrapers/scrape_phoenixrising.py[69-75]
Scrapers/scrape_phoenixrising.py[87-117]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The scraper claims robots compliance via a guard, but `fetch()` only checks the input URL. If the server redirects (e.g. to `/login/`), the code can fetch disallowed content without ever re-checking robots constraints.
## Issue Context
`ROBOTS_DISALLOW` explicitly includes paths like `/login/` and `/search/`, and the module docstring asserts the guard prevents requesting any disallowed path.
## Fix Focus Areas
- Scrapers/scrape_phoenixrising.py[69-75]
- Scrapers/scrape_phoenixrising.py[87-128]
## Suggested fix
- Make redirect handling explicit and robots-aware:
- Option A (preferred): build a custom opener with an `HTTPRedirectHandler` override that calls `_check_robots(new_url)` inside the redirect handling before following it.
- Option B: disable redirects, manually handle 3xx by reading the `Location` header, calling `_check_robots(location)`, then issuing the next request.
- Apply the same approach anywhere you fetch Phoenix Rising HTML/XML (i.e., `fetch()`), so the “only /threads and /sitemap*.xml fetched” guarantee holds end-to-end.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Non-atomic output rewrites 🐞 Bug ☼ Reliability
Description
The scraper rewrites the entire JSON output (json.dumps(results)) in-place after every thread and
labels this as “crash-safe,” but an interruption mid-write can leave a truncated/invalid JSON file
and break resume. This also creates quadratic I/O as results grows, slowing long scrapes
significantly.
Code

Scrapers/scrape_phoenixrising.py[R576-579]

+        results.append(post)
+        total_comments += len(post["comments"])
+        # Incremental, crash-safe write after every thread.
+        out_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
Evidence
The scrape loop overwrites the full output file on each iteration, which is both expensive as the
list grows and vulnerable to producing invalid JSON if interrupted during the write.

Scrapers/scrape_phoenixrising.py[567-580]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The main scrape loop overwrites the full output JSON file after each thread via `Path.write_text(...)`. This is not atomic, so crashes/interruptions can corrupt the output file, and it becomes increasingly expensive as the output grows.
## Issue Context
The code explicitly intends to be “Incremental, crash-safe,” but the current mechanism does not guarantee a valid file on partial writes.
## Fix Focus Areas
- Scrapers/scrape_phoenixrising.py[567-580]
## Suggested fix
- Make the checkpoint write atomic:
- Write to a temporary file in the same directory (e.g., `out_path.with_suffix('.tmp')`), flush/fsync, then `os.replace(tmp, out_path)`.
- Consider switching to an append-friendly format for checkpoints:
- JSONL per thread (plus a small metadata/index file), or
- periodic checkpoints (every N threads) instead of every thread.
- Ensure resume logic can tolerate partial state (e.g., if using JSONL, ignore a final incomplete line).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Anon user ID collision 🐞 Bug ≡ Correctness
Description
import_reddit_posts() maps all missing/deleted authors to the constant user_id "anon", but
users.user_id is the sole PRIMARY KEY, so anonymous authors from different sources/subreddits are
merged into one user with a single source_subreddit value. Because users are inserted with `INSERT
OR IGNORE, later imports can’t correct the source_subreddit`, causing persistent mis-attribution
in the DB.
Code

src/import_posts.py[R66-70]

+def normalize_author(author_hash: str | None) -> str:
+    """Map missing/deleted authors to a stable sentinel user id."""
+    if author_hash is None or not str(author_hash).strip():
+        return ANON_USER_ID
+    return str(author_hash)
Evidence
The importer always returns the constant ANON_USER_ID for missing authors, while the DB schema
allows only one row per user_id and inserts into users are non-updating (INSERT OR IGNORE), so
multiple sources must collide on the same anon row.

src/import_posts.py[33-70]
src/import_posts.py[154-158]
schema.sql[8-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`normalize_author()` collapses all missing authors into a single `ANON_USER_ID` (`"anon"`). Since `users.user_id` is a global primary key, this merges anonymous authors across different imports (different `source_subreddit` values) and can permanently mislabel the anon user's `source_subreddit` due to `INSERT OR IGNORE`.
## Issue Context
The schema models `source_subreddit` as required per user row, but the current sentinel design cannot represent multiple sources.
## Fix Focus Areas
- src/import_posts.py[33-70]
- src/import_posts.py[116-152]
- src/import_posts.py[154-158]
## Suggested fix
- Make the anon sentinel **source-scoped**, e.g. `anon:<source_subreddit>` (or `anon@<hostname>`), so multiple imports don’t collide.
- Implement this by either:
- changing `normalize_author()` to accept `sub` and return `f"anon:{sub}"` when missing, or
- mapping missing authors in the import loop after `sub` is known.
- Update tests expecting `ANON_USER_ID` accordingly (or keep `ANON_USER_ID` as a prefix and assert with `startswith('anon')`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a Python scraper (scrape_phoenixrising.py) designed to extract patient self-report threads from the Phoenix Rising ME/CFS forum, along with associated target lists, search queries, drug alias files, and documentation of real-world use statistics. Feedback on the scraper focuses on improving robustness: first, by wrapping sub-sitemap fetching in a try-except block to prevent a single network failure from crashing the entire sitemap discovery process; and second, by writing incrementally to a temporary file and atomically replacing the target output file to avoid potential file corruption during long-running operations.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +217 to +222
for sm in sub_sitemaps:
xml = fetch(sm, delay)
for loc in re.findall(r"<loc>([^<]+)</loc>", xml):
normalized = normalize_thread_url(loc)
if normalized:
all_threads.add(normalized)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If fetching or parsing a single sub-sitemap fails (e.g., due to a temporary network glitch or 500 error), the entire sitemap discovery process will crash. Wrapping the sub-sitemap processing in a try-except block ensures that a single failure does not prevent the discovery of threads from other healthy sub-sitemaps.

    for sm in sub_sitemaps:\n        try:\n            xml = fetch(sm, delay)\n            for loc in re.findall(r\"<loc>([^<]+)</loc>\", xml):\n                normalized = normalize_thread_url(loc)\n                if normalized:\n                    all_threads.add(normalized)\n        except Exception as e:\n            print(f\"  ! failed to fetch sub-sitemap {sm}: {e}\", file=sys.stderr)

results.append(post)
total_comments += len(post["comments"])
# Incremental, crash-safe write after every thread.
out_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Writing directly to the target output file (out_path) during long-running scraping operations poses a risk of file corruption if the process is interrupted (e.g., via Ctrl+C or a crash) mid-write. To ensure robustness, write the JSON to a temporary file first and then atomically replace the target file.

        temp_path = out_path.with_suffix(\".tmp\")\n        temp_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding=\"utf-8\")\n        temp_path.replace(out_path)

Ely-S and others added 2 commits June 10, 2026 12:37
Keep Phoenix Rising and sample scrape outputs out of git by deleting tracked data dumps/logs and tightening ignore rules for regenerated files.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep Phoenix Rising and sample scrape outputs out of git by deleting tracked data dumps/logs and tightening ignore rules for regenerated files.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Ely-S

Ely-S commented Jun 10, 2026

Copy link
Copy Markdown
Owner Author

Superseded by a cleanup-only PR from branch cleanup/scraper-artifacts.

@Ely-S Ely-S closed this Jun 10, 2026
@Ely-S Ely-S reopened this Jun 10, 2026
@qodo-code-review

qodo-code-review Bot commented Jun 10, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0)

Grey Divider


Action required

1. Robots redirect bypass 🐞 Bug ☼ Reliability
Description
fetch() enforces _check_robots(url) only on the initially requested URL, but it does not
validate the final URL after urlopen completes, so a redirect can lead to fetching a
robots-disallowed path while still passing the guard. This contradicts the scraper’s stated
guarantee that it “refuses any path in the robots.txt Disallow list.”
Code

Scrapers/scrape_phoenixrising.py[R100-116]

+def fetch(url: str, delay: float, retries: int = 5) -> str:
+    """GET a URL as text with polite delay and exponential backoff."""
+    _check_robots(url)
+    last_exc = None
+    for attempt in range(retries):
+        try:
+            req = urllib.request.Request(
+                url,
+                headers={
+                    "User-Agent": UA,
+                    "Accept": "text/html,application/xhtml+xml,application/xml",
+                    "Accept-Encoding": "identity",
+                },
+            )
+            with urllib.request.urlopen(req, timeout=60) as resp:
+                raw = resp.read()
+            time.sleep(delay)
Evidence
The file’s own documentation promises the guard blocks all disallowed paths, and the code’s disallow
list includes sensitive paths; however, fetch() validates only the original URL and then performs
the network request without any validation of the ultimately fetched URL.

Scrapers/scrape_phoenixrising.py[17-22]
Scrapers/scrape_phoenixrising.py[69-75]
Scrapers/scrape_phoenixrising.py[87-117]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The scraper claims robots compliance via a guard, but `fetch()` only checks the input URL. If the server redirects (e.g. to `/login/`), the code can fetch disallowed content without ever re-checking robots constraints.

## Issue Context
`ROBOTS_DISALLOW` explicitly includes paths like `/login/` and `/search/`, and the module docstring asserts the guard prevents requesting any disallowed path.

## Fix Focus Areas
- Scrapers/scrape_phoenixrising.py[69-75]
- Scrapers/scrape_phoenixrising.py[87-128]

## Suggested fix
- Make redirect handling explicit and robots-aware:
 - Option A (preferred): build a custom opener with an `HTTPRedirectHandler` override that calls `_check_robots(new_url)` inside the redirect handling before following it.
 - Option B: disable redirects, manually handle 3xx by reading the `Location` header, calling `_check_robots(location)`, then issuing the next request.
- Apply the same approach anywhere you fetch Phoenix Rising HTML/XML (i.e., `fetch()`), so the “only /threads and /sitemap*.xml fetched” guarantee holds end-to-end.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Non-atomic output rewrites 🐞 Bug ☼ Reliability
Description
The scraper rewrites the entire JSON output (json.dumps(results)) in-place after every thread and
labels this as “crash-safe,” but an interruption mid-write can leave a truncated/invalid JSON file
and break resume. This also creates quadratic I/O as results grows, slowing long scrapes
significantly.
Code

Scrapers/scrape_phoenixrising.py[R576-579]

+        results.append(post)
+        total_comments += len(post["comments"])
+        # Incremental, crash-safe write after every thread.
+        out_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
Evidence
The scrape loop overwrites the full output file on each iteration, which is both expensive as the
list grows and vulnerable to producing invalid JSON if interrupted during the write.

Scrapers/scrape_phoenixrising.py[567-580]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The main scrape loop overwrites the full output JSON file after each thread via `Path.write_text(...)`. This is not atomic, so crashes/interruptions can corrupt the output file, and it becomes increasingly expensive as the output grows.

## Issue Context
The code explicitly intends to be “Incremental, crash-safe,” but the current mechanism does not guarantee a valid file on partial writes.

## Fix Focus Areas
- Scrapers/scrape_phoenixrising.py[567-580]

## Suggested fix
- Make the checkpoint write atomic:
 - Write to a temporary file in the same directory (e.g., `out_path.with_suffix('.tmp')`), flush/fsync, then `os.replace(tmp, out_path)`.
- Consider switching to an append-friendly format for checkpoints:
 - JSONL per thread (plus a small metadata/index file), or
 - periodic checkpoints (every N threads) instead of every thread.
- Ensure resume logic can tolerate partial state (e.g., if using JSONL, ignore a final incomplete line).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Anon user ID collision 🐞 Bug ≡ Correctness
Description
import_reddit_posts() maps all missing/deleted authors to the constant user_id "anon", but
users.user_id is the sole PRIMARY KEY, so anonymous authors from different sources/subreddits are
merged into one user with a single source_subreddit value. Because users are inserted with `INSERT
OR IGNORE, later imports can’t correct the source_subreddit`, causing persistent mis-attribution
in the DB.
Code

src/import_posts.py[R66-70]

+def normalize_author(author_hash: str | None) -> str:
+    """Map missing/deleted authors to a stable sentinel user id."""
+    if author_hash is None or not str(author_hash).strip():
+        return ANON_USER_ID
+    return str(author_hash)
Evidence
The importer always returns the constant ANON_USER_ID for missing authors, while the DB schema
allows only one row per user_id and inserts into users are non-updating (INSERT OR IGNORE), so
multiple sources must collide on the same anon row.

src/import_posts.py[33-70]
src/import_posts.py[154-158]
schema.sql[8-12]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`normalize_author()` collapses all missing authors into a single `ANON_USER_ID` (`"anon"`). Since `users.user_id` is a global primary key, this merges anonymous authors across different imports (different `source_subreddit` values) and can permanently mislabel the anon user's `source_subreddit` due to `INSERT OR IGNORE`.

## Issue Context
The schema models `source_subreddit` as required per user row, but the current sentinel design cannot represent multiple sources.

## Fix Focus Areas
- src/import_posts.py[33-70]
- src/import_posts.py[116-152]
- src/import_posts.py[154-158]

## Suggested fix
- Make the anon sentinel **source-scoped**, e.g. `anon:<source_subreddit>` (or `anon@<hostname>`), so multiple imports don’t collide.
- Implement this by either:
 - changing `normalize_author()` to accept `sub` and return `f"anon:{sub}"` when missing, or
 - mapping missing authors in the import loop after `sub` is known.
- Update tests expecting `ANON_USER_ID` accordingly (or keep `ANON_USER_ID` as a prefix and assert with `startswith('anon')`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented Jun 10, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Add Phoenix Rising scraper with Brave discovery and query set-cover tooling
✨ Enhancement 📝 Documentation 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

Walkthroughs

Description
• Add robots-compliant Phoenix Rising XenForo scraper emitting PatientPunk corpus JSON.
• Add Brave Search API discovery and query-set cover minimization to reduce queries.
• Import scraped forum data into SQLite and publish LDN/Mestinon use/sentiment write-up.
Diagram
graph TD
  QFiles["Search query files"] --> Cover["Query cover tool"] --> Brave{{"Brave Search API"}} --> Cover
  Cover --> MinQ["Min query files"] --> Scraper["Phoenix Rising scraper"] --> Posts["Scraped posts JSON"] --> Importer["DB importer"] --> DB[("SQLite DB")]
  DB --> Analysis["Analysis scripts"] --> Docs["RWE docs"]
  subgraph Legend
    direction LR
    _ext{{"External API"}} ~~~ _svc["Script/Module"] ~~~ _file["Data file"] ~~~ _db[("Database")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use OR-Tools / ILP solver for set cover
  • ➕ Scales to larger query sets without exponential blowup
  • ➕ Provides optimal solution with clear constraints/cost functions
  • ➖ Adds a heavy dependency and packaging complexity
  • ➖ More operational overhead than brute-force+greedy for small N
2. Centralize Brave API client shared by scraper + tooling
  • ➕ Avoids duplicated pagination/normalization logic
  • ➕ Easier to enforce rate-limit/backoff behavior consistently
  • ➖ More refactoring now; current duplication is limited in scope
3. Prefer sitemap-first, search as incremental recall-only mode
  • ➕ Minimizes external API usage/cost
  • ➕ Keeps discovery deterministic and reproducible
  • ➖ Lower recall for body-mention threads unless supplemented by search

Recommendation: Current approach (exact set cover when small, greedy fallback; sitemap/thread-list options retained) is a good tradeoff for reliability and cost. If query sets grow beyond ~20–30 queries, consider introducing an ILP-based solver and/or a shared Brave client module to reduce duplicated logic.

Grey Divider

File Changes

Enhancement (7)
scrape_phoenixrising.py Add robots-compliant XenForo scraper with Brave discovery mode +620/-0

Add robots-compliant XenForo scraper with Brave discovery mode

• Implements Phoenix Rising thread discovery (sitemap, Brave Search API query set, or explicit thread list) and scrapes XenForo threads into the existing PatientPunk corpus JSON schema. Adds robots.txt disallow guarding, username hashing, canonical thread URL normalization, quote-stripping, and reply parent linkage via quoted-post detection. Adds CLI flags for Brave pagination/limits and optional alias-based mention filtering.

Scrapers/scrape_phoenixrising.py


brave_query_set_cover.py Add Brave query pagination + minimum set cover optimizer +402/-0

Add Brave query pagination + minimum set cover optimizer

• Paginates LDN/Mestinon Brave query sets, normalizes discovered thread URLs, and writes per-set JSON results plus CSV summaries. Computes a minimum query subset covering all discovered pages (exact for small N, greedy fallback) and optionally emits minimized query files. Includes retry/backoff handling for 429 throttling and run-metadata output.

scripts/brave_query_set_cover.py


free_use_counts.py Add no-LLM use/dose/barrier counting script for Phoenix Rising DB +124/-0

Add no-LLM use/dose/barrier counting script for Phoenix Rising DB

• Adds a SQLite-driven script to compute per-drug mention volume, participant counts, discussion span, dose mentions, and barrier theme counts using regex heuristics.

scripts/free_use_counts.py


export_for_manual_classification.py Add reproducible random sample export for manual sentiment labeling +71/-0

Add reproducible random sample export for manual sentiment labeling

• Exports a seeded random sample of drug-mentioning posts with thread title and parent context into JSONL batches for in-session/no-API labeling.

scripts/export_for_manual_classification.py


export_full_for_classification.py Add full-census export + manifest for agent fan-out labeling +77/-0

Add full-census export + manifest for agent fan-out labeling

• Exports the complete set of drug-mentioning posts into fixed-size JSONL batches and writes a manifest describing batch inputs/outputs for workflow orchestration.

scripts/export_full_for_classification.py


aggregate_manual_sentiment.py Add aggregation of sample sentiment labels with Wilson CIs +77/-0

Add aggregation of sample sentiment labels with Wilson CIs

• Reads manually labeled sample batches and prints per-drug sentiment breakdowns with 95% Wilson confidence intervals; writes a JSON summary artifact.

scripts/aggregate_manual_sentiment.py


aggregate_census.py Add aggregation/validation for full-census sentiment labels +162/-0

Add aggregation/validation for full-census sentiment labels

• Aggregates full-census labels for each drug based on final alias-driven denominators, emits straggler batches if any posts are unlabeled, and computes summary stats including full-dose naltrexone flags and gold-set validation overlap metrics.

scripts/aggregate_census.py


Bug fix (1)
import_posts.py Infer non-Reddit source label from hostname; harden author/parent handling +60/-5

Infer non-Reddit source label from hostname; harden author/parent handling

• Adds hostname→source label inference (e.g., forums.phoenixrising.me→phoenixrising) when --subreddit is not provided, and raises a clear error for unknown sources. Normalizes missing/deleted authors to a stable anon sentinel and repairs comment parent_ids that reference missing posts by attaching to the thread root. Broadens valid-id tracking to validate parent references across posts and comments.

src/import_posts.py


Tests (1)
populate_db_test.py Add importer tests for anon authors and source inference +82/-4

Add importer tests for anon authors and source inference

• Refactors DB fixture setup and adds coverage for missing-author normalization, missing-parent repair behavior, Phoenix Rising hostname source inference, and unknown-hostname failure mode.

tests/populate_db_test.py


Documentation (2)
phoenixrising_use_stats.md Document Phoenix Rising off-label use/dosing/barriers and sentiment results +160/-0

Document Phoenix Rising off-label use/dosing/barriers and sentiment results

• Adds a write-up summarizing corpus composition, off-label mention volumes, dosing distributions, access barriers, and sentiment classification results (including validation notes) for LDN and pyridostigmine/Mestinon.

docs/phoenixrising_use_stats.md


recall_search_provenance.md Document AI-assisted external search provenance for recall expansion +56/-0

Document AI-assisted external search provenance for recall expansion

• Records the exact site-scoped web-search queries used to discover the 17 recall-expansion threads and explains reproducibility limitations for transparency.

docs/recall_search_provenance.md


Other (126)
phoenixrising_search_queries.txt Add baseline Brave query set for Phoenix Rising discovery +6/-0

Add baseline Brave query set for Phoenix Rising discovery

• Adds a small, high-yield set of site-scoped Brave queries for LDN/Mestinon thread discovery.

Scrapers/phoenixrising_search_queries.txt


phoenixrising_search_queries_ldn.txt Add LDN-specific Brave query set +4/-0

Add LDN-specific Brave query set

• Adds LDN-focused site-scoped Brave queries intended for pagination and set-cover optimization.

Scrapers/phoenixrising_search_queries_ldn.txt


phoenixrising_search_queries_mestinon.txt Add Mestinon-specific Brave query set +5/-0

Add Mestinon-specific Brave query set

• Adds Mestinon/pyridostigmine-focused site-scoped Brave queries intended for pagination and set-cover optimization.

Scrapers/phoenixrising_search_queries_mestinon.txt


phoenixrising_targets.txt Add sitemap-derived Phoenix Rising target thread list (276) +276/-0

Add sitemap-derived Phoenix Rising target thread list (276)

• Adds a curated list of canonical Phoenix Rising /threads/<slug>.<id>/ URLs used for deterministic thread-list scraping.

Scrapers/phoenixrising_targets.txt


phoenixrising_recall_targets.txt Add recall expansion thread list discovered via external search +17/-0

Add recall expansion thread list discovered via external search

• Adds 17 additional threads surfaced via site-scoped external web search to capture body-mention discussions not present in thread titles.

Scrapers/phoenixrising_recall_targets.txt


phoenixrising_tag_targets.txt Add tag-discovered Phoenix Rising target thread list +5/-0

Add tag-discovered Phoenix Rising target thread list

• Adds 5 tag-page-discovered threads to supplement sitemap/title-based discovery with a deterministic robots-allowed method.

Scrapers/phoenixrising_tag_targets.txt


naltrexone.txt Add naltrexone/LDN alias list +9/-0

Add naltrexone/LDN alias list

• Adds curated aliases used for mention filtering and downstream counting/classification workflows.

drugs/naltrexone.txt


pyridostigmine.txt Add pyridostigmine/Mestinon alias list +11/-0

Add pyridostigmine/Mestinon alias list

• Adds curated aliases (including ER/CR variants and common misspelling stem) used for mention filtering and downstream workflows.

drugs/pyridostigmine.txt


pyproject.toml Add BeautifulSoup dependency for Phoenix Rising scraping +1/-0

Add BeautifulSoup dependency for Phoenix Rising scraping

• Adds beautifulsoup4 to project dependencies to support XenForo HTML parsing.

pyproject.toml


phoenixrising_posts.json Add scraped Phoenix Rising corpus export (276 threads) +47657/-0

Add scraped Phoenix Rising corpus export (276 threads)

• Adds the scraped thread+comment corpus JSON in PatientPunk schema for deterministic sitemap/thread-list targets.

output/phoenixrising_posts.json


phoenixrising_metadata.json Add scrape metadata for main Phoenix Rising corpus run +17/-0

Add scrape metadata for main Phoenix Rising corpus run

• Adds scrape provenance metadata including discovery method, thread counts, reply totals, timestamps, and privacy/robots compliance notes.

output/phoenixrising_metadata.json


phoenixrising_recall_metadata.json Add scrape metadata for recall-expansion run +17/-0

Add scrape metadata for recall-expansion run

• Adds scrape provenance metadata for the 17 externally discovered recall threads.

output/phoenixrising_recall_metadata.json


phoenixrising_tag_metadata.json Add scrape metadata for tag-discovery run +17/-0

Add scrape metadata for tag-discovery run

• Adds scrape provenance metadata for the 5 tag-discovered threads.

output/phoenixrising_tag_metadata.json


phoenixrising_tag_posts.json Add scraped corpus export for tag-discovered threads +703/-0

Add scraped corpus export for tag-discovered threads

• Adds scraped thread+comment JSON for the tag-discovered target list.

output/phoenixrising_tag_posts.json


sentiment_summary.json Add aggregated sample sentiment summary artifact +32/-0

Add aggregated sample sentiment summary artifact

• Adds JSON summary output produced by the manual-sentiment aggregation script for LDN and Mestinon samples.

outputs/manual/sentiment_summary.json


census_summary.json Add aggregated full-census sentiment summary artifact +46/-0

Add aggregated full-census sentiment summary artifact

• Adds JSON summary output produced by the census aggregation script, including validation overlap metrics.

outputs/manual/census_summary.json


canonicalized_mentions.json Add derived naltrexone mention canonicalization output +394/-0

Add derived naltrexone mention canonicalization output

• Adds derived mention records (post text + metadata) used for downstream analysis/labeling workflows.

outputs/naltrexone/canonicalized_mentions.json


tagged_mentions.json Add derived naltrexone tagged-mention output +394/-0

Add derived naltrexone tagged-mention output

• Adds derived tagged-mention records used for downstream aggregation and reporting.

outputs/naltrexone/tagged_mentions.json


labels_ldn_b1.json Add manual sentiment labels (LDN batch 1) +102/-0

Add manual sentiment labels (LDN batch 1)

• Adds manually produced sentiment label outputs for the LDN sampled batch.

outputs/manual/labels_ldn_b1.json


labels_ldn_b2.json Add manual sentiment labels (LDN batch 2) +102/-0

Add manual sentiment labels (LDN batch 2)

• Adds manually produced sentiment label outputs for the LDN sampled batch.

outputs/manual/labels_ldn_b2.json


labels_mestinon_b1.json Add manual sentiment labels (Mestinon batch 1) +102/-0

Add manual sentiment labels (Mestinon batch 1)

• Adds manually produced sentiment label outputs for the Mestinon sampled batch.

outputs/manual/labels_mestinon_b1.json


labels_mestinon_b2.json Add manual sentiment labels (Mestinon batch 2) +102/-0

Add manual sentiment labels (Mestinon batch 2)

• Adds manually produced sentiment label outputs for the Mestinon sampled batch.

outputs/manual/labels_mestinon_b2.json


ldn_b1.jsonl Add manual-classification input batch (LDN b1) +100/-0

Add manual-classification input batch (LDN b1)

• Adds JSONL input batch exported for in-session manual labeling of LDN posts.

outputs/manual/ldn_b1.jsonl


ldn_b2.jsonl Add manual-classification input batch (LDN b2) +100/-0

Add manual-classification input batch (LDN b2)

• Adds JSONL input batch exported for in-session manual labeling of LDN posts.

outputs/manual/ldn_b2.jsonl


mestinon_b1.jsonl Add manual-classification input batch (Mestinon b1) +100/-0

Add manual-classification input batch (Mestinon b1)

• Adds JSONL input batch exported for in-session manual labeling of Mestinon posts.

outputs/manual/mestinon_b1.jsonl


mestinon_b2.jsonl Add manual-classification input batch (Mestinon b2) +100/-0

Add manual-classification input batch (Mestinon b2)

• Adds JSONL input batch exported for in-session manual labeling of Mestinon posts.

outputs/manual/mestinon_b2.jsonl


manifest.json Add full-census batch manifest +0/-0

Add full-census batch manifest

• Adds the manifest describing full-census JSONL input batches and corresponding label output paths.

outputs/manual/full/manifest.json


stragglers_ldn.jsonl Add full-census straggler export (LDN) +23/-0

Add full-census straggler export (LDN)

• Adds straggler JSONL batch for any unlabeled LDN candidate posts (used to complete census coverage).

outputs/manual/full/stragglers_ldn.jsonl


stragglers_mestinon.jsonl Add full-census straggler export (Mestinon) +3/-0

Add full-census straggler export (Mestinon)

• Adds straggler JSONL batch for any unlabeled Mestinon candidate posts (used to complete census coverage).

outputs/manual/full/stragglers_mestinon.jsonl


labels_ldn_stragglers.json Add census labels for LDN stragglers +5/-0

Add census labels for LDN stragglers

• Adds label output completing the LDN census for any previously unlabeled straggler items.

outputs/manual/full/labels_ldn_stragglers.json


labels_mestinon_000.json Add census sentiment labels (Mestinon batch 000) +62/-0

Add census sentiment labels (Mestinon batch 000)

• Adds workflow-generated census sentiment labels for a Mestinon batch file.

outputs/manual/full/labels_mestinon_000.json


labels_mestinon_001.json Add census sentiment labels (Mestinon batch 001) +62/-0

Add census sentiment labels (Mestinon batch 001)

• Adds workflow-generated census sentiment labels for a Mestinon batch file.

outputs/manual/full/labels_mestinon_001.json


labels_mestinon_002.json Add census sentiment labels (Mestinon batch 002) +62/-0

Add census sentiment labels (Mestinon batch 002)

• Adds workflow-generated census sentiment labels for a Mestinon batch file.

outputs/manual/full/labels_mestinon_002.json


labels_mestinon_003.json Add census sentiment labels (Mestinon batch 003) +62/-0

Add census sentiment labels (Mestinon batch 003)

• Adds workflow-generated census sentiment labels for a Mestinon batch file.

outputs/manual/full/labels_mestinon_003.json


labels_mestinon_004.json Add census sentiment labels (Mestinon batch 004) +62/-0

Add census sentiment labels (Mestinon batch 004)

• Adds workflow-generated census sentiment labels for a Mestinon batch file.

outputs/manual/full/labels_mestinon_004.json


labels_mestinon_005.json Add census sentiment labels (Mestinon batch 005) +35/-0

Add census sentiment labels (Mestinon batch 005)

• Adds workflow-generated census sentiment labels for a Mestinon batch file.

outputs/manual/full/labels_mestinon_005.json


labels_recall_stragglers.json Add census labels for recall stragglers +92/-0

Add census labels for recall stragglers

• Adds label output for straggler items associated with recall-expanded discovery sets.

outputs/manual/full/labels_recall_stragglers.json


labels_tag_stragglers.json Add census labels for tag stragglers +28/-0

Add census labels for tag stragglers

• Adds label output for straggler items associated with tag-discovered discovery sets.

outputs/manual/full/labels_tag_stragglers.json


ldn_000.jsonl Add full-census classification input (LDN batch 000) +60/-0

Add full-census classification input (LDN batch 000)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_000.jsonl


ldn_001.jsonl Add full-census classification input (LDN batch 001) +60/-0

Add full-census classification input (LDN batch 001)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_001.jsonl


ldn_002.jsonl Add full-census classification input (LDN batch 002) +60/-0

Add full-census classification input (LDN batch 002)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_002.jsonl


ldn_003.jsonl Add full-census classification input (LDN batch 003) +60/-0

Add full-census classification input (LDN batch 003)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_003.jsonl


ldn_004.jsonl Add full-census classification input (LDN batch 004) +60/-0

Add full-census classification input (LDN batch 004)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_004.jsonl


ldn_005.jsonl Add full-census classification input (LDN batch 005) +60/-0

Add full-census classification input (LDN batch 005)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_005.jsonl


ldn_006.jsonl Add full-census classification input (LDN batch 006) +60/-0

Add full-census classification input (LDN batch 006)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_006.jsonl


ldn_007.jsonl Add full-census classification input (LDN batch 007) +60/-0

Add full-census classification input (LDN batch 007)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_007.jsonl


ldn_008.jsonl Add full-census classification input (LDN batch 008) +60/-0

Add full-census classification input (LDN batch 008)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_008.jsonl


ldn_009.jsonl Add full-census classification input (LDN batch 009) +60/-0

Add full-census classification input (LDN batch 009)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_009.jsonl


ldn_010.jsonl Add full-census classification input (LDN batch 010) +60/-0

Add full-census classification input (LDN batch 010)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_010.jsonl


ldn_011.jsonl Add full-census classification input (LDN batch 011) +60/-0

Add full-census classification input (LDN batch 011)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_011.jsonl


ldn_012.jsonl Add full-census classification input (LDN batch 012) +60/-0

Add full-census classification input (LDN batch 012)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_012.jsonl


ldn_013.jsonl Add full-census classification input (LDN batch 013) +60/-0

Add full-census classification input (LDN batch 013)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_013.jsonl


ldn_014.jsonl Add full-census classification input (LDN batch 014) +60/-0

Add full-census classification input (LDN batch 014)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_014.jsonl


ldn_015.jsonl Add full-census classification input (LDN batch 015) +60/-0

Add full-census classification input (LDN batch 015)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_015.jsonl


ldn_016.jsonl Add full-census classification input (LDN batch 016) +60/-0

Add full-census classification input (LDN batch 016)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_016.jsonl


ldn_017.jsonl Add full-census classification input (LDN batch 017) +60/-0

Add full-census classification input (LDN batch 017)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_017.jsonl


ldn_018.jsonl Add full-census classification input (LDN batch 018) +60/-0

Add full-census classification input (LDN batch 018)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_018.jsonl


ldn_019.jsonl Add full-census classification input (LDN batch 019) +60/-0

Add full-census classification input (LDN batch 019)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_019.jsonl


ldn_020.jsonl Add full-census classification input (LDN batch 020) +60/-0

Add full-census classification input (LDN batch 020)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_020.jsonl


ldn_021.jsonl Add full-census classification input (LDN batch 021) +60/-0

Add full-census classification input (LDN batch 021)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_021.jsonl


ldn_022.jsonl Add full-census classification input (LDN batch 022) +60/-0

Add full-census classification input (LDN batch 022)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_022.jsonl


ldn_023.jsonl Add full-census classification input (LDN batch 023) +60/-0

Add full-census classification input (LDN batch 023)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_023.jsonl


ldn_024.jsonl Add full-census classification input (LDN batch 024) +60/-0

Add full-census classification input (LDN batch 024)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_024.jsonl


ldn_025.jsonl Add full-census classification input (LDN batch 025) +60/-0

Add full-census classification input (LDN batch 025)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_025.jsonl


ldn_026.jsonl Add full-census classification input (LDN batch 026) +60/-0

Add full-census classification input (LDN batch 026)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_026.jsonl


ldn_027.jsonl Add full-census classification input (LDN batch 027) +60/-0

Add full-census classification input (LDN batch 027)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_027.jsonl


ldn_028.jsonl Add full-census classification input (LDN batch 028) +60/-0

Add full-census classification input (LDN batch 028)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_028.jsonl


ldn_029.jsonl Add full-census classification input (LDN batch 029) +60/-0

Add full-census classification input (LDN batch 029)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_029.jsonl


ldn_030.jsonl Add full-census classification input (LDN batch 030) +60/-0

Add full-census classification input (LDN batch 030)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_030.jsonl


ldn_031.jsonl Add full-census classification input (LDN batch 031) +60/-0

Add full-census classification input (LDN batch 031)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_031.jsonl


ldn_032.jsonl Add full-census classification input (LDN batch 032) +60/-0

Add full-census classification input (LDN batch 032)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_032.jsonl


ldn_033.jsonl Add full-census classification input (LDN batch 033) +60/-0

Add full-census classification input (LDN batch 033)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_033.jsonl


ldn_034.jsonl Add full-census classification input (LDN batch 034) +60/-0

Add full-census classification input (LDN batch 034)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_034.jsonl


ldn_035.jsonl Add full-census classification input (LDN batch 035) +60/-0

Add full-census classification input (LDN batch 035)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_035.jsonl


ldn_036.jsonl Add full-census classification input (LDN batch 036) +60/-0

Add full-census classification input (LDN batch 036)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_036.jsonl


ldn_037.jsonl Add full-census classification input (LDN batch 037) +60/-0

Add full-census classification input (LDN batch 037)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_037.jsonl


ldn_038.jsonl Add full-census classification input (LDN batch 038) +60/-0

Add full-census classification input (LDN batch 038)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_038.jsonl


ldn_039.jsonl Add full-census classification input (LDN batch 039) +60/-0

Add full-census classification input (LDN batch 039)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_039.jsonl


ldn_040.jsonl Add full-census classification input (LDN batch 040) +47/-0

Add full-census classification input (LDN batch 040)

• Adds a JSONL batch of LDN candidate posts exported for census labeling.

outputs/manual/full/ldn_040.jsonl


mestinon_000.jsonl Add full-census classification input (Mestinon batch 000) +0/-0

Add full-census classification input (Mestinon batch 000)

• Adds a JSONL batch of Mestinon candidate posts exported for census labeling (file present as a batch artifact).

outputs/manual/full/mestinon_000.jsonl


mestinon_001.jsonl Add full-census classification input (Mestinon batch 001) +0/-0

Add full-census classification input (Mestinon batch 001)

• Adds a JSONL batch of Mestinon candidate posts exported for census labeling (file present as a batch artifact).

outputs/manual/full/mestinon_001.jsonl


mestinon_002.jsonl Add full-census classification input (Mestinon batch 002) +0/-0

Add full-census classification input (Mestinon batch 002)

• Adds a JSONL batch of Mestinon candidate posts exported for census labeling (file present as a batch artifact).

outputs/manual/full/mestinon_002.jsonl


mestinon_003.jsonl Add full-census classification input (Mestinon batch 003) +0/-0

Add full-census classification input (Mestinon batch 003)

• Adds a JSONL batch of Mestinon candidate posts exported for census labeling (file present as a batch artifact).

outputs/manual/full/mestinon_003.jsonl


mestinon_004.jsonl Add full-census classification input (Mestinon batch 004) +0/-0

Add full-census classification input (Mestinon batch 004)

• Adds a JSONL batch of Mestinon candidate posts exported for census labeling (file present as a batch artifact).

outputs/manual/full/mestinon_004.jsonl


mestinon_005.jsonl Add full-census classification input (Mestinon batch 005) +0/-0

Add full-census classification input (Mestinon batch 005)

• Adds a JSONL batch of Mestinon candidate posts exported for census labeling (file present as a batch artifact).

outputs/manual/full/mestinon_005.jsonl


labels_ldn_000.json Add census sentiment labels (LDN batch 000) +62/-0

Add census sentiment labels (LDN batch 000)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_000.json


labels_ldn_001.json Add census sentiment labels (LDN batch 001) +62/-0

Add census sentiment labels (LDN batch 001)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_001.json


labels_ldn_002.json Add census sentiment labels (LDN batch 002) +62/-0

Add census sentiment labels (LDN batch 002)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_002.json


labels_ldn_003.json Add census sentiment labels (LDN batch 003) +62/-0

Add census sentiment labels (LDN batch 003)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_003.json


labels_ldn_004.json Add census sentiment labels (LDN batch 004) +62/-0

Add census sentiment labels (LDN batch 004)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_004.json


labels_ldn_005.json Add census sentiment labels (LDN batch 005) +62/-0

Add census sentiment labels (LDN batch 005)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_005.json


labels_ldn_006.json Add census sentiment labels (LDN batch 006) +62/-0

Add census sentiment labels (LDN batch 006)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_006.json


labels_ldn_007.json Add census sentiment labels (LDN batch 007) +62/-0

Add census sentiment labels (LDN batch 007)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_007.json


labels_ldn_008.json Add census sentiment labels (LDN batch 008) +62/-0

Add census sentiment labels (LDN batch 008)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_008.json


labels_ldn_009.json Add census sentiment labels (LDN batch 009) +62/-0

Add census sentiment labels (LDN batch 009)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_009.json


labels_ldn_010.json Add census sentiment labels (LDN batch 010) +62/-0

Add census sentiment labels (LDN batch 010)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_010.json


labels_ldn_011.json Add census sentiment labels (LDN batch 011) +62/-0

Add census sentiment labels (LDN batch 011)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_011.json


labels_ldn_012.json Add census sentiment labels (LDN batch 012) +62/-0

Add census sentiment labels (LDN batch 012)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_012.json


labels_ldn_013.json Add census sentiment labels (LDN batch 013) +61/-0

Add census sentiment labels (LDN batch 013)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_013.json


labels_ldn_014.json Add census sentiment labels (LDN batch 014) +62/-0

Add census sentiment labels (LDN batch 014)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_014.json


labels_ldn_015.json Add census sentiment labels (LDN batch 015) +62/-0

Add census sentiment labels (LDN batch 015)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_015.json


labels_ldn_016.json Add census sentiment labels (LDN batch 016) +62/-0

Add census sentiment labels (LDN batch 016)

• Adds workflow-generated census sentiment labels for an LDN batch file.

outputs/manual/full/labels_ldn_016.json


labels_ldn_017.json

Comment on lines +100 to +116
def fetch(url: str, delay: float, retries: int = 5) -> str:
"""GET a URL as text with polite delay and exponential backoff."""
_check_robots(url)
last_exc = None
for attempt in range(retries):
try:
req = urllib.request.Request(
url,
headers={
"User-Agent": UA,
"Accept": "text/html,application/xhtml+xml,application/xml",
"Accept-Encoding": "identity",
},
)
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
time.sleep(delay)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Robots redirect bypass 🐞 Bug ☼ Reliability

fetch() enforces _check_robots(url) only on the initially requested URL, but it does not
validate the final URL after urlopen completes, so a redirect can lead to fetching a
robots-disallowed path while still passing the guard. This contradicts the scraper’s stated
guarantee that it “refuses any path in the robots.txt Disallow list.”
Agent Prompt
## Issue description
The scraper claims robots compliance via a guard, but `fetch()` only checks the input URL. If the server redirects (e.g. to `/login/`), the code can fetch disallowed content without ever re-checking robots constraints.

## Issue Context
`ROBOTS_DISALLOW` explicitly includes paths like `/login/` and `/search/`, and the module docstring asserts the guard prevents requesting any disallowed path.

## Fix Focus Areas
- Scrapers/scrape_phoenixrising.py[69-75]
- Scrapers/scrape_phoenixrising.py[87-128]

## Suggested fix
- Make redirect handling explicit and robots-aware:
  - Option A (preferred): build a custom opener with an `HTTPRedirectHandler` override that calls `_check_robots(new_url)` inside the redirect handling before following it.
  - Option B: disable redirects, manually handle 3xx by reading the `Location` header, calling `_check_robots(location)`, then issuing the next request.
- Apply the same approach anywhere you fetch Phoenix Rising HTML/XML (i.e., `fetch()`), so the “only /threads and /sitemap*.xml fetched” guarantee holds end-to-end.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +576 to +579
results.append(post)
total_comments += len(post["comments"])
# Incremental, crash-safe write after every thread.
out_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Non-atomic output rewrites 🐞 Bug ☼ Reliability

The scraper rewrites the entire JSON output (json.dumps(results)) in-place after every thread and
labels this as “crash-safe,” but an interruption mid-write can leave a truncated/invalid JSON file
and break resume. This also creates quadratic I/O as results grows, slowing long scrapes
significantly.
Agent Prompt
## Issue description
The main scrape loop overwrites the full output JSON file after each thread via `Path.write_text(...)`. This is not atomic, so crashes/interruptions can corrupt the output file, and it becomes increasingly expensive as the output grows.

## Issue Context
The code explicitly intends to be “Incremental, crash-safe,” but the current mechanism does not guarantee a valid file on partial writes.

## Fix Focus Areas
- Scrapers/scrape_phoenixrising.py[567-580]

## Suggested fix
- Make the checkpoint write atomic:
  - Write to a temporary file in the same directory (e.g., `out_path.with_suffix('.tmp')`), flush/fsync, then `os.replace(tmp, out_path)`.
- Consider switching to an append-friendly format for checkpoints:
  - JSONL per thread (plus a small metadata/index file), or
  - periodic checkpoints (every N threads) instead of every thread.
- Ensure resume logic can tolerate partial state (e.g., if using JSONL, ignore a final incomplete line).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

…outputs

- scrape_phoenixrising.py: remove mutually exclusive source group; allow
  --from-search-api + --thread-list to combine; make both appendable for
  multiple files; add --url-out flag (collect URLs, write list, exit without
  scraping); accept multiple --search-query-file args; add flush=True to
  print calls for background-task visibility
- drugs/naltrexone.txt, drugs/pyridostigmine.txt: canonical alias files now
  read by all four analysis scripts (aggregate_census, export_for_manual,
  export_full, free_use_counts) — single source of truth for alias sets
- scripts/aggregate_census.py: replace hardcoded DB path with --db arg
- outputs/manual/: remove 110 generated classification artifacts from version
  control; add outputs/manual/ to .gitignore

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@Ely-S Ely-S changed the title Phoenix Rising: LDN/Mestinon RWE scraper + Brave query optimization Phoenix Rising corpus: LDN & Mestinon scraper, analysis scripts, S3 data upload Jun 10, 2026
@Ely-S
Ely-S requested a review from Airwhale June 10, 2026 20:43
@Airwhale

Airwhale commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Eli, sorry, completely missed this requested review. I'm not too worried about this bc of the one offness of this, but a few small things I caught through codex.

@Airwhale Airwhale left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving a few comment-only notes. Most of these are small reliability or reproducibility edges rather than concerns about the overall one-off workflow.

Comment on lines +416 to +418
except Exception as e: # noqa: BLE001
print(f" ! failed {url} page {n}: {e}", file=sys.stderr)
break

@Airwhale Airwhale Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI found:

A partially scraped multi-page thread as complete when a later page fails. Right now this breaks out of the page loop, but the caller still appends the thread to --out; on the next run, done_ids will skip that thread_id, so a transient page error can become a persistent undercount. Returning None here keeps the thread out of the completed set so a later resume can retry it.

Suggested change
except Exception as e: # noqa: BLE001
print(f" ! failed {url} page {n}: {e}", file=sys.stderr)
break
except Exception as e: # noqa: BLE001
print(f" ! failed {url} page {n}: {e}", file=sys.stderr)
return None

@@ -0,0 +1,394 @@
[

@Airwhale Airwhale Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI found: (Note that I don't really care that much if these are in the github: up to your judgment -SG)

I think these generated artifacts may have slipped in unintentionally. The PR description says generated data lives in S3 and only code/config are tracked, but this file and outputs/naltrexone/tagged_mentions.json contain derived scraped forum text plus author hashes. Since the new ignore rule only covers outputs/manual/, could we remove these two files from git and add a scoped ignore for the generated outputs/* paths that should remain local?

Comment on lines +63 to +66
aliases_by_label = {label: read_alias_file(path) for label, path in DEFAULT_ALIAS_FILES.items()}
conn = sqlite3.connect(args.db)
rows = conn.execute("SELECT post_id,title,parent_id,user_id,body_text FROM posts").fetchall()
conn.close()

@Airwhale Airwhale Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI found:

Small fresh-checkout guard: outputs/manual/ is ignored, so outputs/manual/full will not exist until someone has generated local classification files. Creating it before writing stragglers keeps the script from failing on the first run.

Suggested change
aliases_by_label = {label: read_alias_file(path) for label, path in DEFAULT_ALIAS_FILES.items()}
conn = sqlite3.connect(args.db)
rows = conn.execute("SELECT post_id,title,parent_id,user_id,body_text FROM posts").fetchall()
conn.close()
FULL.mkdir(parents=True, exist_ok=True)
aliases_by_label = {label: read_alias_file(path) for label, path in DEFAULT_ALIAS_FILES.items()}
conn = sqlite3.connect(args.db)
rows = conn.execute("SELECT post_id,title,parent_id,user_id,body_text FROM posts").fetchall()
conn.close()

print(f"{label} — FULL CENSUS (final aliases)")
print("=" * 70)
print(f" Candidate posts (new aliases): {len(cand_ids)} | labeled: {n} | unlabeled stragglers: {len(stragglers)}")
print(f" Personal-experience posts : {exp} ({100*exp/n:.0f}% of labeled)")

@Airwhale Airwhale Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI found:

Related fresh-checkout edge: if no label files have been loaded yet, n can be zero here and this print path raises before writing the summary/stragglers. A small guard keeps the script usable before the classification artifacts exist.

Suggested change
print(f" Personal-experience posts : {exp} ({100*exp/n:.0f}% of labeled)")
pct_labeled = f"{100 * exp / n:.0f}%" if n else "n/a"
print(f" Personal-experience posts : {exp} ({pct_labeled} of labeled)")

@@ -0,0 +1,160 @@
# Phoenix Rising — real-world USE data for the FDA comment (Section 6 & 7)

**Source:** Phoenix Rising ME/CFS Forums (forums.phoenixrising.me), 276 drug-focused

@Airwhale Airwhale Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI found:

Could we refresh this tracked write-up before merge? It appears to describe an earlier corpus snapshot (276 title-discovered threads, then 293/298 after recall expansion, 5,081 posts/comments), while the PR body's final reproduction path says the uploaded artifact is based on 316 input URLs, 314 imported threads, 6,657 posts/comments, and 853 participants. Keeping the committed methods/results doc aligned with the final artifact would avoid conflicting denominators later.

Comment thread tests/populate_db_test.py
def _fresh_db(path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(path, check_same_thread=False)
conn.execute("PRAGMA foreign_keys = ON")
conn.executescript(SCHEMA.read_text())

@Airwhale Airwhale Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI found:

Tiny portability fix: Path.read_text() uses the platform default encoding. On Windows this test fails before reaching the assertions because schema.sql contains UTF-8 box-drawing characters and the default codec is cp1252. Pinning the encoding keeps local runs consistent with CI/Linux.

Suggested change
conn.executescript(SCHEMA.read_text())
conn.executescript(SCHEMA.read_text(encoding="utf-8"))

@Airwhale

Airwhale commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Just a thought: maybe we should localize all of the different specific research work : this, the FDA stuff, the pilot, into a specific folder.

Airwhale pushed a commit that referenced this pull request Jul 7, 2026
Establish a root-level studies/ folder to hold all study methodology in one
place, and relocate the IRR pilot into it as a sub-study of the RCT
historical-validation study:

  docs/irr_pilot/  ->  studies/rct_validation/irr_pilot/

Adds studies/README.md (the source-of-truth index of every study and where
it belongs) and studies/rct_validation/README.md (context for the nesting).
Updates the .gitignore sample-pack paths and one path reference in the codebook.

Intended full layout, landed per-branch as each PR merges:
  studies/
  ├── rct_validation/        (from docs/RCT_historical_validation/ on main)
  │   └── irr_pilot/         (this PR)
  ├── fda_letter/            (PR #52)
  │   └── phoenix_rising/    (PR #41)
  └── natural_exploration/   (TrialScout — not ready to merge)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Airwhale pushed a commit that referenced this pull request Jul 7, 2026
Establish a root-level studies/ folder to hold all study methodology in one
place, and relocate the IRR pilot into it as a sub-study of the RCT
historical-validation study:

  docs/irr_pilot/  ->  studies/rct_validation/irr_pilot/

Adds studies/README.md (the source-of-truth index of every study and where
it belongs) and studies/rct_validation/README.md (context for the nesting).
Updates the .gitignore sample-pack paths and one path reference in the codebook.

Intended full layout, landed per-branch as each PR merges:
  studies/
  ├── rct_validation/        (from docs/RCT_historical_validation/ on main)
  │   └── irr_pilot/         (this PR)
  ├── fda_letter/            (PR #52)
  │   └── phoenix_rising/    (PR #41)
  └── natural_exploration/   (TrialScout — not ready to merge)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Airwhale pushed a commit that referenced this pull request Jul 7, 2026
Relocate the study into the root-level studies/ folder so all study
methodology lives in one place:

  docs/FDA_letter_analysis/  ->  studies/fda_letter/

Pure move — the build scripts use __file__-relative paths at the same
directory depth (parents[1]=package, parents[3]=repo root both still
resolve), so no code changes are needed. Updates the one external path
reference in the root README index.

Part of consolidating every study under studies/ (see PR #39). The Phoenix
Rising corpus will nest here as studies/fda_letter/phoenix_rising/ once its
PR (#41) lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants