17 Sep 2022
Since last year, I have started managing clipboard-based actions with my own Python script, instead of 3rd party solutions - they all have limitations.
I'm using this script on a daily basis.
Workflow
Python script assigned ot a key on my Streamdeck Streamdeck for business users
- copy something
- press key on Streamdeck
Script prints to a Terminal window, and copies output back to clipboard, ready to be pasted anywhere.
TODO:
- explore AppleScript with keyboard shortcut assignment
Initial script
from pandas.io.clipboard import clipboard_get
import re
import subprocess
from urllib.parse import urlparse
from tldextract import extract
import requests
from datetime import date
from datetime import datetime
from bs4 import BeautifulSoup
from urllib.parse import urlparse
from urllib import request
d = datetime.now()
date = d.strftime('%Y%m%d-%H%M%S')
count_url = 0
def separator(count=50, lines=3, symbol='='):
separator = f"{symbol * count}" + '\n'
separator = f"\n{separator * lines}"
print(separator)
sep = separator()
### Functions
def cleanurl(url: str) -> str:
from urllib.parse import urlparse
purl = urlparse(url)
scheme = purl.scheme + '://' if purl.scheme else ''
return f'{scheme}{purl.netloc}{purl.path}'
def write_to_clipboard(output):
process = subprocess.Popen(
'pbcopy', env={'LANG': 'en_US.UTF-8'}, stdin=subprocess.PIPE)
process.communicate(output.encode('utf-8'))
print(f"\nOUTPUT COPIED TO CLIPBOARD\n")
def html_for_note(text, v=True):
url = cleanurl(text)
if v:
print(f"\n{url=}")
try:
html = request.urlopen(url).read().decode('utf8')
if v:
print(f"\n{html=}")
soup = BeautifulSoup(html, "html.parser")
if v:
print(f"\n{soup=}")
title = soup.title.text
if v:
print(f"\n{title=}")
try:
header = soup.find('h1').text
if v:
print(f"{header=}")
output = f"<div class=\"link_border\">\n<div class=\"link_title\">{title}</div>\n<div class=\"link_title\">{header}</div>\n<div class=\"link_url\"><a href=\"{url}\" target=\"_blank\">{url}</a></div></div>\n"
except Exception as e:
print(f"\nheader ERROR: {e}")
print(f"NO Header found, returning with Title only")
output = f"<div class=\"link_border\">\n<div class=\"link_title\">{title}</div>\n<div class=\"link_url\"><a href=\"{url}\" target=\"_blank\">{url}</a></div></div>\n"
print(f'\nOutput:\n--------\n{output}--------\n')
write_to_clipboard(output)
except Exception as e:
print(f"\nhtml_for_note ERROR: {e}\nReturning empty div:")
output = f"<div class=\"link_border\">\n<div class=\"link_title\">XXXXXXX</div>\n<div class=\"link_url\"><a href=\"{url}\" target=\"_blank\">{url}</a></div></div>\n"
print(f'\nOutput:\n--------\n{output}--------\n')
write_to_clipboard(output)
### Switches
def clipee_processing(text):
global sep
if "vimeo.com" in text:
print(f"{sep}\nProcessing as VIMEO LINK EXTRACTION...")
print(f'\nInput Vimeo: {type(text)}, {text}')
src = re.search(r'(?<=src=").*?(?=[\?"])', text)
text = src[0]
write_to_clipboard(text)
print(f'\nOutput: {text}\n')
elif 'fantastical' in text:
print(f"{sep}\nProcessing as FANTASTICAL DATE LIST...")
o = urlparse(text)
uid = f'{o.path}'.replace('/p/', "")
url = f'https://hub.flexibits.com/scheduling/public/{uid}/'
html = requests.get(url).text
soup = BeautifulSoup(open(html), "html.parser")
name = soup.find(
'div', class_='TimePicker_timePicker__5yclN')
count = 0
for div in name:
count += 1
div_name = ''
for span in div:
slot = span.find_all('span')
slot = list(slot)
join = ": ".join(map(str, slot)).replace(
'<span>', "").replace('</span>', "").replace(',', "")
if join != '':
paste = f'{count}) {join}'
print(paste)
print()
elif "mailto" in text:
print(f"{sep}\nProcessing as MAILTO CLEANING...\n")
print(f'\nInput Mailto: {type(text)}, {text}')
text = text.replace('mailto:', '').strip()
write_to_clipboard(text)
print(f'\nOutput: {text}\n')
elif "?" in text:
print(f"{sep}\nProcessing as URL CLEANING...\n")
print(f'\nInput URL with query params: {type(text)}, {text}')
short_url = cleanurl(text)
write_to_clipboard(short_url)
print(f'\nOutput short URL: {short_url}\n')
elif ".gif" in text:
print(f"{sep}\nProcessing as GIF DOWNLOAD...\n")
print(f'\nInput code with .gif: {type(text)}, {text}')
print('Download Starting...')
url = text
r = requests.get(url)
# this will take only -1 splitted part of the url
filename = f'/Users/xxx/GIF/{date}.gif'
with open(filename, 'wb') as output_file:
output_file.write(r.content)
print('Download Completed!!!')
elif '(Event Time Zone)' in text:
print(f"{sep}\nProcessing as TIMEZONE CLEANING...\n")
text = text.replace(' (Event Time Zone)', '')
write_to_clipboard(text)
print(f'\nOutput: {text}\n')
elif 'xxx@yyy' in text:
print(f"{sep}\nProcessing as TERMINAL CLEANING...")
text = text.replace('xxx@yyy', 'xxx@yyy')
if '/Users/yyyy/' in text:
text = text.replace('/Users/yyyy/', '/Users/xxxx/')
print(f"\n{text}\n")
write_to_clipboard(text)
elif '/Users/yyyy/' in text:
print(f"{sep}\nProcessing as TERMINAL CLEANING...")
text = text.replace('/Users/yyyy/', '/Users/xxxx/')
print(f"\n{text}\n")
write_to_clipboard(text)
elif text.strip().startswith('http'):
print(f"{sep}\nProcessing as NOTE LINK...\n")
html_for_note(text)
else:
print(f"\nNO LOGIC identified for this text.")
text = clipboard_get()
print(f"\nProcessing: {repr(text)}\n")
clipee_processing(text)
while count_url < 1000:
print(f"[CLIPEE] Want to process another?...\n")
valid = False
new_url = input(f'Enter new URL: ')
clipee_processing(new_url)
Clipee family
Rather than continue implementing logic or switches for the script to identify what to do with clipboard content, I broke out the script:
- Clipee Clean: most of the generic clipboard content cleaning of the initial script.
and new additions: - Clipee Note: generate rich HTML snippet from URL.
- Clipee Atom: paste clipboard content to new named file in Atom.
- Clipee Tweet: send a tweet from content of clipboard.
Clipee Clean
Pared down version of the initial script above. No change to it, but simplified script will allow for more flexibility in cleaning tasks.
Clipee Note
Generate HTML snippet with site icon and header/tagline from a URL in clipboard.
Implemented a couple weeks ago and using several times a day since.
Code:
06 Feb 2023 using every day since implementation.
TODO: automate process so any image added to my Notes images folders gets added as hyperlink to helpers/images.md
➤ easy to copy/paste from there and/or keep as an ongoing image clipboard.
Clipee Atom
For the last couple years, I have relied daily on a shortcut on my Streamdeck that was pasting the content of my clipboard to Atom (my text editor Atom).
While I have a clipboard manager, this serves the purpose of keeping the pasted content on a separate screen in front of me, explore content within the simplicity of a text editor, etc.
Challenges were:
- extra gesture to reach out to the Streamdeck to hit the right key (I know!!)
- couple seconds of processing (managed via keyboard shortcut automation, eg
Cmd N
,Cmd V
) to get result - untitled file means prompt for name when closing or losing data if crash.
As Clipee is designed to enable clipboard actions, I thought about improving on this automation.
Result now:
- Alfred keyword
pa
(Paste to Atom) is all I need to do. - AppleScript triggered creates a new
.txt
file with timestamp as name in a central folder, and opens it in Atom.
It's near instantaneous 😁 and all my "pasteboard" is archived, searchable and safe.
Plus it avoids the risk of misfiring if app is not open and at the foreground quickly enough when the automated keyboard shortcuts kick in (another challenge with the initial Streamdeck approack).
See implementation and code in:
Clipee Tweet
27 Sep 2022
See origination: How to use Twitter via CLI (Command Line Interface) on macOS
Goal: make it as seamless as possible in my workflow to tweet.
- write anywhere and copy content
- Alfred workflow with keyword
ct
(Clipee Tweet) tweets content of clipboard
Danger to tweet something wrong 😅😂 but oopening tweet after posting for double-check, and deleting tweet if mistake, should be a fine workaround.
resources
implementation
Done in 30mns and working 😁
"Trickiest" bit was the flurry of authentication strings (API KEY, API SECRET, ACCESS_TOKEN & ACCESS_TOKEN_SECRET), and their permissions.
First try returned an Unauthorized response.
Had to re-generate the keys, with a user having read/write permissions.
from pandas.io.clipboard import clipboard_get
import os
# environment variables, ie credentials
from dotenv import load_dotenv
load_dotenv()
# see resources above
import tweepy
# to open my Twitter profile after sending for double-checking
import webbrowser
# to display a confirmation window
from tkinter import simpledialog
# credentials
api_key = os.getenv("API_KEY")
api_secret = os.getenv("API_SECRET")
access_token = os.getenv("ACCESS_TOKEN")
access_token_secret = os.getenv("ACCESS_TOKEN_SECRET")
# authentication of consumer key and secret
auth = tweepy.OAuthHandler(api_key, api_secret)
# authentication of access token and secret
auth.set_access_token(access_token, access_token_secret)
api = tweepy.API(auth)
def tweet(text, v=False):
print(f"Ready to send:\n\n{text}\n\n")
approval = simpledialog.askstring(f"Sure?",f"Ready to send:\n\n{text}\n\nReally?\n(y to confirm)")
if approval == 'y':
api.update_status(status=text)
webbrowser.get('chrome').open_new_tab(f'https://twitter.com/ndeville')
text = clipboard_get()
print(f"\nProcessing: {repr(text)}\n")
tweet(text)
next: add logic to include images.
Working script 😁
# credentials
api_key = os.getenv("API_KEY")
api_secret = os.getenv("API_SECRET")
access_token = os.getenv("ACCESS_TOKEN")
access_token_secret = os.getenv("ACCESS_TOKEN_SECRET")
# authentication of consumer key and secret
auth = tweepy.OAuthHandler(api_key, api_secret)
# authentication of access token and secret
auth.set_access_token(access_token, access_token_secret)
api = tweepy.API(auth)
def tweet(text, v=False):
multimedia = False
parts = text.split('\n')
for part in parts:
part = part.strip()
print()
print(f"{part=}")
if part.startswith('/Users') and part.endswith('jpg'):
multimedia = True
image_path = part
text = text.replace(image_path, '') # remove the path URL from the tweet text
print(f"\n{image_path=}\n")
if multimedia:
approval = simpledialog.askstring(f"Sure?",f"Ready to send WITH IMAGE attached:\n\n{text}\n\nReally?\n(y to confirm)")
else:
approval = simpledialog.askstring(f"Sure?",f"Ready to send:\n\n{text}\n\nReally?\n(y to confirm)")
if approval == 'y':
if multimedia:
api.update_status_with_media(text, image_path)
else:
api.update_status(status=text)
webbrowser.get('chrome').open_new_tab(f'https://twitter.com/ndeville')
text = clipboard_get()
print(f"\nProcessing:\n{repr(text)}\n")
tweet(text)
17 Apr 2023 API access deactivated by Twitter. Would need to switch to paid plan to continue - but not enough use for it.
next: Clipee Grist
Clipee Grist
28 Sep 2022
Pain point: I come across a lot of apps (SaaS mostly) that I want to add to my list (library) to keep them on my radar. This list lives in Grist Grist | The Evolution of Spreadsheets.
Goal: add enriched record to Grist from a URL copied (ie in clipboard).
Separating scripts per Grist document for convenience.
29 Sep 2022
Clipee Grist Apps
- Alfred shortcut cga
- working with script:
# default boilerplate removed for brevity, see https://notes.nicolasdeville/python/boilerplate/
####################
# Clipee Grist - Add Apps
### GLOBAL VARIABLES
test = False
v = True # verbose mode
doc_id = os.getenv("DOC_ID_PE_APPS")
api_key = os.getenv("GRIST_API_KEY")
apps_data = grist_PE.Apps.fetch_table('Master')
if v:
print(f"\n{len(apps_data)} apps in Grist.\n")
existing_domains = [x.domain for x in apps_data]
# Categories & Tags
categories_in_grist = set()
tags_in_grist = set()
for app in apps_data:
categories = app.category
if categories != None:
for cat in categories:
if cat != 'L':
categories_in_grist.add(cat.lower())
tags = app.tags
if tags != None:
for tag in tags:
if tag != 'L':
tags_in_grist.add(tag.lower())
# TODO build dicts for categories and tags to match
category_keywords = list(categories_in_grist) + [
# ' ai ',
# 'ai-',
]
tag_keywords = list(tags_in_grist) + [
]
###
import webbrowser
### UI
# from tkinter import simpledialog
import pymsgbox # simpler than tkinter / see python/library-pymsgbox
# import pymsgbox.native as pymsgbox # not working, revisit later
### Functions
def separator(count=50, lines=3, symbol='='):
separator = f"{symbol * count}" + '\n'
separator = f"\n{separator * lines}"
print(separator)
sep = separator()
def cleanurl(url: str) -> str:
from urllib.parse import urlparse
purl = urlparse(url)
scheme = purl.scheme + '://' if purl.scheme else ''
return f'{scheme}{purl.netloc}{purl.path}'
def domain_from_url(url):
o = tldextract.extract(url)
domain = f"{o.domain}.{o.suffix}".lower()
if 'www.' in domain:
domain = domain.replace('www.','')
return domain
def domain_name_from_url(url):
o = tldextract.extract(url)
domain_name = o.domain.lower()
if 'www.' in domain_name:
domain_name = domain_name.replace('www.','')
return domain_name
def add_to_grist(name, url, summary, app_type, category, tags, slug, domain):
grist_PE.Apps.add_records('Master', [
{ 'name': name,
'url': url,
'status': 'radar',
'summary': summary,
'type': app_type,
'category': category,
'tags': tags,
'slug': slug,
'domain': domain,
}
])
def find_category(text):
category_list = ['L']
for keyword in category_keywords:
if keyword.lower() in text.lower():
category_list.append(keyword.lower())
return category_list
def find_tags(text):
tags_list = ['L']
for keyword in tag_keywords:
if keyword.lower() in text.lower():
tags_list.append(keyword.lower())
return tags_list
### Main
def add_app(text, v=v):
url = cleanurl(text.strip())
if url.startswith('http'):
print(f"{get_linenumber()} {url=}")
name = ''
summary = ''
logo = ''
category = ['L']
tags = ['L']
domain = domain_from_url(url)
slug = domain_name_from_url(url)
app_type = 'SaaS'
if v:
print(f"\n{get_linenumber()} {url=}")
print(f"{get_linenumber()} {domain=}")
print(f"{get_linenumber()} {slug=}")
print(f"{get_linenumber()} {app_type=}")
if domain not in existing_domains:
try:
html = request.urlopen(url).read().decode('utf8')
# if v:
# print(f"\n{html=}")
try:
soup = BeautifulSoup(html, "html.parser")
# if v:
# print(f"\n{soup=}")
try:
title = soup.title.text
if '\n' in title:
title = title.replace('\n', ' ').strip()
if v:
print(f"\n{get_linenumber()} {title=}")
try:
header = soup.find('h1').text
if '\n' in header:
header = header.replace('\n', ' ').strip()
if v:
print(f"{get_linenumber()} {header=}")
if header in title:
header = domain
if not test:
name = title
if test:
print(f"{get_linenumber()} {name=}")
summary = header
if test:
print(f"{get_linenumber()} {summary=}")
text = f"{name} {summary}"
category = find_category(text)
if test:
print(f"{get_linenumber()} {category=}")
tags = find_tags(text)
if test:
print(f"{get_linenumber()} {tags=}")
except Exception as e:
print(f"\n{get_linenumber()} h1 ERROR: {e}")
name = title
except Exception as e:
print(f"\n{get_linenumber()} title ERROR: {e}")
except Exception as e:
print(f"\n{get_linenumber()} soup ERROR: {e}")
except Exception as e:
print(f"\n{get_linenumber()} html ERROR: {e}")
print(f"\n{get_linenumber()} Data passed to function add_to_grist:")
print(f"{name=}")
print(f"{url=}")
print(f"{summary=}")
print(f"{app_type=}")
print(f"{category=}")
print(f"{tags=}")
print(f"{slug=}")
print(f"{domain=}\n\n")
print(f"line {get_linenumber()} passed.")
add_to_grist(name, url, summary, app_type, category, tags, slug, domain)
if not test:
webbrowser.get('chrome').open_new_tab(f'<link_to_my_grist_page>')
else:
print(f"\n{url} is NOT a URL.")
pymsgbox.alert(f"\n{url} is NOT a URL.")
text = clipboard_get()
print(f"\nProcessing:\n{repr(text)}\n")
add_app(text)
run_time = round((time.time() - start_time), 1)
print(f'finished in {run_time}s.\n')
Re-using now that script logic for additional list, eg List of Recruiters (Clipee Grist Recruiters
/ Alfred shortcut cgr
) for https://btobsales.eu/.
Clipee CSV
generate Python code of CSV headers given a path to .csv
file.
'''
CLIPEE CSV
CSV HEADERS TO PYTHON
Input: path to .csv file
Output: python row code for headers
'''
from pandas.io.clipboard import clipboard_get
import subprocess
import time
start_time = time.time()
import csv
import pymsgbox
import os
pymsgbox.alert(f"Starting {os.path.basename(__file__)}...")
count_col_csv = 0
def write_to_clipboard(output):
process = subprocess.Popen(
'pbcopy', env={'LANG': 'en_US.UTF-8'}, stdin=subprocess.PIPE)
process.communicate(output.encode('utf-8'))
print(f"\nOUTPUT COPIED TO CLIPBOARD\n")
def process_csv(text, v=False):
global count_col_csv
headers = ''
try:
path_to_csv = text.strip()
if path_to_csv.startswith('/Users'):
with open(path_to_csv, 'r', newline='', encoding='UTF-8') as h:
reader = csv.reader(h, delimiter=",")
header_row = list(reader)[0]
count_for = -1
for title in header_row:
count_col_csv += 1
count_for += 1
title = title.lower()
if ' ' in title:
title = title.replace(' ', '_').lower()
if '/' in title:
title = title.replace('/', '_').lower()
print(f"{title} = row[{count_for}]\n")
headers = headers + f"{title} = row[{count_for}]\n"
write_to_clipboard(headers)
pymsgbox.alert(f"SUCCESS\nCopied to clipboard: {headers}")
except Exception as e:
pymsgbox.alert(f"ERROR: {e}")
text = clipboard_get()
print(f"\nProcessing: {repr(text)}\n")
process_csv(text)