├── img ├── logo.png ├── settings.png └── logo-small.png ├── app.py ├── semiphemeral ├── static │ ├── img │ │ └── loading.gif │ ├── js │ │ ├── base.js │ │ ├── settings.js │ │ └── tweets.js │ └── style.css ├── templates │ ├── tweets.html │ ├── base.html │ └── settings.html ├── import_export.py ├── settings.py ├── __init__.py ├── db.py ├── common.py ├── web.py └── twitter.py ├── Pipfile ├── scripts └── pypi_release.sh ├── LICENSE ├── setup.py ├── CHANGELOG.md ├── .gitignore ├── README.md └── Pipfile.lock /img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/micahflee/semiphemeral/HEAD/img/logo.png -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import semiphemeral 3 | 4 | semiphemeral.main() 5 | -------------------------------------------------------------------------------- /img/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/micahflee/semiphemeral/HEAD/img/settings.png -------------------------------------------------------------------------------- /img/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/micahflee/semiphemeral/HEAD/img/logo-small.png -------------------------------------------------------------------------------- /semiphemeral/static/img/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/micahflee/semiphemeral/HEAD/semiphemeral/static/img/loading.gif -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | twine = "*" 8 | black = "*" 9 | 10 | [packages] 11 | click = "*" 12 | colorama = "*" 13 | flask = "*" 14 | sqlalchemy = "*" 15 | tweepy = "~=3.10" 16 | 17 | [requires] 18 | python_version = "3" 19 | 20 | [pipenv] 21 | allow_prereleases = true 22 | -------------------------------------------------------------------------------- /scripts/pypi_release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && cd .. && pwd )" 4 | cd $DIR 5 | 6 | # Delete old build 7 | rm -rf build dist > /dev/null 8 | 9 | # Make sure pipenv is good 10 | pipenv install --dev 11 | 12 | # Create new source and binary build 13 | pipenv run python setup.py sdist bdist_wheel 14 | 15 | # Upload to PyPI 16 | pipenv run python -m twine upload dist/* 17 | -------------------------------------------------------------------------------- /semiphemeral/templates/tweets.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Semiphemeral Tweets{% endblock %} 3 | {% block header_text %}Semiphemeral Tweets{% endblock %} 4 | {% block content %} 5 | 6 |
7 |
8 |
9 | 10 |
11 |
12 |
13 | 14 |
15 | 16 |
17 |
18 | 19 | 20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2010-2019 Google, Inc. http://angularjs.org 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /semiphemeral/static/js/base.js: -------------------------------------------------------------------------------- 1 | function comma_formatted(x) { 2 | return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); 3 | } 4 | 5 | $(function () { 6 | $.get('/api/statistics', function (data) { 7 | is_configured = (data.is_configured ? 'yes' : 'no'); 8 | $('.statistics .is_configured').text(is_configured); 9 | $('.statistics .last_fetch').text(data.last_fetch); 10 | $('.statistics .my_tweets').text(comma_formatted(data.my_tweets)); 11 | $('.statistics .tweets_to_delete').text(comma_formatted(data.tweets_to_delete)); 12 | $('.statistics .my_retweets').text(comma_formatted(data.my_retweets)); 13 | $('.statistics .my_likes').text(comma_formatted(data.my_likes)); 14 | $('.statistics .deleted_tweets').text(comma_formatted(data.deleted_tweets)); 15 | $('.statistics .deleted_retweets').text(comma_formatted(data.deleted_retweets)); 16 | $('.statistics .unliked_tweets').text(comma_formatted(data.unliked_tweets)); 17 | $('.statistics .excluded_tweets').text(comma_formatted(data.excluded_tweets)); 18 | $('.statistics .other_tweets').text(comma_formatted(data.other_tweets)); 19 | $('.statistics .threads').text(comma_formatted(data.threads)); 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | import semiphemeral 3 | 4 | with open("README.md", "r") as fh: 5 | long_description = fh.read() 6 | 7 | setuptools.setup( 8 | name="semiphemeral", 9 | version=semiphemeral.version, 10 | author="Micah Lee", 11 | author_email="micah@micahflee.com", 12 | long_description=long_description, 13 | description="Automatically delete your old tweets, except for the ones you want to keep", 14 | long_description_content_type="text/markdown", 15 | license="MIT", 16 | url="https://github.com/micahflee/semiphemeral", 17 | packages=["semiphemeral"], 18 | package_data={ 19 | "semiphemeral": [ 20 | "templates/*", 21 | "static/*", 22 | "static/img/*", 23 | "static/js/*", 24 | "static/js/lib/*", 25 | ] 26 | }, 27 | classifiers=( 28 | "Development Status :: 2 - Pre-Alpha", 29 | "Environment :: Console", 30 | "Intended Audience :: End Users/Desktop", 31 | "License :: OSI Approved :: MIT License", 32 | ), 33 | entry_points={"console_scripts": ["semiphemeral = semiphemeral:main",],}, 34 | install_requires=["click", "colorama", "tweepy", "flask", "sqlalchemy"], 35 | ) 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Semiphemeral Changelog 2 | 3 | ## 0.7 4 | 5 | * Feature: Support for proxies, including Tor 6 | * Feature: Add `--host` option to complement `--port` option 7 | * Bugfix: Fix `direct-messages.js` filename check in Twitter archive 8 | 9 | ## 0.6 10 | 11 | * Feature: Support deleting DMs 12 | 13 | ## 0.5 14 | 15 | * Bugfix: Fixed issue fetching tweets from the Twitter API in unlike mode 16 | 17 | ## 0.4 18 | 19 | * Feature: Support for unliking old tweets that the Twitter API doesn't make easy, by reliking/unliking them all 20 | * Feature: Add support for logging events to file 21 | * Feature: Add `configure --debug` option 22 | * Feature: Add `configure --port` option 23 | * Bugfix: Fetches 240 character tweets, instead of just the first 140 characters 24 | 25 | ## 0.3 26 | 27 | * Bugfix: jQuery wasn't included in the PyPi package, so tweets page of configure web app was broken. jQuery is now included 28 | * Bugfix: When fetching, fetch likes if the delete likes setting is enabled rather than the delete tweets setting 29 | * Bugfix: Exit gracefully when running stats if semiphemeral isn't configured yet 30 | 31 | ## 0.2 32 | 33 | * Bugfix: PyPi package now include HTML templates and static resources 34 | 35 | ## 0.1 36 | 37 | * First release 38 | -------------------------------------------------------------------------------- /semiphemeral/static/js/settings.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | function update_ui() { 3 | if ($('.log-to-file-checkbox').prop('checked')) { 4 | $('.log-to-file-fieldset').show(); 5 | } else { 6 | $('.log-to-file-fieldset').hide(); 7 | } 8 | 9 | if ($('.delete-tweets-checkbox').prop('checked')) { 10 | $('.delete-tweets-fieldset').show(); 11 | } else { 12 | $('.delete-tweets-fieldset').hide(); 13 | } 14 | 15 | if ($('.retweets-likes-checkbox').prop('checked')) { 16 | $('.retweets-likes-fieldset').show(); 17 | } else { 18 | $('.retweets-likes-fieldset').hide(); 19 | } 20 | 21 | if ($('.dms-checkbox').prop('checked')) { 22 | $('.dms-fieldset').show(); 23 | } else { 24 | $('.dms-fieldset').hide(); 25 | } 26 | 27 | if ($('.use-tor-checkbox').prop('checked')) { 28 | $('.proxy-text').val('socks5://localhost:9050').attr('readonly', 'readonly'); 29 | } else{ 30 | $('.proxy-text').removeAttr('readonly', 'false'); 31 | } 32 | } 33 | 34 | $('.log-to-file-checkbox').change(update_ui); 35 | $('.delete-tweets-checkbox').change(update_ui); 36 | $('.retweets-likes-checkbox').change(update_ui); 37 | $('.use-tor-checkbox').change(update_ui); 38 | $('.dms-checkbox').change(update_ui); 39 | update_ui(); 40 | }) 41 | -------------------------------------------------------------------------------- /semiphemeral/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 35 |
36 |

