Skip to content

Commit cb621cb

Browse files
committed
feat(skill): add reddit best-time-to-post skill for Fortress MCP
Parameterized helper (subreddit + post count) that reads a subreddit's top posts from old.reddit through the Fortress engine and reports the day/hour distribution of the winners in any timezone. Uses old.reddit HTML for exact data-timestamp/data-score because the .json API hard-blocks flagged IPs.
1 parent 8602b22 commit cb621cb

2 files changed

Lines changed: 209 additions & 0 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
name: reddit-best-time-to-post
3+
description: >-
4+
Use when someone wants the best day and time to post in a subreddit, or asks to
5+
analyze a subreddit's posting-time patterns. Drives the Fortress stealth engine to
6+
read a subreddit's top posts (all/year/month/week) from old.reddit, extracts each
7+
post's exact submission time and score, and reports the day-of-week and hour-of-day
8+
distribution of the winners in any timezone. Takes two inputs: the subreddit and the
9+
number of posts to analyze.
10+
---
11+
12+
# Reddit best-time-to-post skill
13+
14+
## When to use this
15+
Someone asks a version of "when should I post in r/X" or "what's the best time to post
16+
in this subreddit," or wants the posting-time pattern of a community. This skill answers
17+
it from data rather than folklore.
18+
19+
## Inputs
20+
- **subreddit** (required): the community, without `r/` (for example `DataHoarder`).
21+
- **posts** (required): how many top posts to analyze. 300 to 700 gives a stable pattern.
22+
Fewer is faster but noisier; more flattens out past a few hundred.
23+
- **tz** (optional): the IANA timezone for the report, default `America/New_York`. Use the
24+
timezone the person cares about, for example `America/Los_Angeles` for Pacific.
25+
26+
## Why this works, and its one limit
27+
Reddit's top listings are the posts that won. Reading when those posts were submitted gives
28+
a strong proxy for the best time to post. The limit is survivorship: the data shows when
29+
hits landed, not the posts that flopped at the same hours. Treat the result as "when
30+
successful posts tend to go up," not a guarantee.
31+
32+
Source is `old.reddit.com`, not the `.json` API. Reddit's JSON endpoint and normal request
33+
paths hard-block datacenter and flagged IPs, but old.reddit's HTML carries exact
34+
`data-timestamp` (epoch milliseconds) and `data-score` attributes, so the times are precise
35+
instead of relative estimates like "3 hours ago." The Fortress stealth engine loads
36+
old.reddit without tripping the block page.
37+
38+
## Setup
39+
```bash
40+
pip install "tilion[mcp]" playwright
41+
```
42+
No `playwright install` is needed. The script connects to Fortress over CDP rather than
43+
launching Playwright's own browser. On macOS the engine runs as a Docker image, so a Docker
44+
daemon must be running (`colima start` if you use Colima). See [`mcp/README.md`](../../README.md#macos-setup-apple-silicon-and-intel).
45+
46+
## Run it
47+
```bash
48+
python best_time.py --subreddit DataHoarder --posts 600 --tz America/Los_Angeles
49+
```
50+
The script starts a Fortress instance on port 9400 (override with `--port` if that is taken),
51+
pages through the top listings until it has the requested number of unique posts, and prints
52+
the analysis. Roughly 100 posts per page load, with a short politeness pause between pages,
53+
so 600 posts takes well under a minute once the engine is warm.
54+
55+
## Reading the output
56+
The report has three parts and a recommendation:
57+
- **Day of week**: the share of top posts on each day, plus the average score. The day with
58+
the largest share is the safest bet.
59+
- **Hour of day**: the share of top posts in each hour, in the requested timezone. Look for
60+
the plateau of high-share hours, not a single spike.
61+
- **Top three-hour windows**: the day-and-hour blocks that produced the most top posts. The
62+
first row is the single strongest window.
63+
64+
The final `RECOMMENDATION` line names the best day and the best three-hour window. Ignore any
65+
hour that shows a high average score but a tiny post count, since one viral thread skews it.
66+
67+
## If it returns nothing
68+
The subreddit may be private or quarantined, or the engine may be down. On macOS the usual
69+
cause is a stopped Docker daemon, so run `colima start` and retry. For a genuinely hard block,
70+
route the engine through a residential proxy with `TILION_PROXY` before running.
71+
72+
## Reference
73+
Fortress tools and setup: [`mcp/README.md`](../../README.md).
74+
General stealth-browser skill: [`mcp/skill/SKILL.md`](../../skill/SKILL.md).
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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

Comments
 (0)