Compare commits

..

44 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
6475a117e7 build: update entrypoint 2025-12-18 16:06:16 +01:00
28a193078a docs: update readme 2025-12-18 16:06:09 +01:00
07bbe88784 refactor: move the entrypoint inside package 2025-12-18 16:06:04 +01:00
59554f6f17 feat: plan quotes command 2025-12-18 15:28:23 +01:00
02f609d829 docs: plan to have a quotes-only command 2025-12-18 15:28:16 +01:00
c8df3d0460 docs: update roadmap 2025-12-18 15:13:55 +01:00
5afee97259 feat: like undoing module 2025-12-18 15:13:51 +01:00
ebbcbeeaa7 feat: undo_likes() implemented 2025-12-18 15:13:36 +01:00
044ec67aa3 feat: prepare bookmark command 2025-12-18 14:36:41 +01:00
e871d19a9f docs: wording 2025-12-18 14:34:44 +01:00
0ec562e0d2 docs: add bookmarks on the roadmap 2025-12-18 14:32:59 +01:00
276d177c4d docs: forgotten word 2025-12-18 13:50:45 +01:00
ed0076a34e docs: link to file 2025-12-18 13:50:18 +01:00
e99defc533 docs: update readme 2025-12-18 13:48:26 +01:00
50288e9130 feat: run_medias() is now implemented 2025-12-18 13:48:22 +01:00
c7ef63cc05 feat: media post deletion module 2025-12-18 13:48:13 +01:00
2395f60d11 chore: clean pyproject.toml 2025-12-18 13:47:39 +01:00
edba17e9a3 refactor: unify docstrings 2025-12-18 13:14:27 +01:00
e3da6c4f12 fix: restore final message 2025-12-18 13:09:12 +01:00
debf55577d refactor: use direct registry export and migrate methods to public scope 2025-12-18 13:07:16 +01:00
2efe83650b refactor: use a single registry object 2025-12-18 13:05:31 +01:00
1c7a903131 docs: update roadmap 2025-12-18 13:02:43 +01:00
472b828f72 feat: add post deletion module 2025-12-18 13:02:18 +01:00
8090a3432c feat: add is_logged method 2025-12-14 17:34:58 +01:00
8b406f5d4e docs: update roadmap 2025-12-14 17:15:50 +01:00
053bb8696f feat: add a method to load configuration 2025-12-14 17:15:31 +01:00
a31df05bb8 feat: define authentication logic to at protocol 2025-12-14 17:15:22 +01:00
13 changed files with 620 additions and 78 deletions

View File

@@ -1,6 +1,6 @@
# Skywipe
Skywipe is a work-in-progress Python 3.13+ CLI that helps you wipe data from your Bluesky account using the AT Protocol.
Skywipe is a work-in-progress Python 3.13+ CLI that helps you wipe data from your Bluesky account using the AT Protocol SDK.
## Warning
@@ -8,9 +8,9 @@ This tool performs destructive operations. Only use it if you intend to erase da
## Requirements
We're using [`uv`](https://github.com/astral-sh/uv) for dependency and virtual environment management.
Check [pyproject.toml](pyproject.toml).
You can setup the project (aka create a virtual environment and install dependencies) with :
You can use `uv` to install dependencies:
```bash
git clone https://git.kharec.info/Kharec/skywipe.git
@@ -20,31 +20,21 @@ uv sync
## 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 # only posts
skywipe medias # only posts with medias
skywipe likes # only likes
skywipe reposts # only reposts
skywipe follows # only follows
```
While it's being developed, you can use the tool using `uv` :
```bash
uv run main.py all # target everything
uv run main.py configure # create configuration
uv run main.py posts # only posts
uv run main.py medias # only posts with medias
uv run main.py likes # only likes
uv run main.py reposts # only reposts
uv run main.py follows # only follows
uv run python -m skywipe.cli all # target everything
uv run python -m skywipe.cli configure # create configuration
uv run python -m skywipe.cli posts # delete posts
uv run python -m skywipe.cli medias # delete posts with medias
uv run python -m skywipe.cli likes # undo likes
uv run python -m skywipe.cli reposts # undo reposts
uv run python -m skywipe.cli quotes # delete quotes
uv run python -m skywipe.cli follows # unfollow all
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` :
@@ -60,17 +50,20 @@ BE SURE TO USE A [BLUESKY APP PASSWORD](https://blueskyfeeds.com/faq-app-passwor
## Roadmap
- [ ] build cli parameter management
- [ ] handle configuration logic
- [ ] sign in to at protocol
- [ ] delete posts in groups
- [ ] only delete posts with media
- [ ] remove likes
- [ ] remove reposts
- [ ] unfollow accounts
- [ ] make `all` run the other commands
- [x] build cli parameter management
- [x] handle configuration logic
- [x] sign in to at protocol
- [x] delete posts in batch
- [x] only delete posts with media
- [x] undo likes
- [x] undo reposts
- [x] delete quotes
- [x] unfollow accounts
- [x] remove bookmarks
- [x] make `all` run the other commands
- [ ] add simple progress and logging
- [ ] add safeguards like confirmations and clear dry-run info
- [ ] add safeguards (confirmation, dry-run flag)
- [ ] decent code architecture
- [ ] installation and run process
## License

View File

@@ -1,13 +1,10 @@
[project]
name = "skywipe"
version = "0.1.0"
description = "Clean your bluesky account with style"
description = "Clean your bluesky account"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"atproto>=0.0.65",
"pyyaml>=6.0",
]
dependencies = ["atproto>=0.0.65", "pyyaml>=6.0"]
[project.scripts]
skywipe = "main:main"
skywipe = "skywipe.cli:main"

28
skywipe/auth.py Normal file
View File

@@ -0,0 +1,28 @@
"""Authentication module for Skywipe"""
from atproto import Client
from .configure import Configuration
class Auth:
def __init__(self):
self.config = Configuration()
self.client = None
def login(self) -> Client:
config_data = self.config.load()
handle = config_data.get("handle")
password = config_data.get("password")
if not handle or not password:
raise ValueError(
"handle and password must be set in configuration")
self.client = Client()
self.client.login(handle, password)
return self.client
def is_logged(self) -> bool:
return bool(getattr(self.client, "me", None))

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,12 +3,11 @@
import sys
import argparse
from skywipe.commands import get_registry
from skywipe.configure import Configuration
from .commands import registry
from .configure import Configuration
def _create_parser():
registry = get_registry()
def create_parser():
commands = registry.get_all_commands()
parser = argparse.ArgumentParser(
@@ -29,7 +28,7 @@ def _create_parser():
return parser
def _require_config():
def require_config():
config = Configuration()
if not config.exists():
print("Error: Configuration file not found.")
@@ -38,13 +37,11 @@ def _require_config():
def main():
parser = _create_parser()
parser = create_parser()
args = parser.parse_args()
registry = get_registry()
if registry.requires_config(args.command):
_require_config()
require_config()
try:
registry.execute(args.command)

View File

@@ -1,7 +1,14 @@
"""Command implementations for Skywipe CLI."""
"""Command implementations for Skywipe"""
from typing import Callable, Dict, Optional
from skywipe.configure import Configuration
from .configure import Configuration
from .posts import delete_all_posts
from .medias import delete_posts_with_medias
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]
@@ -9,9 +16,9 @@ CommandHandler = Callable[[], None]
class CommandRegistry:
def __init__(self):
self._commands: Dict[str, CommandHandler] = {}
self._help_texts: Dict[str, str] = {}
self._requires_config: Dict[str, bool] = {}
self._commands = {}
self._help_texts = {}
self._requires_config = {}
def register(
self,
@@ -44,11 +51,7 @@ class CommandRegistry:
raise ValueError(f"Unknown command: {name}")
_registry = CommandRegistry()
def get_registry() -> CommandRegistry:
return _registry
registry = CommandRegistry()
def run_configure():
@@ -57,39 +60,51 @@ def run_configure():
def run_posts():
print("Command 'posts' is not yet implemented.")
delete_all_posts()
def run_medias():
print("Command 'medias' is not yet implemented.")
delete_posts_with_medias()
def run_likes():
print("Command 'likes' is not yet implemented.")
undo_likes()
def run_reposts():
print("Command 'reposts' is not yet implemented.")
undo_reposts()
def run_quotes():
delete_quotes_posts()
def run_follows():
print("Command 'follows' is not yet implemented.")
unfollow_all()
def run_bookmarks():
delete_bookmarks()
def run_all():
registry = get_registry()
registry.execute("posts")
registry.execute("medias")
registry.execute("likes")
registry.execute("reposts")
registry.execute("follows")
commands = ["posts", "likes", "reposts", "follows", "bookmarks"]
for cmd in commands:
try:
registry.execute(cmd)
except Exception as e:
print(f"Error running '{cmd}': {e}")
continue
_registry.register("configure", run_configure,
"create configuration", requires_config=False)
_registry.register("posts", run_posts, "only posts")
_registry.register("medias", run_medias, "only posts with medias")
_registry.register("likes", run_likes, "only likes")
_registry.register("reposts", run_reposts, "only reposts")
_registry.register("follows", run_follows, "only follows")
_registry.register("all", run_all, "target everything")
registry.register("configure", run_configure,
"create configuration", requires_config=False)
registry.register("posts", run_posts, "only posts")
registry.register("medias", run_medias, "only posts with medias")
registry.register("likes", run_likes, "only likes")
registry.register("reposts", run_reposts, "only reposts")
registry.register("quotes", run_quotes, "only quotes")
registry.register("follows", run_follows, "only follows")
registry.register("bookmarks", run_bookmarks, "only bookmarks")
registry.register("all", run_all, "target everything")

View File

@@ -1,4 +1,4 @@
"""Core configuration handling class and related logic."""
"""Core configuration module for Skywipe"""
import getpass
from pathlib import Path
@@ -52,3 +52,10 @@ class Configuration:
yaml.dump(config_data, f, default_flow_style=False)
print(f"\nConfiguration saved to {self.config_file}")
def load(self) -> dict:
if not self.exists():
raise FileNotFoundError(
f"Configuration file not found: {self.config_file}")
with open(self.config_file, "r") as f:
return yaml.safe_load(f)

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.")

69
skywipe/likes.py Normal file
View File

@@ -0,0 +1,69 @@
"""Like undoing module for Skywipe"""
import time
from atproto import models
from .auth import Auth
from .configure import Configuration
LIKE_COLLECTION = "app.bsky.feed.like"
def undo_likes():
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 like 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=LIKE_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": LIKE_COLLECTION,
"rkey": rkey
}
client.com.atproto.repo.delete_record(data=delete_data)
total_undone += 1
if verbose:
print(f"Undone like: {record_uri}")
except Exception as e:
record_uri = getattr(record, "uri", "unknown")
if verbose:
print(f"Error undoing like {record_uri}: {e}")
cursor = response.cursor
if not cursor:
break
if delay > 0:
time.sleep(delay)
print(f"Undone {total_undone} likes.")

92
skywipe/medias.py Normal file
View File

@@ -0,0 +1,92 @@
"""Media post deletion module for Skywipe"""
import time
from .auth import Auth
from .configure import Configuration
def delete_posts_with_medias():
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 media 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_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:
print(f"Skipping post without media: {post_record.uri}")
continue
try:
client.delete_post(post_record.uri)
total_deleted += 1
if verbose:
print(f"Deleted post with media: {post_record.uri}")
except Exception as e:
if verbose:
print(f"Error deleting post {post_record.uri}: {e}")
cursor = response.cursor
if not cursor:
break
if delay > 0:
time.sleep(delay)
print(f"Deleted {total_deleted} posts with media.")

56
skywipe/posts.py Normal file
View File

@@ -0,0 +1,56 @@
"""Post deletion module for Skywipe"""
import time
from .auth import Auth
from .configure import Configuration
def delete_all_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 post deletion with batch_size={batch_size}, delay={delay}s")
did = client.me.did
cursor = None
total_deleted = 0
while True:
if cursor:
response = client.get_author_feed(
actor=did, limit=batch_size, cursor=cursor)
else:
response = client.get_author_feed(actor=did, limit=batch_size)
posts = response.feed
if not posts:
break
post_uris = [post.post.uri for post in posts]
for uri in post_uris:
try:
client.delete_post(uri)
total_deleted += 1
if verbose:
print(f"Deleted post: {uri}")
except Exception as e:
if verbose:
print(f"Error deleting post {uri}: {e}")
cursor = response.cursor
if not cursor:
break
if delay > 0:
time.sleep(delay)
print(f"Deleted {total_deleted} posts.")

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.")