{% block header_text %}{% endblock %}

37 | {% block content %}{% endblock %} 38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # other 107 | .DS_Store 108 | .vscode/ 109 | -------------------------------------------------------------------------------- /semiphemeral/import_export.py: -------------------------------------------------------------------------------- 1 | import click 2 | import json 3 | 4 | from .db import Tweet 5 | 6 | 7 | class ImportExport: 8 | def __init__(self, common, twitter=None): 9 | self.common = common 10 | self.twitter = twitter 11 | 12 | def excluded_export(self, filename): 13 | tweets = ( 14 | self.common.session.query(Tweet) 15 | .filter(Tweet.user_id == int(self.common.settings.get("user_id"))) 16 | .filter(Tweet.exclude_from_delete == 1) 17 | .order_by(Tweet.created_at) 18 | .all() 19 | ) 20 | 21 | tweets_to_exclude = [] 22 | for tweet in tweets: 23 | tweets_to_exclude.append(tweet.status_id) 24 | 25 | with open(filename, "w") as f: 26 | f.write(json.dumps(tweets_to_exclude)) 27 | 28 | click.echo("Exported {} tweet status_ids".format(len(tweets_to_exclude))) 29 | 30 | def excluded_import(self, filename): 31 | with open(filename, "r") as f: 32 | try: 33 | status_ids = json.loads(f.read()) 34 | except: 35 | click.echo("Error JSON decoding input file") 36 | return 37 | 38 | # Validate 39 | if type(status_ids) != list: 40 | click.echo("Input file should be a list") 41 | return 42 | for status_id in status_ids: 43 | if type(status_id) != int: 44 | click.echo("All items in the input file list should be ints") 45 | return 46 | 47 | # Import 48 | for status_id in status_ids: 49 | tweet = ( 50 | self.common.session.query(Tweet).filter_by(status_id=status_id).first() 51 | ) 52 | if tweet: 53 | tweet.exclude_from_delete = True 54 | self.common.session.add(tweet) 55 | tweet.excluded_summarize() 56 | else: 57 | try: 58 | status = self.twitter.api.get_status(status_id) 59 | tweet = Tweet(status) 60 | tweet.exclude_from_delete = True 61 | self.common.session.add(tweet) 62 | tweet.excluded_fetch_summarize() 63 | except tweepy.error.TweepError as e: 64 | click.echo("Error for tweet {}: {}".format(tweet.status_id, e)) 65 | 66 | self.common.session.commit() 67 | -------------------------------------------------------------------------------- /semiphemeral/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | 5 | class Settings(object): 6 | def __init__(self, filename): 7 | self.filename = filename 8 | self.default_settings = { 9 | "api_key": "", 10 | "api_secret": "", 11 | "access_token_key": "", 12 | "access_token_secret": "", 13 | "username": "", 14 | "user_id": None, 15 | "delete_tweets": True, 16 | "tweets_days_threshold": 30, 17 | "tweets_retweet_threshold": 100, 18 | "tweets_like_threshold": 100, 19 | "tweets_threads_threshold": True, 20 | "retweets_likes": True, 21 | "retweets_likes_delete_retweets": True, 22 | "retweets_likes_retweets_threshold": 30, 23 | "retweets_likes_delete_likes": True, 24 | "retweets_likes_likes_threshold": 60, 25 | "delete_dms": True, 26 | "dms_days_threshold": 14, 27 | "since_id": None, 28 | "last_fetch": None, 29 | "unlike_ignore_list": [], 30 | "logging": False, 31 | "log_filename": os.path.expanduser("~/.semiphemeral/log"), 32 | "log_format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", 33 | 'proxy': "", 34 | 'use_tor': False, 35 | } 36 | self.load() 37 | 38 | def get(self, key): 39 | return self.settings[key] 40 | 41 | def set(self, key, val): 42 | self.settings[key] = val 43 | 44 | def load(self): 45 | if os.path.exists(self.filename): 46 | with open(self.filename, "r") as f: 47 | self.settings = json.load(f) 48 | 49 | for key in self.default_settings: 50 | if key not in self.settings: 51 | self.set(key, self.default_settings[key]) 52 | else: 53 | self.settings = self.default_settings.copy() 54 | 55 | def save(self): 56 | with open(self.filename, "w") as f: 57 | os.chmod(self.filename, 0o0600) 58 | json.dump(self.settings, f) 59 | 60 | def is_configured(self): 61 | if ( 62 | self.get("api_key") == "" 63 | or self.get("api_secret") == "" 64 | or self.get("access_token_key") == "" 65 | or self.get("access_token_secret") == "" 66 | or self.get("username") == "" 67 | ): 68 | return False 69 | return True 70 | 71 | def unlike_should_ignore(self, status_id): 72 | return status_id in self.get("unlike_ignore_list") 73 | 74 | def unlike_ignore(self, status_id): 75 | if status_id not in self.settings["unlike_ignore_list"]: 76 | self.settings["unlike_ignore_list"].append(status_id) 77 | self.save() 78 | -------------------------------------------------------------------------------- /semiphemeral/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | .sidebar { 6 | height: 100%; 7 | width: 250px; 8 | position: fixed; 9 | z-index: 1; 10 | top: 0; 11 | left: 0; 12 | background-color: #111111; 13 | color: #ffffff; 14 | overflow-x: hidden; 15 | padding-top: 20px; 16 | padding: 10px; 17 | } 18 | 19 | .sidebar ul { 20 | list-style: none; 21 | margin: 0; 22 | padding: 0; 23 | } 24 | 25 | .sidebar ul li { 26 | text-align: center; 27 | } 28 | 29 | .sidebar ul li a { 30 | display: inline-block; 31 | padding: 5px; 32 | margin: 0 10px 0 0; 33 | text-decoration: none; 34 | color: #a1acfc; 35 | font-weight: bold; 36 | } 37 | 38 | .sidebar .statistics { 39 | margin-top: 20px; 40 | } 41 | 42 | .sidebar .statistics ul li { 43 | text-align: center; 44 | color: #666666; 45 | margin-bottom: 10px; 46 | } 47 | 48 | .sidebar .statistics .value { 49 | color: #e5d204; 50 | } 51 | 52 | .content { 53 | margin-left: 270px; 54 | padding: 0px 10px; 55 | } 56 | 57 | .content h1 { 58 | font-style: italic; 59 | } 60 | 61 | .content a { 62 | color: #444d93; 63 | } 64 | 65 | .settings form ul { 66 | list-style: none; 67 | margin: 0; 68 | padding: 0; 69 | } 70 | 71 | .settings form ul li { 72 | margin-bottom: 1em; 73 | } 74 | 75 | .settings form label { 76 | display: inline-block; 77 | width: 12em; 78 | text-align: right; 79 | } 80 | 81 | .settings form input[type="text"] { 82 | width: 500px; 83 | } 84 | 85 | .settings form label.checkbox, .settings fieldset label { 86 | display: inline; 87 | width: auto; 88 | text-align: left; 89 | } 90 | 91 | .settings form input.small { 92 | width: 50px; 93 | } 94 | 95 | .tweets .controls { 96 | display: none; 97 | position: fixed; 98 | bottom: 10px; 99 | z-index: 999; 100 | height: 100px; 101 | background-color: #ffffff; 102 | border: 1px solid #000000; 103 | padding: 20px; 104 | } 105 | 106 | .tweets .controls .filter { 107 | margin: 0 0 10px 0; 108 | } 109 | 110 | .tweets .controls .filter input { 111 | width: 600px; 112 | padding: 5px; 113 | font-size: 1.2em; 114 | } 115 | 116 | .tweets .controls .options { 117 | margin: 0 20px 10px 0; 118 | color: #666666; 119 | font-size: 0.8em; 120 | display: inline-block; 121 | } 122 | 123 | .tweets .controls .info { 124 | margin: 0 0 10px 0; 125 | color: #666666; 126 | font-size: 0.8em; 127 | display: inline-block; 128 | } 129 | 130 | .tweets .controls .pagination { 131 | margin: 15px 0 0 0; 132 | } 133 | 134 | .tweets .controls .pagination .pagination-item { 135 | padding: 5px 10px; 136 | margin: 0 5px 0 0; 137 | font-size: 1em; 138 | border: 1px solid #999999; 139 | } 140 | 141 | .tweets .pagination .pagination-item a { 142 | text-decoration: none; 143 | color: #034b9e; 144 | } 145 | 146 | .tweets .controls .pagination .pagination-item-current { 147 | color: #000000; 148 | font-weight: bold; 149 | border: 0; 150 | } 151 | 152 | .tweets .tweets-to-delete { 153 | padding-bottom: 130px; 154 | } 155 | 156 | .tweets .tweets-to-delete .tweet { 157 | display: inline-block; 158 | margin: 0 20px 20px 0; 159 | margin-top: 5px; 160 | } 161 | 162 | .tweets .tweets-to-delete .tweet .info .toggle-exclude-label { 163 | margin-left: 5px; 164 | } 165 | 166 | .tweets .tweets-to-delete .tweet .info .toggle-exclude-label.excluded { 167 | font-weight: bold; 168 | } 169 | 170 | .tweets .tweets-to-delete .tweet .info .stats { 171 | font-size: 0.8em; 172 | color: #666666; 173 | } 174 | -------------------------------------------------------------------------------- /semiphemeral/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import click 3 | import json 4 | 5 | from .common import Common 6 | from .settings import Settings 7 | from .db import create_db 8 | from .web import create_app 9 | from .twitter import Twitter 10 | from .import_export import ImportExport 11 | 12 | version = "0.7" 13 | 14 | 15 | def init(): 16 | click.echo(click.style("semiphemeral {}".format(version), fg="yellow")) 17 | 18 | # Initialize stuff 19 | base = os.path.expanduser("~/.semiphemeral") 20 | os.makedirs(base, mode=0o700, exist_ok=True) 21 | # Fix insecure prior installation permissions 22 | os.chmod(base, 0o700) 23 | settings = Settings(os.path.join(base, "settings.json")) 24 | session = create_db(os.path.join(base, "tweets.db")) 25 | 26 | common = Common(settings, session) 27 | return common 28 | 29 | 30 | @click.group() 31 | def main(): 32 | """Automatically delete your old tweets, except for the ones you want to keep""" 33 | 34 | 35 | @main.command("configure", short_help="Start the web server to configure semiphemeral") 36 | @click.option("--debug", is_flag=True, help="Start web server in debug mode") 37 | @click.option("--host", default="127.0.0.1", help="Host to expose the web server on") 38 | @click.option("--port", default=8080, help="Port to expose the web server on") 39 | def configure(debug, host, port): 40 | common = init() 41 | click.echo( 42 | "Load this website in a browser to configure semiphemeral, and press Ctrl-C when done" 43 | ) 44 | click.echo("http://{host}:{port}".format(host=host, port=port)) 45 | click.echo("") 46 | app = create_app(common) 47 | app.run(host=host, port=port, threaded=False, debug=debug) 48 | 49 | 50 | @main.command("stats", short_help="Show stats about tweets in the database") 51 | def stats(): 52 | common = init() 53 | t = Twitter(common) 54 | if common.settings.is_configured(): 55 | t.stats() 56 | 57 | 58 | @main.command("fetch", short_help="Download all tweets/DMs") 59 | def fetch(): 60 | common = init() 61 | t = Twitter(common) 62 | if common.settings.is_configured(): 63 | t.fetch() 64 | 65 | 66 | @main.command( 67 | "delete", 68 | short_help="Delete tweets that aren't automatically or manually excluded, likes, and DMs", 69 | ) 70 | def delete(): 71 | common = init() 72 | t = Twitter(common) 73 | if common.settings.is_configured(): 74 | t.delete() 75 | 76 | 77 | @main.command('import', short_help='Import tweets from a Twitter data export') 78 | @click.argument('path', type=click.Path(exists=True)) 79 | def archive_import(path): 80 | common = init() 81 | t = Twitter(common) 82 | if common.settings.is_configured(): 83 | t.import_dump(path) 84 | 85 | 86 | @main.command( 87 | "unlike", 88 | short_help="Delete old likes that aren't available through the Twitter API", 89 | ) 90 | @click.option( 91 | "--filename", 92 | required=True, 93 | help="Path to like.js from Twitter data downloaded from https://twitter.com/settings/your_twitter_data", 94 | ) 95 | def unlike(filename): 96 | common = init() 97 | 98 | t = Twitter(common) 99 | if common.settings.is_configured(): 100 | t.unlike(filename) 101 | 102 | 103 | @main.command( 104 | "delete_dms", short_help="Delete DMs that aren't available through the Twitter API" 105 | ) 106 | @click.option( 107 | "--filename", 108 | required=True, 109 | help="Path to direct-message.js from Twitter data downloaded from https://twitter.com/settings/your_twitter_data", 110 | ) 111 | def delete_dms(filename): 112 | common = init() 113 | 114 | t = Twitter(common) 115 | if common.settings.is_configured(): 116 | t.delete_dms(filename) 117 | 118 | 119 | @main.command( 120 | "excluded_export", 121 | short_help="Export tweets excluded that are excluded from deletion", 122 | ) 123 | @click.option( 124 | "--filename", 125 | required=True, 126 | help="Output JSON file to save a list of tweet status_ids", 127 | ) 128 | def excluded_export(filename): 129 | common = init() 130 | ie = ImportExport(common) 131 | ie.excluded_export(filename) 132 | 133 | 134 | @main.command( 135 | "excluded_import", 136 | short_help="Import tweets excluded that are excluded from deletion", 137 | ) 138 | @click.option( 139 | "--filename", 140 | required=True, 141 | help="Input JSON file that contains a list of tweet status_ids", 142 | ) 143 | def excluded_import(filename): 144 | common = init() 145 | t = Twitter(common) 146 | if common.settings.is_configured(): 147 | ie = ImportExport(common, t) 148 | ie.excluded_import(filename) 149 | -------------------------------------------------------------------------------- /semiphemeral/db.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from datetime import datetime 4 | from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey 5 | from sqlalchemy.ext.declarative import declarative_base 6 | from sqlalchemy import create_engine 7 | from sqlalchemy.orm import sessionmaker, relationship 8 | 9 | Base = declarative_base() 10 | 11 | 12 | class Thread(Base): 13 | __tablename__ = "threads" 14 | id = Column(Integer, primary_key=True) 15 | root_status_id = Column(Integer) 16 | should_exclude = Column(Boolean) 17 | 18 | tweets = relationship("Tweet", back_populates="thread") 19 | 20 | def __init__(self, root_status_id): 21 | self.root_status_id = root_status_id 22 | self.should_exclude = False 23 | 24 | 25 | class Tweet(Base): 26 | __tablename__ = "tweets" 27 | id = Column(Integer, primary_key=True) 28 | created_at = Column(DateTime) 29 | user_id = Column(Integer) # we download all threads too, including with other users 30 | user_screen_name = Column(String) 31 | status_id = Column(Integer) 32 | lang = Column(String) 33 | source = Column(String) 34 | source_url = Column(String) 35 | text = Column(String) 36 | in_reply_to_screen_name = Column(String) 37 | in_reply_to_status_id = Column(Integer) 38 | in_reply_to_user_id = Column(Integer) 39 | retweet_count = Column(Integer) 40 | favorite_count = Column(Integer) 41 | retweeted = Column(Boolean) 42 | favorited = Column(Boolean) 43 | is_retweet = Column(Boolean) 44 | is_deleted = Column(Boolean) 45 | is_unliked = Column(Boolean) 46 | exclude_from_delete = Column(Boolean) 47 | 48 | thread_id = Column(Integer, ForeignKey("threads.id")) 49 | thread = relationship("Thread", back_populates="tweets") 50 | 51 | def __init__(self, status): 52 | self.created_at = status.created_at 53 | self.user_id = status.author.id 54 | self.user_screen_name = status.author.screen_name 55 | self.status_id = status.id 56 | self.lang = status.lang 57 | self.source = status.source 58 | self.source_url = status.source_url 59 | self.text = status.full_text 60 | 61 | # These fields don't exist in imported non-reply tweets 62 | for field in ('in_reply_to_screen_name', 'in_reply_to_status_id', 'in_reply_to_user_id'): 63 | setattr(self, field, getattr(status, field, None)) 64 | 65 | self.retweet_count = status.retweet_count 66 | self.favorite_count = status.favorite_count 67 | self.retweeted = status.retweeted 68 | self.favorited = status.favorited 69 | self.is_retweet = hasattr(status, "retweeted_status") 70 | self.is_deleted = False 71 | self.is_unliked = False 72 | self.exclude_from_delete = False 73 | 74 | def already_saved(self, session): 75 | """ 76 | Returns true if a tweet with this status_id is already in the db 77 | """ 78 | tweet = session.query(Tweet).filter_by(status_id=self.status_id).first() 79 | if tweet: 80 | click.secho( 81 | "Skipped {} @{}, id={}".format( 82 | self.created_at.strftime("%Y-%m-%d"), 83 | self.user_screen_name, 84 | self.status_id, 85 | ), 86 | dim=True, 87 | ) 88 | return True 89 | 90 | def fetch_summarize(self): 91 | click.echo("Fetched {}".format(self.summarize_string())) 92 | 93 | def unretweet_summarize(self): 94 | click.echo("Unretweeted {}".format(self.summarize_string(True))) 95 | 96 | def unlike_summarize(self): 97 | click.echo("Unliked {}".format(self.summarize_string())) 98 | 99 | def relike_unlike_summarize(self): 100 | click.echo("Reliked and unliked {}".format(self.summarize_string())) 101 | 102 | def delete_summarize(self): 103 | click.echo("Deleted {}".format(self.summarize_string())) 104 | 105 | def excluded_summarize(self): 106 | click.echo("Excluded from deletion {}".format(self.summarize_string())) 107 | 108 | def excluded_fetch_summarize(self): 109 | click.echo( 110 | "Fetched and excluded from deletion {}".format(self.summarize_string()) 111 | ) 112 | 113 | def summarize_string(self, include_rt_user=False): 114 | if include_rt_user: 115 | return "{} @{} {}, id={}".format( 116 | self.created_at.strftime("%Y-%m-%d"), 117 | self.user_screen_name, 118 | self.text.split(":")[0], 119 | self.status_id, 120 | ) 121 | else: 122 | return "{} @{}, id={}".format( 123 | self.created_at.strftime("%Y-%m-%d"), 124 | self.user_screen_name, 125 | self.status_id, 126 | ) 127 | 128 | 129 | def create_db(database_path): 130 | engine = create_engine("sqlite:///{}".format(database_path)) 131 | 132 | session = sessionmaker() 133 | session.configure(bind=engine) 134 | Base.metadata.create_all(engine) 135 | 136 | return session() 137 | -------------------------------------------------------------------------------- /semiphemeral/common.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from .db import Tweet, Thread 4 | 5 | import logging 6 | 7 | 8 | class Common: 9 | def __init__(self, settings, session): 10 | self.settings = settings 11 | self.session = session 12 | 13 | self.logger = logging.getLogger("semiphemeral-log") 14 | self.logger.setLevel(logging.INFO) 15 | handler = logging.FileHandler(self.settings.get("log_filename")) 16 | handler.setLevel(logging.INFO) 17 | formatter = logging.Formatter(self.settings.get("log_format")) 18 | handler.setFormatter(formatter) 19 | self.logger.addHandler(handler) 20 | 21 | def get_stats(self): 22 | self.settings.load() 23 | 24 | is_configured = self.settings.is_configured() 25 | if not is_configured: 26 | return { 27 | "is_configured": is_configured, 28 | } 29 | 30 | last_fetch = self.settings.get("last_fetch") 31 | my_tweets = self.session.execute( 32 | "SELECT COUNT(*) FROM tweets WHERE user_id={} AND is_deleted=0 AND is_retweet=0".format( 33 | int(self.settings.get("user_id")) 34 | ) 35 | ).first()[0] 36 | my_retweets = self.session.execute( 37 | "SELECT COUNT(*) FROM tweets WHERE user_id={} AND is_deleted=0 AND is_retweet=1".format( 38 | int(self.settings.get("user_id")) 39 | ) 40 | ).first()[0] 41 | my_likes = self.session.execute( 42 | "SELECT COUNT(*) FROM tweets WHERE favorited=1" 43 | ).first()[0] 44 | deleted_tweets = self.session.execute( 45 | "SELECT COUNT(*) FROM tweets WHERE user_id={} AND is_deleted=1 AND is_retweet=0".format( 46 | int(self.settings.get("user_id")) 47 | ) 48 | ).first()[0] 49 | deleted_retweets = self.session.execute( 50 | "SELECT COUNT(*) FROM tweets WHERE user_id={} AND is_deleted=1 AND is_retweet=1".format( 51 | int(self.settings.get("user_id")) 52 | ) 53 | ).first()[0] 54 | unliked_tweets = self.session.execute( 55 | "SELECT COUNT(*) FROM tweets WHERE favorited=1 AND is_unliked=1" 56 | ).first()[0] 57 | excluded_tweets = self.session.execute( 58 | "SELECT COUNT(*) FROM tweets WHERE user_id={} AND exclude_from_delete=1".format( 59 | int(self.settings.get("user_id")) 60 | ) 61 | ).first()[0] 62 | other_tweets = self.session.execute( 63 | "SELECT COUNT(*) FROM tweets WHERE user_id!={}".format( 64 | int(self.settings.get("user_id")) 65 | ) 66 | ).first()[0] 67 | threads = self.session.execute("SELECT COUNT(*) FROM threads").first()[0] 68 | tweets_to_delete = self.get_tweets_to_delete() 69 | 70 | return { 71 | "is_configured": is_configured, 72 | "last_fetch": last_fetch, 73 | "my_tweets": my_tweets, 74 | "my_retweets": my_retweets, 75 | "my_likes": my_likes, 76 | "deleted_tweets": deleted_tweets, 77 | "deleted_retweets": deleted_retweets, 78 | "unliked_tweets": unliked_tweets, 79 | "excluded_tweets": excluded_tweets, 80 | "other_tweets": other_tweets, 81 | "threads": threads, 82 | "tweets_to_delete": len(tweets_to_delete), 83 | } 84 | 85 | def get_tweets_to_delete(self, include_excluded=False): 86 | """ 87 | Returns a list of Tweet objects for tweets that should be deleted based 88 | on criteria in settings. This list includes tweets where exclude_from_delete=True, 89 | so it's important to manually exclude those before deleting 90 | """ 91 | self.settings.load() 92 | datetime_threshold = datetime.datetime.utcnow() - datetime.timedelta( 93 | days=self.settings.get("tweets_days_threshold") 94 | ) 95 | 96 | # Select tweets from threads to exclude 97 | tweets_to_exclude = [] 98 | threads = self.session.query(Thread).filter(Thread.should_exclude == True).all() 99 | for thread in threads: 100 | for tweet in thread.tweets: 101 | if tweet.user_id == self.settings.get("user_id"): 102 | tweets_to_exclude.append(tweet.status_id) 103 | 104 | # Select tweets that we will delete 105 | tweets_to_delete = [] 106 | q = ( 107 | self.session.query(Tweet) 108 | .filter(Tweet.user_id == int(self.settings.get("user_id"))) 109 | .filter(Tweet.is_deleted == 0) 110 | .filter(Tweet.is_retweet == 0) 111 | .filter(Tweet.created_at < datetime_threshold) 112 | .filter(Tweet.retweet_count < self.settings.get("tweets_retweet_threshold")) 113 | .filter(Tweet.favorite_count < self.settings.get("tweets_like_threshold")) 114 | ) 115 | 116 | # Should we also filter out exclude_from_delete? 117 | if not include_excluded: 118 | q = q.filter(Tweet.exclude_from_delete != True) 119 | 120 | q = q.order_by(Tweet.created_at) 121 | 122 | tweets = q.all() 123 | for tweet in tweets: 124 | if tweet.status_id not in tweets_to_exclude: 125 | tweets_to_delete.append(tweet) 126 | 127 | return tweets_to_delete 128 | 129 | def log(self, message): 130 | if self.settings.get("logging"): 131 | self.logger.info(message) 132 | -------------------------------------------------------------------------------- /semiphemeral/templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Semiphemeral Settings{% endblock %} 3 | {% block header_text %}Semiphemeral Settings{% endblock %} 4 | {% block content %} 5 | 6 |
7 |
8 | 9 |

