threads-cleaner 0.2.0__py3-none-any.whl
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.
- threads_cleaner/__init__.py +0 -0
- threads_cleaner/__main__.py +3 -0
- threads_cleaner/browser.py +308 -0
- threads_cleaner/config.py +23 -0
- threads_cleaner/main.py +54 -0
- threads_cleaner-0.2.0.dist-info/METADATA +116 -0
- threads_cleaner-0.2.0.dist-info/RECORD +9 -0
- threads_cleaner-0.2.0.dist-info/WHEEL +4 -0
- threads_cleaner-0.2.0.dist-info/entry_points.txt +2 -0
|
File without changes
|
|
@@ -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()
|
threads_cleaner/main.py
ADDED
|
@@ -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()
|
|
@@ -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,9 @@
|
|
|
1
|
+
threads_cleaner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
threads_cleaner/__main__.py,sha256=sLeU-jMHe8u2idO7EUUp_jppqJZHQPwb3Id3IXrJta4,44
|
|
3
|
+
threads_cleaner/browser.py,sha256=nlUadej22F0qw_iSTjJCjeCo2aqwKHxIeWwkjD0_DSc,12646
|
|
4
|
+
threads_cleaner/config.py,sha256=I5c06VZr1_Ax2mAgbzVMEQ8Z6KSSMIRNlbQ0BEqkrS8,558
|
|
5
|
+
threads_cleaner/main.py,sha256=auSjUbIBWtRxoDqgnrayPaCLs1XcJXKW1deA8ZyyNvs,1597
|
|
6
|
+
threads_cleaner-0.2.0.dist-info/METADATA,sha256=32BqlXpWsd1q3jKeU1_Zf7o-UQrKxyDV8rabn5q1JTA,3924
|
|
7
|
+
threads_cleaner-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
threads_cleaner-0.2.0.dist-info/entry_points.txt,sha256=GBBgATn5zyM0Bpv7c2FNN6CL_0OgQMyCyM6rkZ8M5oI,61
|
|
9
|
+
threads_cleaner-0.2.0.dist-info/RECORD,,
|