threadfox-lite 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/.claude-plugin/marketplace.json +1 -1
  2. threadfox_lite-0.2.0/.claude-plugin/plugin.json +31 -0
  3. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/LAUNCHGUIDE.md +3 -3
  4. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/PKG-INFO +6 -5
  5. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/README.md +5 -3
  6. threadfox_lite-0.2.0/manifest.json +75 -0
  7. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/pyproject.toml +2 -2
  8. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/server.json +4 -4
  9. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/skills/reddit-rules-first/SKILL.md +1 -1
  10. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/src/threadfox_lite/__init__.py +1 -1
  11. threadfox_lite-0.2.0/src/threadfox_lite/browser.py +156 -0
  12. threadfox_lite-0.2.0/src/threadfox_lite/reddit.py +176 -0
  13. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/src/threadfox_lite/server.py +17 -1
  14. threadfox_lite-0.2.0/tests/test_reddit.py +132 -0
  15. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/uv.lock +2 -6
  16. threadfox_lite-0.1.1/.claude-plugin/plugin.json +0 -20
  17. threadfox_lite-0.1.1/manifest.json +0 -39
  18. threadfox_lite-0.1.1/src/threadfox_lite/reddit.py +0 -88
  19. threadfox_lite-0.1.1/tests/test_reddit.py +0 -42
  20. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/.github/workflows/publish.yml +0 -0
  21. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/.gitignore +0 -0
  22. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/.mcpbignore +0 -0
  23. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/LICENSE +0 -0
  24. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/glama.json +0 -0
  25. {threadfox_lite-0.1.1 → threadfox_lite-0.2.0}/icon.png +0 -0
@@ -12,7 +12,7 @@
12
12
  "name": "threadfox-lite",
13
13
  "source": "./",
14
14
  "description": "Free, read-only Reddit tools: subreddit rules before you post, post status after, and the reddit-rules-first skill.",
15
- "version": "0.1.1",
15
+ "version": "0.2.0",
16
16
  "category": "marketing",
17
17
  "homepage": "https://threadfox.vip"
18
18
  }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "threadfox-lite",
3
+ "version": "0.2.0",
4
+ "description": "Free, read-only Reddit tools: find communities where you may post, read their rules, check your account and whether a post stayed up, plus the reddit-rules-first skill.",
5
+ "author": {
6
+ "name": "ThreadFox (AMF Indiana)",
7
+ "email": "support@threadfox.vip",
8
+ "url": "https://threadfox.vip"
9
+ },
10
+ "homepage": "https://threadfox.vip",
11
+ "repository": "https://github.com/amflimited/threadfox-lite",
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "reddit",
15
+ "subreddit-rules",
16
+ "marketing",
17
+ "mcp",
18
+ "skills"
19
+ ],
20
+ "mcpServers": {
21
+ "threadfox-lite": {
22
+ "command": "uv",
23
+ "args": [
24
+ "run",
25
+ "--directory",
26
+ "${CLAUDE_PLUGIN_ROOT}",
27
+ "threadfox-lite"
28
+ ]
29
+ }
30
+ }
31
+ }
@@ -6,12 +6,12 @@ Reddit tools for Claude Code and Codex: rules first, posts that fit, checks that
6
6
  ## Description
7
7
  ThreadFox gives your AI the Reddit job. Buying on MCP Marketplace gets you the full ThreadFox kit: your Claude Code or Codex reads each community's rules, writes a post that fits that community, publishes it from your own signed-in Chrome (no Reddit API keys, no exported cookies), answers the comments, and records every outcome in a ledger, stopping where a community removed you. It includes a 12-module course, 48 prompts and a one-command install for Windows, Mac and Linux.
8
8
 
9
- This server (ThreadFox Lite) runs locally and gives every user two free, read-only tools: subreddit_rules and post_status. Your license key opens the full kit: set MCP_LICENSE_KEY, run threadfox_full_kit, or redeem the key at https://threadfox.vip/redeem for your private library.
9
+ This server (ThreadFox Lite) runs locally and gives every user four free, read-only tools: find_communities, subreddit_rules, account_check and post_status. They read through your own signed-in Chrome (Reddit now asks everyone to sign in before it shows anything), using Microsoft's Playwright Extension. Your license key opens the full kit: set MCP_LICENSE_KEY, run threadfox_full_kit, or redeem the key at https://threadfox.vip/redeem for your private library.
10
10
 
11
11
  Built from our own run: 978,227 recorded views from two days of posts (Sept 20-21 UTC), with no ad spend. That is our result, not a promise about yours. Made by AMF Indiana; not affiliated with Reddit, OpenAI or Anthropic.
12
12
 
13
13
  ## Setup Requirements
14
- MCP_LICENSE_KEY (optional): your MCP Marketplace license key. The two read-only tools work without it. Requires uv (uvx) and Python 3.10+.
14
+ MCP_LICENSE_KEY (optional): your MCP Marketplace license key. The read-only tools work without it. Requires uv (uvx), Python 3.10+, Node.js and Microsoft's Playwright Extension in the Chrome where you are signed in to Reddit.
15
15
 
16
16
  ## Category
17
17
  Marketing
@@ -23,7 +23,7 @@ Reddit marketing, Product launches, Community research, Content distribution, So
23
23
  - subreddit_rules: rules, size and description of any subreddit, with self-promotion rules flagged
24
24
  - post_status: live, removed or deleted, with score and comment count
25
25
  - Full kit with a license: publishing from your own Chrome, replies, outcome ledger, 12-module course, 48 prompts
26
- - No Reddit API keys, no login for the free tools
26
+ - No Reddit API keys; reads run in your own signed-in Chrome and never post
27
27
  - Works with Claude Code, Codex and other MCP clients
28
28
 
29
29
  ## Getting Started
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: threadfox-lite
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Read a subreddit's rules and check whether a Reddit post is still up, from Claude Code, Codex or any MCP client. Free tools from ThreadFox.
5
5
  Project-URL: Homepage, https://threadfox.vip
6
6
  Project-URL: Source, https://github.com/amflimited/threadfox-lite
@@ -8,19 +8,20 @@ Author-email: "AMF Indiana (ThreadFox)" <support@threadfox.vip>
8
8
  License: MIT
9
9
  License-File: LICENSE
10
10
  Requires-Python: >=3.10
11
- Requires-Dist: httpx>=0.27
12
11
  Requires-Dist: mcp<2,>=1.2.0
13
12
  Description-Content-Type: text/markdown
14
13
 
15
14
  # ThreadFox Lite
16
15
 
17
- Free, read-only Reddit tools for Claude Code, Codex and any MCP client, plus a `reddit-rules-first` agent skill.
16
+ Free, read-only Reddit tools for Claude Desktop, Claude Code, Codex and any MCP client, plus a `reddit-rules-first` agent skill.
18
17
 
19
18
  - `subreddit_rules`: a subreddit's rules, size and description, with the self-promotion rules flagged. Call it before writing a post.
19
+ - `find_communities`: subreddits about a topic ("bakery marketing", "indie games"), each with its self-promotion rules flagged and whether it mentions a promo thread.
20
+ - `account_check`: a Reddit account's age, karma and standing, and whether its recent posts are removed or hidden from search (the practical sign of a spam filter or shadowban).
20
21
  - `post_status`: whether a post is still live, removed by moderators or deleted, with score and comments. Check at 1, 6 and 24 hours.
