Compare commits

..

17 Commits

Author SHA1 Message Date
dba06e642a docs: update roadmap 2025-12-19 14:39:15 +01:00
5868c1649b docs: update readme 2025-12-19 14:35:25 +01:00
a14184cddc feat: run all 2025-12-19 14:35:21 +01:00
6587f8c39c feat: implement bookmark deletion module 2025-12-19 14:35:05 +01:00
ae6663572c refactor: inline has_quote_embed 2025-12-19 14:34:42 +01:00
3eb456e999 refactor: delete_posts is now delete_all_posts 2025-12-19 14:34:25 +01:00
cfa5773e62 refactor: inline has_media_embed 2025-12-19 14:34:02 +01:00
ddee2a6029 feat: implement follow undoing module 2025-12-19 14:33:43 +01:00
5e2b4f3408 docs: update readme 2025-12-19 14:10:37 +01:00
c238278df6 feat: add quote post deletion 2025-12-19 14:10:33 +01:00
a9c25c8c10 feat: implemented delete_quotes() 2025-12-19 14:10:25 +01:00
5ff25b3eb6 docs: update readme 2025-12-19 12:51:30 +01:00
c396ba8ae9 feat: create repost undoing module 2025-12-19 12:51:25 +01:00
d12f14a994 feat: implemented undo_reposts() 2025-12-19 12:51:03 +01:00
a4b622bfd3 docs: update chips 2025-12-19 10:11:50 +01:00
005c76119f docs: update readme 2025-12-18 16:29:44 +01:00
b1e2b266f4 refactor: switch to relative imports 2025-12-18 16:08:15 +01:00
11 changed files with 374 additions and 102 deletions

View File

