Created
July 31, 2026 12:32
-
-
Save mphinance/8be410783fe65efe3894198f88388d2a to your computer and use it in GitHub Desktop.
Download your entire AfterHour post history — just run it, no login/API key needed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| fetch_my_afterhour_posts.py — grab your ENTIRE AfterHour post history in one go. | |
| No login. No API key. No app. Just your username. | |
| Turns out AfterHour's post feed is powered by a public API that anyone can call — | |
| you don't need to be logged in, and you don't need this script to be fancy. It just | |
| asks AfterHour for your posts, 100 at a time (that's the most it'll hand over in one | |
| request), and keeps asking for "the next 100" until it has everything. Then it saves | |
| all of it as a spreadsheet (CSV) and a JSON file, in case you want to dig in with | |
| code later. | |
| HOW TO RUN IT | |
| python3 fetch_my_afterhour_posts.py | |
| That's it — it'll ask for your username interactively. Or skip the prompt: | |
| python3 fetch_my_afterhour_posts.py --username YourHandle | |
| WHAT YOU GET | |
| YourHandle.csv <- open this in Excel/Numbers/Google Sheets, every post as a row | |
| YourHandle.json <- the same data, structured, if you're going to write code against it | |
| TWO THINGS THAT'LL TRIP YOU UP IF YOU DON'T KNOW ABOUT THEM | |
| 1. The "amount" column is your TOTAL portfolio value (cash + everything you're | |
| holding) at the exact moment you hit post — it is NOT a gain or loss number. | |
| AfterHour's own sync with your broker has been known to glitch and show a | |
| wrong number for a post or two. If a number in there looks insane, it | |
| probably is a glitch, not something you actually did. | |
| 2. Some rows have `is_hunt_post` set to True. On those, the "body" text is | |
| something YOU posted, but it's actually a tweet from someone else that you | |
| shared — check the `embedded_tweet_from` column, that's whose words it is. | |
| Requires nothing you don't already have — just Python 3. No pip install needed. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import json | |
| import re | |
| import sys | |
| import time | |
| import urllib.request | |
| from collections import Counter | |
| UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/120.0 Safari/537.36") | |
| API_BASE = "https://api.afterhour.com/social/feed" | |
| def _get(url: str, retries: int = 3): | |
| """Fetch a URL. Retries a couple times on flaky server errors, but gives up | |
| right away on a clean "that's wrong" response (like a bad username) instead of | |
| banging its head against something that isn't going to fix itself.""" | |
| for attempt in range(retries): | |
| req = urllib.request.Request(url, headers={"User-Agent": UA}) | |
| try: | |
| with urllib.request.urlopen(req, timeout=20) as r: | |
| body = r.read().decode("utf-8", "replace") | |
| ct = r.headers.get("content-type", "") | |
| return json.loads(body) if ct.startswith("application/json") else body | |
| except urllib.error.HTTPError as e: | |
| if e.code < 500 or attempt == retries - 1: | |
| raise | |
| except (urllib.error.URLError, TimeoutError): | |
| if attempt == retries - 1: | |
| raise | |
| time.sleep(1.5 * (attempt + 1)) | |
| def profile_id(username: str) -> str: | |
| """ | |
| Every AfterHour post request needs your internal account ID (looks like | |
| "prf_abc123..."), not your username. There's no simple endpoint that just | |
| hands that over, so we grab your profile page's HTML and dig your ID out of | |
| the data Next.js embeds in it. It's a bit of a treasure hunt, but it works. | |
| """ | |
| try: | |
| html = _get(f"https://afterhour.com/{username}") | |
| except urllib.error.HTTPError as e: | |
| if e.code == 404: | |
| raise LookupError( | |
| f"'{username}' doesn't seem to exist on AfterHour. Double-check the " | |
| f"spelling matches exactly what's in the afterhour.com/{username} URL " | |
| f"(it's case-sensitive)." | |
| ) from None | |
| raise | |
| if isinstance(html, dict): | |
| raise LookupError("Got an unexpected response — AfterHour's site may have changed.") | |
| for m in re.finditer(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)</script>', html, re.S): | |
| chunk = json.loads('"' + m.group(1) + '"') # unescape the embedded JS string | |
| found = re.search(r'"id":"(prf_[a-f0-9]+)".{0,400}?"username":"' + re.escape(username) + r'"', chunk, re.I | re.S) | |
| if not found: | |
| found = re.search(r'"username":"' + re.escape(username) + r'".{0,400}?"id":"(prf_[a-f0-9]+)"', chunk, re.I | re.S) | |
| if found: | |
| return found.group(1) | |
| raise LookupError( | |
| f"Couldn't find a profile for '{username}'. Double-check the spelling matches " | |
| f"exactly what's in your afterhour.com/{username} URL (it's case-sensitive)." | |
| ) | |
| def fetch_all_posts(author_id: str) -> list[dict]: | |
| """ | |
| Pulls every post, one page at a time. AfterHour caps each request at 100 | |
| posts — ask for more and it just says no — so instead we grab 100, ask "what's | |
| next?" (that's the `cursor`), and repeat until there's nothing left. | |
| """ | |
| posts: list[dict] = [] | |
| cursor = None | |
| total = None | |
| while True: | |
| url = f"{API_BASE}?take=100&contentTypes=post&authorId={author_id}" | |
| if cursor is not None: | |
| url += f"&cursor={cursor}" | |
| page = _get(url) | |
| if total is None: | |
| total = page.get("totalCount", 0) | |
| print(f"Found {total} posts. Downloading...") | |
| batch = page.get("items", []) | |
| if not batch: | |
| break | |
| posts.extend(batch) | |
| pct = int(100 * len(posts) / total) if total else 100 | |
| bar = "#" * (pct // 4) + "-" * (25 - pct // 4) | |
| print(f" [{bar}] {len(posts)}/{total}", end="\r" if len(posts) < total else "\n") | |
| if len(posts) >= total: | |
| break | |
| cursor = page.get("cursor") | |
| if cursor is None: | |
| break | |
| time.sleep(0.3) # be a little polite between requests | |
| return posts | |
| def normalize(item: dict) -> dict: | |
| """Turns one raw API post into a flat row that's easy to read in a spreadsheet.""" | |
| post = item.get("post") or {} | |
| # "amount" = your account's total value at that moment, not a profit/loss figure. | |
| snapshot = item.get("portfolioSnapshot") or {} | |
| total_value = snapshot.get("totalValue") | |
| amount = f"${total_value / 1000:.1f}K" if total_value is not None else "" | |
| # The Gain/Loss badge you see in the app is really just the topic you picked | |
| # when posting — it's not a verified result. | |
| tag = (item.get("primaryTopicKey") or "").capitalize() | |
| gain_loss = tag if tag in ("Gain", "Loss") else "" | |
| # Hunt posts: the real text is an embedded tweet from someone else, not your words. | |
| body = post.get("body", "") | |
| tweet_source = "" | |
| if not body: | |
| tweets = item.get("tweets") or [] | |
| if tweets: | |
| body = tweets[0].get("text", "") | |
| tweet_source = tweets[0].get("username", "") | |
| tickers = [s.get("tickerSymbol") for s in (item.get("securities") or []) if s and s.get("tickerSymbol")] | |
| link_urls = [lp.get("url", "") for lp in (item.get("linkPreviews") or []) if lp.get("url")] | |
| return { | |
| "date": (post.get("createdAt") or item.get("createdAt") or "")[:10], | |
| "created_at": post.get("createdAt") or item.get("createdAt") or "", | |
| "tag": tag, | |
| "gain_loss": gain_loss, | |
| "amount": amount, | |
| "tickers": ",".join(tickers), | |
| "title": post.get("title", ""), | |
| "body": body, | |
| "embedded_tweet_from": tweet_source, | |
| "is_hunt_post": bool(item.get("isHuntPost")), | |
| "comment_count": item.get("commentCount", 0), | |
| "reaction_count": sum((item.get("reactionCounts") or {}).values()), | |
| "view_count": item.get("viewCount", 0), | |
| "id": post.get("id") or item.get("id"), | |
| "share_url": post.get("shareUrl", ""), | |
| "link_urls": ",".join(link_urls), | |
| } | |
| def print_recap(username: str, posts: list[dict]) -> None: | |
| """A little "here's what we got" summary — mostly so you can eyeball that it | |
| looks right before you go trust a spreadsheet with 1,000+ rows in it.""" | |
| if not posts: | |
| return | |
| dates = sorted(p["date"] for p in posts if p["date"]) | |
| tags = Counter(p["tag"] for p in posts if p["tag"]) | |
| top_tags = ", ".join(f"{t} ({c})" for t, c in tags.most_common(4)) | |
| print(f"\n{'-' * 44}") | |
| print(f" @{username}: {len(posts)} posts") | |
| print(f" {dates[0]} → {dates[-1]}" if dates else "") | |
| if top_tags: | |
| print(f" most-used tags: {top_tags}") | |
| print(f"{'-' * 44}") | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description="Download your entire AfterHour post history as CSV + JSON.") | |
| parser.add_argument("--username", help="your AfterHour handle (skip this and it'll just ask)") | |
| parser.add_argument("--out", default=None, help="output filename, no extension (default: your username)") | |
| args = parser.parse_args() | |
| username = args.username or input("Your AfterHour username: ").strip() | |
| if not username: | |
| sys.exit("Need a username to look anything up — try again.") | |
| out_stem = args.out or username | |
| try: | |
| author_id = profile_id(username) | |
| posts = [normalize(item) for item in fetch_all_posts(author_id)] | |
| except LookupError as e: | |
| sys.exit(f"\n✗ {e}") | |
| except Exception as e: | |
| sys.exit(f"\n✗ Something went wrong talking to AfterHour: {e}") | |
| posts.sort(key=lambda p: p["created_at"], reverse=True) | |
| with open(f"{out_stem}.json", "w", encoding="utf-8") as f: | |
| json.dump(posts, f, ensure_ascii=False, indent=2) | |
| columns = ["date", "tag", "gain_loss", "amount", "tickers", "title", "body", | |
| "embedded_tweet_from", "is_hunt_post", "comment_count", "reaction_count", | |
| "view_count", "id", "share_url", "link_urls"] | |
| with open(f"{out_stem}.csv", "w", encoding="utf-8", newline="") as f: | |
| writer = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore") | |
| writer.writeheader() | |
| writer.writerows(posts) | |
| print(f"\n✓ saved {out_stem}.csv and {out_stem}.json") | |
| print_recap(username, posts) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment