Skip to content

Instantly share code, notes, and snippets.

@nickludlam
Created October 29, 2023 17:54
Show Gist options
  • Save nickludlam/8d94904915290fb1dec2613a7145f768 to your computer and use it in GitHub Desktop.
Save nickludlam/8d94904915290fb1dec2613a7145f768 to your computer and use it in GitHub Desktop.
Sync mastodon bookmarks to linkding
"""Sync Mastodon bookmarks to Linkding
This script will automatically fetch a user's bookmarks from a
Mastodon instance. If the bookmark is not yet present in the
Linkding bookmark site, it's created as an unread, along with
any other things like custom tags.
Optionally, if the bookmarked Mastodon toot only contains one
link, excluding any others like user mentions and hashtags,
then that link is used for the bookmark URL, instead of the
toot itself.
Note: that this script expects MASTODON_API_TOKEN and
LINKDING_API_TOKEN to be set in the environment
"""
import html.parser
import itertools
import json
import os
import requests
import sys
# The default number of returned bookmarks is 20, but we can increase this to 40 by adding '?limit=40' to the URL
MASTODON_BOOKMARKS_URL = 'https://amok.recoil.org/api/v1/bookmarks'
# The URL of the Linkding bookmark instance to sync to
LINKDING_SITE="http://bookmarks.local/"
# The tag to use for all bookmarks created by this script
LINKDING_TAG="mastodonautobookmark"
# If this is set to True, then all bookmarks created by this script will be marked as unread in Linkding
LINKDING_UNREAD_BOOKMARKS=True
# If this is set to True, then if a bookmarked toot only contains one link, that link is bookmarked instead of the toot itself
SINGLE_LINK_PASS_THROUGH=True
# Parse the HTML contained in the response from Mastodon
class LinkParser(html.parser.HTMLParser):
# From looking at the API, most extraneous tags will be covered by excluding the following classes
# when we parse the 'content' field returned from Mastodon's API
excluded_anchor_classes = ['mention', 'hashtag']
def reset(self):
super().reset()
self.links = iter([])
def handle_starttag(self, tag, attrs):
if tag == 'a':
anchor_classes = [item[1] for item in attrs if item[0] == 'class']
for excluded_anchor_class in self.excluded_anchor_classes:
for anchor_class in anchor_classes:
if excluded_anchor_class in anchor_class:
# We don't want any links that have a class corresponding to excluded_anchor_classes
return
# Keep the rest of the links
for (name, value) in attrs:
if name == 'href':
self.links = itertools.chain(self.links, [value])
# Check if we have an existing bookmark for this URL
def bookmark_exists(linkding_api_token, url):
headers = { 'Authorization': f'Token {linkding_api_token}' }
params = { 'q' : url }
r = requests.get(LINKDING_SITE + 'api/bookmarks/', params=params, headers=headers)
if r.status_code != 200:
print("Failed to check link existence on the bookmarks server")
existing_bookmarks = json.loads(r.content)
count = existing_bookmarks['count']
return count != 0
def create_bookmark(linkding_api_token, url, additional_description=None):
if bookmark_exists(linkding_api_token, url):
return
headers = { 'Authorization': f'Token {linkding_api_token}' }
params = { 'url' : url }
# These might need some alteration to find a suitable workflow
if additional_description != None:
params['description'] = additional_description
params['unread'] = LINKDING_TAG
params['tag_names'] = [LINKDING_TAG]
r = requests.post(LINKDING_SITE + 'api/bookmarks/', data=params, headers=headers)
if r.status_code != 201:
raise Exception(f"Failed to create a link on the bookmarks server {r.status_code}: {r.content}")
def process_bookmark_toot(linkding_api_token, mastodon_bookmark_url, mastodon_bookmark_links):
# If there's only one URL contained in the toot, we optionally unwrap it and bookmark that URL directly,
# instead of the Mastodon toot itself. Otherwise we bookmark the toot
if SINGLE_LINK_PASS_THROUGH and len(mastodon_bookmark_links) == 1:
create_bookmark(linkding_api_token, mastodon_bookmark_links[0], f"Bookmarked from {mastodon_bookmark_url}")
else:
create_bookmark(linkding_api_token, mastodon_bookmark_url)
# By default this is in reverse chronological order
def fetch_mastodon_bookmarks(mastodon_api_token):
headers = {'Authorization': f'Bearer {mastodon_api_token}' }
r = requests.get(MASTODON_BOOKMARKS_URL, headers=headers)
if r.status_code != 200:
print("Failed to contact mastodon server")
sys.exit(1)
bookmarks = json.loads(r.content)
response = []
parser = LinkParser()
for bookmark in bookmarks:
bookmark_url = bookmark['url']
#print(f"Processing bookmark toot: {bookmark_url}")
content = bookmark['content']
parser.feed(content)
response.append((bookmark_url, list(parser.links)))
return response
if __name__ == '__main__':
mastodon_api_token = os.getenv("MASTODON_API_TOKEN")
if mastodon_api_token == None:
print("Please ensure you specify MASTODON_API_TOKEN in the environment")
sys.exit(1)
linkding_api_token = os.getenv("LINKDING_API_TOKEN")
if linkding_api_token == None:
print("Please ensure you specify LINKDING_API_TOKEN in the environment")
sys.exit(1)
bookmarks_and_links = fetch_mastodon_bookmarks(mastodon_api_token)
for (mastodon_bookmark_url, mastodon_bookmark_links) in bookmarks_and_links:
process_bookmark_toot(linkding_api_token, mastodon_bookmark_url, mastodon_bookmark_links)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment