Skip to content

Instantly share code, notes, and snippets.

@mphinance
Created July 31, 2026 12:32
Show Gist options
  • Select an option

  • Save mphinance/8be410783fe65efe3894198f88388d2a to your computer and use it in GitHub Desktop.

Select an option

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
#!/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