Twitter settings

10 | 11 |

See here for documentation on how to get these Twitter API credentials.

12 | 13 | 35 | 36 |

37 | 41 |

42 |
43 | Logging 44 |

45 | 46 | 47 |

48 |

49 | 50 | 51 |

52 |
53 | 54 |

55 | 59 |

60 |
61 | Tweets 62 |

63 | Delete tweets older than 64 | 65 | days 66 |

67 |

68 | Unless they have at least 69 | 70 | retweets 71 |

72 |

73 | Or at least 74 | 75 | likes 76 |

77 |

78 | 82 |

83 |
84 | 85 |

86 | 90 |

91 | 92 |
93 | Retweets and likes 94 | 95 |

96 | 100 | older than 101 | 102 | days 103 |

104 | 105 |

106 | 110 | older than 111 | 112 | days 113 |

114 | 115 |
116 | 117 |

118 | 122 |

123 | 124 |
125 | Direct messages 126 | 127 |

128 | Delete direct messages older than 129 | 130 | days 131 |

132 |
133 | 134 |
135 | Privacy 136 | 137 |

138 | 139 | 140 | 144 |

145 |
146 | 147 |

148 | 149 |
150 |
151 | 152 | 153 | {% endblock %} 154 | -------------------------------------------------------------------------------- /semiphemeral/static/js/tweets.js: -------------------------------------------------------------------------------- 1 | $(function () { 2 | 3 | function change_state(q, page, count, replies) { 4 | var new_state = { q: q, page: page, count: count, replies: replies }; 5 | window.location = '#' + JSON.stringify(new_state); 6 | } 7 | 8 | function toggle_exclude(id, excluded) { 9 | function update_text(excluded) { 10 | if (excluded) { 11 | $('#toggle-exclude-label-' + id).addClass('excluded').text('Excluded from deletion'); 12 | } else { 13 | $('#toggle-exclude-label-' + id).removeClass('excluded').text('Staged for deletion'); 14 | } 15 | } 16 | 17 | // If excluded is the same as we already know, just update the text 18 | if (window.semiphemeral.tweets[id].excluded == excluded) { 19 | update_text(excluded); 20 | return; 21 | } 22 | 23 | // Otherwise make ajax request 24 | $('#toggle-exclude-label-' + id).removeClass('excluded').text('Saving...'); 25 | var exclude_from_delete = (excluded ? 1 : 0); 26 | $.ajax('/api/exclude/' + id + '/' + exclude_from_delete, { 27 | method: 'POST', 28 | success: function () { 29 | // Update the tweets, and the text 30 | window.semiphemeral.tweets[id].excluded = excluded; 31 | update_text(excluded); 32 | }, 33 | error: function () { 34 | alert('API error'); 35 | } 36 | }); 37 | } 38 | 39 | function display_tweets() { 40 | console.log('display_tweets', window.semiphemeral); 41 | 42 | var q = window.semiphemeral.state.q; 43 | var page = window.semiphemeral.state.page; 44 | var count = window.semiphemeral.state.count; 45 | var replies = window.semiphemeral.state.replies; 46 | 47 | // Build list of ids to filter 48 | var ids = []; 49 | for (var i = 0; i < window.semiphemeral.ids.length; i++) { 50 | var id = window.semiphemeral.ids[i]; 51 | if (window.semiphemeral.tweets[id].text.toLowerCase().includes(q.toLowerCase())) { 52 | if (replies || (!replies && !window.semiphemeral.tweets[id].is_reply)) { 53 | ids.push(id); 54 | } 55 | } 56 | } 57 | 58 | // Empty what previous page 59 | $('.info').empty(); 60 | $('.tweets-to-delete').empty(); 61 | $('.pagination').empty(); 62 | 63 | // Display info 64 | var num_pages = Math.ceil(ids.length / count); 65 | var info_string = 'Page ' + comma_formatted(page) + ' of ' + comma_formatted(num_pages) + ' - '; 66 | if (ids.length != window.semiphemeral.ids.length) { 67 | info_string += 'filtering to ' + comma_formatted(ids.length) + ' tweets - ' 68 | } 69 | info_string += comma_formatted(window.semiphemeral.ids.length) + ' tweets are staged for deletion'; 70 | $('.info').append($('
').html(info_string)); 71 | 72 | // Pagination controls 73 | function add_pagination_item(text, new_page) { 74 | var $item = $('').addClass('pagination-item'); 75 | if (new_page == page) { 76 | $item.addClass('pagination-item-current').text(text); 77 | } else { 78 | var new_state = { q: q, page: new_page, count: count, replies: true }; 79 | var $link = $('').attr('href', '#' + JSON.stringify(new_state)).text(text); 80 | $item.append($link); 81 | } 82 | 83 | $('.pagination').append($item); 84 | } 85 | if (page > 0) { 86 | add_pagination_item('Previous', page - 1); 87 | } 88 | for (var i = page - 5; i < page + 5; i++) { 89 | if (i >= 0 && i <= num_pages - 1) { 90 | add_pagination_item(i, i); 91 | } 92 | } 93 | if (page < num_pages - 1) { 94 | add_pagination_item('Next', page + 1); 95 | } 96 | 97 | // Display the page of tweets 98 | for (var i = page * count; i < (page + 1) * count; i++) { 99 | var $embed = $('
').prop('id', 'tweet-' + ids[i]); 100 | var $info = $('
').addClass('info') 101 | .append( 102 | $('