threads-cleaner 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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .ruff_cache/
5
+ dist/
6
+ *.egg-info/
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: threads-cleaner
3
+ Version: 0.2.0
4
+ Summary: Bulk-delete your Threads posts and replies. Free, open-source, runs locally.
5
+ Project-URL: Homepage, https://github.com/vincentmathis/threads-cleaner
6
+ Project-URL: Repository, https://github.com/vincentmathis/threads-cleaner
7
+ Author-email: Vince <vince@example.com>
8
+ License: MIT
9
+ Keywords: browser-automation,bulk,cleaner,delete,threads
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: playwright>=1.59.0
18
+ Requires-Dist: rich>=13.0.0
19
+ Requires-Dist: typer>=0.12.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # threads-cleaner
23
+
24
+ Bulk-delete your Threads posts and replies via browser automation.
25
+ Clicks the web UI like a human — no API keys, no developer account, no rate limits.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install threads-cleaner
31
+ # or
32
+ uv add threads-cleaner
33
+ ```
34
+
35
+ Then install the Playwright browser:
36
+
37
+ ```bash
38
+ playwright install chromium
39
+ # or
40
+ uv run playwright install chromium
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ ```bash
46
+ # 1. Log in (opens a browser — go to threads.net, sign in, navigate to your profile)
47
+ threads-cleaner browser-login
48
+
49
+ # 2. Delete all posts
50
+ threads-cleaner browser-delete
51
+
52
+ # 3. Show the browser window to watch what happens
53
+ threads-cleaner browser-delete --headed --max 5
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ | Command | Description |
59
+ |---------|-------------|
60
+ | `browser-login` | Opens a headed browser — log into Threads and session is saved automatically |
61
+ | `browser-delete` | Deletes posts by clicking the Threads UI |
62
+ | `browser-delete --include-replies` | Also delete your replies |
63
+ | `browser-delete --max N` | Stop after N deletions (0 = unlimited) |
64
+ | `browser-delete --dry-run` | Open browser, navigate to profile, but don't confirm any deletes |
65
+ | `browser-delete --headed` | Show the browser (useful for debugging) |
66
+ | `browser-delete --yes` | Skip the confirmation prompt (for scripting) |
67
+
68
+ ### Examples
69
+
70
+ ```bash
71
+ # Delete up to 10 posts, show the browser
72
+ threads-cleaner browser-delete --headed --max 10
73
+
74
+ # Delete everything including replies, no prompts
75
+ threads-cleaner browser-delete --include-replies --yes
76
+
77
+ # Preview what the tool would do
78
+ threads-cleaner browser-delete --dry-run --headed
79
+
80
+ # Delete 50 replies only
81
+ threads-cleaner browser-delete --include-replies --max 50
82
+ ```
83
+
84
+ ## How it works
85
+
86
+ 1. **`browser-login`** opens a Chromium window — you sign into threads.net and the session cookies are saved locally.
87
+ 2. **`browser-delete`** loads those cookies into a fresh browser, navigates to your profile, and for each post:
88
+ - Clicks the **More** (⋮) button
89
+ - Clicks **Delete** in the popup menu
90
+ - Clicks **Delete** in the confirmation dialog
91
+ 3. With `--include-replies`, it also navigates to `/replies/` and repeats the same loop.
92
+
93
+ Each delete takes ~3-4 seconds (limited by UI animations).
94
+ The tool scrolls down automatically as it runs, so it can delete hundreds of items in one session.
95
+
96
+ ### Safety
97
+
98
+ - **`--dry-run`** opens the browser and navigates to your profile but never clicks anything destructive.
99
+ - **`--max N`** stops after N successful deletes.
100
+ - If a "Something went wrong" toast appears, the tool counts it as a failure (not a success) and continues.
101
+ - Session cookies expire — re-run `browser-login` if you get a login error.
102
+
103
+ ## Requirements
104
+
105
+ - Python 3.11+
106
+ - Chromium (installed via `playwright install chromium`)
107
+
108
+ ## Why no API?
109
+
110
+ Meta's Threads Graph API requires an approved Facebook Developer account (the author's was suspended).
111
+ The Instagram REST API endpoints that Threads used internally are now returning 404 or killing sessions.
112
+ The only reliable approach is real browser automation that clicks the UI like a human.
113
+
114
+ ## License
115
+
116
+ MIT
@@ -0,0 +1,95 @@
1
+ # threads-cleaner
2
+
3
+ Bulk-delete your Threads posts and replies via browser automation.
4
+ Clicks the web UI like a human — no API keys, no developer account, no rate limits.
5
+
6
+ ## Installation
7
+
8
+ ```bash
9
+ pip install threads-cleaner
10
+ # or
11
+ uv add threads-cleaner
12
+ ```
13
+
14
+ Then install the Playwright browser:
15
+
16
+ ```bash
17
+ playwright install chromium
18
+ # or
19
+ uv run playwright install chromium
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ # 1. Log in (opens a browser — go to threads.net, sign in, navigate to your profile)
26
+ threads-cleaner browser-login
27
+
28
+ # 2. Delete all posts
29
+ threads-cleaner browser-delete
30
+
31
+ # 3. Show the browser window to watch what happens
32
+ threads-cleaner browser-delete --headed --max 5
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ | Command | Description |
38
+ |---------|-------------|
39
+ | `browser-login` | Opens a headed browser — log into Threads and session is saved automatically |
40
+ | `browser-delete` | Deletes posts by clicking the Threads UI |
41
+ | `browser-delete --include-replies` | Also delete your replies |
42
+ | `browser-delete --max N` | Stop after N deletions (0 = unlimited) |
43
+ | `browser-delete --dry-run` | Open browser, navigate to profile, but don't confirm any deletes |
44
+ | `browser-delete --headed` | Show the browser (useful for debugging) |
45
+ | `browser-delete --yes` | Skip the confirmation prompt (for scripting) |
46
+
47
+ ### Examples
48
+
49
+ ```bash
50
+ # Delete up to 10 posts, show the browser
51
+ threads-cleaner browser-delete --headed --max 10
52
+
53
+ # Delete everything including replies, no prompts
54
+ threads-cleaner browser-delete --include-replies --yes
55
+
56
+ # Preview what the tool would do
57
+ threads-cleaner browser-delete --dry-run --headed
58
+
59
+ # Delete 50 replies only
60
+ threads-cleaner browser-delete --include-replies --max 50
61
+ ```
62
+
63
+ ## How it works
64
+
65
+ 1. **`browser-login`** opens a Chromium window — you sign into threads.net and the session cookies are saved locally.
66
+ 2. **`browser-delete`** loads those cookies into a fresh browser, navigates to your profile, and for each post:
67
+ - Clicks the **More** (⋮) button
68
+ - Clicks **Delete** in the popup menu
69
+ - Clicks **Delete** in the confirmation dialog
70
+ 3. With `--include-replies`, it also navigates to `/replies/` and repeats the same loop.
71
+
72
+ Each delete takes ~3-4 seconds (limited by UI animations).
73
+ The tool scrolls down automatically as it runs, so it can delete hundreds of items in one session.
74
+
75
+ ### Safety
76
+
77
+ - **`--dry-run`** opens the browser and navigates to your profile but never clicks anything destructive.
78
+ - **`--max N`** stops after N successful deletes.
79
+ - If a "Something went wrong" toast appears, the tool counts it as a failure (not a success) and continues.
80
+ - Session cookies expire — re-run `browser-login` if you get a login error.
81
+
82
+ ## Requirements
83
+
84
+ - Python 3.11+
85
+ - Chromium (installed via `playwright install chromium`)
86
+
87
+ ## Why no API?
88
+
89
+ Meta's Threads Graph API requires an approved Facebook Developer account (the author's was suspended).
90
+ The Instagram REST API endpoints that Threads used internally are now returning 404 or killing sessions.
91
+ The only reliable approach is real browser automation that clicks the UI like a human.
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "threads-cleaner"
7
+ version = "0.2.0"
8
+ description = "Bulk-delete your Threads posts and replies. Free, open-source, runs locally."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ keywords = ["threads", "delete", "bulk", "cleaner", "browser-automation"]
13
+ authors = [
14
+ { name = "Vince", email = "vince@example.com" },
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ ]
24
+ dependencies = [
25
+ "typer>=0.12.0",
26
+ "rich>=13.0.0",
27
+ "playwright>=1.59.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/vincentmathis/threads-cleaner"
32
+ Repository = "https://github.com/vincentmathis/threads-cleaner"
33
+
34
+ [project.scripts]
35
+ threads-cleaner = "threads_cleaner.main:app"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/threads_cleaner"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["src/threads_cleaner"]
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=8.0.0",
46
+ "ruff>=0.4.0",
47
+ ]
File without changes
@@ -0,0 +1,3 @@
1
+ from threads_cleaner.main import app
2
+
3
+ app()
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from rich.console import Console
6
+
7
+ from threads_cleaner import config
8
+
9
+ console = Console()
10
+
11
+
12
+ class BrowserDeleter:
13
+ def __init__(self, session: dict, *, headed: bool = False):
14
+ self.session = session
15
+ self._headed = headed
16
+ self._browser = None
17
+ self._context = None
18
+ self._page = None
19
+ self._pw = None
20
+
21
+ def _cookies_for_playwright(self) -> list[dict]:
22
+ cookies = []
23
+ now_ts = int(time.time()) + 86400 * 30
24
+ for name in ("sessionid", "csrftoken", "ds_user_id", "rur"):
25
+ val = self.session.get(name, "")
26
+ if val:
27
+ for domain in (".threads.net", ".threads.com"):
28
+ cookies.append({
29
+ "name": name, "value": val,
30
+ "domain": domain, "path": "/",
31
+ "secure": True, "sameSite": "Lax",
32
+ "expires": now_ts,
33
+ **({"httpOnly": True} if name == "sessionid" else {}),
34
+ })
35
+ return cookies
36
+
37
+ def start(self):
38
+ from playwright.sync_api import sync_playwright
39
+ self._pw = sync_playwright().start()
40
+ self._browser = self._pw.chromium.launch(
41
+ headless=not self._headed,
42
+ args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
43
+ )
44
+ self._context = self._browser.new_context(
45
+ viewport={"width": 1280, "height": 720},
46
+ user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
47
+ )
48
+ self._page = self._context.new_page()
49
+ console.print("[dim] setting session cookies...[/]")
50
+ self._context.add_cookies(self._cookies_for_playwright())
51
+ self._page.goto("about:blank")
52
+
53
+ def stop(self):
54
+ if self._browser:
55
+ self._browser.close()
56
+ if self._pw:
57
+ self._pw.stop()
58
+
59
+ def _dismiss_popups(self):
60
+ try:
61
+ self._page.keyboard.press("Escape")
62
+ time.sleep(0.3)
63
+ except: pass
64
+ for text in ["Allow", "Accept", "Accept all", "Allow all", "Reject", "Close", "Got it"]:
65
+ try:
66
+ btn = self._page.locator(f'button:has-text("{text}")').first
67
+ if btn.is_visible(timeout=500):
68
+ btn.click(timeout=1000)
69
+ time.sleep(0.3)
70
+ except: pass
71
+ try:
72
+ self._page.evaluate("document.querySelectorAll('[role=dialog]').forEach(e=>e.remove()); document.body.style.overflow='';")
73
+ except: pass
74
+
75
+ def _find_and_click_more(self) -> bool:
76
+ return self._page.evaluate("""
77
+ (() => {
78
+ const icons = document.querySelectorAll('svg[aria-label="More"]');
79
+ for (const icon of icons) {
80
+ if (icon.offsetParent === null) continue;
81
+ if (icon.dataset.tried) continue;
82
+ const r = icon.getBoundingClientRect();
83
+ if (r.width < 5 || r.height < 5) continue;
84
+ if (r.y < 100) continue;
85
+ icon.dispatchEvent(new MouseEvent('click', {bubbles: true}));
86
+ return true;
87
+ }
88
+ return false;
89
+ })()
90
+ """)
91
+
92
+ def _click_menu_delete(self) -> bool:
93
+ return self._page.evaluate("""
94
+ (() => {
95
+ const menu = document.querySelector('[role="menu"]');
96
+ if (!menu) return false;
97
+ const all = menu.querySelectorAll('span, div, button, [role="button"]');
98
+ for (const el of all) {
99
+ if (el.offsetParent === null) continue;
100
+ const txt = el.innerText || el.textContent || '';
101
+ if (txt.trim() === 'Delete' && el.childElementCount === 0) {
102
+ el.dispatchEvent(new MouseEvent('click', {bubbles: true}));
103
+ return true;
104
+ }
105
+ }
106
+ // No Delete — this More belongs to someone else's post.
107
+ // Mark the first untried More SVG so we skip it next time.
108
+ const icons = document.querySelectorAll('svg[aria-label="More"]');
109
+ for (const icon of icons) {
110
+ if (icon.offsetParent === null) continue;
111
+ if (icon.dataset.tried) continue;
112
+ const r = icon.getBoundingClientRect();
113
+ if (r.width < 5 || r.height < 5) continue;
114
+ if (r.y < 100) continue;
115
+ icon.dataset.tried = '1';
116
+ break;
117
+ }
118
+ return false;
119
+ })()
120
+ """)
121
+
122
+ def _click_confirm_delete(self) -> bool:
123
+ return self._page.evaluate("""
124
+ (() => {
125
+ const all = document.querySelectorAll('span, div, button, [role="button"]');
126
+ for (const el of all) {
127
+ if (el.offsetParent === null) continue;
128
+ const txt = el.innerText || el.textContent || '';
129
+ if (txt.trim() === 'Delete' && el.childElementCount === 0) {
130
+ if (el.closest('[role="dialog"]')) {
131
+ el.dispatchEvent(new MouseEvent('click', {bubbles: true}));
132
+ return true;
133
+ }
134
+ }
135
+ }
136
+ return false;
137
+ })()
138
+ """)
139
+
140
+ def _dismiss_toasts(self):
141
+ try:
142
+ self._page.keyboard.press("Escape")
143
+ time.sleep(0.4)
144
+ self._page.keyboard.press("Escape")
145
+ time.sleep(0.3)
146
+ except:
147
+ pass
148
+
149
+ def _has_error_toast(self) -> bool:
150
+ return self._page.evaluate("""
151
+ (() => {
152
+ const all = document.querySelectorAll('span, div, [role="alert"]');
153
+ for (const el of all) {
154
+ const txt = (el.innerText || el.textContent || '').toLowerCase();
155
+ if (txt.includes('something went wrong') || txt.includes('try again')) {
156
+ return true;
157
+ }
158
+ }
159
+ return false;
160
+ })()
161
+ """)
162
+
163
+ def _delete_next_item(self) -> bool:
164
+ self._dismiss_toasts()
165
+ if not self._find_and_click_more():
166
+ return False
167
+ time.sleep(0.8)
168
+ if not self._click_menu_delete():
169
+ self._page.keyboard.press("Escape")
170
+ time.sleep(0.3)
171
+ return False
172
+ time.sleep(0.8)
173
+ if not self._click_confirm_delete():
174
+ self._page.keyboard.press("Escape")
175
+ time.sleep(0.3)
176
+ return False
177
+ time.sleep(1.5)
178
+ had_error = self._has_error_toast()
179
+ self._dismiss_toasts()
180
+ if had_error:
181
+ return False
182
+ return True
183
+
184
+ def _delete_loop(self, label: str, max_deletes: int = 0) -> int:
185
+ deleted = 0
186
+ consecutive_fails = 0
187
+ total_fails = 0
188
+ while True:
189
+ if max_deletes and deleted >= max_deletes:
190
+ console.print(f"[dim] hit limit of {max_deletes} {label}[/]")
191
+ break
192
+ ok = self._delete_next_item()
193
+ if ok:
194
+ deleted += 1
195
+ consecutive_fails = 0
196
+ total_fails = 0
197
+ if deleted % 10 == 0:
198
+ console.print(f"[dim] deleted {deleted} {label}...[/]")
199
+ continue
200
+ consecutive_fails += 1
201
+ total_fails += 1
202
+ if total_fails > 60:
203
+ console.print(f"[dim] gave up after {total_fails} failures[/]")
204
+ break
205
+ if consecutive_fails >= 5:
206
+ before = self._page.evaluate("window.scrollY")
207
+ self._page.evaluate("window.scrollBy(0, 5000)")
208
+ time.sleep(2)
209
+ after = self._page.evaluate("window.scrollY")
210
+ if after == before:
211
+ console.print(f"[dim] reached end of {label}[/]")
212
+ break
213
+ consecutive_fails = 0
214
+ else:
215
+ self._page.evaluate("window.scrollBy(0, 300)")
216
+ time.sleep(0.8)
217
+ return deleted
218
+
219
+ def delete_posts(self, max_deletes: int = 0) -> int:
220
+ username = self.session.get("username", "")
221
+ profile_url = f"https://www.threads.com/@{username}"
222
+ console.print(f"[dim] navigating to {profile_url}...[/]")
223
+ try:
224
+ self._page.goto(profile_url, wait_until="domcontentloaded", timeout=60000)
225
+ except: pass
226
+ time.sleep(4)
227
+ if "login" in self._page.url.lower():
228
+ console.print(f"[red]Not logged in (current URL: {self._page.url}).[/]")
229
+ console.print("[yellow]Run [bold]threads-cleaner browser-login[/] to refresh the session.[/]")
230
+ raise RuntimeError("Session expired or invalid")
231
+ self._dismiss_popups()
232
+ return self._delete_loop("posts", max_deletes)
233
+
234
+ def delete_replies(self, max_deletes: int = 0) -> int:
235
+ username = self.session.get("username", "")
236
+ replies_url = f"https://www.threads.com/@{username}/replies/"
237
+ console.print(f"[dim] navigating to {replies_url}...[/]")
238
+ try:
239
+ self._page.goto(replies_url, wait_until="domcontentloaded", timeout=30000)
240
+ except:
241
+ pass
242
+ time.sleep(4)
243
+ self._dismiss_popups()
244
+ return self._delete_loop("replies", max_deletes)
245
+
246
+ def run_browser_delete(*, include_replies=False, max_deletes=None, dry_run=False, yes=False, headed=False):
247
+ session = config.load_session()
248
+ if not session:
249
+ raise RuntimeError("Not logged in. Run: threads-cleaner browser-login")
250
+ targets = "posts" + (" + replies" if include_replies else "")
251
+ label = f" (max {max_deletes})" if max_deletes else ""
252
+ mode = "DRY RUN (no deletes)" if dry_run else "LIVE"
253
+ console.print("[bold]Threads Cleaner - Browser Delete[/bold]\n"
254
+ f" Mode: {mode}\n"
255
+ f" Targets: {targets}{label}\n")
256
+ if not dry_run and not yes:
257
+ result = console.input("[yellow]This will delete items. Continue? [y/N] [/]")
258
+ if result.lower() != "y": return
259
+ if dry_run:
260
+ console.print("[blue]Dry run — nothing was deleted.[/]")
261
+ return
262
+ deleter = BrowserDeleter(session=session, headed=headed)
263
+ try:
264
+ deleter.start()
265
+ total = 0
266
+ total += deleter.delete_posts(max_deletes or 0)
267
+ if include_replies:
268
+ remaining = (max_deletes - total) if max_deletes else 0
269
+ total += deleter.delete_replies(remaining)
270
+ console.print(f"\n[bold green]Done.[/] Deleted {total} item(s).")
271
+ finally:
272
+ deleter.stop()
273
+
274
+
275
+ def run_browser_login() -> dict:
276
+ from playwright.sync_api import sync_playwright, TimeoutError as PwTimeout
277
+ console.print("[bold]Threads Cleaner - Browser Login[/bold]\n")
278
+ with sync_playwright() as pw:
279
+ browser = pw.chromium.launch(headless=False, args=["--no-sandbox"])
280
+ ctx = browser.new_context(viewport={"width": 1280, "height": 800})
281
+ page = ctx.new_page()
282
+ page.goto("about:blank")
283
+ console.print("[yellow]Go to [bold]https://www.threads.net/[/bold] -> log in -> go to your profile -> wait[/]")
284
+ try:
285
+ page.wait_for_url("**/***@**", timeout=600000)
286
+ except PwTimeout:
287
+ browser.close()
288
+ raise RuntimeError("Login timed out")
289
+ time.sleep(2)
290
+ cookies_raw = ctx.cookies()
291
+ cookies = {c["name"]: c["value"] for c in cookies_raw if c["name"] in ("sessionid", "csrftoken", "ds_user_id", "rur")}
292
+ if not cookies.get("sessionid"):
293
+ browser.close()
294
+ raise RuntimeError("No session cookie")
295
+ sessionid = cookies["sessionid"]
296
+ csrftoken = cookies.get("csrftoken", "")
297
+ ds_user_id = cookies.get("ds_user_id", "")
298
+ cookies["sessionid"] = sessionid
299
+ cookies["csrftoken"] = csrftoken
300
+ cookies["ds_user_id"] = ds_user_id
301
+ # Extract username from the URL
302
+ username = page.url.split("/@")[-1].split("/")[0].split("?")[0]
303
+ user_id = ds_user_id or ""
304
+ session = {"sessionid": sessionid, "csrftoken": csrftoken, "ds_user_id": user_id, "rur": cookies.get("rur", ""), "user_id": user_id, "username": username}
305
+ config.save_session(session)
306
+ browser.close()
307
+ console.print(f"[green]Session saved.[/] Logged in as [bold]@{username}[/]")
308
+ return session
@@ -0,0 +1,23 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ # Session storage
5
+ CONFIG_DIR = Path.home() / ".config" / "threads-cleaner"
6
+ SESSION_FILE = CONFIG_DIR / "session.json"
7
+
8
+
9
+ def save_session(data: dict) -> None:
10
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
11
+ SESSION_FILE.write_text(json.dumps(data, indent=2))
12
+ SESSION_FILE.chmod(0o600)
13
+
14
+
15
+ def load_session() -> dict | None:
16
+ if SESSION_FILE.exists():
17
+ return json.loads(SESSION_FILE.read_text())
18
+ return None
19
+
20
+
21
+ def clear_session() -> None:
22
+ if SESSION_FILE.exists():
23
+ SESSION_FILE.unlink()
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from threads_cleaner.browser import run_browser_delete, run_browser_login
9
+
10
+ app = typer.Typer(help="Bulk-delete your Threads posts and replies via browser automation.")
11
+ console = Console()
12
+
13
+
14
+ @app.command()
15
+ def browser_login():
16
+ """Log in via a browser window (no manual cookie copying)."""
17
+ try:
18
+ run_browser_login()
19
+ console.print("Run [bold]threads-cleaner browser-delete --headed[/] to test it.")
20
+ except Exception as e:
21
+ console.print(f"[red]Login failed:[/] {e}")
22
+ raise typer.Exit(1)
23
+
24
+
25
+ @app.command()
26
+ def browser_delete(
27
+ include_replies: bool = typer.Option(
28
+ False, "--include-replies", help="Also delete replies from the Replies tab."
29
+ ),
30
+ max_deletes: Optional[int] = typer.Option(
31
+ None, "--max", "-m", help="Stop after deleting this many items (0 = unlimited)."
32
+ ),
33
+ dry_run: bool = typer.Option(
34
+ False, "--dry-run", help="Preview without deleting."
35
+ ),
36
+ yes: bool = typer.Option(
37
+ False, "--yes", "-y", help="Skip the confirmation prompt."
38
+ ),
39
+ headed: bool = typer.Option(
40
+ False, "--headed", help="Show the browser window (for debugging)."
41
+ ),
42
+ ):
43
+ """Delete posts (and optionally replies) via a real browser (Playwright). Clicks the UI like a human."""
44
+ run_browser_delete(
45
+ include_replies=include_replies,
46
+ max_deletes=max_deletes,
47
+ dry_run=dry_run,
48
+ yes=yes,
49
+ headed=headed,
50
+ )
51
+
52
+
53
+ if __name__ == "__main__":
54
+ app()