@@ -20,35 +20,21 @@ uv sync
## How to run ## How to run
When installation will be worked out, you'll be able to :
```bash
skywipe all # target everything
skywipe configure # create configuration
skywipe posts # delete posts
skywipe medias # delete posts with medias
skywipe likes # undo likes
skywipe reposts # undo reposts
skywipe quotes # delete quotes
skywipe follows # unfollow all
skywipe bookmarks # delete bookmarks
```
While it's being developed, you can use the tool using `uv` : While it's being developed, you can use the tool using `uv` :
```bash ```bash
uv run skywipe/cli.py all # target everything uv run python -m skywipe.cli all # target everything
uv run skywipe/cli.py configure # create configuration uv run python -m skywipe.cli configure # create configuration
uv run skywipe/cli.py posts # delete posts uv run python -m skywipe.cli posts # delete posts
uv run skywipe/cli.py medias # delete posts with medias uv run python -m skywipe.cli medias # delete posts with medias
uv run skywipe/cli.py likes # undo likes uv run python -m skywipe.cli likes # undo likes
uv run skywipe/cli.py reposts # undo reposts uv run python -m skywipe.cli reposts # undo reposts
uv run skywipe/cli.py quotes # delete quotes uv run python -m skywipe.cli quotes # delete quotes
uv run skywipe/cli.py follows # unfollow all uv run python -m skywipe.cli follows # unfollow all
uv run skywipe/cli.py bookmarks # delete bookmarks uv run python -m skywipe.cli bookmarks # delete bookmarks
``` ```
### Configuration ## Configuration
If you run the tool for the first time, it will prompt you to use `skywipe configure` to create the configuration file, which is located in `~/.config/skywipe/config.yml` : If you run the tool for the first time, it will prompt you to use `skywipe configure` to create the configuration file, which is located in `~/.config/skywipe/config.yml` :
@@ -70,16 +56,13 @@ BE SURE TO USE A [BLUESKY APP PASSWORD](https://blueskyfeeds.com/faq-app-passwor
- [x] delete posts in batch - [x] delete posts in batch
- [x] only delete posts with media - [x] only delete posts with media
- [x] undo likes - [x] undo likes
- [ ] undo reposts - [x] undo reposts
- [ ] delete quotes - [x] delete quotes
- [ ] unfollow accounts - [x] unfollow accounts
- [ ] remove bookmarks - [x] remove bookmarks
- [ ] make `all` run the other commands - [x] make `all` run the other commands
- [ ] add simple progress and logging - [ ] add simple progress and logging
- [ ] add safeguards like confirmations and clear dry-run info - [ ] add safeguards (confirmation, dry-run flag)
Once it's done, we'll think:
- [ ] decent code architecture - [ ] decent code architecture
- [ ] installation and run process - [ ] installation and run process

View File

@@ -1,7 +1,7 @@
"""Authentication module for Skywipe""" """Authentication module for Skywipe"""
from atproto import Client from atproto import Client
from skywipe.configure import Configuration from .configure import Configuration
class Auth: class Auth:

75
skywipe/bookmarks.py Normal file
View File

@@ -0,0 +1,75 @@
"""Bookmark deletion module for Skywipe"""
import time
from atproto import models
from .auth import Auth
from .configure import Configuration
def delete_bookmarks():
auth = Auth()
client = auth.login()
config = Configuration()
config_data = config.load()
batch_size = config_data.get("batch_size", 10)
delay = config_data.get("delay", 1)
verbose = config_data.get("verbose", False)
if verbose:
print(
f"Starting bookmark deletion with batch_size={batch_size}, delay={delay}s")
cursor = None
total_deleted = 0
while True:
get_params = models.AppBskyBookmarkGetBookmarks.Params(
limit=batch_size,
cursor=cursor
)
response = client.app.bsky.bookmark.get_bookmarks(params=get_params)
bookmarks = response.bookmarks
if not bookmarks:
break
for bookmark in bookmarks:
try:
bookmark_uri = None
if hasattr(bookmark, "uri"):
bookmark_uri = bookmark.uri
else:
for attr_name in ("subject", "record", "post", "item"):
if hasattr(bookmark, attr_name):
nested = getattr(bookmark, attr_name)
if hasattr(nested, "uri"):
bookmark_uri = nested.uri
break
if not bookmark_uri:
if verbose:
print(f"Skipping bookmark: unable to find uri")
continue
delete_data = models.AppBskyBookmarkDeleteBookmark.Data(
uri=bookmark_uri
)
client.app.bsky.bookmark.delete_bookmark(data=delete_data)
total_deleted += 1
if verbose:
print(f"Deleted bookmark: {bookmark_uri}")
except Exception as e:
bookmark_uri = getattr(bookmark, "uri", "unknown")
if verbose:
print(f"Error deleting bookmark {bookmark_uri}: {e}")
cursor = response.cursor
if not cursor:
break
if delay > 0:
time.sleep(delay)
print(f"Deleted {total_deleted} bookmarks.")

View File

@@ -3,8 +3,8 @@
import sys import sys
import argparse import argparse
from skywipe.commands import registry from .commands import registry
from skywipe.configure import Configuration from .configure import Configuration
def create_parser(): def create_parser():

View File

@@ -1,10 +1,14 @@
"""Command implementations for Skywipe""" """Command implementations for Skywipe"""
from typing import Callable, Dict, Optional from typing import Callable, Dict, Optional
from skywipe.configure import Configuration from .configure import Configuration
from skywipe.posts import delete_posts from .posts import delete_all_posts
from skywipe.medias import delete_posts_with_medias from .medias import delete_posts_with_medias
from skywipe.likes import undo_likes from .likes import undo_likes
from .reposts import undo_reposts
from .quotes import delete_quotes_posts
from .follows import unfollow_all
from .bookmarks import delete_bookmarks
CommandHandler = Callable[[], None] CommandHandler = Callable[[], None]
@@ -56,7 +60,7 @@ def run_configure():
def run_posts(): def run_posts():
delete_posts() delete_all_posts()
def run_medias(): def run_medias():
@@ -68,29 +72,30 @@ def run_likes():
def run_reposts(): def run_reposts():
print("Command 'reposts' is not yet implemented.") undo_reposts()
def run_quotes(): def run_quotes():
print("Command 'quotes' is not yet implemented.") delete_quotes_posts()
def run_follows(): def run_follows():
print("Command 'follows' is not yet implemented.") unfollow_all()
def run_bookmarks(): def run_bookmarks():
print("Command 'bookmarks' is not yet implemented") delete_bookmarks()
def run_all(): def run_all():
registry.execute("posts") commands = ["posts", "likes", "reposts", "follows", "bookmarks"]
registry.execute("medias")
registry.execute("likes") for cmd in commands:
registry.execute("reposts") try:
registry.execute("quotes") registry.execute(cmd)
registry.execute("follows") except Exception as e:
registry.execute("bookmarks") print(f"Error running '{cmd}': {e}")
continue
registry.register("configure", run_configure, registry.register("configure", run_configure,
@@ -99,7 +104,7 @@ registry.register("posts", run_posts, "only posts")
registry.register("medias", run_medias, "only posts with medias") registry.register("medias", run_medias, "only posts with medias")
registry.register("likes", run_likes, "only likes") registry.register("likes", run_likes, "only likes")
registry.register("reposts", run_reposts, "only reposts") registry.register("reposts", run_reposts, "only reposts")
registry.register("quotes", run_reposts, "only quotes") registry.register("quotes", run_quotes, "only quotes")
registry.register("follows", run_follows, "only follows") registry.register("follows", run_follows, "only follows")
registry.register("bookmarks", run_follows, "only bookmarks") registry.register("bookmarks", run_bookmarks, "only bookmarks")
registry.register("all", run_all, "target everything") registry.register("all", run_all, "target everything")

69
skywipe/follows.py Normal file
View File

@@ -0,0 +1,69 @@
"""Follow undoing module for Skywipe"""
import time
from atproto import models
from .auth import Auth
from .configure import Configuration
FOLLOW_COLLECTION = "app.bsky.graph.follow"
def unfollow_all():
auth = Auth()
client = auth.login()
config = Configuration()
config_data = config.load()
batch_size = config_data.get("batch_size", 10)
delay = config_data.get("delay", 1)
verbose = config_data.get("verbose", False)
if verbose:
print(
f"Starting unfollow operation with batch_size={batch_size}, delay={delay}s")
did = client.me.did
cursor = None
total_unfollowed = 0
while True:
list_params = models.ComAtprotoRepoListRecords.Params(
repo=did,
collection=FOLLOW_COLLECTION,
limit=batch_size,
cursor=cursor
)
response = client.com.atproto.repo.list_records(params=list_params)
records = response.records
if not records:
break
for record in records:
try:
record_uri = record.uri
rkey = record_uri.rsplit("/", 1)[-1]
delete_data = {
"repo": did,
"collection": FOLLOW_COLLECTION,
"rkey": rkey
}
client.com.atproto.repo.delete_record(data=delete_data)
total_unfollowed += 1
if verbose:
print(f"Unfollowed: {record_uri}")
except Exception as e:
record_uri = getattr(record, "uri", "unknown")
if verbose:
print(f"Error unfollowing {record_uri}: {e}")
cursor = response.cursor
if not cursor:
break
if delay > 0:
time.sleep(delay)
print(f"Unfollowed {total_unfollowed} accounts.")

View File

@@ -2,8 +2,8 @@
import time import time
from atproto import models from atproto import models
from skywipe.auth import Auth from .auth import Auth
from skywipe.configure import Configuration from .configure import Configuration
LIKE_COLLECTION = "app.bsky.feed.like" LIKE_COLLECTION = "app.bsky.feed.like"

View File

@@ -1,43 +1,8 @@
"""Media post deletion module for Skywipe""" """Media post deletion module for Skywipe"""
import time import time
from skywipe.auth import Auth from .auth import Auth
from skywipe.configure import Configuration from .configure import Configuration
def has_media_embed(post_record):
embed = getattr(post_record, 'embed', None)
if not embed:
return False
embed_type = getattr(embed, 'py_type', None)
media_types = {
'app.bsky.embed.images',
'app.bsky.embed.video',
'app.bsky.embed.external'
}
if embed_type:
embed_type_base = embed_type.split('#')[0]
if embed_type_base in media_types:
return True
if embed_type_base in ('app.bsky.embed.recordWithMedia', 'app.bsky.embed.record_with_media'):
media = getattr(embed, 'media', None)
if media:
media_type = getattr(media, 'py_type', None)
if media_type:
media_type_base = media_type.split('#')[0]
if media_type_base in media_types:
return True
for attr in ('images', 'video', 'external'):
if hasattr(embed, attr):
return True
if isinstance(embed, dict) and embed.get(attr):
return True
return False
def delete_posts_with_medias(): def delete_posts_with_medias():
@@ -59,11 +24,8 @@ def delete_posts_with_medias():
total_deleted = 0 total_deleted = 0
while True: while True:
if cursor: response = client.get_author_feed(
response = client.get_author_feed( actor=did, limit=batch_size, cursor=cursor)
actor=did, limit=batch_size, cursor=cursor)
else:
response = client.get_author_feed(actor=did, limit=batch_size)
posts = response.feed posts = response.feed
if not posts: if not posts:
@@ -72,7 +34,41 @@ def delete_posts_with_medias():
for post in posts: for post in posts:
post_record = post.post post_record = post.post
if not has_media_embed(post_record): embed = getattr(post_record, 'embed', None)
has_media = False
if embed:
embed_type = getattr(embed, 'py_type', None)
media_types = {
'app.bsky.embed.images',
'app.bsky.embed.video',
'app.bsky.embed.external'
}
if embed_type:
embed_type_base = embed_type.split('#')[0]
if embed_type_base in media_types:
has_media = True
if not has_media and embed_type_base in ('app.bsky.embed.recordWithMedia', 'app.bsky.embed.record_with_media'):
media = getattr(embed, 'media', None)
if media:
media_type = getattr(media, 'py_type', None)
if media_type:
media_type_base = media_type.split('#')[0]
if media_type_base in media_types:
has_media = True
if not has_media:
for attr in ('images', 'video', 'external'):
if hasattr(embed, attr):
has_media = True
break
if isinstance(embed, dict) and embed.get(attr):
has_media = True
break
if not has_media:
if verbose: if verbose:
print(f"Skipping post without media: {post_record.uri}") print(f"Skipping post without media: {post_record.uri}")
continue continue

View File

@@ -1,11 +1,11 @@
"""Post deletion module for Skywipe""" """Post deletion module for Skywipe"""
import time import time
from skywipe.auth import Auth from .auth import Auth
from skywipe.configure import Configuration from .configure import Configuration
def delete_posts(): def delete_all_posts():
auth = Auth() auth = Auth()
client = auth.login() client = auth.login()
config = Configuration() config = Configuration()

75
skywipe/quotes.py Normal file
View File

@@ -0,0 +1,75 @@
"""Quote post deletion module for Skywipe"""
import time
from .auth import Auth
from .configure import Configuration
def delete_quotes_posts():
auth = Auth()
client = auth.login()
config = Configuration()
config_data = config.load()
batch_size = config_data.get("batch_size", 10)
delay = config_data.get("delay", 1)
verbose = config_data.get("verbose", False)
if verbose:
print(
f"Starting quote post deletion with batch_size={batch_size}, delay={delay}s")
did = client.me.did
cursor = None
total_deleted = 0
while True:
response = client.get_author_feed(
actor=did, limit=batch_size, cursor=cursor)
posts = response.feed
if not posts:
break
for post in posts:
post_record = post.post
embed = getattr(post_record, 'embed', None)
has_quote = False
if embed:
embed_type = getattr(embed, 'py_type', None)
if embed_type:
embed_type_base = embed_type.split('#')[0]
quote_types = {
'app.bsky.embed.record',
'app.bsky.embed.recordWithMedia',
'app.bsky.embed.record_with_media'
}
if embed_type_base in quote_types:
has_quote = True
if not has_quote and (hasattr(embed, 'record') or (isinstance(embed, dict) and embed.get('record'))):
has_quote = True
if not has_quote:
if verbose:
print(f"Skipping post without quote: {post_record.uri}")
continue
try:
client.delete_post(post_record.uri)
total_deleted += 1
if verbose:
print(f"Deleted quote post: {post_record.uri}")
except Exception as e:
if verbose:
print(f"Error deleting quote post {post_record.uri}: {e}")
cursor = response.cursor
if not cursor:
break
if delay > 0:
time.sleep(delay)
print(f"Deleted {total_deleted} quote posts.")

69
skywipe/reposts.py Normal file
View File

@@ -0,0 +1,69 @@
"""Repost undoing module for Skywipe"""
import time
from atproto import models
from .auth import Auth
from .configure import Configuration
REPOST_COLLECTION = "app.bsky.feed.repost"
def undo_reposts():
auth = Auth()
client = auth.login()
config = Configuration()
config_data = config.load()
batch_size = config_data.get("batch_size", 10)
delay = config_data.get("delay", 1)
verbose = config_data.get("verbose", False)
if verbose:
print(
f"Starting repost deletion with batch_size={batch_size}, delay={delay}s")
did = client.me.did
cursor = None
total_undone = 0
while True:
list_params = models.ComAtprotoRepoListRecords.Params(
repo=did,
collection=REPOST_COLLECTION,
limit=batch_size,
cursor=cursor
)
response = client.com.atproto.repo.list_records(params=list_params)
records = response.records
if not records:
break
for record in records:
try:
record_uri = record.uri
rkey = record_uri.rsplit("/", 1)[-1]
delete_data = {
"repo": did,
"collection": REPOST_COLLECTION,
"rkey": rkey
}
client.com.atproto.repo.delete_record(data=delete_data)
total_undone += 1
if verbose:
print(f"Undone repost: {record_uri}")
except Exception as e:
record_uri = getattr(record, "uri", "unknown")
if verbose:
print(f"Error undoing repost {record_uri}: {e}")
cursor = response.cursor
if not cursor:
break
if delay > 0:
time.sleep(delay)
print(f"Undone {total_undone} reposts.")