|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Best time to post in a subreddit, inferred from when its top posts were submitted. |
| 3 | +
|
| 4 | +Pulls the subreddit's top listings (all / year / month / week) through the Fortress |
| 5 | +stealth engine, reads each post's exact submission time and score, and reports the |
| 6 | +day-of-week and hour-of-day distribution of the winners. |
| 7 | +
|
| 8 | +Why old.reddit: Reddit's `.json` API and normal request paths hard-block datacenter and |
| 9 | +flagged IPs, but old.reddit's HTML carries exact `data-timestamp` (epoch ms) and |
| 10 | +`data-score` attributes, so the times are precise rather than "3 hours ago" estimates. |
| 11 | +
|
| 12 | +Usage: |
| 13 | + python best_time.py --subreddit DataHoarder --posts 600 |
| 14 | + python best_time.py --subreddit selfhosted --posts 300 --tz America/Los_Angeles |
| 15 | +
|
| 16 | +Requires: pip install "tilion[mcp]" playwright |
| 17 | + (no `playwright install` needed — it connects to Fortress over CDP, it does not |
| 18 | + launch Playwright's own browser.) |
| 19 | +""" |
| 20 | +import argparse, sys, time, collections |
| 21 | +from datetime import datetime, timezone |
| 22 | +from zoneinfo import ZoneInfo |
| 23 | + |
| 24 | +from tilion_fortress import Fortress |
| 25 | +from playwright.sync_api import sync_playwright |
| 26 | + |
| 27 | +FRAMES = ["all", "year", "month", "week"] # widest first: biggest hits lead the sample |
| 28 | +MAX_PAGES_PER_FRAME = 10 # Reddit caps top pagination near 1000 posts |
| 29 | +DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] |
| 30 | + |
| 31 | +EXTRACT = """() => { |
| 32 | + const out = []; |
| 33 | + document.querySelectorAll('div.thing[data-timestamp]').forEach(el => { |
| 34 | + if (el.getAttribute('data-promoted') === 'true') return; // skip ads |
| 35 | + out.push({ts: +el.getAttribute('data-timestamp'), |
| 36 | + score:+el.getAttribute('data-score'), |
| 37 | + id: el.getAttribute('data-fullname')}); |
| 38 | + }); |
| 39 | + return out; |
| 40 | +}""" |
| 41 | + |
| 42 | + |
| 43 | +def scrape(subreddit, target, port, pause): |
| 44 | + """Return {id: (ts_ms, score)} until `target` unique posts or the listings run dry.""" |
| 45 | + posts = {} |
| 46 | + with Fortress(port=port) as f: |
| 47 | + with sync_playwright() as p: |
| 48 | + browser = p.chromium.connect_over_cdp(f.cdp_url) |
| 49 | + ctx = browser.contexts[0] if browser.contexts else browser.new_context() |
| 50 | + page = ctx.new_page() |
| 51 | + for t in FRAMES: |
| 52 | + after, count = "", 0 |
| 53 | + for _ in range(MAX_PAGES_PER_FRAME): |
| 54 | + url = (f"https://old.reddit.com/r/{subreddit}/top/?sort=top&t={t}" |
| 55 | + f"&limit=100&count={count}" + (f"&after={after}" if after else "")) |
| 56 | + page.goto(url, wait_until="domcontentloaded", timeout=60000) |
| 57 | + try: |
| 58 | + page.wait_for_selector("div.thing[data-timestamp]", timeout=15000) |
| 59 | + except Exception: |
| 60 | + break # blocked or empty listing for this frame |
| 61 | + rows = page.evaluate(EXTRACT) |
| 62 | + if not rows: |
| 63 | + break |
| 64 | + for r in rows: |
| 65 | + posts.setdefault(r["id"], (r["ts"], r["score"])) |
| 66 | + after, count = rows[-1]["id"], count + len(rows) |
| 67 | + time.sleep(pause) |
| 68 | + if len(posts) >= target: |
| 69 | + return posts |
| 70 | + print(f" [{t}] running total: {len(posts)} unique", file=sys.stderr) |
| 71 | + if len(posts) >= target: |
| 72 | + break |
| 73 | + return posts |
| 74 | + |
| 75 | + |
| 76 | +def report(posts, subreddit, tzname): |
| 77 | + tz = ZoneInfo(tzname) |
| 78 | + dow_c, dow_s = collections.Counter(), collections.Counter() |
| 79 | + hr_c, hr_s = collections.Counter(), collections.Counter() |
| 80 | + slot = collections.Counter() |
| 81 | + for ts, score in posts.values(): |
| 82 | + dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc).astimezone(tz) |
| 83 | + dow_c[dt.weekday()] += 1; dow_s[dt.weekday()] += score |
| 84 | + hr_c[dt.hour] += 1; hr_s[dt.hour] += score |
| 85 | + slot[(dt.weekday(), dt.hour // 3 * 3)] += 1 |
| 86 | + |
| 87 | + total = len(posts) |
| 88 | + print(f"\n===== r/{subreddit}: {total} unique top posts (times in {tzname}) =====\n") |
| 89 | + |
| 90 | + print("DAY OF WEEK share of top posts avg score") |
| 91 | + peak = max(dow_c.values()) |
| 92 | + for d in range(7): |
| 93 | + c = dow_c[d]; avg = dow_s[d] / c if c else 0 |
| 94 | + print(f" {DOW[d]} {c:4d} ({c/total*100:4.1f}%) {'#'*round(c/peak*30):30s} {avg:6.0f}") |
| 95 | + |
| 96 | + print(f"\nHOUR OF DAY ({tzname}) share avg score") |
| 97 | + peak = max(hr_c.values()) |
| 98 | + for h in range(24): |
| 99 | + c = hr_c[h] |
| 100 | + if not c: |
| 101 | + continue |
| 102 | + print(f" {h:02d}:00 {c:4d} ({c/total*100:4.1f}%) {'#'*round(c/peak*24):24s} {hr_s[h]/c:6.0f}") |
| 103 | + |
| 104 | + print("\nTOP THREE-HOUR WINDOWS BY COUNT") |
| 105 | + for (d, hb), c in slot.most_common(9): |
| 106 | + print(f" {DOW[d]} {hb:02d}:00-{hb+3:02d}:00 {c:3d} posts ({c/total*100:.1f}%)") |
| 107 | + |
| 108 | + best_day = max(range(7), key=lambda d: dow_c[d]) |
| 109 | + (bd, bh), _ = slot.most_common(1)[0] |
| 110 | + print(f"\nRECOMMENDATION: best day is {DOW[best_day]}; single best window is " |
| 111 | + f"{DOW[bd]} {bh:02d}:00-{bh+3:02d}:00 {tzname}.") |
| 112 | + print("Note: this is when winning posts were submitted (survivorship). Windows with a " |
| 113 | + "high avg score but few posts are outliers, not targets.") |
| 114 | + |
| 115 | + |
| 116 | +def main(): |
| 117 | + ap = argparse.ArgumentParser(description="Find the best time to post in a subreddit.") |
| 118 | + ap.add_argument("--subreddit", required=True, help="subreddit name without r/ (e.g. DataHoarder)") |
| 119 | + ap.add_argument("--posts", type=int, default=500, help="target number of top posts to analyze") |
| 120 | + ap.add_argument("--tz", default="America/New_York", help="IANA timezone for the report") |
| 121 | + ap.add_argument("--port", type=int, default=9400, help="Fortress port (avoid 9222 if the MCP server is up)") |
| 122 | + ap.add_argument("--pause", type=float, default=1.5, help="seconds between page loads (politeness)") |
| 123 | + args = ap.parse_args() |
| 124 | + |
| 125 | + print(f"Scraping up to {args.posts} top posts from r/{args.subreddit} ...", file=sys.stderr) |
| 126 | + posts = scrape(args.subreddit, args.posts, args.port, args.pause) |
| 127 | + if not posts: |
| 128 | + print("No posts scraped. The subreddit may be private/blocked, or the engine is down " |
| 129 | + "(on macOS check `colima start`).", file=sys.stderr) |
| 130 | + sys.exit(1) |
| 131 | + report(posts, args.subreddit, args.tz) |
| 132 | + |
| 133 | + |
| 134 | +if __name__ == "__main__": |
| 135 | + main() |
0 commit comments