21
- - `threadfox_full_kit`: how to get the full ThreadFox kit (posting from your own Chrome, replies, an outcome ledger, a 12-module course).
22
+ - `threadfox_full_kit`: what the full ThreadFox kit adds (finding conversations where people ask for what you sell, posting from your own Chrome, replies, an outcome ledger, a 12-module course).
22
23
 
23
- No login, no posting, no Reddit API keys: each call is one request to Reddit's public JSON with a descriptive user agent.
24
+ **Reads only, through your own signed-in Chrome.** Reddit now sends every signed-out request, including its public JSON, to a login page, so ThreadFox Lite reads the way you do: in your Chrome, signed in as you, one small read at a time. It never posts, votes or changes anything, and needs no Reddit API keys. One-time setup: [Node.js](https://nodejs.org) and Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. The first read asks you to pick a tab in Chrome.
24
25
 
25
26
  ## Install
26
27
 
@@ -1,12 +1,14 @@
1
1
  # ThreadFox Lite
2
2
 
3
- Free, read-only Reddit tools for Claude Code, Codex and any MCP client, plus a `reddit-rules-first` agent skill.
3
+ Free, read-only Reddit tools for Claude Desktop, Claude Code, Codex and any MCP client, plus a `reddit-rules-first` agent skill.
4
4
 
5
5
  - `subreddit_rules`: a subreddit's rules, size and description, with the self-promotion rules flagged. Call it before writing a post.
6
+ - `find_communities`: subreddits about a topic ("bakery marketing", "indie games"), each with its self-promotion rules flagged and whether it mentions a promo thread.
7
+ - `account_check`: a Reddit account's age, karma and standing, and whether its recent posts are removed or hidden from search (the practical sign of a spam filter or shadowban).
6
8
  - `post_status`: whether a post is still live, removed by moderators or deleted, with score and comments. Check at 1, 6 and 24 hours.
7
- - `threadfox_full_kit`: how to get the full ThreadFox kit (posting from your own Chrome, replies, an outcome ledger, a 12-module course).
9
+ - `threadfox_full_kit`: what the full ThreadFox kit adds (finding conversations where people ask for what you sell, posting from your own Chrome, replies, an outcome ledger, a 12-module course).
8
10
 
9
- No login, no posting, no Reddit API keys: each call is one request to Reddit's public JSON with a descriptive user agent.
11
+ **Reads only, through your own signed-in Chrome.** Reddit now sends every signed-out request, including its public JSON, to a login page, so ThreadFox Lite reads the way you do: in your Chrome, signed in as you, one small read at a time. It never posts, votes or changes anything, and needs no Reddit API keys. One-time setup: [Node.js](https://nodejs.org) and Microsoft's [Playwright Extension](https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm) in the Chrome where you are signed in to Reddit. The first read asks you to pick a tab in Chrome.
10
12
 
11
13
  ## Install
12
14
 
@@ -0,0 +1,75 @@
1
+ {
2
+ "manifest_version": "0.4",
3
+ "name": "threadfox-lite",
4
+ "display_name": "ThreadFox Lite",
5
+ "version": "0.2.0",
6
+ "description": "Find subreddits where you may post, read their rules, check your account and whether a post stayed up. Free, read-only.",
7
+ "long_description": "Free, read-only Reddit tools for Claude and any MCP client. find_communities lists subreddits about a topic with their self-promotion rules flagged; subreddit_rules reads one community's rules, size and description; account_check reports an account's age, karma, standing and whether its recent posts are removed or hidden from search; post_status says whether a post is live, removed or deleted. Reddit now sends signed-out requests to a login page, so every read runs in your own Chrome, signed in as you, through Microsoft's Playwright Extension (needs Node.js and the extension; the first read asks you to pick a tab). Nothing is posted or changed. The full ThreadFox kit (finding conversations where people ask for what you sell, posting from your own Chrome, an outcome ledger, a 12-module course) is sold separately at https://threadfox.vip.",
8
+ "author": {
9
+ "name": "AMF Indiana (ThreadFox)",
10
+ "email": "support@threadfox.vip",
11
+ "url": "https://threadfox.vip"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/amflimited/threadfox-lite"
16
+ },
17
+ "homepage": "https://threadfox.vip",
18
+ "support": "https://threadfox.vip/help",
19
+ "icon": "icon.png",
20
+ "server": {
21
+ "type": "uv",
22
+ "entry_point": "src/threadfox_lite/server.py",
23
+ "mcp_config": {
24
+ "command": "uv",
25
+ "args": [
26
+ "run",
27
+ "--directory",
28
+ "${__dirname}",
29
+ "threadfox-lite"
30
+ ]
31
+ }
32
+ },
33
+ "tools": [
34
+ {
35
+ "name": "find_communities",
36
+ "description": "Subreddits about a topic, each with its self-promotion rules flagged."
37
+ },
38
+ {
39
+ "name": "subreddit_rules",
40
+ "description": "A subreddit's rules, size and description, with the self-promotion rules flagged."
41
+ },
42
+ {
43
+ "name": "account_check",
44
+ "description": "An account's age, karma, standing and whether its recent posts are hidden."
45
+ },
46
+ {
47
+ "name": "post_status",
48
+ "description": "Whether a Reddit post is still live, removed by moderators or deleted, with score and comments."
49
+ },
50
+ {
51
+ "name": "threadfox_full_kit",
52
+ "description": "What the full ThreadFox kit adds and where to get it."
53
+ }
54
+ ],
55
+ "keywords": [
56
+ "reddit",
57
+ "subreddit",
58
+ "rules",
59
+ "marketing",
60
+ "claude-code",
61
+ "codex",
62
+ "mcp"
63
+ ],
64
+ "license": "MIT",
65
+ "compatibility": {
66
+ "platforms": [
67
+ "darwin",
68
+ "linux",
69
+ "win32"
70
+ ],
71
+ "runtimes": {
72
+ "python": ">=3.10"
73
+ }
74
+ }
75
+ }
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "threadfox-lite"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Read a subreddit's rules and check whether a Reddit post is still up, from Claude Code, Codex or any MCP client. Free tools from ThreadFox."
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
7
7
  requires-python = ">=3.10"
8
8
  authors = [{ name = "AMF Indiana (ThreadFox)", email = "support@threadfox.vip" }]
9
- dependencies = ["mcp>=1.2.0,<2", "httpx>=0.27"]
9
+ dependencies = ["mcp>=1.2.0,<2"]
10
10
 
11
11
  [project.urls]
12
12
  Homepage = "https://threadfox.vip"
@@ -2,8 +2,8 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.amflimited/threadfox-lite",
4
4
  "title": "ThreadFox Lite",
5
- "description": "Read subreddit rules before posting and check whether a Reddit post is still up. Free and read-only.",
6
- "version": "0.1.1",
5
+ "description": "Find subreddits you may post in, read their rules, check your account and posts. Free, read-only.",
6
+ "version": "0.2.0",
7
7
  "websiteUrl": "https://threadfox.vip",
8
8
  "repository": {
9
9
  "url": "https://github.com/amflimited/threadfox-lite",
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "pypi",
15
15
  "identifier": "threadfox-lite",
16
- "version": "0.1.1",
16
+ "version": "0.2.0",
17
17
  "runtimeHint": "uvx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -21,7 +21,7 @@
21
21
  },
22
22
  {
23
23
  "registryType": "mcpb",
24
- "identifier": "https://github.com/amflimited/threadfox-lite/releases/download/v0.1.1/threadfox-lite-0.1.1.mcpb",
24
+ "identifier": "https://github.com/amflimited/threadfox-lite/releases/download/v0.2.0/threadfox-lite-0.2.0.mcpb",
25
25
  "fileSha256": "0000000000000000000000000000000000000000000000000000000000000000",
26
26
  "transport": {
27
27
  "type": "stdio"
@@ -9,7 +9,7 @@ Most Reddit promotion fails for the same reasons: the post ignores the community
9
9
 
10
10
  ## Before writing
11
11
 
12
- 1. Pick communities where your product answers a question people there already ask. Fit comes before size.
12
+ 1. Pick communities where your product answers a question people there already ask. Fit comes before size. With the ThreadFox Lite MCP server, `find_communities` lists candidates with their promotion rules flagged; `account_check` tells you whether your account is old enough and whether your recent posts are being hidden.
13
13
  2. Read each community's rules the day you post. With the ThreadFox Lite MCP server, call `subreddit_rules`; otherwise open `https://www.reddit.com/r/<name>/about/rules` and the sidebar.
14
14
  3. Look for: self-promotion limits (for example a 10% rule), a weekly promo thread, required flair, link bans, account age or karma minimums. If self-promotion isn't allowed, use the promo thread or don't post the link.
15
15
 
@@ -1,2 +1,2 @@
1
1
  """ThreadFox Lite: free, read-only Reddit tools for AI agents."""
2
- __version__ = "0.1.1"
2
+ __version__ = "0.2.0"
@@ -0,0 +1,156 @@
1
+ """Reddit reads through your own signed-in Chrome.
2
+
3
+ Reddit now sends every signed-out request, including its public JSON, to a login page. So ThreadFox Lite reads
4
+ the way you do: in your Chrome, signed in as you. It starts Microsoft's Playwright MCP server in extension mode
5
+ (`npx @playwright/mcp --extension`, needs Node.js and the Playwright Extension in Chrome) and runs one small
6
+ fetch of Reddit's JSON inside an old.reddit.com tab per read. Nothing is posted, voted or changed.
7
+ """
8
+ import atexit
9
+ import glob
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import subprocess
15
+ import threading
16
+ import time
17
+
18
+ EXTENSION_URL = "https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm"
19
+ SETUP = ("ThreadFox Lite reads Reddit through your own signed-in Chrome, because Reddit now asks everyone to sign in "
20
+ "before it shows anything. One-time setup: install Node.js (https://nodejs.org), add Microsoft's Playwright "
21
+ "Extension to Chrome (" + EXTENSION_URL + "), sign in to Reddit in that Chrome, then try again.")
22
+ SPACING = 1.5 # seconds between reads, so the account is never rate-limited
23
+
24
+
25
+ class SetupError(RuntimeError):
26
+ """The browser route is not ready; the message says what to do."""
27
+
28
+
29
+ def find_npx():
30
+ """npx on PATH, or in the usual Node.js folders (apps started from the Dock or Start menu get a short PATH)."""
31
+ found = shutil.which("npx")
32
+ if found:
33
+ return found
34
+ home = os.path.expanduser("~")
35
+ candidates = ["/usr/local/bin/npx", "/opt/homebrew/bin/npx", os.path.join(home, ".volta", "bin", "npx")]
36
+ candidates += sorted(glob.glob(os.path.join(home, ".nvm", "versions", "node", "*", "bin", "npx")), reverse=True)
37
+ for base in filter(None, [os.environ.get("ProgramFiles"), os.environ.get("APPDATA")]):
38
+ candidates += [os.path.join(base, "nodejs", "npx.cmd"), os.path.join(base, "npm", "npx.cmd")]
39
+ return next((c for c in candidates if os.path.isfile(c)), None)
40
+
41
+
42
+ class Chrome:
43
+ """A Playwright MCP child process spoken to over newline-delimited JSON-RPC."""
44
+
45
+ def __init__(self, command=None, timeout=90):
46
+ self.command = command
47
+ self.timeout = timeout
48
+ self.proc = None
49
+ self.n = 0
50
+ self.tool = None
51
+ self.last = 0.0
52
+ self.lock = threading.Lock()
53
+
54
+ def start(self):
55
+ if self.proc and self.proc.poll() is None:
56
+ return
57
+ cmd = list(self.command or [])
58
+ env = dict(os.environ)
59
+ if not cmd:
60
+ npx = find_npx()
61
+ if not npx:
62
+ raise SetupError(SETUP)
63
+ cmd = [npx, "-y", "@playwright/mcp@latest", "--extension"]
64
+ env["PATH"] = os.path.dirname(npx) + os.pathsep + env.get("PATH", "")
65
+ self.proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
66
+ text=True, encoding="utf-8", bufsize=1, env=env)
67
+ self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
68
+ "clientInfo": {"name": "threadfox-lite", "version": "1"}}, 120)
69
+ self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
70
+ self.proc.stdin.flush()
71
+ names = [t.get("name") for t in self.request("tools/list", {}).get("tools", [])]
72
+ self.tool = next((n for n in ("browser_run_code_unsafe", "browser_run_code") if n in names), None)
73
+ if not self.tool:
74
+ raise SetupError("This Playwright MCP version cannot run a read; update it (npx -y @playwright/mcp@latest).")
75
+
76
+ def request(self, method, params, timeout=None):
77
+ self.n += 1
78
+ mid, box = self.n, {}
79
+ self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": mid, "method": method, "params": params}) + "\n")
80
+ self.proc.stdin.flush()
81
+
82
+ def read():
83
+ for line in self.proc.stdout:
84
+ try:
85
+ msg = json.loads(line)
86
+ except ValueError:
87
+ continue
88
+ if msg.get("id") == mid:
89
+ box["msg"] = msg
90
+ return
91
+ reader = threading.Thread(target=read, daemon=True)
92
+ reader.start()
93
+ reader.join(timeout or self.timeout)
94
+ if "msg" not in box:
95
+ self.close(tidy=False)
96
+ raise SetupError("Chrome did not answer. Open Chrome, click the Playwright Extension and pick a tab when it "
97
+ "asks to connect, then try again. " + SETUP)
98
+ if "error" in box["msg"]:
99
+ raise SetupError(box["msg"]["error"].get("message", "browser error"))
100
+ return box["msg"].get("result", {})
101
+
102
+ def get(self, path):
103
+ """(status, body) for one Reddit JSON path, fetched in the signed-in old.reddit.com tab."""
104
+ code = """async (page) => {
105
+ if (!page.url().startsWith('https://old.reddit.com/')) await page.goto('https://old.reddit.com/robots.txt', {waitUntil: 'domcontentloaded'});
106
+ const out = await page.evaluate(async (path) => {
107
+ const r = await fetch(path, {credentials: 'include', headers: {'Accept': 'application/json'}});
108
+ return {status: r.status, body: r.ok ? await r.json() : null};
109
+ }, %s);
110
+ return JSON.stringify(out);
111
+ }""" % json.dumps(path)
112
+ with self.lock:
113
+ wait = self.last + SPACING - time.time()
114
+ if wait > 0:
115
+ time.sleep(wait)
116
+ self.start()
117
+ try:
118
+ result = self.request("tools/call", {"name": self.tool, "arguments": {"code": code}})
119
+ finally:
120
+ self.last = time.time()
121
+ text = "\n".join(c.get("text", "") for c in result.get("content", []) if c.get("type") == "text")
122
+ if result.get("isError") or "### Error" in text:
123
+ message = re.sub(r"^#+\s*Error\s*", "", text).strip()
124
+ raise SetupError((SETUP if re.search(r"extension not found|install it from", message, re.I) else message[:400]) or SETUP)
125
+ m = re.search(r'### Result\s*\n(".*?")\s*\n', text + "\n", re.S)
126
+ if not m:
127
+ raise SetupError("No answer came back from Chrome: " + text[:200])
128
+ out = json.loads(json.loads(m.group(1)))
129
+ return out.get("status"), out.get("body")
130
+
131
+ def close(self, tidy=True):
132
+ """Close this session's tab in Chrome, then end the Playwright process."""
133
+ if not self.proc:
134
+ return
135
+ if tidy and self.proc.poll() is None:
136
+ try:
137
+ self.request("tools/call", {"name": "browser_tabs", "arguments": {"action": "close"}}, timeout=10)
138
+ except Exception:
139
+ pass
140
+ try:
141
+ self.proc.kill()
142
+ except Exception:
143
+ pass
144
+ self.proc = None
145
+
146
+
147
+ _chrome = None
148
+
149
+
150
+ def get(path):
151
+ global _chrome
152
+ if _chrome is None:
153
+ command = os.environ.get("THREADFOX_LITE_BROWSER_CMD")
154
+ _chrome = Chrome(command.split() if command else None)
155
+ atexit.register(_chrome.close)
156
+ return _chrome.get(path)
@@ -0,0 +1,176 @@
1
+ """Read-only Reddit reads for the ThreadFox Lite tools. Each read goes through your own signed-in Chrome
2
+ (browser.py); nothing is posted, voted or changed. Every function takes an optional `get(path) -> (status, body)`
3
+ so the tests can stand in for Reddit."""
4
+ import re
5
+ import time
6
+ from urllib.parse import quote, urlsplit
7
+
8
+ from . import browser
9
+
10
+ PROMO = re.compile(r"self[- ]?promo|promot|advertis|spam|affiliate|referral|\b10\s*%|link[- ]?drop|no links?\b|marketing|sell|product|business", re.I)
11
+ PROMO_THREAD = re.compile(r"(weekly|monthly|daily|dedicated|pinned|sticky|stickied)[^.\n]{0,40}(thread|post|megathread)|megathread|"
12
+ r"self[- ]?promo(tion)? (thread|saturday|sunday|day)", re.I)
13
+ SUB = re.compile(r"^[A-Za-z0-9_]{2,21}$")
14
+ USER = re.compile(r"^[A-Za-z0-9_-]{3,20}$")
15
+ RATE = "Reddit asked us to slow down (HTTP 429). Wait a few minutes before the next check."
16
+
17
+
18
+ def _read(get, path):
19
+ """(status, body), or an error dict when the browser route is not set up."""
20
+ try:
21
+ return (get or browser.get)(path), None
22
+ except browser.SetupError as exc:
23
+ return (None, None), str(exc)
24
+
25
+
26
+ def subreddit_rules(name: str, get=None) -> dict:
27
+ name = name.strip().removeprefix("/").removeprefix("r/").strip("/")
28
+ if not SUB.match(name):
29
+ return {"error": "Give a subreddit name like 'SaaS' or 'r/SaaS'."}
30
+ (status, rules), err = _read(get, f"/r/{name}/about/rules.json?raw_json=1")
31
+ if err:
32
+ return {"subreddit": name, "error": err, "open_instead": f"https://www.reddit.com/r/{name}/about/rules"}
33
+ if status == 429:
34
+ return {"subreddit": name, "error": RATE}
35
+ if status != 200 or not isinstance(rules, dict):
36
+ return {"subreddit": name, "error": f"r/{name} is private, banned or does not exist (HTTP {status})."}
37
+ (status, about), err = _read(get, f"/r/{name}/about.json?raw_json=1")
38
+ data = (about or {}).get("data", {}) if status == 200 and isinstance(about, dict) else {}
39
+ items = []
40
+ for r in rules.get("rules", []):
41
+ text = f"{r.get('short_name', '')}\n{r.get('description', '')}".strip()
42
+ items.append({"rule": r.get("short_name", ""), "details": (r.get("description") or "")[:1200],
43
+ "applies_to": r.get("kind", "all"), "mentions_promotion": bool(PROMO.search(text))})
44
+ return {
45
+ "subreddit": data.get("display_name", name),
46
+ "subscribers": data.get("subscribers"),
47
+ "description": (data.get("public_description") or "")[:500],
48
+ "rules": items,
49
+ "promotion_rules": [i["rule"] for i in items if i["mentions_promotion"]],
50
+ "read_before_posting": "Follow every rule above. If a rule limits self-promotion, use the community's promo thread or don't post the link. Write a new post for this community; never paste the same post in several.",
51
+ }
52
+
53
+
54
+ def post_status(url: str, get=None) -> dict:
55
+ try:
56
+ parts = urlsplit(url.strip())
57
+ except ValueError:
58
+ return {"error": "Give a Reddit post URL."}
59
+ host = (parts.hostname or "").lower()
60
+ m = re.match(r"^/r/[^/]+/comments/([a-z0-9]+)", parts.path or "")
61
+ if not host.endswith("reddit.com") or not m:
62
+ return {"error": "Give a full post URL like https://www.reddit.com/r/SaaS/comments/abc123/title/"}
63
+ (status, body), err = _read(get, f"/comments/{m.group(1)}.json?raw_json=1&limit=1")
64
+ if err:
65
+ return {"error": err, "open_instead": url.strip()}
66
+ if status == 404:
67
+ return {"status": "not_found", "id": m.group(1)}
68
+ if status == 429:
69
+ return {"error": RATE}
70
+ if status != 200 or not isinstance(body, list):
71
+ return {"error": f"Reddit answered HTTP {status} for this post."}
72
+ post = body[0]["data"]["children"][0]["data"]
73
+ removed = post.get("removed_by_category")
74
+ status = "removed" if removed else ("deleted" if post.get("author") == "[deleted]" or post.get("selftext") == "[deleted]" else "live")
75
+ return {
76
+ "status": status, "removed_by": removed, "title": post.get("title"), "subreddit": post.get("subreddit"),
77
+ "score": post.get("score"), "comments": post.get("num_comments"), "locked": post.get("locked"),
78
+ "shown_in_search": post.get("is_robot_indexable"),
79
+ "created_utc": post.get("created_utc"), "permalink": "https://www.reddit.com" + (post.get("permalink") or ""),
80
+ "note": "Check again at 1, 6 and 24 hours. If a community removes you twice, stop posting there.",
81
+ }
82
+
83
+
84
+ def account_check(username: str, get=None) -> dict:
85
+ """An account's age, karma and standing, and whether its recent posts are hidden from other people."""
86
+ name = username.strip().removeprefix("/").removeprefix("u/").strip("/")
87
+ if not USER.match(name):
88
+ return {"error": "Give a Reddit username like 'spez' or 'u/spez'."}
89
+ (status, about), err = _read(get, f"/user/{name}/about.json?raw_json=1")
90
+ if err:
91
+ return {"account": name, "error": err, "open_instead": f"https://www.reddit.com/user/{name}"}
92
+ if status == 429:
93
+ return {"account": name, "error": RATE}
94
+ if status == 404:
95
+ return {"account": name, "status": "not_found",
96
+ "meaning": "Reddit shows no profile under this name: it does not exist, was deleted, or is suspended or hidden."}
97
+ if status != 200 or not isinstance(about, dict):
98
+ return {"account": name, "error": f"Reddit answered HTTP {status} for this account."}
99
+ data = about.get("data", {})
100
+ if data.get("is_suspended"):
101
+ return {"account": name, "status": "suspended",
102
+ "meaning": "Reddit has suspended this account. Nothing it posts will be seen until the suspension ends."}
103
+ (status, posts), err = _read(get, f"/user/{name}/submitted.json?raw_json=1&limit=10")
104
+ recent = []
105
+ for child in ((posts or {}).get("data", {}).get("children", []) if status == 200 and isinstance(posts, dict) else [])[:10]:
106
+ d = child.get("data", {})
107
+ recent.append({"subreddit": d.get("subreddit"), "title": (d.get("title") or "")[:120], "score": d.get("score"),
108
+ "comments": d.get("num_comments"), "removed_by": d.get("removed_by_category"),
109
+ "hidden_from_search": d.get("is_robot_indexable") is False,
110
+ "permalink": "https://www.reddit.com" + (d.get("permalink") or "")})
111
+ days = int((time.time() - (data.get("created_utc") or time.time())) // 86400)
112
+ karma = (data.get("link_karma") or 0) + (data.get("comment_karma") or 0)
113
+ notes = []
114
+ if days < 30:
115
+ notes.append(f"The account is {days} days old; many communities require 7 to 30 days before you can post.")
116
+ if karma < 100:
117
+ notes.append(f"{karma} karma; many communities require 10 to 100. Build it with genuinely useful comments first.")
118
+ removed = [r for r in recent if r["removed_by"]]
119
+ hidden = [r for r in recent if r["hidden_from_search"] and not r["removed_by"]]
120
+ if len(removed) >= 2:
121
+ notes.append(f"{len(removed)} of the last {len(recent)} posts were removed. Read those communities' rules before posting again.")
122
+ if recent and len(hidden) >= max(2, len(recent) // 2):
123
+ notes.append("Most recent posts are hidden from search and listings without a moderator removal: Reddit's spam filter "
124
+ "or a shadowban. Check each post in a window where you are not signed in, and appeal at https://www.reddit.com/appeal.")
125
+ return {"account": data.get("name", name), "status": "active", "account_age_days": days,
126
+ "post_karma": data.get("link_karma"), "comment_karma": data.get("comment_karma"),
127
+ "verified_email": data.get("has_verified_email"), "recent_posts": recent,
128
+ "notes": notes or ["Enough age and karma for most communities, and recent posts are not hidden. Each community's rules still apply."]}
129
+
130
+
131
+ def find_communities(topic: str, limit: int = 5, get=None) -> dict:
132
+ """Subreddits about a topic, each with its self-promotion rules flagged."""
133
+ topic = (topic or "").strip()
134
+ if not 2 <= len(topic) <= 100:
135
+ return {"error": "Describe the topic in a few words, e.g. 'bakery marketing' or 'indie games'."}
136
+ limit = max(1, min(int(limit or 5), 8))
137
+ # Reddit's community search needs every word to match, so a two-word topic can find almost nothing.
138
+ # The whole topic goes first; when it finds too few, each main word is searched on its own.
139
+ words = re.findall(r"[A-Za-z0-9]{3,}", topic)
140
+ searches = [topic] + (words[:3] if len(words) > 1 else [])
141
+ subs, seen = [], set()
142
+ for q in searches:
143
+ if len(subs) >= limit:
144
+ break
145
+ (status, found), err = _read(get, f"/subreddits/search.json?raw_json=1&q={quote(q)}&limit=25&include_over_18=off")
146
+ if err:
147
+ return {"topic": topic, "error": err, "open_instead": f"https://www.reddit.com/search/?q={quote(topic)}&type=communities"}
148
+ if status == 429:
149
+ return {"topic": topic, "error": RATE}
150
+ if status != 200 or not isinstance(found, dict):
151
+ return {"topic": topic, "error": f"Reddit answered HTTP {status} for this search."}
152
+ for child in found.get("data", {}).get("children", []):
153
+ d = child.get("data", {})
154
+ name = (d.get("display_name") or "").lower()
155
+ if name and name not in seen and d.get("subreddit_type") == "public" and not d.get("over18") and (d.get("subscribers") or 0) >= 500:
156
+ seen.add(name); subs.append(d)
157
+ subs = subs[:limit]
158
+ out = []
159
+ for d in subs:
160
+ name = d.get("display_name")
161
+ (status, rules), err = _read(get, f"/r/{name}/about/rules.json?raw_json=1")
162
+ if err:
163
+ return {"topic": topic, "error": err, "communities": out}
164
+ items = rules.get("rules", []) if status == 200 and isinstance(rules, dict) else []
165
+ texts = [f"{r.get('short_name', '')}\n{r.get('description', '')}" for r in items]
166
+ promo = [r.get("short_name", "") for r, text in zip(items, texts) if PROMO.search(text)]
167
+ thread = bool(PROMO_THREAD.search(" ".join(texts) + " " + (d.get("public_description") or "")))
168
+ out.append({"subreddit": name, "subscribers": d.get("subscribers"), "description": (d.get("public_description") or "")[:300],
169
+ "promotion_rules": promo, "promo_thread_mentioned": thread, "rules_read": status == 200,
170
+ "verdict": ("rules not readable: open them before posting" if status != 200 else
171
+ "self-promotion limited: use the promo thread it mentions, or answer questions without links" if promo and thread else
172
+ "self-promotion limited: read these rules before any link" if promo else
173
+ "no rule mentions promotion: still read every rule, disclose, and post something useful"),
174
+ "rules_url": f"https://www.reddit.com/r/{name}/about/rules"})
175
+ return {"topic": topic, "communities": out,
176
+ "next": "Call subreddit_rules on the ones that fit before writing anything, and write a separate post for each."}
@@ -26,6 +26,20 @@ def post_status(url: str) -> dict:
26
26
  return reddit.post_status(url)
27
27
 
28
28
 
29
+ @mcp.tool()
30
+ def account_check(username: str) -> dict:
31
+ """Check a Reddit account before posting: whether other people can see it (not shadowbanned or suspended),
32
+ its age and karma, and its recent posts with any removals."""
33
+ return reddit.account_check(username)
34
+
35
+
36
+ @mcp.tool()
37
+ def find_communities(topic: str, limit: int = 5) -> dict:
38
+ """Find subreddits about a topic, each with its self-promotion rules flagged and whether it mentions a promo thread.
39
+ Use a few words, e.g. 'bakery marketing' or 'indie games'."""
40
+ return reddit.find_communities(topic, limit)
41
+
42
+
29
43
  @mcp.tool()
30
44
  def threadfox_full_kit() -> dict:
31
45
  """How to get the full ThreadFox kit: your AI reads the rules, writes a post that fits each community,
@@ -36,10 +50,12 @@ def threadfox_full_kit() -> dict:
36
50
  "next_step": "Open https://threadfox.vip/redeem, paste your MCP Marketplace license key and your email, and you land in your private ThreadFox library (one-command install, lessons, full download).",
37
51
  "support": "support@threadfox.vip"}
38
52
  return {"licensed": False,
39
- "what_it_adds": ["Publishing from your own signed-in Chrome, no Reddit API keys", "Replies to comments",
53
+ "what_it_adds": ["Finds conversations where people already ask for what you sell, and drafts replies you approve before anything is posted",
54
+ "Publishing from your own signed-in Chrome, no Reddit API keys", "Replies to comments",
40
55
  "A ledger of every post and outcome, and it stops where a community removed you",
41
56
  "12-module course, 48 prompts"],
42
57
  "proof": "Built from our own run: 978,227 recorded views from two days of posts (Sept 20-21 UTC), with no ad spend. Our result, not a promise of yours.",
58
+ "works_in": "Claude Desktop (one file, no terminal), Claude Code and Codex",
43
59
  "get_it": "https://threadfox.vip ($49 once, 30-day refund) or ThreadFox on MCP Marketplace"}
44
60
 
45
61
 
@@ -0,0 +1,132 @@
1
+ import sys, textwrap, time, unittest
2
+ from pathlib import Path
3
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
4
+ from threadfox_lite import browser, reddit
5
+
6
+
7
+ def fake(routes):
8
+ """A stand-in for the Chrome route: path (without its query) -> (status, body)."""
9
+ seen = []
10
+ def get(path):
11
+ seen.append(path)
12
+ return routes.get(path.split("?")[0], (404, None))
13
+ get.seen = seen
14
+ return get
15
+
16
+
17
+ RULES = {"rules": [{"short_name": "No self-promotion", "description": "Keep promo to the weekly thread", "kind": "all"},
18
+ {"short_name": "Be kind", "description": "", "kind": "all"}]}
19
+
20
+
21
+ class Tools(unittest.TestCase):
22
+ def test_rules_flag_promotion(self):
23
+ get = fake({"/r/SaaS/about/rules.json": (200, RULES),
24
+ "/r/SaaS/about.json": (200, {"data": {"display_name": "SaaS", "subscribers": 400000, "public_description": "SaaS"}})})
25
+ r = reddit.subreddit_rules("r/SaaS", get=get)
26
+ self.assertEqual(r["promotion_rules"], ["No self-promotion"])
27
+ self.assertEqual(r["subscribers"], 400000)
28
+ self.assertTrue(all("raw_json=1" in p for p in get.seen))
29
+ self.assertIn("error", reddit.subreddit_rules("not a sub!", get=get))
30
+ self.assertIn("private, banned", reddit.subreddit_rules("gone", get=get)["error"])
31
+
32
+ def test_post_status(self):
33
+ live = [{"data": {"children": [{"data": {"title": "T", "subreddit": "SaaS", "score": 5, "num_comments": 2, "author": "me",
34
+ "removed_by_category": None, "locked": False, "is_robot_indexable": True,
35
+ "permalink": "/r/SaaS/comments/abc123/t/"}}]}}]
36
+ get = fake({"/comments/abc123.json": (200, live)})
37
+ r = reddit.post_status("https://www.reddit.com/r/SaaS/comments/abc123/t/", get=get)
38
+ self.assertEqual((r["status"], r["shown_in_search"]), ("live", True))
39
+ live[0]["data"]["children"][0]["data"]["removed_by_category"] = "moderator"
40
+ self.assertEqual(reddit.post_status("https://old.reddit.com/r/SaaS/comments/abc123/t/", get=get)["status"], "removed")
41
+ self.assertIn("error", reddit.post_status("https://example.com/x", get=get))
42
+ self.assertEqual(reddit.post_status("https://www.reddit.com/r/SaaS/comments/zzz999/t/", get=get)["status"], "not_found")
43
+
44
+
45
+ class AccountAndCommunities(unittest.TestCase):
46
+ def test_account_new_low_karma_with_removed_and_hidden_posts(self):
47
+ posts = {"data": {"children": [
48
+ {"data": {"subreddit": "SaaS", "title": "a", "removed_by_category": "moderator", "is_robot_indexable": False, "permalink": "/r/SaaS/comments/a/x/"}},
49
+ {"data": {"subreddit": "startups", "title": "b", "removed_by_category": "moderator", "is_robot_indexable": False, "permalink": "/r/startups/comments/b/y/"}},
50
+ {"data": {"subreddit": "webdev", "title": "c", "removed_by_category": None, "is_robot_indexable": False, "permalink": "/r/webdev/comments/c/z/"}},
51
+ {"data": {"subreddit": "webdev", "title": "d", "removed_by_category": None, "is_robot_indexable": False, "permalink": "/r/webdev/comments/d/z/"}}]}}
52
+ get = fake({"/user/newbie/about.json": (200, {"data": {"name": "newbie", "created_utc": time.time() - 3 * 86400,
53
+ "link_karma": 1, "comment_karma": 4}}),
54
+ "/user/newbie/submitted.json": (200, posts)})
55
+ r = reddit.account_check("u/newbie", get=get)
56
+ self.assertEqual((r["status"], r["account_age_days"]), ("active", 3))
57
+ self.assertEqual(len(r["notes"]), 4)
58
+ self.assertTrue(any("shadowban" in n for n in r["notes"]))
59
+
60
+ def test_account_missing_or_suspended(self):
61
+ get = fake({"/user/gone/about.json": (200, {"data": {"name": "gone", "is_suspended": True}})})
62
+ self.assertEqual(reddit.account_check("ghost", get=get)["status"], "not_found")
63
+ self.assertEqual(reddit.account_check("gone", get=get)["status"], "suspended")
64
+ self.assertIn("error", reddit.account_check("not a name!", get=get))
65
+
66
+ def test_find_communities_flags_promotion_rules(self):
67
+ subs = {"data": {"children": [
68
+ {"data": {"display_name": "Baking", "subscribers": 900000, "subreddit_type": "public", "over18": False, "public_description": "Bake"}},
69
+ {"data": {"display_name": "BakeryOwners", "subscribers": 12000, "subreddit_type": "public", "over18": False, "public_description": "Run a bakery"}},
70
+ {"data": {"display_name": "tinybake", "subscribers": 40, "subreddit_type": "public", "over18": False}},
71
+ {"data": {"display_name": "privatebake", "subscribers": 9000, "subreddit_type": "private", "over18": False}}]}}
72
+ get = fake({"/subreddits/search.json": (200, subs),
73
+ "/r/Baking/about/rules.json": (200, {"rules": [{"short_name": "No self-promotion", "description": "Use the weekly promo thread"}]}),
74
+ "/r/BakeryOwners/about/rules.json": (200, {"rules": [{"short_name": "Be kind", "description": ""}]})})
75
+ r = reddit.find_communities("bakery owners", get=get)
76
+ self.assertEqual([c["subreddit"] for c in r["communities"]], ["Baking", "BakeryOwners"])
77
+ self.assertEqual(r["communities"][0]["promotion_rules"], ["No self-promotion"])
78
+ self.assertTrue(r["communities"][0]["promo_thread_mentioned"])
79
+ self.assertIn("promo thread", r["communities"][0]["verdict"])
80
+ self.assertIn("no rule mentions promotion", r["communities"][1]["verdict"])
81
+ self.assertIn("q=bakery%20owners", get.seen[0])
82
+ self.assertIn("error", reddit.find_communities("x", get=get))
83
+
84
+ def test_a_topic_that_finds_too_little_is_searched_word_by_word(self):
85
+ calls = []
86
+ def get(path):
87
+ calls.append(path)
88
+ if "q=bakery%20marketing" in path:
89
+ return 200, {"data": {"children": [{"data": {"display_name": "bakerymarketing", "subscribers": 2, "subreddit_type": "public"}}]}}
90
+ if "q=bakery&" in path:
91
+ return 200, {"data": {"children": [{"data": {"display_name": "Baking", "subscribers": 4600000, "subreddit_type": "public"}}]}}
92
+ if "q=marketing&" in path:
93
+ return 200, {"data": {"children": [{"data": {"display_name": "marketing", "subscribers": 2000000, "subreddit_type": "public"}},
94
+ {"data": {"display_name": "Baking", "subscribers": 4600000, "subreddit_type": "public"}}]}}
95
+ return 200, {"rules": []}
96
+ r = reddit.find_communities("bakery marketing", limit=5, get=get)
97
+ self.assertEqual([c["subreddit"] for c in r["communities"]], ["Baking", "marketing"])
98
+
99
+
100
+ class Setup(unittest.TestCase):
101
+ def test_missing_setup_is_explained_not_raised(self):
102
+ def get(path):
103
+ raise browser.SetupError(browser.SETUP)
104
+ for r in (reddit.subreddit_rules("SaaS", get=get), reddit.account_check("someone", get=get),
105
+ reddit.post_status("https://www.reddit.com/r/SaaS/comments/abc123/t/", get=get), reddit.find_communities("bakery", get=get)):
106
+ self.assertIn("Playwright Extension", r["error"]); self.assertIn("nodejs.org", r["error"])
107
+
108
+ def test_chrome_client_reads_through_a_playwright_mcp(self):
109
+ """The real client against a stand-in Playwright MCP that answers the way the real one formats results."""
110
+ stub = textwrap.dedent('''
111
+ import json, sys
112
+ for line in sys.stdin:
113
+ msg = json.loads(line)
114
+ if "id" not in msg: continue
115
+ if msg["method"] == "initialize": out = {"protocolVersion": "2025-06-18", "capabilities": {}}
116
+ elif msg["method"] == "tools/list": out = {"tools": [{"name": "browser_run_code_unsafe"}, {"name": "browser_tabs"}]}
117
+ elif msg["params"]["name"] == "browser_tabs": out = {"content": []}
118
+ else:
119
+ code = msg["params"]["arguments"]["code"]
120
+ body = {"status": 200, "body": {"ok": "old.reddit.com/robots.txt" in code and "/r/SaaS/about.json" in code}}
121
+ out = {"content": [{"type": "text", "text": "### Result\\n" + json.dumps(json.dumps(body)) + "\\n"}]}
122
+ sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": out}) + "\\n"); sys.stdout.flush()
123
+ ''')
124
+ chrome = browser.Chrome([sys.executable, "-c", stub], timeout=20)
125
+ try:
126
+ self.assertEqual(chrome.get("/r/SaaS/about.json?raw_json=1"), (200, {"ok": True}))
127
+ finally:
128
+ chrome.close()
129
+
130
+
131
+ if __name__ == "__main__":
132
+ unittest.main()
@@ -859,18 +859,14 @@ wheels = [
859
859
 
860
860
  [[package]]
861
861
  name = "threadfox-lite"
862
- version = "0.1.1"
862
+ version = "0.2.0"
863
863
  source = { editable = "." }
864
864
  dependencies = [
865
- { name = "httpx" },
866
865
  { name = "mcp" },
867
866
  ]
868
867
 
869
868
  [package.metadata]
870
- requires-dist = [
871
- { name = "httpx", specifier = ">=0.27" },
872
- { name = "mcp", specifier = ">=1.2.0,<2" },
873
- ]
869
+ requires-dist = [{ name = "mcp", specifier = ">=1.2.0,<2" }]
874
870
 
875
871
  [[package]]
876
872
  name = "typing-extensions"
@@ -1,20 +0,0 @@
1
- {
2
- "name": "threadfox-lite",
3
- "version": "0.1.1",
4
- "description": "Free, read-only Reddit tools: read a subreddit's rules before posting and check whether a post stayed up, plus the reddit-rules-first skill.",
5
- "author": {
6
- "name": "ThreadFox (AMF Indiana)",
7
- "email": "support@threadfox.vip",
8
- "url": "https://threadfox.vip"
9
- },
10
- "homepage": "https://threadfox.vip",
11
- "repository": "https://github.com/amflimited/threadfox-lite",
12
- "license": "MIT",
13
- "keywords": ["reddit", "subreddit-rules", "marketing", "mcp", "skills"],
14
- "mcpServers": {
15
- "threadfox-lite": {
16
- "command": "uv",
17
- "args": ["run", "--directory", "${CLAUDE_PLUGIN_ROOT}", "threadfox-lite"]
18
- }
19
- }
20
- }
@@ -1,39 +0,0 @@
1
- {
2
- "manifest_version": "0.4",
3
- "name": "threadfox-lite",
4
- "display_name": "ThreadFox Lite",
5
- "version": "0.1.1",
6
- "description": "Read a subreddit's rules and check whether a Reddit post is still up. Free, read-only tools from ThreadFox.",
7
- "long_description": "Two free, read-only Reddit tools for Claude and any MCP client. subreddit_rules returns a subreddit's rules, size and description and flags the rules that mention self-promotion, so your AI reads them before it writes a post. post_status tells you whether a post is still live, removed by moderators or deleted, with its score and comment count. No login, no posting and no Reddit API keys: each call is one request to Reddit's public JSON. Reddit blocks many cloud and VPN networks, so run it from your own computer. The full ThreadFox kit (posting from your own Chrome, replies, an outcome ledger, a 12-module course) is sold separately at https://threadfox.vip.",
8
- "author": {
9
- "name": "AMF Indiana (ThreadFox)",
10
- "email": "support@threadfox.vip",
11
- "url": "https://threadfox.vip"
12
- },
13
- "repository": {
14
- "type": "git",
15
- "url": "https://github.com/amflimited/threadfox-lite"
16
- },
17
- "homepage": "https://threadfox.vip",
18
- "support": "https://threadfox.vip/help",
19
- "icon": "icon.png",
20
- "server": {
21
- "type": "uv",
22
- "entry_point": "src/threadfox_lite/server.py",
23
- "mcp_config": {
24
- "command": "uv",
25
- "args": ["run", "--directory", "${__dirname}", "threadfox-lite"]
26
- }
27
- },
28
- "tools": [
29
- {"name": "subreddit_rules", "description": "A subreddit's rules, size and description, with the self-promotion rules flagged."},
30
- {"name": "post_status", "description": "Whether a Reddit post is still live, removed by moderators or deleted, with score and comments."},
31
- {"name": "threadfox_full_kit", "description": "What the full ThreadFox kit adds and where to get it."}
32
- ],
33
- "keywords": ["reddit", "subreddit", "rules", "marketing", "claude-code", "codex", "mcp"],
34
- "license": "MIT",
35
- "compatibility": {
36
- "platforms": ["darwin", "linux", "win32"],
37
- "runtimes": {"python": ">=3.10"}
38
- }
39
- }
@@ -1,88 +0,0 @@
1
- """Read-only calls to Reddit's public JSON. No login, no posting, one request per tool call."""
2
- import re
3
- from urllib.parse import urlsplit
4
-
5
- import httpx
6
-
7
- UA = "threadfox-lite/0.1.1 (+https://threadfox.vip; read-only rules and post-status checks)"
8
- PROMO = re.compile(r"self[- ]?promo|promot|advertis|spam|affiliate|referral|\b10\s*%|link[- ]?drop|no links?\b|marketing|sell|product|business", re.I)
9
- SUB = re.compile(r"^[A-Za-z0-9_]{2,21}$")
10
-
11
-
12
- BLOCKED = ("Reddit refused this network (it blocks many servers, VPNs and cloud machines). Run the tool from your own "
13
- "computer's connection, or open the page in a browser.")
14
-
15
-
16
- def _blocked(resp):
17
- """Reddit's network block is a 403 whose body is not JSON (plain 'Blocked'); a private subreddit's 403 is JSON."""
18
- if resp.status_code != 403:
19
- return False
20
- try:
21
- resp.json()
22
- return False
23
- except ValueError:
24
- return True
25
-
26
-
27
- def _client(transport=None):
28
- return httpx.Client(headers={"User-Agent": UA}, timeout=15, follow_redirects=True, transport=transport)
29
-
30
-
31
- def subreddit_rules(name: str, transport=None) -> dict:
32
- name = name.strip().removeprefix("/").removeprefix("r/").strip("/")
33
- if not SUB.match(name):
34
- return {"error": "Give a subreddit name like 'SaaS' or 'r/SaaS'."}
35
- with _client(transport) as c:
36
- rules = c.get(f"https://www.reddit.com/r/{name}/about/rules.json")
37
- about = c.get(f"https://www.reddit.com/r/{name}/about.json")
38
- blocked = _blocked(rules) or _blocked(about)
39
- if blocked:
40
- return {"subreddit": name, "error": BLOCKED, "open_instead": f"https://www.reddit.com/r/{name}/about/rules"}
41
- if rules.status_code in (403, 404) or about.status_code in (403, 404):
42
- return {"subreddit": name, "error": f"r/{name} is private, banned or does not exist (HTTP {rules.status_code})."}
43
- if rules.status_code == 429 or about.status_code == 429:
44
- return {"subreddit": name, "error": "Reddit rate-limited this request. Wait a minute and try again."}
45
- rules.raise_for_status(); about.raise_for_status()
46
- data = about.json().get("data", {})
47
- items = []
48
- for r in rules.json().get("rules", []):
49
- text = f"{r.get('short_name', '')}\n{r.get('description', '')}".strip()
50
- items.append({"rule": r.get("short_name", ""), "details": (r.get("description") or "")[:1200],
51
- "applies_to": r.get("kind", "all"), "mentions_promotion": bool(PROMO.search(text))})
52
- return {
53
- "subreddit": data.get("display_name", name),
54
- "subscribers": data.get("subscribers"),
55
- "description": (data.get("public_description") or "")[:500],
56
- "rules": items,
57
- "promotion_rules": [i["rule"] for i in items if i["mentions_promotion"]],
58
- "read_before_posting": "Follow every rule above. If a rule limits self-promotion, use the community's promo thread or don't post the link. Write a new post for this community; never paste the same post in several.",
59
- }
60
-
61
-
62
- def post_status(url: str, transport=None) -> dict:
63
- try:
64
- parts = urlsplit(url.strip())
65
- except ValueError:
66
- return {"error": "Give a Reddit post URL."}
67
- host = (parts.hostname or "").lower()
68
- m = re.match(r"^/r/[^/]+/comments/([a-z0-9]+)", parts.path or "")
69
- if not host.endswith("reddit.com") or not m:
70
- return {"error": "Give a full post URL like https://www.reddit.com/r/SaaS/comments/abc123/title/"}
71
- with _client(transport) as c:
72
- resp = c.get(f"https://www.reddit.com/comments/{m.group(1)}.json", params={"raw_json": 1, "limit": 1})
73
- if _blocked(resp):
74
- return {"error": BLOCKED, "open_instead": url.strip()}
75
- if resp.status_code == 404:
76
- return {"status": "not_found", "id": m.group(1)}
77
- if resp.status_code == 429:
78
- return {"error": "Reddit rate-limited this request. Wait a minute and try again."}
79
- resp.raise_for_status()
80
- post = resp.json()[0]["data"]["children"][0]["data"]
81
- removed = post.get("removed_by_category")
82
- status = "removed" if removed else ("deleted" if post.get("author") == "[deleted]" or post.get("selftext") == "[deleted]" else "live")
83
- return {
84
- "status": status, "removed_by": removed, "title": post.get("title"), "subreddit": post.get("subreddit"),
85
- "score": post.get("score"), "comments": post.get("num_comments"), "locked": post.get("locked"),
86
- "created_utc": post.get("created_utc"), "permalink": "https://www.reddit.com" + (post.get("permalink") or ""),
87
- "note": "Check again at 1, 6 and 24 hours. If a community removes you twice, stop posting there.",
88
- }
@@ -1,42 +0,0 @@
1
- import json, sys, unittest
2
- from pathlib import Path
3
- sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
4
- import httpx
5
- from threadfox_lite import reddit
6
-
7
- def transport(routes):
8
- def handler(request):
9
- for key, (status, body) in routes.items():
10
- if key in str(request.url):
11
- return httpx.Response(status, json=body)
12
- return httpx.Response(404, json={})
13
- return httpx.MockTransport(handler)
14
-
15
- class Tools(unittest.TestCase):
16
- def test_rules_flag_promotion(self):
17
- t = transport({"/about/rules.json": (200, {"rules": [{"short_name": "No self-promotion", "description": "Keep promo to the weekly thread", "kind": "all"},
18
- {"short_name": "Be kind", "description": "", "kind": "all"}]}),
19
- "/about.json": (200, {"data": {"display_name": "SaaS", "subscribers": 400000, "public_description": "SaaS"}})})
20
- r = reddit.subreddit_rules("r/SaaS", transport=t)
21
- self.assertEqual(r["promotion_rules"], ["No self-promotion"])
22
- self.assertEqual(r["subscribers"], 400000)
23
- self.assertIn("error", reddit.subreddit_rules("not a sub!", transport=t))
24
-
25
- def test_post_status(self):
26
- live = [{"data": {"children": [{"data": {"title": "T", "subreddit": "SaaS", "score": 5, "num_comments": 2, "author": "me",
27
- "removed_by_category": None, "locked": False, "permalink": "/r/SaaS/comments/abc123/t/"}}]}}]
28
- t = transport({"/comments/abc123.json": (200, live)})
29
- self.assertEqual(reddit.post_status("https://www.reddit.com/r/SaaS/comments/abc123/t/", transport=t)["status"], "live")
30
- live[0]["data"]["children"][0]["data"]["removed_by_category"] = "moderator"
31
- self.assertEqual(reddit.post_status("https://old.reddit.com/r/SaaS/comments/abc123/t/", transport=t)["status"], "removed")
32
- self.assertIn("error", reddit.post_status("https://example.com/x", transport=t))
33
-
34
- class Blocked(unittest.TestCase):
35
- def test_network_block_is_reported_as_such(self):
36
- t = httpx.MockTransport(lambda req: httpx.Response(403, text="Blocked"))
37
- r = reddit.subreddit_rules("SaaS", transport=t)
38
- self.assertIn("refused this network", r["error"])
39
- self.assertIn("refused this network", reddit.post_status("https://www.reddit.com/r/SaaS/comments/abc123/t/", transport=t)["error"])
40
-
41
- if __name__ == "__main__":
42
- unittest.main()
File without changes
File without changes